fix(openai): render Responses reasoning as chat reasoning_content
TL;DR
When a chat request routes onto a Responses-only upstream (today: the chatgpt provider on the Codex backend), reasoning silently vanished: the gateway never asked for reasoning summaries, and neither chat-via-Responses converter rendered them. An Anthropic client on /v1/messages with thinking enabled got its answer but never a thinking block. This PR requests summaries when the caller asks for reasoning and renders them as reasoning_content on the chat response, which the dialect layers already turn into thinking.
Verified end to end against a fake Codex backend driving the full Messages → chat → Responses → chat → Messages chain (streaming and non-streaming), and live against the Codex backend from a running gateway.
Files to review (5, +329/-12):
| File | Why |
|---|---|
internal/providers/openai/chat_via_responses_stream.go (start here) | Non-streaming: reasoning item summaries become the message's reasoning_content. Streaming: response.reasoning_summary_text.delta becomes reasoning_content chunk deltas, with \n\n separators at summary-part and item boundaries. |
internal/providers/openai/chat_via_responses.go | Requests reasoning.summary: "auto" whenever the caller asks for reasoning, so a summary exists to render. |
internal/core/types.go | Reasoning gains the optional Summary member. |
internal/providers/openai/chat_via_responses_test.go | +230 LOC: request mapping, converter, boundary, and empty-delta tests. |
docs/adr/0011-field-forwarding-on-translated-paths.md | Ladder rows for the new rendering rule and the gateway-set summary member. |
Request side: ask for the summary
The Responses API emits reasoning summaries only when reasoning.summary is set; chatToResponsesRequest sent only effort. Without the ask, the whole render path downstream has nothing to work with — this was the major CodeRabbit finding. applyMappedChatFields now sets summary: "auto" whenever the request carries a reasoning effort that is not off ("none" keeps just the effort; the model is told not to reason, so there is nothing to summarize). "auto" picks the model's own summarizer rather than pinning a verbosity.
The summary member rides the new optional Summary field on core.Reasoning instead of an untyped extra, so the serialized request carries exactly one reasoning member and the field stays discoverable for the other Responses-speaking adapters.
Response side: render what comes back
Non-streaming, chatResponseFromResponses collects each reasoning item's summary_text parts and merges them as the chat message's reasoning_content extension — the spelling every other adapter emits (internal/providers/openai/reasoning.go, DeepSeek, Fireworks) and the one the Responses and Messages translation layers render as thinking. Items and parts join with \n\n; empty items and empty parts contribute nothing.
Streaming, responsesChatStream maps response.reasoning_summary_text.delta onto reasoning_content chunk deltas. Summary parts are distinct summaries, so an advancing summary_index emits the \n\n separator first — matching the non-streaming join byte-for-byte. A different reasoning item starting text also emits the separator, so two items never merge into a run-on. Empty deltas carry no text: they emit nothing and touch no tracked state, the same rule as the non-streaming filter. An omitted summary_index (a non-compliant upstream) forwards the delta as before, with no separator tracking. response.reasoning_text.delta (raw chain of thought, where offered) rides the same channel.
What this deliberately does not do
- No signature minting. Anthropic thinking signatures must come from Anthropic; reasoning that crossed a non-Anthropic upstream comes back as an unsigned thinking block. The ingress already treats unsigned blocks correctly (
HasUnsignedThinkingkeeps such turns on the translated pipeline instead of forwarding them to Anthropic, which would refuse the foreign signature). - No separate reasoning field.
reasoning_contentis reused rather than adding a second channel, so dashboard, Messages translation, and replay state all read one member. - No change for chat-upstream providers. Everything lives in the chat-via-Responses converters;
ChatCompletionagainst a native chat endpoint is untouched.
Tests
TestChatToResponsesRequest_ReasoningRequestsSummary: the serialized request carries{"effort": ..., "summary": "auto"}for nested and flat effort,"none"gets no summary, no reasoning gets no member, passthrough fields coexist, exactly onereasoningkey on the wire.TestChatResponseFromResponses_ReasoningBecomesReasoningContent: join order across items and parts, absent reasoning, empty items between non-empty ones, refusal coexistence.TestReasoningSummaryText: missing, null, non-array, empty-part, and multi-part summary shapes.TestResponsesChatStream_ReasoningDeltas: summary deltas becomereasoning_contentchunks; repeated deltas within one part concatenate; an advancing index emits the separator; rawreasoning_textdeltas ride along.TestResponsesChatStream_ReasoningDeltas_IndexEdges: per-item index state, second item gets the item separator, omitted index forwards untouched.TestResponsesChatStream_ReasoningDeltas_EmptyDeltas: empty deltas emit and advance nothing.
go build ./..., go vet ./..., golangci-lint (pinned version), and go test -race ./internal/... all pass.
Refs #1085. Built on the ChatViaResponses work from #1084; this closes the reasoning gap that issue's "Thinking and reasoning replay" bullet tracks.
Summary by CodeRabbit
- New Features
- Reasoning summaries from Responses-only models now appear as reasoning content in chat responses and streaming updates.
- Summary parts are separated by blank lines, and empty parts are omitted.
- Requests ask for an automatic reasoning summary when reasoning is enabled; no summary is requested when reasoning is off.
- Explicitly requested reasoning summaries are preserved when a reasoning-effort override is applied.
- Behavior Changes
- Echoed assistant reasoning content is omitted during request translation. User-supplied reasoning content remains unsupported.