Skip to content

fix codex(openai/responses): universal reasoning_content round-trip support:eq:deepseak - #3159

Open
OmMaBaMiHong wants to merge 5 commits into
router-for-me:mainfrom
OmMaBaMiHong:fix/deepseek-thinking-compat
Open

fix codex(openai/responses): universal reasoning_content round-trip support:eq:deepseak#3159
OmMaBaMiHong wants to merge 5 commits into
router-for-me:mainfrom
OmMaBaMiHong:fix/deepseek-thinking-compat

Conversation

@OmMaBaMiHong

Copy link
Copy Markdown

Summary

fix codex新版user+response强制转换不兼容deepseak等三方模型问题

  • 响应方向: 所有模型返回的 reasoning_content 统一转换为 type: "reasoning" output item + message content 中的 type: "reasoning_text" 内容片段
  • 请求方向: 从 reasoning_texttype: "reasoning" summary 中提取推理文本,注入 assistant message 的 reasoning_content 字段
  • 思考模式控制: 客户端不传 reasoning.effort 时 → thinking: disabled(防止 DeepSeek 默认思考模式产生 echo-back 要求);传了 → 映射 reasoning_effort
  • tool_calls 三缓冲: 修复 function_call 与 developer message 交错时的顺序问题

验证

  • 非流式响应: message content 包含 reasoning_text
  • 流式响应: SSE 事件包含 reasoning_text content part
  • reasoning: null: 不返回 reasoning_content,无 echo-back 要求
  • reasoning: {effort: "high"}: 正确启用思考模式
  • go vet / go build 错误

OmMaBaMiHong and others added 3 commits April 30, 2026 12:44
… DeepSeek compat

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a "Reasoning Content Round-Trip" mechanism to ensure that reasoning and thinking content are correctly preserved when translating between the OpenAI Responses API and Chat Completions. Key changes include a new group-buffering approach for tool calls to satisfy strict ordering requirements, the extraction of reasoning text from input items to inject into upstream requests, and the inclusion of reasoning content in both streaming and non-streaming responses. Feedback from the review indicates a potential issue where the non-standard 'thinking' parameter might cause 400 errors on providers like OpenAI, suggesting it should be restricted to DeepSeek models. Additionally, there is a logic error in the response handler where reasoning content is not correctly matched to specific message choices when multiple outputs are generated.

Comment on lines +322 to +324
} else {
// reasoning not present — disable thinking to prevent echo-back requirement.
out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Unconditionally adding the thinking parameter when reasoning is absent will cause 400 Bad Request errors on providers that do not support this non-standard field (e.g., the official OpenAI API). This field is specific to DeepSeek and should only be injected when the target model or provider is known to support it.

Suggested change
} else {
// reasoning not present — disable thinking to prevent echo-back requirement.
out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"})
} else if strings.Contains(strings.ToLower(modelName), "deepseek") {
// reasoning not present — disable thinking to prevent echo-back requirement for DeepSeek.
out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"})
}

