diff --git a/changelog/unreleased/codex-lite-reasoning-context.md b/changelog/unreleased/codex-lite-reasoning-context.md new file mode 100644 index 00000000..6d5cf0ab --- /dev/null +++ b/changelog/unreleased/codex-lite-reasoning-context.md @@ -0,0 +1,7 @@ +### English + +- Codex Responses-Lite models (such as GPT-5.6) now send `reasoning.context: "all_turns"`, fixing the upstream 400 "requires reasoning.context to be all_turns". + +### 中文 + +- Codex Responses-Lite 模型(如 GPT-5.6)现在会发送 `reasoning.context: "all_turns"`,修复上游返回 400 “requires reasoning.context to be all_turns” 的问题。 diff --git a/internal/providers/codex/responses_lite_test.go b/internal/providers/codex/responses_lite_test.go new file mode 100644 index 00000000..4834a6e2 --- /dev/null +++ b/internal/providers/codex/responses_lite_test.go @@ -0,0 +1,44 @@ +package codex + +import ( + "encoding/json" + "testing" +) + +// Regression for #300: requests sent under the Responses-Lite header must +// carry reasoning.context=all_turns, or the backend rejects them with 400. +func TestNormalizeCodexUpstreamLiteForcesReasoningContext(t *testing.T) { + for name, raw := range map[string]string{ + "missing": "", + "effort": `{"effort":"high","summary":"auto"}`, + "override": `{"context":"current_turn"}`, + } { + t.Run(name, func(t *testing.T) { + fields := map[string]json.RawMessage{"input": json.RawMessage(`[]`)} + if raw != "" { + fields["reasoning"] = json.RawMessage(raw) + } + if err := normalizeCodexUpstream(fields, "gpt-5.6-sol", true, ""); err != nil { + t.Fatal(err) + } + var reasoning map[string]string + if err := json.Unmarshal(fields["reasoning"], &reasoning); err != nil { + t.Fatalf("reasoning %s: %v", fields["reasoning"], err) + } + if reasoning["context"] != "all_turns" || string(fields["parallel_tool_calls"]) != "false" { + t.Fatalf("reasoning=%s parallel_tool_calls=%s", fields["reasoning"], fields["parallel_tool_calls"]) + } + if name == "effort" && (reasoning["effort"] != "high" || reasoning["summary"] != "auto") { + t.Fatalf("existing members lost: %s", fields["reasoning"]) + } + }) + } + + fields := map[string]json.RawMessage{"input": json.RawMessage(`[]`)} + if err := normalizeCodexUpstream(fields, "gpt-5.5", false, ""); err != nil { + t.Fatal(err) + } + if _, ok := fields["reasoning"]; ok { + t.Fatalf("non-lite request gained reasoning: %s", fields["reasoning"]) + } +} diff --git a/internal/providers/codex/upstream.go b/internal/providers/codex/upstream.go index 8df8c570..110a01e1 100644 --- a/internal/providers/codex/upstream.go +++ b/internal/providers/codex/upstream.go @@ -110,6 +110,12 @@ func normalizeCodexUpstream(fields map[string]json.RawMessage, model string, lit // except on lite where the backend still expects the explicit false. if lite { fields["parallel_tool_calls"] = json.RawMessage(`false`) + // The lite header also requires reasoning.context=all_turns (#300). + reasoning, err := withReasoningContextAllTurns(fields["reasoning"]) + if err != nil { + return err + } + fields["reasoning"] = reasoning } else if !emptyJSONArray(fields["tools"]) { fields["parallel_tool_calls"] = json.RawMessage(`true`) } else { @@ -122,6 +128,19 @@ func normalizeCodexUpstream(fields map[string]json.RawMessage, model string, lit return nil } +// withReasoningContextAllTurns sets reasoning.context to all_turns and keeps +// every other reasoning member as sent. +func withReasoningContextAllTurns(raw json.RawMessage) (json.RawMessage, error) { + members := map[string]json.RawMessage{} + if !isNullJSON(raw) { + if err := json.Unmarshal(raw, &members); err != nil { + return nil, err + } + } + members["context"] = json.RawMessage(`"all_turns"`) + return json.Marshal(members) +} + // normalizeServiceTier keeps only the tiers the ChatGPT Codex backend accepts. // "fast" is the client spelling of "priority" and is rewritten; anything else // is removed rather than forwarded.