| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- package apicompat
- import (
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "strings"
- "time"
- )
- // ---------------------------------------------------------------------------
- // Non-streaming: ResponsesResponse → ChatCompletionsResponse
- // ---------------------------------------------------------------------------
- // ResponsesToChatCompletions converts a Responses API response into a Chat
- // Completions response. Text output items are concatenated into
- // choices[0].message.content; function_call items become tool_calls.
- func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatCompletionsResponse {
- id := resp.ID
- if id == "" {
- id = generateChatCmplID()
- }
- out := &ChatCompletionsResponse{
- ID: id,
- Object: "chat.completion",
- Created: time.Now().Unix(),
- Model: model,
- ServiceTier: resp.ServiceTier,
- }
- var contentText string
- var reasoningText string
- var toolCalls []ChatToolCall
- for _, item := range resp.Output {
- switch item.Type {
- case "message":
- for _, part := range item.Content {
- if part.Type == "output_text" && part.Text != "" {
- contentText += part.Text
- }
- }
- case "function_call":
- toolCalls = append(toolCalls, ChatToolCall{
- ID: item.CallID,
- Type: "function",
- Function: ChatFunctionCall{
- Name: item.Name,
- Arguments: item.Arguments,
- },
- })
- case "reasoning":
- for _, s := range item.Summary {
- if s.Type == "summary_text" && s.Text != "" {
- reasoningText += s.Text
- }
- }
- case "web_search_call":
- // silently consumed — results already incorporated into text output
- }
- }
- msg := ChatMessage{Role: "assistant"}
- if len(toolCalls) > 0 {
- msg.ToolCalls = toolCalls
- }
- if contentText != "" {
- raw, _ := json.Marshal(contentText)
- msg.Content = raw
- }
- if reasoningText != "" {
- msg.ReasoningContent = reasoningText
- }
- finishReason := responsesStatusToChatFinishReason(resp.Status, resp.IncompleteDetails, toolCalls)
- out.Choices = []ChatChoice{{
- Index: 0,
- Message: msg,
- FinishReason: finishReason,
- }}
- out.Usage = chatUsageFromResponsesUsage(resp.Usage)
- return out
- }
- func responsesStatusToChatFinishReason(status string, details *ResponsesIncompleteDetails, toolCalls []ChatToolCall) string {
- switch status {
- case "incomplete":
- if details != nil {
- switch details.Reason {
- case "max_output_tokens":
- return "length"
- case "content_filter":
- return "content_filter"
- }
- }
- return "stop"
- case "completed":
- if len(toolCalls) > 0 {
- return "tool_calls"
- }
- return "stop"
- default:
- return "stop"
- }
- }
- // ---------------------------------------------------------------------------
- // Streaming: ResponsesStreamEvent → []ChatCompletionsChunk (stateful converter)
- // ---------------------------------------------------------------------------
- // ResponsesEventToChatState tracks state for converting a sequence of Responses
- // SSE events into Chat Completions SSE chunks.
- type ResponsesEventToChatState struct {
- ID string
- Model string
- Created int64
- ServiceTier string // upstream tier observed on response events; echoed on chunks
- SentRole bool
- SawToolCall bool
- SawText bool
- Finalized bool // true after finish chunk has been emitted
- NextToolCallIndex int // next sequential tool_call index to assign
- OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
- OutputIndexToArguments map[int]string
- IncludeUsage bool
- Usage *ChatUsage
- }
- // NewResponsesEventToChatState returns an initialised stream state.
- func NewResponsesEventToChatState() *ResponsesEventToChatState {
- return &ResponsesEventToChatState{
- ID: generateChatCmplID(),
- Created: time.Now().Unix(),
- OutputIndexToToolIndex: make(map[int]int),
- OutputIndexToArguments: make(map[int]string),
- }
- }
- // ResponsesEventToChatChunks converts a single Responses SSE event into zero
- // or more Chat Completions chunks, updating state as it goes.
- func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- switch evt.Type {
- case "response.created":
- return resToChatHandleCreated(evt, state)
- case "response.output_text.delta":
- return resToChatHandleTextDelta(evt, state)
- case "response.output_item.added":
- return resToChatHandleOutputItemAdded(evt, state)
- case "response.function_call_arguments.delta",
- // custom/freeform 工具(如新版 apply_patch)的输入增量与 function_call 参数增量同形,
- // 均按 OutputIndex 累加到对应工具调用。
- "response.custom_tool_call_input.delta":
- return resToChatHandleFuncArgsDelta(evt, state)
- case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
- return resToChatHandleFuncArgsDone(evt, state)
- case "response.reasoning_summary_text.delta",
- // 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta),
- // 与 reasoning summary 一样映射为 reasoning_content。
- "response.reasoning_text.delta":
- return resToChatHandleReasoningDelta(evt, state)
- case "response.reasoning_summary_text.done":
- return nil
- // response.done 是 Realtime/WS 与项目透传路径使用的终止别名;
- // 普通 Responses HTTP SSE 的公开终止事件仍以 response.completed 为主。
- case "response.completed", "response.done", "response.incomplete", "response.failed":
- return resToChatHandleCompleted(evt, state)
- default:
- return nil
- }
- }
- // FinalizeResponsesChatStream emits a final chunk with finish_reason if the
- // stream ended without a proper completion event (e.g. upstream disconnect).
- // It is idempotent: if a completion event already emitted the finish chunk,
- // this returns nil.
- func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatCompletionsChunk {
- if state.Finalized {
- return nil
- }
- state.Finalized = true
- finishReason := "stop"
- if state.SawToolCall {
- finishReason = "tool_calls"
- }
- chunks := []ChatCompletionsChunk{makeChatFinishChunk(state, finishReason)}
- if state.IncludeUsage && state.Usage != nil {
- chunks = append(chunks, ChatCompletionsChunk{
- ID: state.ID,
- Object: "chat.completion.chunk",
- Created: state.Created,
- Model: state.Model,
- ServiceTier: state.ServiceTier,
- Choices: []ChatChunkChoice{},
- Usage: state.Usage,
- })
- }
- return chunks
- }
- // ChatChunkToSSE formats a ChatCompletionsChunk as an SSE data line.
- func ChatChunkToSSE(chunk ChatCompletionsChunk) (string, error) {
- data, err := json.Marshal(chunk)
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("data: %s\n\n", data), nil
- }
- // --- internal handlers ---
- func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- if evt.Response != nil {
- if evt.Response.ID != "" {
- state.ID = evt.Response.ID
- }
- if state.Model == "" && evt.Response.Model != "" {
- state.Model = evt.Response.Model
- }
- if evt.Response.ServiceTier != "" {
- state.ServiceTier = evt.Response.ServiceTier
- }
- }
- // Emit the role chunk.
- if state.SentRole {
- return nil
- }
- state.SentRole = true
- role := "assistant"
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Role: role})}
- }
- func resToChatHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- if evt.Delta == "" {
- return nil
- }
- state.SawText = true
- content := evt.Delta
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Content: &content})}
- }
- func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- // function_call 与 custom_tool_call(custom/freeform 工具)均按工具调用注册,
- // 以便后续 *_input.delta / *_arguments.delta 能映射到正确的工具索引。
- if evt.Item == nil || (evt.Item.Type != "function_call" && evt.Item.Type != "custom_tool_call") {
- return nil
- }
- state.SawToolCall = true
- idx := state.NextToolCallIndex
- state.OutputIndexToToolIndex[evt.OutputIndex] = idx
- state.NextToolCallIndex++
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
- ToolCalls: []ChatToolCall{{
- Index: &idx,
- ID: evt.Item.CallID,
- Type: "function",
- Function: ChatFunctionCall{
- Name: evt.Item.Name,
- },
- }},
- })}
- }
- func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- if evt.Delta == "" {
- return nil
- }
- idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
- if !ok {
- return nil
- }
- state.OutputIndexToArguments[evt.OutputIndex] += evt.Delta
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
- ToolCalls: []ChatToolCall{{
- Index: &idx,
- Function: ChatFunctionCall{
- Arguments: evt.Delta,
- },
- }},
- })}
- }
- func resToChatHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
- if !ok {
- return nil
- }
- completed := evt.Arguments
- if evt.Type == "response.custom_tool_call_input.done" {
- completed = evt.Input
- }
- current := state.OutputIndexToArguments[evt.OutputIndex]
- if completed == "" || !strings.HasPrefix(completed, current) || completed == current {
- return nil
- }
- remainder := completed[len(current):]
- state.OutputIndexToArguments[evt.OutputIndex] = completed
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
- ToolCalls: []ChatToolCall{{
- Index: &idx,
- Function: ChatFunctionCall{
- Arguments: remainder,
- },
- }},
- })}
- }
- func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- if evt.Delta == "" {
- return nil
- }
- reasoning := evt.Delta
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{ReasoningContent: &reasoning})}
- }
- func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
- state.Finalized = true
- finishReason := "stop"
- if evt.Usage != nil {
- state.Usage = chatUsageFromResponsesUsage(evt.Usage)
- }
- if evt.Response != nil {
- if evt.Response.Usage != nil {
- state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage)
- }
- if evt.Response.ServiceTier != "" {
- state.ServiceTier = evt.Response.ServiceTier
- }
- switch evt.Response.Status {
- case "incomplete":
- if evt.Response.IncompleteDetails != nil {
- switch evt.Response.IncompleteDetails.Reason {
- case "max_output_tokens":
- finishReason = "length"
- case "content_filter":
- finishReason = "content_filter"
- }
- }
- case "completed":
- if state.SawToolCall {
- finishReason = "tool_calls"
- }
- }
- } else if state.SawToolCall {
- finishReason = "tool_calls"
- }
- var chunks []ChatCompletionsChunk
- chunks = append(chunks, makeChatFinishChunk(state, finishReason))
- if state.IncludeUsage && state.Usage != nil {
- chunks = append(chunks, ChatCompletionsChunk{
- ID: state.ID,
- Object: "chat.completion.chunk",
- Created: state.Created,
- Model: state.Model,
- ServiceTier: state.ServiceTier,
- Choices: []ChatChunkChoice{},
- Usage: state.Usage,
- })
- }
- return chunks
- }
- func chatUsageFromResponsesUsage(u *ResponsesUsage) *ChatUsage {
- if u == nil {
- return nil
- }
- usage := &ChatUsage{
- PromptTokens: u.InputTokens,
- CompletionTokens: u.OutputTokens,
- TotalTokens: u.InputTokens + u.OutputTokens,
- }
- usage.PromptTokensDetails = promptDetailsFromResponses(u.InputTokensDetails)
- if u.CacheCreationInputTokens > 0 {
- if usage.PromptTokensDetails == nil {
- usage.PromptTokensDetails = &ChatTokenDetails{}
- }
- if usage.PromptTokensDetails.CacheWriteTokens == 0 && usage.PromptTokensDetails.CacheCreationTokens == 0 {
- usage.PromptTokensDetails.CacheCreationTokens = u.CacheCreationInputTokens
- }
- }
- usage.CompletionTokensDetails = completionDetailsFromResponses(u.OutputTokensDetails)
- return usage
- }
- // promptDetailsFromResponses maps Responses-API input_tokens_details into a
- // Chat-Completions prompt_tokens_details. Returns nil when nothing would be
- // emitted, so upstreams that do not break down prompt usage stay clean.
- func promptDetailsFromResponses(src *ResponsesInputTokensDetails) *ChatTokenDetails {
- if src == nil {
- return nil
- }
- if src.CachedTokens == 0 && src.AudioTokens == 0 && src.CacheCreationTokens == 0 && src.CacheWriteTokens == 0 {
- return nil
- }
- return &ChatTokenDetails{
- CachedTokens: src.CachedTokens,
- AudioTokens: src.AudioTokens,
- CacheCreationTokens: src.CacheCreationTokens,
- CacheWriteTokens: src.CacheWriteTokens,
- }
- }
- // completionDetailsFromResponses maps Responses-API output_tokens_details
- // into a Chat-Completions completion_tokens_details. Mirrors the OpenAI
- // official CompletionUsage schema: reasoning_tokens, audio_tokens, and
- // the predicted-outputs accepted/rejected counts. Returns nil when nothing
- // would be emitted so non-reasoning, non-audio responses stay clean.
- func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatTokenDetails {
- if src == nil {
- return nil
- }
- if src.ReasoningTokens == 0 && src.AudioTokens == 0 &&
- src.AcceptedPredictionTokens == 0 && src.RejectedPredictionTokens == 0 {
- return nil
- }
- return &ChatTokenDetails{
- ReasoningTokens: src.ReasoningTokens,
- AudioTokens: src.AudioTokens,
- AcceptedPredictionTokens: src.AcceptedPredictionTokens,
- RejectedPredictionTokens: src.RejectedPredictionTokens,
- }
- }
- func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk {
- return ChatCompletionsChunk{
- ID: state.ID,
- Object: "chat.completion.chunk",
- Created: state.Created,
- Model: state.Model,
- ServiceTier: state.ServiceTier,
- Choices: []ChatChunkChoice{{
- Index: 0,
- Delta: delta,
- FinishReason: nil,
- }},
- }
- }
- func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk {
- empty := ""
- return ChatCompletionsChunk{
- ID: state.ID,
- Object: "chat.completion.chunk",
- Created: state.Created,
- Model: state.Model,
- ServiceTier: state.ServiceTier,
- Choices: []ChatChunkChoice{{
- Index: 0,
- Delta: ChatDelta{Content: &empty},
- FinishReason: &finishReason,
- }},
- }
- }
- // generateChatCmplID returns a "chatcmpl-" prefixed random hex ID.
- func generateChatCmplID() string {
- b := make([]byte, 12)
- _, _ = rand.Read(b)
- return "chatcmpl-" + hex.EncodeToString(b)
- }
- // ---------------------------------------------------------------------------
- // BufferedResponseAccumulator: accumulates SSE delta events for non-streaming
- // paths where the terminal event may have empty output.
- // ---------------------------------------------------------------------------
- type bufferedFuncCall struct {
- OutputIndex int
- CallID string
- Name string
- Args strings.Builder
- }
- // BufferedResponseAccumulator collects content from Responses SSE delta events
- // so that non-streaming handlers can reconstruct output when the terminal event
- // (response.completed / response.done) carries an empty output array.
- type BufferedResponseAccumulator struct {
- text strings.Builder
- reasoning strings.Builder
- funcCalls []bufferedFuncCall
- outputIndexToFuncIdx map[int]int
- }
- // NewBufferedResponseAccumulator returns an initialised accumulator.
- func NewBufferedResponseAccumulator() *BufferedResponseAccumulator {
- return &BufferedResponseAccumulator{
- outputIndexToFuncIdx: make(map[int]int),
- }
- }
- // ProcessEvent inspects a single Responses SSE event and accumulates any
- // content it carries. Only delta events that contribute to the final output
- // are handled; all other event types are silently ignored.
- func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) {
- switch event.Type {
- case "response.output_text.delta":
- if event.Delta != "" {
- _, _ = a.text.WriteString(event.Delta)
- }
- case "response.output_item.added":
- if event.Item != nil && (event.Item.Type == "function_call" || event.Item.Type == "custom_tool_call") {
- idx := len(a.funcCalls)
- a.outputIndexToFuncIdx[event.OutputIndex] = idx
- a.funcCalls = append(a.funcCalls, bufferedFuncCall{
- OutputIndex: event.OutputIndex,
- CallID: event.Item.CallID,
- Name: event.Item.Name,
- })
- }
- case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
- if event.Delta != "" {
- if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
- _, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
- }
- }
- case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
- completed := event.Arguments
- if event.Type == "response.custom_tool_call_input.done" {
- completed = event.Input
- }
- if completed != "" {
- if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
- a.funcCalls[idx].Args.Reset()
- _, _ = a.funcCalls[idx].Args.WriteString(completed)
- }
- }
- case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
- if event.Delta != "" {
- _, _ = a.reasoning.WriteString(event.Delta)
- }
- }
- }
- // HasContent reports whether any content has been accumulated.
- func (a *BufferedResponseAccumulator) HasContent() bool {
- return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0
- }
- // BuildOutput constructs a []ResponsesOutput from the accumulated delta
- // content. The order matches what ResponsesToChatCompletions expects:
- // reasoning → message → function_calls.
- func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
- var out []ResponsesOutput
- if a.reasoning.Len() > 0 {
- out = append(out, ResponsesOutput{
- Type: "reasoning",
- Summary: []ResponsesSummary{{
- Type: "summary_text",
- Text: a.reasoning.String(),
- }},
- })
- }
- if a.text.Len() > 0 {
- out = append(out, ResponsesOutput{
- Type: "message",
- Role: "assistant",
- Content: []ResponsesContentPart{{
- Type: "output_text",
- Text: a.text.String(),
- }},
- })
- }
- for i := range a.funcCalls {
- out = append(out, ResponsesOutput{
- Type: "function_call",
- CallID: a.funcCalls[i].CallID,
- Name: a.funcCalls[i].Name,
- Arguments: a.funcCalls[i].Args.String(),
- })
- }
- return out
- }
- // SupplementResponseOutput fills resp.Output from accumulated stream content
- // when the terminal event delivered an empty output array. It also fills empty
- // function-call arguments from authoritative argument-done events.
- func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
- if resp == nil {
- return
- }
- if len(resp.Output) == 0 {
- if a.HasContent() {
- resp.Output = a.BuildOutput()
- }
- return
- }
- for outputIndex := range resp.Output {
- item := &resp.Output[outputIndex]
- if item.Type != "function_call" || item.Arguments != "" {
- continue
- }
- for funcIndex := range a.funcCalls {
- call := &a.funcCalls[funcIndex]
- matchesCallID := item.CallID != "" && item.CallID == call.CallID
- if !matchesCallID && call.OutputIndex != outputIndex {
- continue
- }
- if call.Args.Len() > 0 {
- item.Arguments = call.Args.String()
- }
- break
- }
- }
- }
|