feat(responses): translate legacy <think> blocks into native reasoning field - #41
Draft
weselben wants to merge 10 commits into
Draft
feat(responses): translate legacy <think> blocks into native reasoning field#41weselben wants to merge 10 commits into
weselben wants to merge 10 commits into
Conversation
…g field Adds a response-path translation pass that detects <think>...</think> (and configured equivalents) embedded in model output and rewrites them into each API surface's native reasoning field. The translation runs on the canonical chat path; the OpenAI chat completions, OpenAI responses, and Anthropic messages dialect converters all surface ExtraFields["reasoning_content"] as their native reasoning field, so a single hook at the chat provider call covers all three surfaces. Default on; opt out per deployment with THINK_EXTRACT_ENABLED=false. Deliberately out of scope for this slice: - Native Responses API surface (responsesProviderCall path) — when the resolved provider speaks Responses natively rather than via chat, the Responses SSE conversion needs its own stream transformer - Responses reasoning.summary population — the source is text only, not a model-generated summary - Fast-path chat passthrough and the native Anthropic messages passthrough deliberately bypass translation today
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Fix chat.go: transformMessage returns true when content was rewritten even if the extracted reasoning is empty (empty-body think block). - Add tests covering: buffer-cap overflow inside think blocks, partial reasoning emitted across Feeds, Flush-at-DONE for unclosed blocks, early reader close on the SSE pipe, non-object delta in chunks, empty open tag guard, content parts with only unclosed text, and non-text-only content parts. - Document behaviour for nested think tags (first close terminates the outer block; the inner open stays inside the reasoning text). Package coverage: 85.5% -> 91.8%.
This was referenced Aug 26, 2026
Implements T8 (#43) and T10 (#45) from the wayfinder map. Per-surface opt-out: - New config keys: THINK_EXTRACT_CHAT_ENABLED, THINK_EXTRACT_MESSAGES_ENABLED. - New config struct fields: ChatEnabled, MessagesEnabled (*bool; nil falls back to the global THINK_EXTRACT_ENABLED). - The translation runs on a Surface identifier carried on the request context; dispatchChatCompletion tags chat, dispatchMessages tags messages. - Options.EnabledFor(surface) gates each extraction call; the chat + messages paths are independent so operators can disable one without affecting the other. Multi-pair tag recognition: - New Options.TagPairs []TagPair supersedes TagOpen/TagClose for new config. - DefaultTagPairs() returns the evidence-backed default list (T9 research, PR #41 ticket #44): <think>, <thinking>, <reasoning>, <reason>, <thought>, <|begin_of_thought|>, Kimi K2 \u25c1think\u25d2 brackets, Mistral Magistral [THINK], gpt-oss harmony markers. Granite's plain-English delimiters and ERNIE's <response> answer-wrapper are excluded by design. - ParseTagPairs(env-string) parses the THINK_EXTRACT_TAG_PAIRS env into a validated pair list, skipping malformed entries. - The streaming state machine iterates the pair list per chunk; the earliest configured open wins, and an unclosed block of any pair aborts the whole extraction conservatively. Tests: - Per-surface defaults, global-off, per-surface override, master-switch semantics (config + thinkextract). - DefaultTagPairs content; ParseTagPairs edge cases; alternate-tag extraction; earliest-open-wins across pairs; default list matching multiple kinds; unclosed alternate tag handling. Coverage: 91.8% -> 90.4% (added branches across more entry points).
…redacted) Implements T11 (#46) from the wayfinder map. The Anthropic messages surface now carries a three-way policy for reasoning synthesized from legacy think-block tags: - off (default): synthesized reasoning is dropped; the tags were already stripped from content at extraction time so no text is lost - unsigned: emit a thinking block with no signature (clients that validate signatures reject it; tolerant clients display the reasoning) - redacted: emit a redacted_thinking block carrying the text in Data (Anthropic's contract accepts these without a signature) Provider-native reasoning_content (no thinkextract marker) is never affected by the policy — it always renders as a thinking block, unchanged from pre-feature behaviour. Wire changes: - thinkextract.Options.MessagesPolicy replaces MessagesEnabled; the surface gate checks ParseMessagesPolicy != off, so the messages surface defaults to no extraction. - thinkextract.SynthesizedMarkerKey marks tag-extracted reasoning on the messages surface only; the Anthropic converters strip the marker before the wire, so it never reaches clients. - anthropicapi.FromChatResponseWithPolicy and NewStreamConverterWithPolicy apply the policy; the old names keep the off default so existing callers are unchanged. - ResponseContentBlock gains a Data field for redacted_thinking blocks. Config: THINK_EXTRACT_MESSAGES_POLICY env, think_extract.messages_policy yaml key. Replaces the earlier THINK_EXTRACT_MESSAGES_ENABLED bool (draft PR, unreleased). Tests cover all three policies in both the non-stream converter and the stream converter, native-reasoning passthrough under every policy, and marker non-leakage to the client wire.
Implements the non-stream half of T7 (#42). When the resolved provider speaks the OpenAI Responses API natively, output_text items carrying legacy think tags are rewritten: a reasoning output item (reasoning_text) is prepended in place before each rewritten message item, matching the shape BuildResponsesOutputItems produces for native reasoning providers. Wire: - Options.ResponsesEnabled + THINK_EXTRACT_RESPONSES_ENABLED env and think_extract.responses_enabled config key (per T8's per-surface opt-out design). - dispatchResponses tags the request context with SurfaceResponses. - responsesProviderCall transforms ResponsesResponse.Output when the responses surface is enabled. Out of scope for this commit: the native Responses SSE stream transformer (response.output_text.delta events) — the non-stream transform ships first; the stream variant follows.
Completes T7 (#42): the native Responses surface now covers both the non-stream response body and the SSE event stream. TransformResponsesStream wraps the native Responses SSE stream and rewrites response.output_text.delta events carrying legacy think tags into a synthesized reasoning item lifecycle: - response.output_item.added with type=reasoning, status=in_progress, empty summary (matches the gateway's native reasoning shape) - response.reasoning_text.delta events per reasoning chunk - response.output_item.done with status=completed and the full reasoning text in a single reasoning_text part The cleaned text delta continues on the original message item. A block that opens but never closes within the stream is forwarded verbatim so nothing is dropped; end-of-stream flushes any open reasoning item. Wire: streamResponsesProviderCall applies the transformer when the responses surface is enabled (THINK_EXTRACT_RESPONSES_ENABLED). Tests cover: full lifecycle synthesis, plain-text passthrough, non-delta events, reasoning-only items, empty deltas, malformed JSON forwarding, non-data lines, and unclosed-tag flush.
Adds tests for WithSurface/SurfaceFrom context plumbing and for markSynthesizedOnMessage with and without pre-existing ExtraFields. Package coverage: 87.9% -> 89.7%.
…aces Adds an end-to-end test that proves the orchestrator hooks actually fire when a provider returns a response or stream carrying think tags: - ChatCompletion: <think> extracted into reasoning_content on the response - ChatCompletion with chat surface disabled: no rewrite - StreamChatCompletion: stream rewritten to reasoning_content delta - Responses non-stream: reasoning item prepended on output - StreamResponses: reasoning item synthesized on the stream - Messages surface default-off (policy=off) leaves content untouched - Messages surface with unsigned policy: rewrite + marker set - Nil thinkExtractOptions: feature off, no rewrite All tests use the mock provider pattern from the gateway package and assert on the wire shape the client actually sees.
…lush - writeLine / writeEvent covered through io.Pipe with a closed reader - mustEncode happy path - TransformResponsesStream EOF-without-DONE flush and multi-block item reuse (single synthesized reasoning item per message item) Package coverage: 89.7% -> 90.7%. Remaining uncovered: json.Marshal fallbacks that cannot fire for map[string]any, and the reasoning-pending Flush branch that tryAdvance makes unreachable.
Fixes a gofmt-style glitch where the docstring was glued to the TransformResponsesStream function declaration without a newline. No functional change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Response-path translation of legacy
<think>...</think>(and configured equivalents) into each API surface's native reasoning field. The canonical chat response hook covers OpenAI chat completions and Anthropic messages because both dialect converters already surfaceExtraFields["reasoning_content"]as their native reasoning payload. The native OpenAI Responses API has its own stream transformer that synthesizes the full reasoning item lifecycle.What
A model that emits
<think>reasoning</think>answerreturns, after translation:message.reasoning_contentcarriesreasoning,message.contentcarriesanswer.thinkingcontent block whose thinking text isreasoning, plus atextcontent block whose text isanswer. Policy-gated: default off,unsigned, orredactedper config.reasoning, plus a normal message output item whose text isanswer.response.output_item.added(type=reasoning, status=in_progress, empty summary) →response.reasoning_text.deltaevents →response.output_item.done(status=completed, full reasoning in one reasoning_text part). The cleaned text delta continues on the original message item.Request bodies are never modified. Translation is lossless on the wire: no model-visible character is dropped. A
<think>block whose closing tag has not yet arrived is buffered and waits; a never-closed block is forwarded verbatim with no rewrite applied, so a chunk-boundary cut can never strand user content as reasoning.Why
Modern models (Kimi K3, Claude Fable 5, o-series, …) and Kimi Code-class harnesses expect reasoning in the structured field, not as XML inside the message text. A client that reads
reasoning_contentgets nothing from a model that emits<think>inline; a client that reads the text alone sees the XML tags as noise. The gateway translating on the response path is the only place that can reconcile both sides without touching the model or the client.How
Files to review (28, +~3300 / -~80):
internal/thinkextract/thinkextract.go(start here)internal/thinkextract/stream.gointernal/thinkextract/responses_stream.gointernal/thinkextract/responses.gointernal/thinkextract/chat.gointernal/thinkextract/surface.gointernal/thinkextract/*_test.goconfig/thinkextract.gointernal/anthropicapi/response.gointernal/anthropicapi/stream.gointernal/anthropicapi/policy_test.gointernal/gateway/inference_orchestrator.gointernal/gateway/inference_execute.gointernal/gateway/thinkextract_integration_test.gointernal/server/translated_inference_service.gointernal/server/messages_handler.gointernal/server/handlers.gointernal/server/http.gointernal/app/app.goconfig/config.goThe single canonical-chat hook (line 402 and 432 of
inference_execute.go) covers chat and messages surfaces because both flow throughchatCompletionProviderCall/streamChatCompletionProviderCall; each dispatch function tags its request context with itsSurfaceidentifier so the gate knows which config flag to check. The native Responses path has its own hook atresponsesProviderCallandstreamResponsesProviderCall.Reviewer notes
off(default) drops synthesized reasoning,unsignedemits a thinking block with no signature,redactedemits a redacted_thinking block with the text inData. Provider-native reasoning is never affected.THINK_EXTRACT_CHAT_ENABLED,THINK_EXTRACT_RESPONSES_ENABLED,THINK_EXTRACT_MESSAGES_POLICY(off/unsigned/redacted). Global off (THINK_EXTRACT_ENABLED=false) is authoritative — a per-surface true cannot resurrect it.<think>,<thinking>,<reasoning>,<reason>,<thought>,<|begin_of_thought|>, Kimi K2◁think▷brackets, Mistral Magistral[THINK], gpt-oss harmony markers.<reflection>and the<|think|>/<|reasoning|>pipe forms are refuted by primary sources and excluded. Custom pairs viaTHINK_EXTRACT_TAG_PAIRS(env) orthink_extract.tag_pairs(config).reasoning_contenton the delta or message, the translator leaves it alone — no stomping of structured data with extracted text.thinkextract_synthesizedis set on the messages surface only; the Anthropic converters strip it before the wire, so it never reaches clients. Chat-surface responses never carry it.Deliberately out of scope
thinkingblocks fromExtraFields["reasoning_content"]today (pre-existing). The signature policy is tracked in #46 (closed, off/unsigned/redacted).reasoning.summarypopulation. The source is raw text only, not a model-generated summary; a summary would require an LLM call. The synthesized reasoning item always carries an empty summary array.tryFastPathStreamingChatPassthrough) and the native Anthropic messages passthrough (dispatchMessagesNative) deliberately bypass translation today and are documented as excluded.Tests
Coverage gaps:
json.Marshalfallbacks that cannot fire formap[string]any,pipe-write error branches that need a closed reader to reach, and a reasoning-pending
Flushbranch thattryAdvancemakes structurally unreachable.AI disclosure
Generated by an AI assistant (kimi-code CLI) under human direction. Reviewed and committed by the repository owner.
Wayfinder
Map: #34. All tickets resolved: #35, #36, #37, #38, #39, #40 (awaiting user verdict on hook-point), #42, #43, #44, #45, #46.