diff --git a/docs/reasoning-content-roundtrip.md b/docs/reasoning-content-roundtrip.md new file mode 100644 index 00000000000..238073565dc --- /dev/null +++ b/docs/reasoning-content-roundtrip.md @@ -0,0 +1,143 @@ +# Reasoning Content Round-Trip: 修复总结 + +## 背景 + +Codex CLI 使用 OpenAI Responses API (`/v1/responses`) 格式与 CLIProxyAPI 通信,代理将其转换为 Chat Completions 格式发送给上游提供商(如 DeepSeek)。Responses API 和 Chat Completions 对 reasoning/thinking 的处理方式不同,导致往返过程中 `reasoning_content` 丢失。 + +## 核心问题 + +### 问题 1: `reasoning_content` 在响应转换中被丢弃 + +**现象**: DeepSeek 返回 `reasoning_content`(思考内容),但代理在转换为 Responses 格式时丢弃了该字段。 + +**根因**: 旧代码中 `response.go` 有 DeepSeek 特判逻辑: + +```go +// NOTE: reasoning_content is intentionally skipped for DeepSeek compatibility. +_ = delta +``` + +**修复**: 所有模型一视同仁——`reasoning_content` 转换为两个 Responses 格式的表达: + +1. **`type: "reasoning"` output item** — 独立推理输出项,包含 summary text +2. **`type: "reasoning_text"` content part** — 嵌入 assistant message 的 content 数组,用于往返透传 + +### 问题 2: `reasoning_content` 在后续请求中未回传 + +**现象**: DeepSeek 要求同一对话中后续请求必须传回 `reasoning_content`,否则报错:`"The reasoning_content in the thinking mode must be passed back to the API"` + +**根因**: 即使代理在响应中正确包含了 `reasoning_text`,Codex CLI(及大多数客户端)在构建后续请求时**不会**将该字段包含在 input 中。有两种情况: + +#### 情况 A: 客户端未回传 `reasoning_text` +客户端发送的 input 中 assistant message 的 content 只有 `output_text`,没有 `reasoning_text`。代理的请求转换器无法从中提取 `reasoning_content`。 + +#### 情况 B: 客户端发来空 summary 的 `type: "reasoning"` 项 +客户端发送 `{"type": "reasoning", "summary": [{"text": ""}]}`,summary text 为空,代理无法提取有效内容来注入。 + +**修复**: 请求方向增加 `case "reasoning":` 分支和 `pendingReasoningContent` 注入机制,同时保留 `case "reasoning_text":` 处理。 + +### 问题 3: DeepSeek 默认思考模式导致连锁反应 + +**现象**: 删除了 `thinking: {type: "disabled"}` 后,DeepSeek 进入默认思考模式,返回 `reasoning_content`,从而触发 echo-back 要求。 + +**根因**: `deepseek-v4-flash` 模型默认启用思考模式。旧代码无条件设置了 `thinking: {type: "disabled"}` 来规避此问题。新代码删除了这个逻辑后,DeepSeek 默认思考,产生 `reasoning_content`,进而要求回传。但客户端不回传 → 报错。 + +**修复**: 根据 `reasoning` 参数的有无/值来决定: + +| 客户端传入 | 代理转换行为 | +|---|---| +| `reasoning: null` 或不存在 | `thinking: {type: "disabled"}` — 禁用思考模式 | +| `reasoning: {effort: "high"}` | `reasoning_effort: "high"` — 启用思考模式并设定强度 | + +这样客户端不要求 reasoning 时,DeepSeek 不会进入思考模式,自然没有 echo-back 要求。 + +## 架构决策 + +### 通用 vs 厂商特化 + +**原则**: 代理应该是通用型的,不针对特定模型做特判。 + +- `reasoning_content` → `reasoning_text` 转换:**通用逻辑**,所有模型一视同仁 +- `thinking: {type: "disabled"}`:**厂商兼容层**,因为只有 DeepSeek 需要此参数来禁用思考模式。其他模型忽略此参数 + +### 数据流 + +``` +Request (Responses API) + │ + ▼ +request.go: ConvertOpenAIResponsesRequestToOpenAIChatCompletions + ├── 处理 input 数组中的 message / function_call / reasoning 项 + ├── 从 reasoning_text content 中提取 → reasoning_content + ├── 从 type:reasoning 项中提取 summary → pendingReasoningContent + ├── pendingReasoningContent → assistant message.reasoning_content + └── reasoning 参数映射 → thinking:disabled / reasoning_effort + │ + ▼ +Chat Completions Request → Upstream Provider + │ + ▼ +Chat Completions Response ← Upstream Provider + │ + ▼ +response.go: ConvertOpenAIChatCompletionsResponseToOpenAIResponses + ├── reasoning_content → type:reasoning output item + ├── reasoning_content → message content 中的 reasoning_text content part + ├── 流式: 逐 chunk 发射 reasoning 事件 + └── 非流式: 聚合到 response.completed + │ + ▼ +Response (Responses API) → Client +``` + +## 修改的文件 + +### `internal/translator/openai/openai/responses/openai_openai-responses_request.go` + +1. **tool_calls 分组缓冲**(此前已修复): + - 旧: `flushFunctionCalls()` — 遇 message 就 flush,不处理交错消息 + - 新: `flushToolGroup()` — 三缓冲(function_calls + bufferedMessages + tool_outputs),按 Chat Completions 严格顺序排放 + +2. **`case "reasoning_text":`** — 从 message content 中提取推理文本到 `reasoning_content` 顶层字段 + +3. **`case "reasoning":`** — 从 standalone reasoning input item 的 summary 中提取文本,注入下一个 assistant message + +4. **`pendingReasoningContent`** — 在 buffered 和 direct 两个消息路径中注入到 assistant message + +5. **`reasoning` 参数处理**: + - `reasoning: {effort: "..."}` → `reasoning_effort: "..."` + - `reasoning: null` 或不存在 → `thinking: {type: "disabled"}` + +### `internal/translator/openai/openai/responses/openai_openai-responses_response.go` + +1. **流式 reasoning 处理**: + - 去掉 DeepSeek 特判的 `_ = delta` + - `reasoning_content` delta → `response.reasoning_summary_part.added` + `response.reasoning_summary_text.delta` + - content delta 前关闭 reasoning → 发射 done 事件 + - content_part.added 支持 `reasoning_text` (index 0) + `output_text` (index 1) + +2. **`buildResponsesCompletedEvent`**: + - message content 动态构建:有 reasoning 时先插 `reasoning_text`,再插 `output_text` + +3. **非流式 `ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream`**: + - 去掉 `_ = rawJSON / _ = requestRawJSON` + - 检测 `choices.0.message.reasoning_content`,创建 reasoning output item + reasoning_text content part + +## 验证清单 + +- [x] 非流式响应: message content 包含 `reasoning_text` +- [x] 流式响应: SSE 事件的 content 包含 `reasoning_text` +- [x] `reasoning: null`: 不返回 reasoning_content,无 echo-back 要求 +- [x] `reasoning: {effort: "low"}`: 返回 reasoning_content + reasoning_text +- [x] 带 function_calls 的 follow-up 请求正常运行 +- [x] 无 `go vet` / `go build` 错误 + +## 适配其他产品的要点 + +1. **request 方向**: 注意客户端是否在 input 中回传 `reasoning_text`。如果客户端不回传,必须通过 `thinking: disabled` 或其他机制防止提供商的思考模式被默认激活 + +2. **response 方向**: 推理内容需要同时以两种形式存在——独立 reasoning item(供 UI 展示)和 message content 中的 reasoning_text(供往返透传) + +3. **厂商差异**: DeepSeek 需要显式 `thinking: {type: "disabled"}` 来禁用思考模式;其他模型可能用不同参数。建议在 provider 配置层抽象 + +4. **客户端行为**: Codex CLI 不会回传 `reasoning_text`,这是一个关键假设。如果客户端行为不同(如回传 `reasoning_text`),可以去掉 `thinking: disabled` 的兜底逻辑 diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 2366c9c37b7..72af1dfc24a 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -1,6 +1,7 @@ package responses import ( + "fmt" "strings" "github.com/tidwall/gjson" @@ -57,32 +58,77 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { - input.ForEach(func(_, item gjson.Result) bool { - itemType := item.Get("type").String() - if itemType == "" && item.Get("role").String() != "" { - itemType = "message" + // Group-buffering approach for tool calls. + // + // In Responses API format, function_call and function_call_output items + // can be interleaved with messages (e.g. developer approval messages + // between a call and its result). Chat Completions is stricter: + // an assistant message with tool_calls MUST be immediately followed by + // the corresponding tool messages. + // + // We buffer the entire tool group and flush it in the correct order: + // 1. One assistant message with all tool_calls + // 2. All tool messages (one per function_call_output) + // 3. Any messages that were interleaved between calls and results + var pendingFunctionCalls []gjson.Result + var bufferedMessages []gjson.Result + var pendingToolOutputs []gjson.Result + var pendingReasoningContent string + + flushToolGroup := func() { + if len(pendingFunctionCalls) == 0 && len(pendingToolOutputs) == 0 { + return } + // 1. Emit one assistant message with all accumulated tool_calls (only if there are function calls to emit) + assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) + for i, fc := range pendingFunctionCalls { + toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) + if callId := fc.Get("call_id"); callId.Exists() { + toolCall, _ = sjson.SetBytes(toolCall, "id", callId.String()) + } + if name := fc.Get("name"); name.Exists() { + toolCall, _ = sjson.SetBytes(toolCall, "function.name", name.String()) + } + if arguments := fc.Get("arguments"); arguments.Exists() { + toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) + } + assistantMessage, _ = sjson.SetRawBytes(assistantMessage, fmt.Sprintf("tool_calls.%d", i), toolCall) + } + out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) - switch itemType { - case "message", "": - // Handle regular message conversion - role := item.Get("role").String() + // 2. Emit tool messages for all collected function_call_output items (in order) + for _, output := range pendingToolOutputs { + toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + if callId := output.Get("call_id"); callId.Exists() { + toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String()) + } + if outputVal := output.Get("output"); outputVal.Exists() { + toolMessage, _ = sjson.SetBytes(toolMessage, "content", outputVal.String()) + } + out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage) + } + + // 3. Emit any messages that were interleaved between function_call + // and function_call_output (e.g. developer approval messages). + for _, msg := range bufferedMessages { + role := msg.Get("role").String() if role == "developer" { role = "user" } message := []byte(`{"role":"","content":[]}`) message, _ = sjson.SetBytes(message, "role", role) - if content := item.Get("content"); content.Exists() && content.IsArray() { - var messageContent string - var toolCalls []interface{} + if role == "assistant" && pendingReasoningContent != "" { + message, _ = sjson.SetBytes(message, "reasoning_content", pendingReasoningContent) + pendingReasoningContent = "" + } + if content := msg.Get("content"); content.Exists() && content.IsArray() { content.ForEach(func(_, contentItem gjson.Result) bool { contentType := contentItem.Get("type").String() if contentType == "" { contentType = "input_text" } - switch contentType { case "input_text", "output_text": text := contentItem.Get("text").String() @@ -94,61 +140,114 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL) message, _ = sjson.SetRawBytes(message, "content.-1", contentPart) + case "reasoning_text": + message, _ = sjson.SetBytes(message, "reasoning_content", contentItem.Get("text").String()) } return true }) - - if messageContent != "" { - message, _ = sjson.SetBytes(message, "content", messageContent) - } - - if len(toolCalls) > 0 { - message, _ = sjson.SetBytes(message, "tool_calls", toolCalls) - } } else if content.Type == gjson.String { message, _ = sjson.SetBytes(message, "content", content.String()) } out, _ = sjson.SetRawBytes(out, "messages.-1", message) + } - case "function_call": - // Handle function call conversion to assistant message with tool_calls - assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) - - toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) - - if callId := item.Get("call_id"); callId.Exists() { - toolCall, _ = sjson.SetBytes(toolCall, "id", callId.String()) - } + // Reset all buffers + pendingFunctionCalls = nil + pendingToolOutputs = nil + bufferedMessages = nil + } - if name := item.Get("name"); name.Exists() { - toolCall, _ = sjson.SetBytes(toolCall, "function.name", name.String()) - } + input.ForEach(func(_, item gjson.Result) bool { + itemType := item.Get("type").String() + if itemType == "" && item.Get("role").String() != "" { + itemType = "message" + } - if arguments := item.Get("arguments"); arguments.Exists() { - toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) - } + switch itemType { + case "message", "": + if len(pendingFunctionCalls) > 0 || len(pendingToolOutputs) > 0 { + // We're inside an active tool group — buffer this message + // so it gets emitted after the tool messages in the correct order. + bufferedMessages = append(bufferedMessages, item) + } else { + // No tool group active, emit directly + role := item.Get("role").String() + if role == "developer" { + role = "user" + } + message := []byte(`{"role":"","content":[]}`) + message, _ = sjson.SetBytes(message, "role", role) - assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall) - out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + if role == "assistant" && pendingReasoningContent != "" { + message, _ = sjson.SetBytes(message, "reasoning_content", pendingReasoningContent) + pendingReasoningContent = "" + } - case "function_call_output": - // Handle function call output conversion to tool message - toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + if content := item.Get("content"); content.Exists() && content.IsArray() { + content.ForEach(func(_, contentItem gjson.Result) bool { + contentType := contentItem.Get("type").String() + if contentType == "" { + contentType = "input_text" + } + switch contentType { + case "input_text", "output_text": + text := contentItem.Get("text").String() + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", text) + message, _ = sjson.SetRawBytes(message, "content.-1", contentPart) + case "input_image": + imageURL := contentItem.Get("image_url").String() + contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL) + message, _ = sjson.SetRawBytes(message, "content.-1", contentPart) + case "reasoning_text": + message, _ = sjson.SetBytes(message, "reasoning_content", contentItem.Get("text").String()) + } + return true + }) + } else if content.Type == gjson.String { + message, _ = sjson.SetBytes(message, "content", content.String()) + } - if callId := item.Get("call_id"); callId.Exists() { - toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String()) + out, _ = sjson.SetRawBytes(out, "messages.-1", message) } - if output := item.Get("output"); output.Exists() { - toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String()) + case "function_call": + // If the previous tool group already has outputs collected, + // this function_call starts a *new* group flush the old one first. + if len(pendingToolOutputs) > 0 { + flushToolGroup() } + pendingFunctionCalls = append(pendingFunctionCalls, item) - out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage) + case "function_call_output": + // Collect the output it will be emitted by flushToolGroup + // in the correct position (after assistant+tool_calls, + // before any buffered messages). + pendingToolOutputs = append(pendingToolOutputs, item) + + case "reasoning": + // Extract summary text from standalone reasoning input items. + // This text will be injected as reasoning_content on the + // subsequent assistant message for models that require echo-back. + if summary := item.Get("summary"); summary.Exists() && summary.IsArray() { + summary.ForEach(func(_, s gjson.Result) bool { + if s.Get("type").String() == "summary_text" { + if text := s.Get("text").String(); text != "" { + pendingReasoningContent = text + } + } + return true + }) + } } return true }) + + // Flush any remaining tool group at end of array + flushToolGroup() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") @@ -198,11 +297,28 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } } - if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { - effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String())) + // Handle reasoning configuration. + // + // When reasoning.effort is explicitly set (e.g. "low", "medium", "high"), + // map it to the Chat Completions reasoning_effort field — this enables + // thinking mode on models that support it. + // + // When reasoning is absent, disable thinking mode via the non-standard + // "thinking" parameter. Without this, DeepSeek (and similar providers) + // default to thinking mode and return reasoning_content, which they then + // require echoed back on every subsequent request. Codex CLI does not + // echo back reasoning_text, so disabling thinking by default is necessary + // for reliable operation. Providers that don't support "thinking" (e.g. + // OpenAI) will return a 400, which is caught and handled by the retry layer. + if reasoning := root.Get("reasoning"); reasoning.Exists() { + effort := reasoning.Get("effort").String() if effort != "" { - out, _ = sjson.SetBytes(out, "reasoning_effort", effort) + out, _ = sjson.SetBytes(out, "reasoning_effort", strings.ToLower(strings.TrimSpace(effort))) + } else { + out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) } + } else { + out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) } // Convert tool_choice if present diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 8a44aede443..dc2cc4ecd92 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -18,6 +18,7 @@ type oaiToResponsesStateReasoning struct { ReasoningID string ReasoningData string OutputIndex int + ChoiceIndex int } type oaiToResponsesState struct { Seq int @@ -45,6 +46,7 @@ type oaiToResponsesState struct { MsgItemDone map[int]bool // whether message done events were emitted // function item done state FuncArgsDone map[string]bool + MsgTextContentIdx map[int]int FuncItemDone map[string]bool // usage aggregation PromptTokens int64 @@ -152,9 +154,17 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte if b := st.MsgTextBuf[i]; b != nil { txt = b.String() } - item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) + item := []byte(`{"id":"","type":"message","status":"completed","content":[],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) - item, _ = sjson.SetBytes(item, "content.0.text", txt) + // Insert reasoning_text content part at index 0 if reasoning data is available + if reasoningText := findReasoningByChoiceIndex(st.Reasonings, i); reasoningText != "" { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", reasoningText) + item, _ = sjson.SetRawBytes(item, "content.-1", rp) + } + op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) + op, _ = sjson.SetBytes(op, "text", txt) + item, _ = sjson.SetRawBytes(item, "content.-1", op) outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item}) } } @@ -212,6 +222,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, MsgContentAdded: make(map[int]bool), MsgItemDone: make(map[int]bool), FuncArgsDone: make(map[string]bool), + MsgTextContentIdx: make(map[int]int), FuncItemDone: make(map[string]bool), Reasonings: make([]oaiToResponsesStateReasoning, 0), } @@ -299,6 +310,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgContentAdded = make(map[int]bool) st.MsgItemDone = make(map[int]bool) st.FuncArgsDone = make(map[string]bool) + st.MsgTextContentIdx = make(map[int]int) st.FuncItemDone = make(map[string]bool) st.PromptTokens = 0 st.CachedTokens = 0 @@ -323,7 +335,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.Started = true } - stopReasoning := func(text string) { + stopReasoning := func(text string, choiceIndex int) { // Emit reasoning done events textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`) textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq()) @@ -344,7 +356,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text) out = append(out, emitRespEvent("response.output_item.done", outputItemDone)) - st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex}) + st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex, ChoiceIndex: choiceIndex}) st.ReasoningID = "" } @@ -355,11 +367,16 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, delta := choice.Get("delta") if delta.Exists() { if c := delta.Get("content"); c.Exists() && c.String() != "" { - // Ensure the message item and its first content part are announced before any text deltas + var reasoningText string if st.ReasoningID != "" { - stopReasoning(st.ReasoningBuf.String()) + reasoningText = st.ReasoningBuf.String() + stopReasoning(st.ReasoningBuf.String(), idx) st.ReasoningBuf.Reset() } + var outputContentIdx int + if reasoningText != "" { + outputContentIdx = 1 + } if _, exists := st.MsgOutputIx[idx]; !exists { st.MsgOutputIx[idx] = allocOutputIndex() } @@ -373,20 +390,32 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgItemAdded[idx] = true } if !st.MsgContentAdded[idx] { + nextContentIdx := 0 + if reasoningText != "" { + rp := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"reasoning_text","text":""}}`) + rp, _ = sjson.SetBytes(rp, "sequence_number", nextSeq()) + rp, _ = sjson.SetBytes(rp, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) + rp, _ = sjson.SetBytes(rp, "output_index", msgOutputIndex) + rp, _ = sjson.SetBytes(rp, "content_index", 0) + rp, _ = sjson.SetBytes(rp, "part.text", reasoningText) + out = append(out, emitRespEvent("response.content_part.added", rp)) + nextContentIdx = 1 + } part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`) part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex) - part, _ = sjson.SetBytes(part, "content_index", 0) + part, _ = sjson.SetBytes(part, "content_index", nextContentIdx) out = append(out, emitRespEvent("response.content_part.added", part)) st.MsgContentAdded[idx] = true + st.MsgTextContentIdx[idx] = nextContentIdx } msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`) msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex) - msg, _ = sjson.SetBytes(msg, "content_index", 0) + msg, _ = sjson.SetBytes(msg, "content_index", outputContentIdx) msg, _ = sjson.SetBytes(msg, "delta", c.String()) out = append(out, emitRespEvent("response.output_text.delta", msg)) // aggregate for response.output @@ -396,7 +425,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgTextBuf[idx].WriteString(c.String()) } - // reasoning_content (OpenAI reasoning incremental text) + // reasoning_content — always pass through from upstream Chat Completions. + // All models that return reasoning_content get it converted to a + // reasoning output item. The text is also embedded as a reasoning_text + // content part in the assistant message for round-trip echo-back. if rc := delta.Get("reasoning_content"); rc.Exists() && rc.String() != "" { // On first appearance, add reasoning item and part if st.ReasoningID == "" { @@ -425,10 +457,16 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // tool calls if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() { + var reasoningText string if st.ReasoningID != "" { - stopReasoning(st.ReasoningBuf.String()) + reasoningText = st.ReasoningBuf.String() + stopReasoning(st.ReasoningBuf.String(), idx) st.ReasoningBuf.Reset() } + var tcsOutputContentIdx int + if reasoningText != "" { + tcsOutputContentIdx = 1 + } // Before emitting any function events, if a message is open for this index, // close its text/content to match Codex expected ordering. if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] { @@ -441,7 +479,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex) - done, _ = sjson.SetBytes(done, "content_index", 0) + done, _ = sjson.SetBytes(done, "content_index", tcsOutputContentIdx) done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitRespEvent("response.output_text.done", done)) @@ -449,15 +487,24 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex) - partDone, _ = sjson.SetBytes(partDone, "content_index", 0) + partDone, _ = sjson.SetBytes(partDone, "content_index", tcsOutputContentIdx) partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) out = append(out, emitRespEvent("response.content_part.done", partDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`) + contentArr := []byte(`[]`) + if reasoningText != "" { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", reasoningText) + contentArr, _ = sjson.SetRawBytes(contentArr, "-1", rp) + } + op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) + op, _ = sjson.SetBytes(op, "text", fullText) + contentArr, _ = sjson.SetRawBytes(contentArr, "-1", op) + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","role":"assistant"}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex) itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx)) - itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText) + itemDone, _ = sjson.SetRawBytes(itemDone, "item.content", contentArr) out = append(out, emitRespEvent("response.output_item.done", itemDone)) st.MsgItemDone[idx] = true } @@ -540,7 +587,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex) - done, _ = sjson.SetBytes(done, "content_index", 0) + done, _ = sjson.SetBytes(done, "content_index", st.MsgTextContentIdx[i]) done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitRespEvent("response.output_text.done", done)) @@ -548,15 +595,25 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex) - partDone, _ = sjson.SetBytes(partDone, "content_index", 0) + partDone, _ = sjson.SetBytes(partDone, "content_index", st.MsgTextContentIdx[i]) partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) out = append(out, emitRespEvent("response.content_part.done", partDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`) + // Build content array with reasoning_text if available + contentArr := []byte(`[]`) + if reasoningText := findReasoningByChoiceIndex(st.Reasonings, i); reasoningText != "" { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", reasoningText) + contentArr, _ = sjson.SetRawBytes(contentArr, "-1", rp) + } + op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) + op, _ = sjson.SetBytes(op, "text", fullText) + contentArr, _ = sjson.SetRawBytes(contentArr, "-1", op) + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","role":"assistant"}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex) itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) - itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText) + itemDone, _ = sjson.SetRawBytes(itemDone, "item.content", contentArr) out = append(out, emitRespEvent("response.output_item.done", itemDone)) st.MsgItemDone[i] = true } @@ -564,7 +621,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } if st.ReasoningID != "" { - stopReasoning(st.ReasoningBuf.String()) + stopReasoning(st.ReasoningBuf.String(), idx) st.ReasoningBuf.Reset() } @@ -620,7 +677,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON // from a non-streaming OpenAI Chat Completions response. -func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { +func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { root := gjson.ParseBytes(rawJSON) // Basic response scaffold @@ -717,7 +774,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Build output list from choices[...] outputsWrapper := []byte(`{"arr":[]}`) - // Detect and capture reasoning content if present + + // Detect reasoning_content from upstream Chat Completions response rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String() includeReasoning := rcText != "" if !includeReasoning && len(requestRawJSON) > 0 { @@ -728,7 +786,6 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co if strings.HasPrefix(rid, "resp_") { rid = strings.TrimPrefix(rid, "resp_") } - // Prefer summary_text from reasoning_content; encrypted_content is optional reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid)) if rcText != "" { @@ -744,9 +801,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co if msg.Exists() { // Text message part if c := msg.Get("content"); c.Exists() && c.String() != "" { - item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) + item := []byte(`{"id":"","type":"message","status":"completed","content":[],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int()))) - item, _ = sjson.SetBytes(item, "content.0.text", c.String()) + // Insert reasoning_text content part at index 0 if present + if rcText != "" { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", rcText) + item, _ = sjson.SetRawBytes(item, "content.-1", rp) + } + op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) + op, _ = sjson.SetBytes(op, "text", c.String()) + item, _ = sjson.SetRawBytes(item, "content.-1", op) outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) } @@ -795,3 +860,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co return resp } + +// findReasoningByChoiceIndex finds the reasoning text for a given choice index. +func findReasoningByChoiceIndex(reasonings []oaiToResponsesStateReasoning, choiceIndex int) string { + for _, r := range reasonings { + if r.ChoiceIndex == choiceIndex { + return r.ReasoningData + } + } + return "" +}