Comment on lines +158 to +162
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using st.Reasonings[len(st.Reasonings)-1] always picks the last reasoning item for every message in the response. If the response contains multiple choices (n > 1), all assistant messages will incorrectly receive the reasoning content of the last choice. The reasoning should be matched to the specific message by its choice index.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5939974123

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +79 to +80
if len(pendingFunctionCalls) == 0 {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve tool outputs when no function_call items are present

The new buffering path can drop function_call_output items in follow-up requests that send only tool results (for example with previous_response_id). function_call_output entries are buffered, but flushToolGroup returns early when there are no pending function_call items, so no role:"tool" message is emitted and upstream never receives the tool output.

Useful? React with 👍 / 👎.

Comment on lines +400 to 401
part, _ = sjson.SetBytes(part, "content_index", nextContentIdx)
out = append(out, emitRespEvent("response.content_part.added", part))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep output_text indexes consistent after adding reasoning_text

After reasoning text is inserted, this branch can publish the output_text content part at index 1, but downstream response.output_text.delta / response.output_text.done events in the same function still use content_index: 0. In streams that include reasoning, that index mismatch can cause clients to associate text deltas with the wrong content part.

Useful? React with 👍 / 👎.

Comment on lines +323 to +324
// reasoning not present — disable thinking to prevent echo-back requirement.
out, _ = sjson.SetBytes(out, "thinking", map[string]interface{}{"type": "disabled"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate thinking=disabled to providers that support this field

This now injects thinking: {"type":"disabled"} for every request where reasoning is absent, regardless of upstream model/provider. Because this translator handles generic OpenAI-chat-completions targets, strict providers can reject requests with an unknown thinking field, turning normal non-reasoning calls into 400s.

Useful? React with 👍 / 👎.

- 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 <noreply@anthropic.com>
@OmMaBaMiHong

Copy link
Copy Markdown
Author

已根据 review 反馈修复,具体改动:

  1. thinking=disabled 仅限 DeepSeek ✅ — 现在只有 deepseek 模型会注入此参数,其他 provider 不再受影响(避免 400 错误)
  2. reasoning_content 按 choice index 匹配 ✅ — 添加了 ChoiceIndex 字段和 findReasoningByChoiceIndex 辅助函数,不再总是取最后一个 reasoning
  3. tool-only follow-up 请求 ✅ — flushToolGroup 现在会正确处理没有 function_calls 但有 tool outputs 的情况
  4. output_text content_index 一致性 ✅ — 添加了 MsgTextContentIdx 追踪每个 choice 的 output_text 索引,reasoning_text 存在时正确使用 index 1

commit: 0b750cf5

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2cd94d2dae

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +79 to +83
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":[]}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip synthetic assistant message for output-only tool groups

When a request contains only function_call_output items (for example a follow-up turn using previous_response_id), this branch still builds an assistant message with tool_calls: [] before emitting tool messages. That produces an invalid chat-completions sequence because the tool messages no longer follow a real tool call, and strict upstreams can reject the request; in this case the converter should emit only role:"tool" messages when no function calls are buffered.

Useful? React with 👍 / 👎.

Comment on lines 371 to 374
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
reasoningText = st.ReasoningBuf.String()
stopReasoning(st.ReasoningBuf.String(), idx)
st.ReasoningBuf.Reset()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep reasoning bound to the originating choice index

Reasoning state is global, but stopReasoning is keyed by the current idx, so in multi-choice streams reasoning from one choice can be attached to another choice if their chunks are interleaved (e.g., choice 0 sends reasoning_content and choice 1 sends content next). That mis-association makes reasoning_text and completed output items reference the wrong assistant message for n>1 responses.

Useful? React with 👍 / 👎.

…sent

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 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bdd64837eb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 779 to 780
rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String()
includeReasoning := rcText != ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use per-choice reasoning_content in non-stream conversion

In ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream, reasoning text is read once from choices.0.message.reasoning_content and then reused while iterating all choices, so n>1 responses can mis-attach choice 0's reasoning to other choices or drop reasoning returned only on non-zero choices. This produces incorrect reasoning/reasoning_text output associations for multi-choice non-stream calls.

Useful? React with 👍 / 👎.

@luispater luispater left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR improves OpenAI Responses → Chat Completions conversion (for clients sending Responses-style payloads to /v1/chat/completions) by:

  • Buffering function_call / function_call_output so Chat Completions ordering stays valid even with interleaved messages.
  • Adding reasoning content round-trip plumbing (reasoning_textreasoning_content) and Responses-side reasoning output emission (stream + non-stream).

Blocking

  1. Unconditional thinking: {"type":"disabled"} injection + incorrect “retry layer” assumption
  • When reasoning is absent (or present without effort), the converter injects thinking: {"type":"disabled"}.
  • This is a non-standard Chat Completions parameter and will likely produce HTTP 400 on providers that do not recognize it.
  • The comment says OpenAI “will return a 400, which is caught and handled by the retry layer”, but sdk/cliproxy/auth/conductor.go:isRequestInvalidError treats many 400 invalid_request_error cases as non-retryable request-shape failures.
  • Please either gate thinking injection by upstream/provider capability, or implement a safe one-shot fallback that retries without thinking only when the error indicates unsupported parameters.
  1. Tool-only follow-up handling can emit invalid Chat Completions history
  • flushToolGroup() can emit an assistant message with empty tool_calls: [] and then emit role:"tool" messages when only function_call_output items are present.
  • For strict OpenAI-compatible Chat Completions, tool messages must correspond to a preceding assistant tool_calls message with matching IDs; an empty tool_calls array is typically rejected.
  • If tool-only follow-ups are a real supported scenario, please add a strategy to reconstruct missing tool_calls (or avoid emitting invalid sequences) plus a regression test.
  1. Docs language mismatch
  • docs/reasoning-content-roundtrip.md is newly added but mostly Chinese. Repo guidance indicates new Markdown docs should be English.

Non-blocking

  • Non-streaming reasoning extraction uses choices.0.message.reasoning_content only; consider guarding/documenting the single-choice assumption.
  • Consider adding focused tests for streaming reasoning_content interleaved with content and tool_calls.

Verification (per PR notes)

  • go test ./internal/translator/openai/openai/responses -count=1
  • go build -o test-output ./cmd/server && rm -f test-output

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants