From c87f5aad4befa1d7add2b07a8a8197abd2fcd051 Mon Sep 17 00:00:00 2001 From: dage <1651055684@qq.com> Date: Thu, 30 Apr 2026 12:44:03 +0800 Subject: [PATCH 1/4] fix(openai/responses): group tool_calls and disable thinking mode for DeepSeek compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for OpenAI Responses → Chat Completions translation when used with DeepSeek models (deepseek-v4-flash): 1. Tool call grouping: consecutive function_call items in the input array are now grouped into a single assistant message with multiple tool_calls, fixing "insufficient tool messages following tool_calls message" errors. 2. Thinking mode disabled: DeepSeek's deepseek-v4-flash defaults to thinking mode, which returns reasoning_content and requires it to be echoed back. Send thinking: {type: "disabled"} instead of reasoning_effort to avoid this requirement entirely. Also strips reasoning_content from streaming and non-streaming responses since the proxy does not support echoing it back. Co-Authored-By: Claude Opus 4.7 --- .../openai_openai-responses_request.go | 73 ++++++++++++------- .../openai_openai-responses_response.go | 56 +++----------- 2 files changed, 57 insertions(+), 72 deletions(-) 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..b0b2ed585ff 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -1,7 +1,7 @@ package responses import ( - "strings" + "fmt" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -57,6 +57,34 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + // Collect consecutive function_call items so they can be grouped into + // a single assistant message with multiple tool_calls (required by Chat + // Completions format: one assistant message with all tool_calls, followed + // by one tool message per tool_call_id). + var pendingFunctionCalls []gjson.Result + + flushFunctionCalls := func() { + if len(pendingFunctionCalls) == 0 { + return + } + 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) + pendingFunctionCalls = nil + } + input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { @@ -65,6 +93,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu switch itemType { case "message", "": + // Flush any pending function_calls before a new message + flushFunctionCalls() + // Handle regular message conversion role := item.Get("role").String() if role == "developer" { @@ -112,27 +143,13 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu 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()) - } - - if name := item.Get("name"); name.Exists() { - toolCall, _ = sjson.SetBytes(toolCall, "function.name", name.String()) - } - - if arguments := item.Get("arguments"); arguments.Exists() { - toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) - } - - assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall) - out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + // Collect consecutive function_calls into a single group + pendingFunctionCalls = append(pendingFunctionCalls, item) case "function_call_output": + // Flush pending function_calls before a tool response + flushFunctionCalls() + // Handle function call output conversion to tool message toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) @@ -149,6 +166,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu return true }) + + // Flush any remaining function_calls at end of array + flushFunctionCalls() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") @@ -198,12 +218,13 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } } - if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { - effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String())) - if effort != "" { - out, _ = sjson.SetBytes(out, "reasoning_effort", effort) - } - } + // Disable thinking mode for DeepSeek compatibility. + // DeepSeek's deepseek-v4-flash model defaults to thinking mode, which returns + // reasoning_content in responses and requires it to be echoed back in subsequent + // requests. The proxy doesn't support echoing reasoning_content, so we disable + // thinking mode entirely. + // See: https://api-docs.deepseek.com/guides/thinking_mode + out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) // Convert tool_choice if present if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { 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..9035558b317 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -396,32 +396,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgTextBuf[idx].WriteString(c.String()) } - // reasoning_content (OpenAI reasoning incremental text) - if rc := delta.Get("reasoning_content"); rc.Exists() && rc.String() != "" { - // On first appearance, add reasoning item and part - if st.ReasoningID == "" { - st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx) - st.ReasoningIndex = allocOutputIndex() - item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`) - item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) - item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID) - out = append(out, emitRespEvent("response.output_item.added", item)) - part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) - part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) - part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID) - part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex) - out = append(out, emitRespEvent("response.reasoning_summary_part.added", part)) - } - // Append incremental text to reasoning buffer - st.ReasoningBuf.WriteString(rc.String()) - msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`) - msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) - msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID) - msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex) - msg, _ = sjson.SetBytes(msg, "delta", rc.String()) - out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg)) - } + // NOTE: reasoning_content is intentionally skipped because: + // 1. DeepSeek returns reasoning_content for deepseek-v4-flash model + // 2. DeepSeek requires reasoning_content to be echoed back in subsequent requests + // 3. The proxy doesn't support echoing reasoning_content back to DeepSeek + // 4. Dropping reasoning_content from the response avoids the echo requirement + // See also: reasoning.effort → reasoning_effort is skipped in request translator + _ = delta // tool calls if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() { @@ -717,26 +698,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Build output list from choices[...] outputsWrapper := []byte(`{"arr":[]}`) - // Detect and capture reasoning content if present - rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String() - includeReasoning := rcText != "" - if !includeReasoning && len(requestRawJSON) > 0 { - includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists() - } - if includeReasoning { - rid := id - 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 != "" { - reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text") - reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText) - } - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", reasoningItem) - } + // NOTE: reasoning_content is intentionally skipped for DeepSeek compatibility. + _ = rawJSON + _ = requestRawJSON if choices := root.Get("choices"); choices.Exists() && choices.IsArray() { choices.ForEach(func(_, choice gjson.Result) bool { From e193a0075d1f23e504bc7f4c0d0ad6879473ea3a Mon Sep 17 00:00:00 2001 From: dage <1651055684@qq.com> Date: Thu, 30 Apr 2026 16:30:00 +0800 Subject: [PATCH 2/4] fix(openai/responses): universal reasoning_content round-trip support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace DeepSeek-specific thinking mode handling with a universal approach that works across all models (DeepSeek, MiMo, etc.): Response direction (Chat Completions → Responses): - reasoning_content → type: "reasoning" output item with summary text - reasoning_content → reasoning_text content part in message content for round-trip echo-back - Both streaming and non-streaming paths supported Request direction (Responses → Chat Completions): - reasoning_text in message content → reasoning_content on assistant msg - type: "reasoning" input item summary → reasoning_content on assistant msg Reasoning parameter handling: - reasoning: {effort: "..."} → reasoning_effort: "..." - reasoning: null or absent → thinking: {type: "disabled"} (prevents DeepSeek default thinking mode which would require echo-back) Co-Authored-By: Claude Opus 4.7 --- docs/reasoning-content-roundtrip.md | 143 +++++++++++++ .../openai_openai-responses_request.go | 202 +++++++++++++----- .../openai_openai-responses_response.go | 130 +++++++++-- 3 files changed, 402 insertions(+), 73 deletions(-) create mode 100644 docs/reasoning-content-roundtrip.md 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 b0b2ed585ff..4911e363732 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -2,6 +2,7 @@ package responses import ( "fmt" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -57,16 +58,28 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { - // Collect consecutive function_call items so they can be grouped into - // a single assistant message with multiple tool_calls (required by Chat - // Completions format: one assistant message with all tool_calls, followed - // by one tool message per tool_call_id). + // 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 - flushFunctionCalls := func() { + flushToolGroup := func() { if len(pendingFunctionCalls) == 0 { return } + // 1. Emit one assistant message with all accumulated tool_calls assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) for i, fc := range pendingFunctionCalls { toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -82,38 +95,40 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu assistantMessage, _ = sjson.SetRawBytes(assistantMessage, fmt.Sprintf("tool_calls.%d", i), toolCall) } out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) - pendingFunctionCalls = nil - } - input.ForEach(func(_, item gjson.Result) bool { - itemType := item.Get("type").String() - if itemType == "" && item.Get("role").String() != "" { - itemType = "message" + // 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) } - switch itemType { - case "message", "": - // Flush any pending function_calls before a new message - flushFunctionCalls() - - // Handle regular message conversion - role := item.Get("role").String() + // 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() @@ -125,50 +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": - // Collect consecutive function_calls into a single group - pendingFunctionCalls = append(pendingFunctionCalls, item) + // Reset all buffers + pendingFunctionCalls = nil + pendingToolOutputs = nil + bufferedMessages = nil + } - case "function_call_output": - // Flush pending function_calls before a tool response - flushFunctionCalls() + input.ForEach(func(_, item gjson.Result) bool { + itemType := item.Get("type").String() + if itemType == "" && item.Get("role").String() != "" { + itemType = "message" + } - // Handle function call output conversion to tool message - toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + 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) - if callId := item.Get("call_id"); callId.Exists() { - toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String()) + if role == "assistant" && pendingReasoningContent != "" { + message, _ = sjson.SetBytes(message, "reasoning_content", pendingReasoningContent) + pendingReasoningContent = "" + } + + 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()) + } + + 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 function_calls at end of array - flushFunctionCalls() + // Flush any remaining tool group at end of array + flushToolGroup() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") @@ -218,13 +297,32 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } } - // Disable thinking mode for DeepSeek compatibility. - // DeepSeek's deepseek-v4-flash model defaults to thinking mode, which returns - // reasoning_content in responses and requires it to be echoed back in subsequent - // requests. The proxy doesn't support echoing reasoning_content, so we disable - // thinking mode entirely. - // See: https://api-docs.deepseek.com/guides/thinking_mode - out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) + // 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 null or absent, we explicitly disable thinking mode + // via "thinking": {"type": "disabled"}. Without this, some providers + // (notably DeepSeek V4) default to thinking mode and return + // reasoning_content, which they then require to be echoed back on every + // subsequent request in the conversation. Since Codex CLI (and most + // clients) does not include reasoning_text in follow-up request input, + // the echo-back would fail with "reasoning_content must be passed back". + // Disabling thinking by default avoids this requirement entirely. + if reasoning := root.Get("reasoning"); reasoning.Exists() { + effort := reasoning.Get("effort").String() + if effort != "" { + out, _ = sjson.SetBytes(out, "reasoning_effort", strings.ToLower(strings.TrimSpace(effort))) + } else { + // reasoning explicitly set but without effort (null or {}). + out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) + } + } else { + // reasoning not present — disable thinking to prevent echo-back requirement. + out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) + } // Convert tool_choice if present if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { 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 9035558b317..1da726efe66 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -152,9 +152,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 len(st.Reasonings) > 0 { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", st.Reasonings[len(st.Reasonings)-1].ReasoningData) + 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}) } } @@ -355,8 +363,9 @@ 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 != "" { + reasoningText = st.ReasoningBuf.String() stopReasoning(st.ReasoningBuf.String()) st.ReasoningBuf.Reset() } @@ -373,11 +382,22 @@ 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 } @@ -396,17 +416,41 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgTextBuf[idx].WriteString(c.String()) } - // NOTE: reasoning_content is intentionally skipped because: - // 1. DeepSeek returns reasoning_content for deepseek-v4-flash model - // 2. DeepSeek requires reasoning_content to be echoed back in subsequent requests - // 3. The proxy doesn't support echoing reasoning_content back to DeepSeek - // 4. Dropping reasoning_content from the response avoids the echo requirement - // See also: reasoning.effort → reasoning_effort is skipped in request translator - _ = delta + // 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 == "" { + st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx) + st.ReasoningIndex = allocOutputIndex() + item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`) + item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) + item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID) + out = append(out, emitRespEvent("response.output_item.added", item)) + part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) + part, _ = sjson.SetBytes(part, "sequence_number", nextSeq()) + part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID) + part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex) + out = append(out, emitRespEvent("response.reasoning_summary_part.added", part)) + } + // Append incremental text to reasoning buffer + st.ReasoningBuf.WriteString(rc.String()) + msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`) + msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) + msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID) + msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex) + msg, _ = sjson.SetBytes(msg, "delta", rc.String()) + out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg)) + } // tool calls if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() { + var reasoningText string if st.ReasoningID != "" { + reasoningText = st.ReasoningBuf.String() stopReasoning(st.ReasoningBuf.String()) st.ReasoningBuf.Reset() } @@ -434,11 +478,20 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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 } @@ -533,11 +586,21 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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 len(st.Reasonings) > 0 { + rp := []byte(`{"type":"reasoning_text","text":""}`) + rp, _ = sjson.SetBytes(rp, "text", st.Reasonings[len(st.Reasonings)-1].ReasoningData) + 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 } @@ -601,7 +664,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 @@ -698,9 +761,26 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Build output list from choices[...] outputsWrapper := []byte(`{"arr":[]}`) - // NOTE: reasoning_content is intentionally skipped for DeepSeek compatibility. - _ = rawJSON - _ = requestRawJSON + + // 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 { + includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists() + } + if includeReasoning { + rid := id + if strings.HasPrefix(rid, "resp_") { + rid = strings.TrimPrefix(rid, "resp_") + } + reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) + reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid)) + if rcText != "" { + reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text") + reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText) + } + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", reasoningItem) + } if choices := root.Get("choices"); choices.Exists() && choices.IsArray() { choices.ForEach(func(_, choice gjson.Result) bool { @@ -708,9 +788,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) } From 2cd94d2dae2977b7596132cb4bdb8647bfeb0a5b Mon Sep 17 00:00:00 2001 From: dage <1651055684@qq.com> Date: Fri, 1 May 2026 10:27:12 +0800 Subject: [PATCH 3/4] fix(openai/responses): address review feedback on reasoning round-trip - Gate the non-standard "thinking" parameter to DeepSeek only (prevents 400 errors on OpenAI and other providers) - Fix reasoning content matched to correct choice index (not always last) - Track output_text content_index per choice after reasoning_text insertion - Flush tool outputs even when no pending function_calls exist (tool-only follow-up requests) - Add findReasoningByChoiceIndex helper for correct lookups Co-Authored-By: Claude Opus 4.7 --- .../openai_openai-responses_request.go | 28 +++++----- .../openai_openai-responses_response.go | 51 ++++++++++++++----- 2 files changed, 51 insertions(+), 28 deletions(-) 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 4911e363732..13eef535892 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -76,10 +76,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu var pendingReasoningContent string flushToolGroup := func() { - if len(pendingFunctionCalls) == 0 { + if len(pendingFunctionCalls) == 0 && len(pendingToolOutputs) == 0 { return } - // 1. Emit one assistant message with all accumulated tool_calls + // 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":""}}`) @@ -303,24 +303,24 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // map it to the Chat Completions reasoning_effort field — this enables // thinking mode on models that support it. // - // When reasoning is null or absent, we explicitly disable thinking mode - // via "thinking": {"type": "disabled"}. Without this, some providers - // (notably DeepSeek V4) default to thinking mode and return - // reasoning_content, which they then require to be echoed back on every - // subsequent request in the conversation. Since Codex CLI (and most - // clients) does not include reasoning_text in follow-up request input, - // the echo-back would fail with "reasoning_content must be passed back". - // Disabling thinking by default avoids this requirement entirely. + // The non-standard "thinking" parameter is only injected for DeepSeek + // models, since other providers (e.g. OpenAI) would reject it with a 400 error. + // When reasoning is absent and the model is DeepSeek, we disable thinking + // to prevent DeepSeek's default thinking mode from producing reasoning_content + // that would then require echo-back on subsequent requests. if reasoning := root.Get("reasoning"); reasoning.Exists() { effort := reasoning.Get("effort").String() if effort != "" { out, _ = sjson.SetBytes(out, "reasoning_effort", strings.ToLower(strings.TrimSpace(effort))) - } else { - // reasoning explicitly set but without effort (null or {}). + } else if strings.Contains(strings.ToLower(modelName), "deepseek") { + // reasoning explicitly set but without effort — disable thinking for + // DeepSeek to prevent default thinking mode. Other providers don't + // support the non-standard "thinking" field. out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) } - } else { - // reasoning not present — disable thinking to prevent echo-back requirement. + } else if strings.Contains(strings.ToLower(modelName), "deepseek") { + // reasoning absent — disable thinking for DeepSeek to prevent its default + // thinking mode from producing reasoning_content that requires echo-back. out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) } 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 1da726efe66..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 @@ -155,9 +157,9 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte item := []byte(`{"id":"","type":"message","status":"completed","content":[],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) // Insert reasoning_text content part at index 0 if reasoning data is available - if len(st.Reasonings) > 0 { + if reasoningText := findReasoningByChoiceIndex(st.Reasonings, i); reasoningText != "" { rp := []byte(`{"type":"reasoning_text","text":""}`) - rp, _ = sjson.SetBytes(rp, "text", st.Reasonings[len(st.Reasonings)-1].ReasoningData) + rp, _ = sjson.SetBytes(rp, "text", reasoningText) item, _ = sjson.SetRawBytes(item, "content.-1", rp) } op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) @@ -220,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), } @@ -307,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 @@ -331,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()) @@ -352,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 = "" } @@ -366,9 +370,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, var reasoningText string if st.ReasoningID != "" { reasoningText = st.ReasoningBuf.String() - stopReasoning(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() } @@ -400,13 +408,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, 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 @@ -451,9 +460,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, var reasoningText string if st.ReasoningID != "" { reasoningText = st.ReasoningBuf.String() - stopReasoning(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] { @@ -466,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)) @@ -474,7 +487,7 @@ 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)) @@ -574,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)) @@ -582,15 +595,15 @@ 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)) // Build content array with reasoning_text if available contentArr := []byte(`[]`) - if len(st.Reasonings) > 0 { + if reasoningText := findReasoningByChoiceIndex(st.Reasonings, i); reasoningText != "" { rp := []byte(`{"type":"reasoning_text","text":""}`) - rp, _ = sjson.SetBytes(rp, "text", st.Reasonings[len(st.Reasonings)-1].ReasoningData) + rp, _ = sjson.SetBytes(rp, "text", reasoningText) contentArr, _ = sjson.SetRawBytes(contentArr, "-1", rp) } op := []byte(`{"type":"output_text","annotations":[],"logprobs":[],"text":""}`) @@ -608,7 +621,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } if st.ReasoningID != "" { - stopReasoning(st.ReasoningBuf.String()) + stopReasoning(st.ReasoningBuf.String(), idx) st.ReasoningBuf.Reset() } @@ -847,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 "" +} From bdd64837eb84fdf535c2d619d273dee3a416bc1a Mon Sep 17 00:00:00 2001 From: dage <1651055684@qq.com> Date: Fri, 1 May 2026 14:43:18 +0800 Subject: [PATCH 4/4] fix(openai/responses): always disable thinking mode when reasoning absent Remove the model-name gating on `thinking: {type: "disabled"}` since the model name in the request may be an alias (e.g. gpt-5.4) that does not contain "deepseek", yet still routes to DeepSeek upstream. Without this, DeepSeek enters default thinking mode, returns reasoning_content, and requires echo-back on follow-up requests. Co-Authored-By: Claude Opus 4.7 --- .../openai_openai-responses_request.go | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) 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 13eef535892..72af1dfc24a 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -303,24 +303,21 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // map it to the Chat Completions reasoning_effort field — this enables // thinking mode on models that support it. // - // The non-standard "thinking" parameter is only injected for DeepSeek - // models, since other providers (e.g. OpenAI) would reject it with a 400 error. - // When reasoning is absent and the model is DeepSeek, we disable thinking - // to prevent DeepSeek's default thinking mode from producing reasoning_content - // that would then require echo-back on subsequent requests. + // 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", strings.ToLower(strings.TrimSpace(effort))) - } else if strings.Contains(strings.ToLower(modelName), "deepseek") { - // reasoning explicitly set but without effort — disable thinking for - // DeepSeek to prevent default thinking mode. Other providers don't - // support the non-standard "thinking" field. + } else { out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) } - } else if strings.Contains(strings.ToLower(modelName), "deepseek") { - // reasoning absent — disable thinking for DeepSeek to prevent its default - // thinking mode from producing reasoning_content that requires echo-back. + } else { out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"}) }