feat(responses): translate legacy <think> blocks into native reasoning field - #41
Draft
weselben wants to merge 3 commits into
Draft
feat(responses): translate legacy <think> blocks into native reasoning field#41weselben wants to merge 3 commits into
weselben wants to merge 3 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).
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. OpenAI responses cascades via the sharedOpenAIResponsesStreamConverter.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.reasoning, plus a normal message output item whose text isanswer(via chat translation).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 (19, +2164 / -1):
internal/thinkextract/thinkextract.go(start here)internal/thinkextract/stream.gointernal/thinkextract/surface.gointernal/thinkextract/chat.gointernal/thinkextract/*_test.goconfig/thinkextract.goconfig/thinkextract_test.gointernal/gateway/inference_orchestrator.gointernal/gateway/inference_execute.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 both chat and messages surfaces because both flow throughchatCompletionProviderCall/streamChatCompletionProviderCall, and each dispatch function tags its request context with itsSurfaceidentifier so the gate knows which config flag to check.Reviewer notes
THINK_EXTRACT_ENABLED=false) or per-surface (THINK_EXTRACT_CHAT_ENABLED=false,THINK_EXTRACT_MESSAGES_ENABLED=false). The global switch is authoritative: a per-surface true cannot resurrect a globally-off feature.<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. Granite's plain-English delimiters and ERNIE's<response>answer-wrapper are excluded by design.THINK_EXTRACT_TAG_PAIRS(env) orthink_extract.tag_pairs(config) overrides the default list with a comma-separated<open>...</close>format. Malformed entries are skipped.reasoning_contenton the delta or message, the translator leaves it alone — no stomping of structured data with extracted text.Deliberately out of scope
responsesProviderCall) — when the resolved provider speaks Responses natively rather than via chat, the Responses SSE conversion needs its own stream transformer. Ticket #42 — native Responses stream transformer.thinkingblocks fromExtraFields["reasoning_content"]today (pre-existing behavior). The signature policy is tracked in #46 — /v1/messages thinking-block policy.reasoning.summarypopulation — the source is raw text only, not a model-generated summary; a summary would require an LLM call.tryFastPathStreamingChatPassthrough) and the native Anthropic messages passthrough (dispatchMessagesNative) deliberately bypass translation today and are documented as excluded.Tests
Coverage gaps: pipe-write error branches,
mustEncodeunreachable fallback,transformMessagenil-guard (all defensive code with no normal-path effect).AI disclosure
Generated by an AI assistant (kimi-code CLI) under human direction. Reviewed and committed by the repository owner.
Wayfinder
Map: #34. Resolved tickets: #35, #36, #37, #39, #43, #44, #45. Open: #40 (hook-point pushback, awaiting user), #42 (native Responses transformer), #46 (messages thinking-block policy).