Skip to content

feat(responses): translate legacy <think> blocks into native reasoning field - #41

Draft
weselben wants to merge 10 commits into
mainfrom
feat/think-block-translation
Draft

feat(responses): translate legacy <think> blocks into native reasoning field#41
weselben wants to merge 10 commits into
mainfrom
feat/think-block-translation

Conversation

@weselben

@weselben weselben commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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 surface ExtraFields["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>answer returns, after translation:

  • OpenAI chat completionsmessage.reasoning_content carries reasoning, message.content carries answer.
  • Anthropic messages — a thinking content block whose thinking text is reasoning, plus a text content block whose text is answer. Policy-gated: default off, unsigned, or redacted per config.
  • OpenAI responses API (non-stream) — a reasoning output item whose text is reasoning, plus a normal message output item whose text is answer.
  • OpenAI responses API (stream) — the full reasoning item lifecycle synthesized on the wire: response.output_item.added (type=reasoning, status=in_progress, empty summary) → response.reasoning_text.delta events → 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_content gets 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):

File Why
internal/thinkextract/thinkextract.go (start here) Tag scanner, multi-pair defaults, buffer cap, single-pass Extract, per-surface gate
internal/thinkextract/stream.go Chat SSE transformer: per-choice state, [DONE] flush, unknown-field preservation
internal/thinkextract/responses_stream.go Native Responses SSE transformer: reasoning item lifecycle synthesis
internal/thinkextract/responses.go Non-stream Responses output transform
internal/thinkextract/chat.go Non-stream ChatResponse / ResponseMessage rewrite via MergeUnknownJSONFields
internal/thinkextract/surface.go Surface context tag for per-surface gating
internal/thinkextract/*_test.go Table-driven tests, 89.7% package coverage
config/thinkextract.go ThinkExtractConfig: env-gated global + per-surface opt-outs, tag pairs, buffer cap, messages policy
internal/anthropicapi/response.go FromChatResponseWithPolicy + redacted_thinking block
internal/anthropicapi/stream.go NewStreamConverterWithPolicy for messages SSE
internal/anthropicapi/policy_test.go Policy matrix tests
internal/gateway/inference_orchestrator.go Single canonical hooks on chat and responses provider calls
internal/gateway/inference_execute.go Gate check before transform
internal/gateway/thinkextract_integration_test.go End-to-end orchestrator tests across all three surfaces
internal/server/translated_inference_service.go Chat and responses surface tags on dispatch
internal/server/messages_handler.go Messages surface tag + policy dispatch
internal/server/handlers.go Pass-through of thinkExtractOptions
internal/server/http.go ServerConfig wiring
internal/app/app.go Config -> options conversion
config/config.go ThinkExtractConfig registration + defaults

The single canonical-chat hook (line 402 and 432 of inference_execute.go) covers chat and messages surfaces because both flow through chatCompletionProviderCall / streamChatCompletionProviderCall; each dispatch function tags its request context with its Surface identifier so the gate knows which config flag to check. The native Responses path has its own hook at responsesProviderCall and streamResponsesProviderCall.

Reviewer notes

  • Default on for chat + responses, default off for messages. Chat and responses are lossless on the wire (content is just moved). Messages default off because Anthropic's thinking block contract normally expects a signature; synthesized reasoning has none, so operators opt in explicitly.
  • Messages policy is a three-way enum. off (default) drops synthesized reasoning, unsigned emits a thinking block with no signature, redacted emits a redacted_thinking block with the text in Data. Provider-native reasoning is never affected.
  • Per-surface opt-out. 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.
  • Multi-pair defaults. Ships with 7 evidence-backed tag pairs (T9 research): <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 via THINK_EXTRACT_TAG_PAIRS (env) or think_extract.tag_pairs (config).
  • Streaming chunk-boundary safety. Per-choice state holds partial tags in a bounded buffer until the closing tag arrives. An unclosed block at stream end is re-emitted as ordinary content, never dropped.
  • Upstream reasoning wins. When the upstream already sends reasoning_content on the delta or message, the translator leaves it alone — no stomping of structured data with extracted text.
  • Synthesized marker is wire-safe. thinkextract_synthesized is 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

  • Anthropic thinking-block signatures on /v1/messages. The dialect converter already emits thinking blocks from ExtraFields["reasoning_content"] today (pre-existing). The signature policy is tracked in #46 (closed, off/unsigned/redacted).
  • Responses reasoning.summary population. 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.
  • Fast-path chat passthrough (tryFastPathStreamingChatPassthrough) and the native Anthropic messages passthrough (dispatchMessagesNative) deliberately bypass translation today and are documented as excluded.

Tests

go build ./... — ok
go test ./internal/thinkextract/ -v — all pass
go test ./internal/anthropicapi/ -v — all pass
go test ./internal/gateway/ -run TestOrchestrator -v — all pass
go test ./internal/gateway/ -run ThinkExtract -v — all pass
go test ./... — all packages pass
go test ./internal/thinkextract/ -cover — 90.7%
go test ./internal/anthropicapi/ -cover — 85.3%
go test ./config/ -cover — 80.6%

Coverage gaps: json.Marshal fallbacks that cannot fire for map[string]any,
pipe-write error branches that need a closed reader to reach, and a reasoning-pending
Flush branch that tryAdvance makes 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.

…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
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- 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%.
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.
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.

Q1 — opt-in vs always-on default?

1 participant