From e74a24b0e333bdcd394bdfc42886a16858d86dd8 Mon Sep 17 00:00:00 2001 From: Joonsuh Park Date: Mon, 21 Sep 2026 19:14:58 +0900 Subject: [PATCH 1/5] fix(openai-chat): preserve empty thinking replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel Sjöstrand <16033062+Danielsjostrand1979@users.noreply.github.com> (cherry picked from commit 65ed520482aa241d8be97bc2fdfdca4874150be5) --- src/adapters/openai-chat/messages.ts | 10 ++--- structure/providers/chat-compat.md | 4 ++ .../deepseek-reasoning-replay-gaps.test.ts | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 9ad0dec86f8..a712c83158a 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -224,7 +224,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv let reasoningContent = thinkingParts.map(p => p.thinking).join(""); if ( reasoningContent.length === 0 - && toolCalls.length > 0 + && (toolCalls.length > 0 || thinkingParts.length > 0) && modelInList(provider.preserveReasoningContentModels, parsed.modelId) ) { const cached = toolCalls @@ -235,11 +235,11 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (cached.length > 0) { reasoningContent = [...new Set(cached)].join("\n"); } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) { - // Fallback (extends #950, closes #1193): the replay cache is + // Fallback (extends #950 and #1193; fixes #5421): the replay cache is // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on - // long sessions, and some tool rounds carry no recorded reasoning - // at all. DeepSeek thinking mode rejects ANY tool_call assistant - // message missing reasoning_content with HTTP 400, so inject a + // long sessions, and some thinking/tool rounds carry no recorded + // reasoning at all. DeepSeek thinking mode rejects replay without + // reasoning_content with HTTP 400, so inject a // minimal placeholder rather than emit a bare continuation the // upstream will reject. Scoped to requiresReasoningPlaceholderModels // (defaulting to the preserve list): preserve-listed providers with diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1c7465bde6e..3067863efe2 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -365,6 +365,10 @@ the desktop thinking band shows the "Thinking…" placeholder, and raw text appe which only fits native OpenAI providers that author real summaries. Diagnosis and codex-rs grouping evidence: `devlog/_fin/260709_native_response_pattern/`. +For models that require a reasoning placeholder, a preserved thinking-only assistant turn with no +plaintext receives that placeholder even when it has no tool call. Otherwise the Chat serializer +drops the turn and strict DeepSeek continuations can reject the following request (#5421). + The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the diff --git a/tests/providers/deepseek-reasoning-replay-gaps.test.ts b/tests/providers/deepseek-reasoning-replay-gaps.test.ts index 4e5c6ea3cae..2ccdd1e641f 100644 --- a/tests/providers/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/providers/deepseek-reasoning-replay-gaps.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { buildResponseJSON } from "../../src/bridge"; import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import { clearReasoningReplayCacheForTests, peekReasoningForCall as peekReasoningForCallRaw, @@ -129,6 +130,29 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(assistant!["reasoning_content"]).toBe(REASONING); }); + test("GAP F (issue #5421): signed thinking-only turn without tools gets a placeholder", () => { + const { messages } = wireFor([ + { + type: "reasoning", + id: "rs_empty", + summary: [], + encrypted_content: encodeReasoningEnvelope({ sig: "opaque-signature" }), + }, + { + type: "agent_message", + author: "parent", + recipient: "child", + content: [{ type: "input_text", text: "return OK" }], + }, + ]); + + const assistantIndex = messages.findIndex(message => message.role === "assistant"); + const taskIndex = messages.findIndex(message => message.role === "user" && message.content === "return OK"); + expect(assistantIndex).toBeGreaterThanOrEqual(0); + expect(assistantIndex).toBeLessThan(taskIndex); + expect(messages[assistantIndex]!["reasoning_content"]).toBe(" "); + }); + test("GAP A: reasoning item arriving AFTER its function_call is attached to its turn", () => { // Reconstructed histories (resume/retry/synthetic) may order the reasoning // item after the call it belongs to. The parser used to clear the pending @@ -287,6 +311,23 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) const miss = toolCallAssistant(missResult.wire.messages); expect(miss).toBeDefined(); expect(miss!["reasoning_content"]).toBeUndefined(); + // Signed thinking without plaintext also stays omitted for an explicit + // placeholder opt-out, even though the parser preserves the thinking turn. + const thinkingOnly = minimaxWire([ + { + type: "reasoning", + id: "rs_minimax_empty", + summary: [], + encrypted_content: encodeReasoningEnvelope({ sig: "opaque-signature" }), + }, + { + type: "agent_message", + author: "parent", + recipient: "child", + content: [{ type: "input_text", text: "return OK" }], + }, + ]).wire.messages; + expect(thinkingOnly.some(message => message.role === "assistant")).toBeFalse(); // Cache hit on the same path: the recorded reasoning still replays. rememberReasoningForCall("call_1", REASONING, missResult.replayScope); const hit = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()]).wire.messages); From 4ecd32bc05dc14d40f922026a6b28e5d4a65b43f Mon Sep 17 00:00:00 2001 From: alexph-dev Date: Sun, 20 Sep 2026 01:09:39 +0700 Subject: [PATCH 2/5] feat(openai-chat): recover inline reasoning behind inlineThinkTagModels A gateway that serves a thinking model without a server-side reasoning parser returns the chain of thought inside message.content as blocks and sends neither reasoning_content nor reasoning_details, so Codex renders the whole chain of thought as the answer. reasoning_split, reasoning.effort and chat_template_kwargs are ignored by such a gateway, so the recovery can only happen client side. Adds the opt-in provider option inlineThinkTagModels. Listed models have their think blocks split back into reasoning on both the streamed and non-streamed openai-chat paths. Off by default: 66 registry providers share this adapter and a gateway that does parse reasoning must keep its visible content byte-exact. Once enabled the splitter still engages only for a response that opens with a thinking tag, so an answer that merely mentions one is never rewritten; after it engages it keeps splitting later blocks, because M-series models interleave thinking with answer segments. An unterminated block flushes as reasoning rather than being lost. The parser is the existing Kiro thinking parser, generalized and renamed to src/adapters/inline-think-tags.ts with an interleaved option. Kiro keeps its single-block behavior and its byte accounting unchanged. The file-size baseline is raised only for src/adapters/openai-chat.ts. The two other offenders the ratchet reports are already present on dev and are untouched. (cherry picked from commit f95a0adee26afdfe28092f27a5a991152855e379) --- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/adapters/inline-think-tags.ts | 227 ++++++++++++++++++ src/adapters/kiro-thinking.ts | 112 --------- src/adapters/kiro/stream.ts | 4 +- src/adapters/openai-chat.ts | 16 +- src/providers/derive.ts | 4 + src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 4 +- src/providers/resolved-model-policy.ts | 4 +- src/router.ts | 2 + src/server/auth-cors.ts | 1 + src/types/provider.ts | 10 + structure/providers/chat-compat.md | 15 ++ .../openai-chat-inline-think-tags.test.ts | 118 +++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/providers/kiro/kiro-stream.test.ts | 4 +- 17 files changed, 403 insertions(+), 122 deletions(-) create mode 100644 src/adapters/inline-think-tags.ts delete mode 100644 src/adapters/kiro-thinking.ts create mode 100644 tests/adapters/openai/openai-chat-inline-think-tags.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c7535203297..2ce10017af7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -215,6 +215,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | +| `inlineThinkTagModels?` | `string[]` | `openai-chat` models served by a gateway that runs no server-side reasoning parser, so the model leaves its chain of thought inline in `content` as `` / `` / `` blocks and sends no `reasoning_content` or `reasoning_details`. Without this the whole chain of thought renders as the answer. Listed models have those blocks split back into reasoning on both the streamed and non-streamed paths. Off by default, and engaged only for a response that opens with a thinking tag, so a model that merely mentions a think tag inside an answer is never rewritten. Prefer a provider-side parser or `reasoningSplitModels` when the upstream supports either. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | | `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. | | `showThinkingSummary?` | `boolean` | Display provider-authored summaries when a Responses client omits `reasoning.summary`. Explicit wire `"none"` wins; a client that serializes its preference as omission cannot be distinguished. Raw reasoning remains content and is never relabeled as a summary. The `google-antigravity` preset defaults to `true`; explicit `false` disables that default. CCA Gemini requests also opt into `generationConfig.thinkingConfig.includeThoughts` when display is enabled; image, Claude and gpt-oss requests do not. This does not change client configuration or global catalog summary defaults. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7117b4679ea..3624b299430 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1087,6 +1087,7 @@ "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", diff --git a/src/adapters/inline-think-tags.ts b/src/adapters/inline-think-tags.ts new file mode 100644 index 00000000000..29aa3e132f1 --- /dev/null +++ b/src/adapters/inline-think-tags.ts @@ -0,0 +1,227 @@ +import type { AdapterEvent } from "../types"; +import { modelInList } from "../types"; +import type { TranslatorBudget } from "../lib/translator-budget"; + +type ThinkingTag = "" | "" | ""; +type ParserState = "pre" | "thinking" | "scanning" | "streaming"; + +const OPEN_TAGS: ThinkingTag[] = ["", "", ""]; +const MAX_OPEN_TAG = Math.max(...OPEN_TAGS.map(t => t.length)); +const MAX_CLOSE_TAG = Math.max(...OPEN_TAGS.map(t => ` tag.startsWith(text) && text.length < tag.length); +} + +/** Move a send boundary back one unit rather than splitting a surrogate pair into U+FFFD. */ +function surrogateSafeCut(text: string, cut: number): number { + if (cut <= 0 || cut >= text.length) return Math.max(0, Math.min(cut, text.length)); + const atCut = text.charCodeAt(cut - 1); + return atCut >= 0xd800 && atCut <= 0xdbff ? cut - 1 : cut; +} + +export interface InlineThinkTagOptions { + /** + * Keep scanning for further think blocks after the first one closes. Kiro emits a single + * leading block, so it leaves this off and streams the rest verbatim. MiniMax M-series + * interleaves several blocks with answer segments, so a reusing adapter opts in. + */ + interleaved?: boolean; +} + +/** + * Recovers thinking that a gateway left inline in visible content as `` blocks instead of + * a separate `reasoning_content` / `reasoning_details` field. Shared by the Kiro adapter and by + * the openai-chat adapter's opt-in `inlineThinkTagModels`. + */ +export class InlineThinkTagParser { + private state: ParserState = "pre"; + private preBuffer = ""; + private thinkingBuffer = ""; + private closeTag = ""; + + private readonly interleaved: boolean; + + constructor(private readonly budget?: TranslatorBudget, options?: InlineThinkTagOptions) { + this.interleaved = options?.interleaved === true; + } + + private replaceCarry(field: "preBuffer" | "thinkingBuffer", next: string): void { + const previous = this[field]; + if (previous === next) return; + const previousBytes = Buffer.byteLength(previous); + const nextBytes = Buffer.byteLength(next); + const reservation = this.budget?.reserveTransient(nextBytes, { kind: "reasoning" }); + this[field] = next; + reservation?.commitRetained(); + this.budget?.releaseRetained(previousBytes, { kind: "reasoning" }); + } + + feed(text: string): AdapterEvent[] { + if (!text) return []; + if (this.state === "streaming") return [{ type: "text_delta", text }]; + if (this.state === "thinking") { + this.replaceCarry("thinkingBuffer", this.thinkingBuffer + text); + return this.drainThinking(); + } + if (this.state === "scanning") { + this.replaceCarry("preBuffer", this.preBuffer + text); + return this.drainScanning(); + } + this.replaceCarry("preBuffer", this.preBuffer + text); + const stripped = this.preBuffer.trimStart(); + const openTag = OPEN_TAGS.find(tag => stripped.startsWith(tag)); + if (openTag) { + this.state = "thinking"; + this.closeTag = closeTagFor(openTag); + this.replaceCarry("thinkingBuffer", stripped.slice(openTag.length)); + this.replaceCarry("preBuffer", ""); + return this.drainThinking(); + } + if (stripped.length <= MAX_OPEN_TAG && isPossibleOpenTagPrefix(stripped)) return []; + this.state = "streaming"; + const out = this.preBuffer; + this.replaceCarry("preBuffer", ""); + return out ? [{ type: "text_delta", text: out }] : []; + } + + flush(): AdapterEvent[] { + if (this.state === "thinking") { + const out = this.thinkingBuffer; + this.replaceCarry("thinkingBuffer", ""); + this.state = "streaming"; + return out ? [{ type: "reasoning_raw_delta", text: out }] : []; + } + if (this.preBuffer) { + const out = this.preBuffer; + this.replaceCarry("preBuffer", ""); + this.state = "streaming"; + return [{ type: "text_delta", text: out }]; + } + return []; + } + + /** Release any partial tag/content carry when the owning stream stops early. */ + dispose(): void { + this.replaceCarry("preBuffer", ""); + this.replaceCarry("thinkingBuffer", ""); + this.closeTag = ""; + this.state = "streaming"; + } + + private drainThinking(): AdapterEvent[] { + const close = this.closeTag; + const idx = this.thinkingBuffer.indexOf(close); + if (idx >= 0) { + const thinking = this.thinkingBuffer.slice(0, idx); + const after = this.thinkingBuffer.slice(idx + close.length).trimStart(); + this.replaceCarry("thinkingBuffer", ""); + const events: AdapterEvent[] = []; + if (thinking) events.push({ type: "reasoning_raw_delta", text: thinking }); + if (this.interleaved) { + this.state = "scanning"; + this.replaceCarry("preBuffer", after); + events.push(...this.drainScanning()); + } else { + this.state = "streaming"; + if (after) events.push({ type: "text_delta", text: after }); + } + return events; + } + if (this.thinkingBuffer.length <= MAX_CLOSE_TAG) return []; + // Hold back a possible partial close tag, and never split a surrogate pair + // at the send boundary: a lone high surrogate encodes as U+FFFD. + const cut = surrogateSafeCut(this.thinkingBuffer, this.thinkingBuffer.length - MAX_CLOSE_TAG); + const send = this.thinkingBuffer.slice(0, cut); + this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(cut)); + return send ? [{ type: "reasoning_raw_delta", text: send }] : []; + } + + /** + * Interleaved mode only: the response already proved it carries inline thinking, so a later + * block can open anywhere in the answer text rather than only at the start. + */ + private drainScanning(): AdapterEvent[] { + const events: AdapterEvent[] = []; + for (;;) { + let openIndex = -1; + let openTag: ThinkingTag | undefined; + for (const tag of OPEN_TAGS) { + const index = this.preBuffer.indexOf(tag); + if (index >= 0 && (openIndex < 0 || index < openIndex)) { + openIndex = index; + openTag = tag; + } + } + if (openIndex >= 0 && openTag) { + const before = this.preBuffer.slice(0, openIndex); + if (before) events.push({ type: "text_delta", text: before }); + this.state = "thinking"; + this.closeTag = closeTagFor(openTag); + this.replaceCarry("thinkingBuffer", this.preBuffer.slice(openIndex + openTag.length)); + this.replaceCarry("preBuffer", ""); + events.push(...this.drainThinking()); + // drainThinking returns to "scanning" only when that block closed inside this chunk. + if ((this.state as ParserState) !== "scanning") return events; + continue; + } + // Hold back only as much as a partial open tag could occupy. + const cut = surrogateSafeCut(this.preBuffer, this.preBuffer.length - (MAX_OPEN_TAG - 1)); + if (cut > 0) { + events.push({ type: "text_delta", text: this.preBuffer.slice(0, cut) }); + this.replaceCarry("preBuffer", this.preBuffer.slice(cut)); + } + return events; + } + } +} + +/** Visible-content splitter the openai-chat adapter holds for the life of one response. */ +export interface InlineThinkContentSplitter { + feed(text: string): AdapterEvent[]; + flush(): AdapterEvent[]; + dispose(): void; +} + +const PASSTHROUGH: InlineThinkContentSplitter = { + feed: text => [{ type: "text_delta", text }], + flush: () => [], + dispose: () => { /* nothing carried */ }, +}; + +/** + * Opt-in recovery for `inlineThinkTagModels`. A model that is not listed gets a passthrough that + * never inspects or rewrites visible content, so the 66 registry providers sharing the openai-chat + * adapter keep byte-exact behavior. + */ +export function createInlineThinkContentSplitter( + models: string[] | undefined, + modelId: string | undefined, + budget?: TranslatorBudget, +): InlineThinkContentSplitter { + if (!modelInList(models, modelId ?? "")) return PASSTHROUGH; + const parser = new InlineThinkTagParser(budget, { interleaved: true }); + return { + // An empty content delta stays an empty delta: it is a wire signal, not thinking. + feed: text => (text.length === 0 ? [{ type: "text_delta", text }] : parser.feed(text)), + flush: () => parser.flush(), + dispose: () => parser.dispose(), + }; +} + +/** One-shot form for a non-streaming response body. */ +export function splitInlineThinkContent( + models: string[] | undefined, + modelId: string | undefined, + budget: TranslatorBudget | undefined, + content: string, +): AdapterEvent[] { + const splitter = createInlineThinkContentSplitter(models, modelId, budget); + const events = [...splitter.feed(content), ...splitter.flush()]; + splitter.dispose(); + return events; +} diff --git a/src/adapters/kiro-thinking.ts b/src/adapters/kiro-thinking.ts deleted file mode 100644 index ee144e28783..00000000000 --- a/src/adapters/kiro-thinking.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { AdapterEvent } from "../types"; -import type { TranslatorBudget } from "../lib/translator-budget"; - -type ThinkingTag = "" | "" | ""; -type ParserState = "pre" | "thinking" | "streaming"; - -const OPEN_TAGS: ThinkingTag[] = ["", "", ""]; -const MAX_OPEN_TAG = Math.max(...OPEN_TAGS.map(t => t.length)); -const MAX_CLOSE_TAG = Math.max(...OPEN_TAGS.map(t => ` tag.startsWith(text) && text.length < tag.length); -} - -export class KiroThinkingParser { - private state: ParserState = "pre"; - private preBuffer = ""; - private thinkingBuffer = ""; - private closeTag = ""; - - constructor(private readonly budget?: TranslatorBudget) {} - - private replaceCarry(field: "preBuffer" | "thinkingBuffer", next: string): void { - const previous = this[field]; - if (previous === next) return; - const previousBytes = Buffer.byteLength(previous); - const nextBytes = Buffer.byteLength(next); - const reservation = this.budget?.reserveTransient(nextBytes, { kind: "reasoning" }); - this[field] = next; - reservation?.commitRetained(); - this.budget?.releaseRetained(previousBytes, { kind: "reasoning" }); - } - - feed(text: string): AdapterEvent[] { - if (!text) return []; - if (this.state === "streaming") return [{ type: "text_delta", text }]; - if (this.state === "thinking") { - this.replaceCarry("thinkingBuffer", this.thinkingBuffer + text); - return this.drainThinking(); - } - this.replaceCarry("preBuffer", this.preBuffer + text); - const stripped = this.preBuffer.trimStart(); - const openTag = OPEN_TAGS.find(tag => stripped.startsWith(tag)); - if (openTag) { - this.state = "thinking"; - this.closeTag = closeTagFor(openTag); - this.replaceCarry("thinkingBuffer", stripped.slice(openTag.length)); - this.replaceCarry("preBuffer", ""); - return this.drainThinking(); - } - if (stripped.length <= MAX_OPEN_TAG && isPossibleOpenTagPrefix(stripped)) return []; - this.state = "streaming"; - const out = this.preBuffer; - this.replaceCarry("preBuffer", ""); - return out ? [{ type: "text_delta", text: out }] : []; - } - - flush(): AdapterEvent[] { - if (this.state === "thinking") { - const out = this.thinkingBuffer; - this.replaceCarry("thinkingBuffer", ""); - this.state = "streaming"; - return out ? [{ type: "reasoning_raw_delta", text: out }] : []; - } - if (this.preBuffer) { - const out = this.preBuffer; - this.replaceCarry("preBuffer", ""); - this.state = "streaming"; - return [{ type: "text_delta", text: out }]; - } - return []; - } - - /** Release any partial tag/content carry when the owning stream stops early. */ - dispose(): void { - this.replaceCarry("preBuffer", ""); - this.replaceCarry("thinkingBuffer", ""); - this.closeTag = ""; - this.state = "streaming"; - } - - private drainThinking(): AdapterEvent[] { - const close = this.closeTag; - const idx = this.thinkingBuffer.indexOf(close); - if (idx >= 0) { - const thinking = this.thinkingBuffer.slice(0, idx); - const after = this.thinkingBuffer.slice(idx + close.length).trimStart(); - this.replaceCarry("thinkingBuffer", ""); - this.state = "streaming"; - const events: AdapterEvent[] = []; - if (thinking) events.push({ type: "reasoning_raw_delta", text: thinking }); - if (after) events.push({ type: "text_delta", text: after }); - return events; - } - if (this.thinkingBuffer.length <= MAX_CLOSE_TAG) return []; - // Never split a surrogate pair at the send boundary: a lone high - // surrogate at the end of one delta encodes as U+FFFD. Move the cut one - // unit earlier so the whole pair stays in the carry. - let cut = this.thinkingBuffer.length - MAX_CLOSE_TAG; - if (cut > 0 && cut < this.thinkingBuffer.length) { - const atCut = this.thinkingBuffer.charCodeAt(cut - 1); - if (atCut >= 0xd800 && atCut <= 0xdbff) cut -= 1; - } - const send = this.thinkingBuffer.slice(0, cut); - this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(cut)); - return send ? [{ type: "reasoning_raw_delta", text: send }] : []; - } -} diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index d10ab1105c0..0536038e7dd 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -16,7 +16,7 @@ import { } from "../kiro-errors"; import { parseKiroEvent } from "../kiro-events"; import { noteKiroTransientThrottle } from "../kiro-retry"; -import { KiroThinkingParser } from "../kiro-thinking"; +import { InlineThinkTagParser } from "../inline-think-tags"; import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation"; import { isValidKiroConversationId } from "../kiro-wire"; import { tagKiroReasoningBlob } from "./reasoning"; @@ -319,7 +319,7 @@ async function* parseKiroAttemptEvents( let authoritativeUsage: OcxUsage | undefined; let stopReason: string | undefined; const fallbackEvents: AdapterEvent[] = []; - const thinking = new KiroThinkingParser(budget); + const thinking = new InlineThinkTagParser(budget); const retainedEventBytes = (event: AdapterEvent): number => Buffer.byteLength(JSON.stringify(event)); const retainEvent = (event: AdapterEvent): void => { diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 98ba27cd203..dbc1ce76954 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -4,6 +4,7 @@ import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wi import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; +import { createInlineThinkContentSplitter, splitInlineThinkContent } from "./inline-think-tags"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -355,6 +356,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Gate on the routed model, not list length: a mixed openai-chat provider // can list MiniMax ids without putting every sibling on MiniMax semantics. const reasoningDetailsOptIn = modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? ""); + // A gateway with no server-side reasoning parser leaves thinking inline in `content` as + // blocks, which would otherwise render as the answer. Passthrough unless opted in. + const inlineThink = createInlineThinkContentSplitter(provider.inlineThinkTagModels, lastRequestedModelId, budget); + const emitContent = function* (events: AdapterEvent[]): Generator { + for (const event of events) { if (event.type === "text_delta") sawUserFacingOutput = true; yield event; } + }; const handleDataLine = function* (line: string): Generator { const rawPayload = sseFieldValue(line, "data"); @@ -362,6 +369,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const payload = rawPayload.trim(); if (payload.length === 0) return "continue"; if (payload === "[DONE]") { + yield* emitContent(inlineThink.flush()); if ((yield* flushToolCalls()) === "terminate") return "terminate"; const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; @@ -422,8 +430,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText }; } if (typeof delta.content === "string" && delta.content.length > 0) { - sawUserFacingOutput = true; - yield { type: "text_delta", text: delta.content }; + yield* emitContent(inlineThink.feed(delta.content)); } const rawToolCalls = delta.tool_calls; @@ -562,6 +569,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (typeof choice.finish_reason === "string" && choice.finish_reason) { + yield* emitContent(inlineThink.flush()); if ((yield* flushToolCalls()) === "terminate") return "terminate"; } return "continue"; @@ -605,6 +613,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (buffer.length > 0) { if ((yield* handleDataLine(buffer)) === "terminate") return; } + yield* emitContent(inlineThink.flush()); const sawFinish = finishReason !== undefined; if (!sawFinish && pendingToolCalls.length > 0) { // Some OpenAI-compatible gateways close immediately after a complete function-call @@ -652,6 +661,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } finally { budget.releaseRetained(bufferBytes, { kind: "live_transient" }); reasoningDetailTracker.release(); + inlineThink.dispose(); closeToolCalls(); reader.releaseLock(); } @@ -738,7 +748,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (segments.length > 0) reasoningText = segments.map(s => s.text).join(""); } if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); - if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content }); + if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); const rawToolCalls = msg.tool_calls; if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 299b95cc413..cc60e657b8a 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -47,6 +47,7 @@ export interface DerivedKeyLoginProvider { requiresReasoningPlaceholderModels?: string[]; showThinkingSummary?: boolean; reasoningSplitModels?: string[]; + inlineThinkTagModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; thinkingBudgetModels?: string[]; @@ -286,6 +287,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.inlineThinkTagModels ? { inlineThinkTagModels: [...entry.inlineThinkTagModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}), @@ -336,6 +338,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), + ...(entry.inlineThinkTagModels ? { inlineThinkTagModels: [...entry.inlineThinkTagModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}), @@ -602,6 +605,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels]; if (!prov.requiresReasoningPlaceholderModels && seed.requiresReasoningPlaceholderModels) prov.requiresReasoningPlaceholderModels = [...seed.requiresReasoningPlaceholderModels]; if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels]; + if (!prov.inlineThinkTagModels && seed.inlineThinkTagModels) prov.inlineThinkTagModels = [...seed.inlineThinkTagModels]; if (!prov.reasoningDetailsModels && seed.reasoningDetailsModels) prov.reasoningDetailsModels = [...seed.reasoningDetailsModels]; if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 94de3fd7d99..4c02ca57cad 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -118,6 +118,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { requiresReasoningPlaceholderModels: NONE, showThinkingSummary: NONE, reasoningSplitModels: NONE, + inlineThinkTagModels: NONE, reasoningDetailsModels: NONE, thinkingToggleModels: NONE, thinkingBudgetModels: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 0a31facef64..e04931bdb63 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -335,6 +335,8 @@ export interface ProviderRegistryEntry { */ showThinkingSummary?: boolean; reasoningSplitModels?: string[]; + /** See OcxProviderConfig.inlineThinkTagModels. */ + inlineThinkTagModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; thinkingBudgetModels?: string[]; @@ -358,6 +360,6 @@ export type ProviderConfigSeed = Pick< | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "inlineThinkTagModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" | "googleMode" | "project" | "location" | "headers" >; diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 7c3553a3d26..3a018ee62da 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -37,7 +37,7 @@ export type StaticProviderPolicyField = | "supportsResponsesCustomTools" | "preserveResponsesReasoningContent" | "dropResponsesReasoningItems" | "modelSupportsReasoningSummaries" | "supportsVerbosity" | "modelSupportsVerbosity" | "responsesItemIdRepair" | "autoToolChoiceOnlyModels" - | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" + | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "inlineThinkTagModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "showThinkingSummary" | "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "modelCapabilities" | "modelAutoCompactTokenLimits" | "modelSuppressSyntheticMax" | "modelReasoningSummaryDelivery" @@ -228,7 +228,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "noVisionModels", "noReasoningModels", "noTemperatureModels", "noTopPModels", "noPenaltyModels", "noJsonSchemaModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", - "reasoningSplitModels", "reasoningDetailsModels", "thinkingToggleModels", "thinkingBudgetModels", + "reasoningSplitModels", "inlineThinkTagModels", "reasoningDetailsModels", "thinkingToggleModels", "thinkingBudgetModels", ] as const) putUnion(key, entry?.[key]); for (const directModel of entry?.directReasoningEffortModels ?? []) { const staleBudget = [directModel, ...(entry?.thinkingBudgetModels ?? [])]; diff --git a/src/router.ts b/src/router.ts index 6d5e4b7310b..3e4182dcc15 100644 --- a/src/router.ts +++ b/src/router.ts @@ -337,6 +337,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const preserveReasoningContentModels = staticPolicy.preserveReasoningContentModels; const requiresReasoningPlaceholderModels = staticPolicy.requiresReasoningPlaceholderModels; const reasoningSplitModels = staticPolicy.reasoningSplitModels; + const inlineThinkTagModels = staticPolicy.inlineThinkTagModels; const reasoningDetailsModels = staticPolicy.reasoningDetailsModels; const thinkingToggleModels = staticPolicy.thinkingToggleModels; const thinkingBudgetModels = staticPolicy.thinkingBudgetModels; @@ -479,6 +480,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), ...(reasoningSplitModels ? { reasoningSplitModels } : {}), + ...(inlineThinkTagModels ? { inlineThinkTagModels } : {}), ...(reasoningDetailsModels ? { reasoningDetailsModels } : {}), ...(thinkingToggleModels ? { thinkingToggleModels } : {}), ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a0ef077dd5d..f5d71343199 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1060,6 +1060,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { transientRetryOn5xx: "editor", retryOnReset: "editor", reasoningSplitModels: "editor", + inlineThinkTagModels: "editor", reasoningDetailsModels: "editor", thinkingToggleModels: "editor", thinkingBudgetModels: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dbf793d9fd..a36bf94074e 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -950,6 +950,16 @@ export interface OcxProviderConfig { * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content. */ reasoningSplitModels?: string[]; + /** + * Model ids served by a gateway that runs no server-side reasoning parser, so a thinking model + * leaves its chain of thought inline in `content` as `` / `` / `` + * blocks and never sends `reasoning_content` or `reasoning_details`. Without this the whole + * chain of thought renders as the answer. The openai-chat adapter then splits those blocks back + * into reasoning. Off by default and narrow on purpose: 66 registry providers share this + * adapter, and a gateway that does parse reasoning must not have its visible content rewritten. + * Prefer a provider-side parser or `reasoningSplitModels` when the upstream supports either. + */ + inlineThinkTagModels?: string[]; /** * Model ids whose chat endpoint carries thinking as a structured `reasoning_details` array * (MiniMax M-series with `reasoning_split`): stream deltas repeat each detail's `text` as a diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 3067863efe2..56426d94667 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -46,6 +46,21 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Reasoning and tool-result compatibility +### Inline think-tag recovery + +A gateway that serves a thinking model without a server-side reasoning parser returns the chain +of thought inside `message.content` as `` / `` / `` blocks and sends +neither `reasoning_content` nor `reasoning_details`. `src/adapters/openai-chat.ts` recovers those +blocks into reasoning only for models listed in `inlineThinkTagModels`. The option is off by +default because 66 registry providers share this adapter and a gateway that does parse reasoning +must keep its visible content byte-exact. Once enabled the splitter still engages only for a +response that opens with a thinking tag, so an answer that merely mentions one is never rewritten; +after it engages it keeps splitting later blocks, because M-series models interleave thinking with +answer segments. A block left unterminated at end of stream flushes as reasoning rather than being +dropped. `src/adapters/inline-think-tags.ts` owns the parser and is shared with the Kiro adapter, +which consumes it in single-block mode. Regression coverage is in +`tests/adapters/openai/openai-chat-inline-think-tags.test.ts`. + Google tool-declaration narrowing is observed by the Google final compiler, not this shared Chat compatibility layer. Its endpoint profile and privacy boundary are specified in the [Google provider contract](google.md#google-tool-schema-loss-reporting). diff --git a/tests/adapters/openai/openai-chat-inline-think-tags.test.ts b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts new file mode 100644 index 00000000000..43b15733213 --- /dev/null +++ b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const MODEL = "GLM-5.3-Flash"; + +function provider(optIn: boolean): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "key", + ...(optIn ? { inlineThinkTagModels: [MODEL] } : {}), + }; +} + +function parsed(): OcxParsedRequest { + return { + modelId: MODEL, + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + }; +} + +/** parseStream gates on the routed model, which buildRequest records. */ +function adapterFor(optIn: boolean) { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapterProduction(provider(optIn))); + adapter.buildRequest(parsed()); + return adapter; +} + +function sse(...contents: string[]): Response { + const lines = contents.map(text => `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`); + lines.push('data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', "data: [DONE]\n\n"); + return new Response(lines.join("")); +} + +async function collect(gen: AsyncGenerator): Promise { + const out: AdapterEvent[] = []; + for await (const event of gen) if (event.type !== "heartbeat") out.push(event); + return out; +} + +function joined(events: AdapterEvent[], type: "text_delta" | "reasoning_raw_delta"): string { + return events + .filter((event): event is Extract => event.type === type) + .map(event => event.text) + .join(""); +} + +describe("openai-chat inline recovery", () => { + test("a gateway without a reasoning parser has its thinking split out of the answer", async () => { + const events = await collect(adapterFor(true).parseStream( + sse("weigh", "ing it up", "OCX_THINK_OK"), + )); + + expect(joined(events, "reasoning_raw_delta")).toBe("weighing it up"); + expect(joined(events, "text_delta")).toBe("OCX_THINK_OK"); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a tag split across chunk boundaries is still recognized", async () => { + const events = await collect(adapterFor(true).parseStream( + sse("whyanswer"), + )); + + expect(joined(events, "reasoning_raw_delta")).toBe("why"); + expect(joined(events, "text_delta")).toBe("answer"); + }); + + test("interleaved blocks keep every later thought out of the answer", async () => { + const events = await collect(adapterFor(true).parseStream( + sse("first", "part one ", "second", "part two"), + )); + + expect(joined(events, "reasoning_raw_delta")).toBe("firstsecond"); + expect(joined(events, "text_delta")).toBe("part one part two"); + }); + + test("a response that opens with ordinary text is never rewritten", async () => { + const answer = "A model may discuss a tag without thinking in it."; + const events = await collect(adapterFor(true).parseStream(sse(answer))); + + expect(joined(events, "text_delta")).toBe(answer); + expect(events.some(event => event.type === "reasoning_raw_delta")).toBe(false); + }); + + test("an unterminated block is flushed as reasoning rather than lost", async () => { + const events = await collect(adapterFor(true).parseStream( + sse("cut off mid thought"), + )); + + expect(joined(events, "reasoning_raw_delta")).toBe("cut off mid thought"); + expect(joined(events, "text_delta")).toBe(""); + }); + + test("without the opt-in the same stream stays byte-exact visible content", async () => { + const events = await collect(adapterFor(false).parseStream( + sse("weighing it up", "OCX_THINK_OK"), + )); + + expect(joined(events, "text_delta")).toBe("weighing it upOCX_THINK_OK"); + expect(events.some(event => event.type === "reasoning_raw_delta")).toBe(false); + }); + + test("the non-streaming path splits the same way", async () => { + const adapter = adapterFor(true); + const response = new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "quietlyOCX_THINK_OK" } }], + }), { headers: { "content-type": "application/json" } }); + + const events = await adapter.parseResponse(response); + + expect(joined(events, "reasoning_raw_delta")).toBe("quietly"); + expect(joined(events, "text_delta")).toBe("OCX_THINK_OK"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1fd592857c0..8cc5bec1b87 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -913,6 +913,7 @@ "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", diff --git a/tests/providers/kiro/kiro-stream.test.ts b/tests/providers/kiro/kiro-stream.test.ts index 336f264d021..5ad687ef6f7 100644 --- a/tests/providers/kiro/kiro-stream.test.ts +++ b/tests/providers/kiro/kiro-stream.test.ts @@ -2231,8 +2231,8 @@ describe("kiro adapter — non-streaming parseResponse", () => { describe("surrogate safety at kiro boundaries", () => { test("the reasoning carry never emits a delta ending on a lone high surrogate", async () => { - const { KiroThinkingParser } = await import("../../../src/adapters/kiro-thinking"); - const parser = new KiroThinkingParser(); + const { InlineThinkTagParser } = await import("../../../src/adapters/inline-think-tags"); + const parser = new InlineThinkTagParser(); // An astral char exactly at the carry/send boundary. const events = parser.feed("🎆aaaaaaaaaaa"); const emitted = JSON.stringify(events); From 1a59f733c2fd12db60641c32d4554302d5dc6b57 Mon Sep 17 00:00:00 2001 From: alexph-dev Date: Sun, 20 Sep 2026 01:22:25 +0700 Subject: [PATCH 3/5] fix(openai-chat): keep answer whitespace around a mid-answer think block Review feedback: trimming the remainder after every closing tag also ate indentation that belongs to the answer, which matters when a block sits inside markdown or code. Only the transition out of the leading block trims now; once answer text has been emitted the remainder is preserved byte-exact. Kiro is single-block, so its behavior is unchanged. (cherry picked from commit 1498edac3ba729497c4ab624d29d87b7084dd620) --- src/adapters/inline-think-tags.ts | 10 ++++++++-- structure/providers/chat-compat.md | 5 ++++- .../openai/openai-chat-inline-think-tags.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/adapters/inline-think-tags.ts b/src/adapters/inline-think-tags.ts index 29aa3e132f1..663eb49a3e2 100644 --- a/src/adapters/inline-think-tags.ts +++ b/src/adapters/inline-think-tags.ts @@ -45,6 +45,7 @@ export class InlineThinkTagParser { private closeTag = ""; private readonly interleaved: boolean; + private sawAnswerText = false; constructor(private readonly budget?: TranslatorBudget, options?: InlineThinkTagOptions) { this.interleaved = options?.interleaved === true; @@ -118,7 +119,11 @@ export class InlineThinkTagParser { const idx = this.thinkingBuffer.indexOf(close); if (idx >= 0) { const thinking = this.thinkingBuffer.slice(0, idx); - const after = this.thinkingBuffer.slice(idx + close.length).trimStart(); + const remainder = this.thinkingBuffer.slice(idx + close.length); + // The blank line a model leaves between its leading block and the answer is formatting + // noise, so it goes. Once the answer has started, whitespace is the answer's own: a + // mid-answer block sits inside markdown or code where indentation is meaningful. + const after = this.sawAnswerText ? remainder : remainder.trimStart(); this.replaceCarry("thinkingBuffer", ""); const events: AdapterEvent[] = []; if (thinking) events.push({ type: "reasoning_raw_delta", text: thinking }); @@ -159,7 +164,7 @@ export class InlineThinkTagParser { } if (openIndex >= 0 && openTag) { const before = this.preBuffer.slice(0, openIndex); - if (before) events.push({ type: "text_delta", text: before }); + if (before) { this.sawAnswerText = true; events.push({ type: "text_delta", text: before }); } this.state = "thinking"; this.closeTag = closeTagFor(openTag); this.replaceCarry("thinkingBuffer", this.preBuffer.slice(openIndex + openTag.length)); @@ -172,6 +177,7 @@ export class InlineThinkTagParser { // Hold back only as much as a partial open tag could occupy. const cut = surrogateSafeCut(this.preBuffer, this.preBuffer.length - (MAX_OPEN_TAG - 1)); if (cut > 0) { + this.sawAnswerText = true; events.push({ type: "text_delta", text: this.preBuffer.slice(0, cut) }); this.replaceCarry("preBuffer", this.preBuffer.slice(cut)); } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 56426d94667..3411c2ac5a3 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -57,7 +57,10 @@ must keep its visible content byte-exact. Once enabled the splitter still engage response that opens with a thinking tag, so an answer that merely mentions one is never rewritten; after it engages it keeps splitting later blocks, because M-series models interleave thinking with answer segments. A block left unterminated at end of stream flushes as reasoning rather than being -dropped. `src/adapters/inline-think-tags.ts` owns the parser and is shared with the Kiro adapter, +dropped. Whitespace between the leading block and the start of the answer is dropped as formatting +noise; once answer text has been emitted, whitespace after a later closing tag is preserved, +because a mid-answer block sits inside markdown or code where indentation is meaningful. +`src/adapters/inline-think-tags.ts` owns the parser and is shared with the Kiro adapter, which consumes it in single-block mode. Regression coverage is in `tests/adapters/openai/openai-chat-inline-think-tags.test.ts`. diff --git a/tests/adapters/openai/openai-chat-inline-think-tags.test.ts b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts index 43b15733213..c520c7b54bf 100644 --- a/tests/adapters/openai/openai-chat-inline-think-tags.test.ts +++ b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts @@ -86,6 +86,15 @@ describe("openai-chat inline recovery", () => { expect(events.some(event => event.type === "reasoning_raw_delta")).toBe(false); }); + test("the blank line before the answer is dropped, but the answer's own indentation survives", async () => { + const events = await collect(adapterFor(true).parseStream( + sse("plan\n\nUse this:\n", "check", " indented line"), + )); + + expect(joined(events, "reasoning_raw_delta")).toBe("plancheck"); + expect(joined(events, "text_delta")).toBe("Use this:\n indented line"); + }); + test("an unterminated block is flushed as reasoning rather than lost", async () => { const events = await collect(adapterFor(true).parseStream( sse("cut off mid thought"), From 9b5898425d125e71fc5d8d2683d903cfb0f0b8f1 Mon Sep 17 00:00:00 2001 From: Yum-wu <1172989563@qq.com> Date: Tue, 22 Sep 2026 13:59:47 +0800 Subject: [PATCH 4/5] fix(responses): preserve visible reasoning when summary mode is omitted (cherry picked from commit 011f98c71f1d277e3b43ca43391d46c5098f1a3e) --- src/combos/request.ts | 8 ++- src/responses/parser.ts | 3 +- .../reasoning-effort-summary-default.test.ts | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 tests/responses/reasoning-effort-summary-default.test.ts diff --git a/src/combos/request.ts b/src/combos/request.ts index e0fa6426087..0d0b0123732 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -110,9 +110,13 @@ export function concreteComboRequestBody( return clone; } if (reasoning === undefined) { - clone.reasoning = { effort: resolvedEffort }; + clone.reasoning = { effort: resolvedEffort, summary: "auto" }; } else { - clone.reasoning = { ...(reasoning as Record), effort: resolvedEffort }; + clone.reasoning = { + ...(reasoning as Record), + effort: resolvedEffort, + ...((reasoning as Record).summary === undefined ? { summary: "auto" } : {}), + }; } return clone; } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 51bb36f5245..3d63480be0b 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -542,7 +542,8 @@ export function parseRequest( options.reasoning = requestedEffort; } const summaryMode = data.reasoning?.summary; - if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true; + const reasoningActive = Boolean(requestedEffort && requestedEffort !== "none" && requestedEffort !== "off"); + if (summaryMode === "none" || (!summaryMode && !reasoningActive)) options.hideThinkingSummary = true; if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty; if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty; if (data.service_tier !== undefined) options.serviceTier = data.service_tier; diff --git a/tests/responses/reasoning-effort-summary-default.test.ts b/tests/responses/reasoning-effort-summary-default.test.ts new file mode 100644 index 00000000000..9dcad9a1c1c --- /dev/null +++ b/tests/responses/reasoning-effort-summary-default.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { parseRequest } from "../../src/responses/parser"; +import { concreteComboRequestBody } from "../../src/combos/request"; +import type { OcxComboTarget } from "../../src/types"; + +describe("reasoning effort preserves visible thinking when summary is omitted", () => { + test("reasoning with active effort does not default to hideThinkingSummary", () => { + const parsed = parseRequest({ + model: "test-model", + reasoning: { effort: "high" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + }); + expect(parsed.options.reasoning).toBe("high"); + expect(parsed.options.hideThinkingSummary).toBeUndefined(); + }); + + test("explicit summary of none still hides thinking summary", () => { + const parsed = parseRequest({ + model: "test-model", + reasoning: { effort: "high", summary: "none" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + }); + expect(parsed.options.reasoning).toBe("high"); + expect(parsed.options.hideThinkingSummary).toBe(true); + }); + + test("omitted reasoning and omitted effort still default to hideThinkingSummary", () => { + const parsed = parseRequest({ + model: "test-model", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + }); + expect(parsed.options.hideThinkingSummary).toBe(true); + }); + + test("reasoning effort of none defaults to hideThinkingSummary", () => { + const parsed = parseRequest({ + model: "test-model", + reasoning: { effort: "none" }, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + }); + expect(parsed.options.hideThinkingSummary).toBe(true); + }); + + test("combo injected effort defaults summary to auto", () => { + const target: Pick = { + provider: "test-provider", + model: "test-model", + }; + const body = { model: "combo/test", input: [] }; + const child = concreteComboRequestBody(body, target, "high", ["high"]); + expect(child.reasoning).toEqual({ effort: "high", summary: "auto" }); + }); +}); From 8028c913ab00ed5b402cb0ab9efee65fbe9e7abb Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:37:36 +0900 Subject: [PATCH 5/5] fix(reasoning): preserve roundtrip format and explicit display boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep inline parser answer whitespace and drain interleaved blocks without recursion. Preserve Kiro single-block behavior and document opt-in tag semantics, including code examples after activation. Honor explicit operator parser lists, including []. Use validated effort for omitted-summary visibility and register the source tests. Adapt the model-rename contribution to the existing extracted field classifier. The carried source message describes a historical cap increase; this integration does not carry that increase and keeps the current dev file-size baseline unchanged. Keep the original source commits and their review follow-up with -x provenance. Co-authored-by: Joonsuh Park Co-authored-by: Daniel Sjöstrand <16033062+Danielsjostrand1979@users.noreply.github.com> Co-authored-by: alexph-dev Co-authored-by: Yum-wu <1172989563@qq.com> --- .../docs/reference/configuration/providers.md | 10 ++- scripts/test-layout/layout.json | 2 + src/adapters/inline-think-tags.ts | 87 ++++++++++--------- src/providers/model-rename-fields.ts | 1 + src/providers/resolved-model-policy.ts | 4 +- src/responses/parser.ts | 2 +- structure/gui-and-management-api.md | 3 + structure/providers-and-adapters.md | 3 + structure/providers/chat-compat.md | 25 ++++-- structure/transports/responses.md | 4 + .../openai/inline-think-boundaries.test.ts | 67 ++++++++++++++ .../openai-chat-inline-think-tags.test.ts | 27 +++++- tests/codex-integration/combos.test.ts | 10 +-- tests/fixtures/test-layout-expected.json | 2 + .../deepseek-reasoning-replay-gaps.test.ts | 1 + .../providers/model-rename-migration.test.ts | 3 + tests/providers/resolved-model-policy.test.ts | 8 ++ .../reasoning-effort-summary-default.test.ts | 32 +++++++ 18 files changed, 234 insertions(+), 57 deletions(-) create mode 100644 tests/adapters/openai/inline-think-boundaries.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2ce10017af7..9080eac0ec7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -215,7 +215,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | -| `inlineThinkTagModels?` | `string[]` | `openai-chat` models served by a gateway that runs no server-side reasoning parser, so the model leaves its chain of thought inline in `content` as `` / `` / `` blocks and sends no `reasoning_content` or `reasoning_details`. Without this the whole chain of thought renders as the answer. Listed models have those blocks split back into reasoning on both the streamed and non-streamed paths. Off by default, and engaged only for a response that opens with a thinking tag, so a model that merely mentions a think tag inside an answer is never rewritten. Prefer a provider-side parser or `reasoningSplitModels` when the upstream supports either. | +| `inlineThinkTagModels?` | `string[]` | Opt-in recovery for `openai-chat` gateways without a server-side reasoning parser. A leading `` / `` / `` block (optionally after whitespace) activates splitting in streamed and buffered replies. All answer whitespace is preserved. Subsequent tags are delimiters anywhere, including same-line interleaving and code fences; this mode does not interpret Markdown. Ordinary text or a code fence before the first tag keeps the whole reply untouched. Off by default; prefer structured upstream reasoning or `reasoningSplitModels` where supported. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | | `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. | | `showThinkingSummary?` | `boolean` | Display provider-authored summaries when a Responses client omits `reasoning.summary`. Explicit wire `"none"` wins; a client that serializes its preference as omission cannot be distinguished. Raw reasoning remains content and is never relabeled as a summary. The `google-antigravity` preset defaults to `true`; explicit `false` disables that default. CCA Gemini requests also opt into `generationConfig.thinkingConfig.includeThoughts` when display is enabled; image, Claude and gpt-oss requests do not. This does not change client configuration or global catalog summary defaults. | @@ -243,6 +243,14 @@ mode, or base URL during search or provider pacing ends the turn with a bridge e provider request is sent. Changing away and back also ends that continuation. Start a new turn to use the new selection. Selection changes before the first provider send retain normal reselection. +An explicit `inlineThinkTagModels` list replaces matching registry defaults; `[]` disables recovery. + +Translated Responses requests with a validated active reasoning effort preserve raw reasoning +when `reasoning.summary` is omitted. Explicit `"none"` keeps it hidden for replay; omission +without an active effort also stays hidden. An injected combo default adds `summary: "auto"` +only when the caller has not chosen a summary mode. Raw reasoning is never relabeled as a +provider-authored summary, and Codex still controls its display with `show_raw_agent_reasoning`. + Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded exception is an explicit custom row whose model id has pinned native Codex capabilities, including Astra or Daybreak on an arbitrary gateway: its advertised list is intersected with diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3624b299430..2d164149a7d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,8 @@ } }, "explicit": { + "inline-think-boundaries.test.ts": "adapters/openai", + "reasoning-effort-summary-default.test.ts": "responses", "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", diff --git a/src/adapters/inline-think-tags.ts b/src/adapters/inline-think-tags.ts index 663eb49a3e2..2bf7a481740 100644 --- a/src/adapters/inline-think-tags.ts +++ b/src/adapters/inline-think-tags.ts @@ -45,7 +45,6 @@ export class InlineThinkTagParser { private closeTag = ""; private readonly interleaved: boolean; - private sawAnswerText = false; constructor(private readonly budget?: TranslatorBudget, options?: InlineThinkTagOptions) { this.interleaved = options?.interleaved === true; @@ -67,21 +66,24 @@ export class InlineThinkTagParser { if (this.state === "streaming") return [{ type: "text_delta", text }]; if (this.state === "thinking") { this.replaceCarry("thinkingBuffer", this.thinkingBuffer + text); - return this.drainThinking(); + return this.drain(); } if (this.state === "scanning") { this.replaceCarry("preBuffer", this.preBuffer + text); - return this.drainScanning(); + return this.drain(); } this.replaceCarry("preBuffer", this.preBuffer + text); const stripped = this.preBuffer.trimStart(); const openTag = OPEN_TAGS.find(tag => stripped.startsWith(tag)); if (openTag) { + const leading = this.interleaved ? this.preBuffer.slice(0, this.preBuffer.length - stripped.length) : ""; this.state = "thinking"; this.closeTag = closeTagFor(openTag); this.replaceCarry("thinkingBuffer", stripped.slice(openTag.length)); this.replaceCarry("preBuffer", ""); - return this.drainThinking(); + const events: AdapterEvent[] = leading ? [{ type: "text_delta", text: leading }] : []; + for (const event of this.drain()) events.push(event); + return events; } if (stripped.length <= MAX_OPEN_TAG && isPossibleOpenTagPrefix(stripped)) return []; this.state = "streaming"; @@ -114,23 +116,32 @@ export class InlineThinkTagParser { this.state = "streaming"; } + private drain(): AdapterEvent[] { + const events: AdapterEvent[] = []; + // State transitions consume a complete tag; incomplete carry ends this feed. + // Do not recurse for each block in one upstream chunk. + for (;;) { + const before = this.state; + const next = before === "thinking" ? this.drainThinking() : this.drainScanning(); + for (const event of next) events.push(event); + if (this.state === before || this.state === "streaming") return events; + } + } + private drainThinking(): AdapterEvent[] { const close = this.closeTag; const idx = this.thinkingBuffer.indexOf(close); if (idx >= 0) { const thinking = this.thinkingBuffer.slice(0, idx); const remainder = this.thinkingBuffer.slice(idx + close.length); - // The blank line a model leaves between its leading block and the answer is formatting - // noise, so it goes. Once the answer has started, whitespace is the answer's own: a - // mid-answer block sits inside markdown or code where indentation is meaningful. - const after = this.sawAnswerText ? remainder : remainder.trimStart(); + // Opt-in Chat answers are byte-preserving; keep Kiro's legacy normalization. + const after = this.interleaved ? remainder : remainder.trimStart(); this.replaceCarry("thinkingBuffer", ""); const events: AdapterEvent[] = []; if (thinking) events.push({ type: "reasoning_raw_delta", text: thinking }); if (this.interleaved) { this.state = "scanning"; this.replaceCarry("preBuffer", after); - events.push(...this.drainScanning()); } else { this.state = "streaming"; if (after) events.push({ type: "text_delta", text: after }); @@ -152,37 +163,31 @@ export class InlineThinkTagParser { */ private drainScanning(): AdapterEvent[] { const events: AdapterEvent[] = []; - for (;;) { - let openIndex = -1; - let openTag: ThinkingTag | undefined; - for (const tag of OPEN_TAGS) { - const index = this.preBuffer.indexOf(tag); - if (index >= 0 && (openIndex < 0 || index < openIndex)) { - openIndex = index; - openTag = tag; - } - } - if (openIndex >= 0 && openTag) { - const before = this.preBuffer.slice(0, openIndex); - if (before) { this.sawAnswerText = true; events.push({ type: "text_delta", text: before }); } - this.state = "thinking"; - this.closeTag = closeTagFor(openTag); - this.replaceCarry("thinkingBuffer", this.preBuffer.slice(openIndex + openTag.length)); - this.replaceCarry("preBuffer", ""); - events.push(...this.drainThinking()); - // drainThinking returns to "scanning" only when that block closed inside this chunk. - if ((this.state as ParserState) !== "scanning") return events; - continue; - } - // Hold back only as much as a partial open tag could occupy. - const cut = surrogateSafeCut(this.preBuffer, this.preBuffer.length - (MAX_OPEN_TAG - 1)); - if (cut > 0) { - this.sawAnswerText = true; - events.push({ type: "text_delta", text: this.preBuffer.slice(0, cut) }); - this.replaceCarry("preBuffer", this.preBuffer.slice(cut)); + let openIndex = -1; + let openTag: ThinkingTag | undefined; + for (const tag of OPEN_TAGS) { + const index = this.preBuffer.indexOf(tag); + if (index >= 0 && (openIndex < 0 || index < openIndex)) { + openIndex = index; + openTag = tag; } + } + if (openIndex >= 0 && openTag) { + const before = this.preBuffer.slice(0, openIndex); + if (before) events.push({ type: "text_delta", text: before }); + this.state = "thinking"; + this.closeTag = closeTagFor(openTag); + this.replaceCarry("thinkingBuffer", this.preBuffer.slice(openIndex + openTag.length)); + this.replaceCarry("preBuffer", ""); return events; } + // Hold back only as much as a partial open tag could occupy. + const cut = surrogateSafeCut(this.preBuffer, this.preBuffer.length - (MAX_OPEN_TAG - 1)); + if (cut > 0) { + events.push({ type: "text_delta", text: this.preBuffer.slice(0, cut) }); + this.replaceCarry("preBuffer", this.preBuffer.slice(cut)); + } + return events; } } @@ -227,7 +232,9 @@ export function splitInlineThinkContent( content: string, ): AdapterEvent[] { const splitter = createInlineThinkContentSplitter(models, modelId, budget); - const events = [...splitter.feed(content), ...splitter.flush()]; - splitter.dispose(); - return events; + try { + return [...splitter.feed(content), ...splitter.flush()]; + } finally { + splitter.dispose(); + } } diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 54cb85575ce..b990a426da3 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -121,6 +121,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { transientRetryOn5xx: "none", retryOnReset: "none", reasoningSplitModels: "list", + inlineThinkTagModels: "list", reasoningDetailsModels: "list", thinkingToggleModels: "list", thinkingBudgetModels: "list", diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 3a018ee62da..956a62d0996 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -228,8 +228,10 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "noVisionModels", "noReasoningModels", "noTemperatureModels", "noTopPModels", "noPenaltyModels", "noJsonSchemaModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", - "reasoningSplitModels", "inlineThinkTagModels", "reasoningDetailsModels", "thinkingToggleModels", "thinkingBudgetModels", + "reasoningSplitModels", "reasoningDetailsModels", "thinkingToggleModels", "thinkingBudgetModels", ] as const) putUnion(key, entry?.[key]); + // This parser is opt-in: an explicit list, including [], overrides registry defaults. + putScalar("inlineThinkTagModels", entry?.inlineThinkTagModels); for (const directModel of entry?.directReasoningEffortModels ?? []) { const staleBudget = [directModel, ...(entry?.thinkingBudgetModels ?? [])]; const routedStaleBudget = [...(entry?.thinkingBudgetModels ?? []), directModel]; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 3d63480be0b..914c2e9cae4 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -542,7 +542,7 @@ export function parseRequest( options.reasoning = requestedEffort; } const summaryMode = data.reasoning?.summary; - const reasoningActive = Boolean(requestedEffort && requestedEffort !== "none" && requestedEffort !== "off"); + const reasoningActive = options.reasoning !== undefined && options.reasoning !== "none"; if (summaryMode === "none" || (!summaryMode && !reasoningActive)) options.hideThinkingSummary = true; if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty; if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty; diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..9147b59e3a8 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -766,6 +766,9 @@ its defaults and exclusions are owned by [Responses transport](transports/respon The provider editor field policy exposes `showThinkingSummary` as a boolean provider option; it controls Responses summary defaults without a dashboard rendering change. See [Google provider](providers/google.md). +The same editor policy accepts the per-model `inlineThinkTagModels` string list. Its opt-in +format contract is owned by [Chat compatibility](providers/chat-compat.md#inline-think-tag-recovery). + Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback, preserved affinity, strategy-specific threshold summaries, and shared short-observation freshness for switch warnings. Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 132265964a9..f311118023b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,5 +1,8 @@ # Providers And Adapters +The opt-in `inlineThinkTagModels` list follows static-policy override and model-rename rules; +shared Kiro/Chat splitting and raw display follow [Chat compatibility](providers/chat-compat.md#inline-think-tag-recovery). + OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 3411c2ac5a3..d7a90a9741e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -51,18 +51,26 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun A gateway that serves a thinking model without a server-side reasoning parser returns the chain of thought inside `message.content` as `` / `` / `` blocks and sends neither `reasoning_content` nor `reasoning_details`. `src/adapters/openai-chat.ts` recovers those -blocks into reasoning only for models listed in `inlineThinkTagModels`. The option is off by -default because 66 registry providers share this adapter and a gateway that does parse reasoning +blocks into reasoning only for models listed in `inlineThinkTagModels`. An explicit operator list, +including `[]`, replaces matching registry defaults. The option is off by default because registry +providers share this adapter and a gateway that does parse reasoning must keep its visible content byte-exact. Once enabled the splitter still engages only for a -response that opens with a thinking tag, so an answer that merely mentions one is never rewritten; +response that opens with a thinking tag (optionally preceded by whitespace), so ordinary prose or +code fences before a tag leave the entire response untouched. Whitespace before that initial tag +and after every closing tag remains answer text; after it engages it keeps splitting later blocks, because M-series models interleave thinking with -answer segments. A block left unterminated at end of stream flushes as reasoning rather than being +answer segments, including same-line interleaving. Once engaged, tags are protocol delimiters even +inside subsequent code fences or quoted examples: this explicit opt-in does not parse Markdown. +Gateways producing ambiguous literals should use structured reasoning instead. Iterative draining +keeps stack depth independent of the number of blocks in an upstream chunk. +A block left unterminated at end of stream flushes as reasoning rather than being dropped. Whitespace between the leading block and the start of the answer is dropped as formatting noise; once answer text has been emitted, whitespace after a later closing tag is preserved, because a mid-answer block sits inside markdown or code where indentation is meaningful. `src/adapters/inline-think-tags.ts` owns the parser and is shared with the Kiro adapter, -which consumes it in single-block mode. Regression coverage is in -`tests/adapters/openai/openai-chat-inline-think-tags.test.ts`. +which consumes it in single-block mode with its existing leading/first-answer normalization. +Regression coverage is in `tests/adapters/openai/openai-chat-inline-think-tags.test.ts` and +`tests/adapters/openai/inline-think-boundaries.test.ts`. Google tool-declaration narrowing is observed by the Google final compiler, not this shared Chat compatibility layer. Its endpoint profile and privacy boundary are specified in the @@ -369,7 +377,10 @@ measurement rather than allocating a serialized copy just to measure it. > Decision record: [ADR-0067](../decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md) -`hideThinkingSummary` (request reasoning summary absent/"none" — the routed catalog default) is +`hideThinkingSummary` is set for explicit summary "none", or omitted summary without a validated +active effort. Accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) +allow raw visibility when summary is omitted; none and invalid efforts do not. Explicit "auto" +still permits raw visibility independently of effort. This flag is honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` (openai-chat `reasoning_content`, kiro tags). Hidden reasoning emits an envelope-only reasoning item (`summary: []`, txt-only `ocxr1:` `encrypted_content`, no text deltas) — invisible in the diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a82e8f8cb08..5b02b2c855b 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -968,6 +968,10 @@ removes those controls for unknown ladders and preserves `reasoning.summary`. Kn ladders retain the existing per-target effort resolution. This request normalization does not change target order or attempt accounting; provider-400 decisions follow the [request-local target compatibility](../runtime.md#request-local-target-compatibility) contract. +An injected combo default supplies `summary: "auto"` only when no summary was specified; caller +summary choices remain intact. Raw display and hidden-envelope replay follow +[reasoning display parity](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary). + The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. ## Upstream key attempt accounting diff --git a/tests/adapters/openai/inline-think-boundaries.test.ts b/tests/adapters/openai/inline-think-boundaries.test.ts new file mode 100644 index 00000000000..4bda09221d0 --- /dev/null +++ b/tests/adapters/openai/inline-think-boundaries.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { InlineThinkTagParser, splitInlineThinkContent } from "../../../src/adapters/inline-think-tags"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +function projection(events: AdapterEvent[]) { + return { + answer: events.filter(e => e.type === "text_delta").map(e => e.text).join(""), + reasoning: events.filter(e => e.type === "reasoning_raw_delta").map(e => e.text).join(""), + }; +} +function split(chunks: string[], interleaved = true) { + const parser = new InlineThinkTagParser(undefined, { interleaved }); + const events: AdapterEvent[] = []; + try { + for (const chunk of chunks) for (const event of parser.feed(chunk)) events.push(event); + events.push(...parser.flush()); + return projection(events); + } finally { parser.dispose(); } +} + +describe("inline thinking format boundaries", () => { + test("leading and first-answer whitespace survives every chunk boundary", () => { + const input = " \nwhy😀\n code();next tail"; + const expected = { answer: " \n\n code(); tail", reasoning: "why😀next" }; + for (let cut = 0; cut <= input.length; cut++) { + expect(split([input.slice(0, cut), input.slice(cut)])).toEqual(expected); + } + }); + test("Kiro retains single-block normalization and subsequent literal tags", () => { + expect(split([" \nwhy\n answerliteral"], false)) + .toEqual({ answer: "answerliteral", reasoning: "why" }); + }); + test("ordinary code examples never activate parsing", () => { + const input = "```xml\nliteral\n```"; + expect(split([...input])).toEqual({ answer: input, reasoning: "" }); + }); + test("after activation tags remain format delimiters even inside a code fence", () => { + const input = "first```xml\nlater\n```"; + expect(split([...input])).toEqual({ answer: "```xml\n\n```", reasoning: "firstlater" }); + }); + test("many same-chunk blocks and split chunks have identical output without recursion", () => { + const block = "ra"; + const count = 12000; + const expected = { answer: "a".repeat(count), reasoning: "r".repeat(count) }; + expect(split([block.repeat(count)])).toEqual(expected); + expect(split(Array(count).fill(block))).toEqual(expected); + }); + test("partial tags and unterminated reasoning flush without loss", () => { + expect(split(["why😀xanswer { + const content = "whyanswer"; + expect(projection(splitInlineThinkContent(["other"], "model", undefined, content))) + .toEqual({ answer: content, reasoning: "" }); + }); + test("dispose releases partial carry and an overflow does not relax the budget", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 128 }); + const parser = new InlineThinkTagParser(budget, { interleaved: true }); + parser.feed("partial"); + expect(() => parser.feed("x".repeat(129))).toThrow(); + parser.dispose(); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); diff --git a/tests/adapters/openai/openai-chat-inline-think-tags.test.ts b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts index c520c7b54bf..d2bd917f60c 100644 --- a/tests/adapters/openai/openai-chat-inline-think-tags.test.ts +++ b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts @@ -2,6 +2,9 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { buildResponseJSON } from "../../../src/bridge"; +import { parseRequest } from "../../../src/responses/parser"; +import { decodeReasoningEnvelope } from "../../../src/responses/reasoning-envelope"; const MODEL = "GLM-5.3-Flash"; @@ -50,6 +53,26 @@ function joined(events: AdapterEvent[], type: "text_delta" | "reasoning_raw_delt } describe("openai-chat inline recovery", () => { + test.each(["none", undefined])("inline reasoning crosses display and next-turn replay with summary %s", async summary => { + const request = parseRequest({ model: MODEL, input: [], reasoning: { effort: "high", summary } }); + const events = await collect(adapterFor(true).parseStream(sse("replay meanswer"))); + const response = buildResponseJSON(events, MODEL, { hideThinkingSummary: request.options.hideThinkingSummary }); + const output = (response as { output: Record[] }).output; + const item = output.find(o => o.type === "reasoning")!; + expect(item.summary).toEqual([]); + if (summary === "none") { + expect(item.content).toBeUndefined(); + expect(decodeReasoningEnvelope(item.encrypted_content as string)?.txt).toBe("replay me"); + } else { + expect(item.content).toEqual([{ type: "reasoning_text", text: "replay me" }]); + } + const next = parseRequest({ model: MODEL, input: [...output, { type: "message", role: "user", content: "next" }] }); + const replayAdapter = withTestTranslatorBudget(createOpenAIChatAdapterProduction({ + ...provider(true), preserveReasoningContentModels: [MODEL], + })); + const wire = JSON.parse(replayAdapter.buildRequest(next).body); + expect(wire.messages.find((m: { role: string }) => m.role === "assistant").reasoning_content).toBe("replay me"); + }); test("a gateway without a reasoning parser has its thinking split out of the answer", async () => { const events = await collect(adapterFor(true).parseStream( sse("weigh", "ing it up", "OCX_THINK_OK"), @@ -86,13 +109,13 @@ describe("openai-chat inline recovery", () => { expect(events.some(event => event.type === "reasoning_raw_delta")).toBe(false); }); - test("the blank line before the answer is dropped, but the answer's own indentation survives", async () => { + test("the initial blank line and the answer's own indentation both survive", async () => { const events = await collect(adapterFor(true).parseStream( sse("plan\n\nUse this:\n", "check", " indented line"), )); expect(joined(events, "reasoning_raw_delta")).toBe("plancheck"); - expect(joined(events, "text_delta")).toBe("Use this:\n indented line"); + expect(joined(events, "text_delta")).toBe("\n\nUse this:\n indented line"); }); test("an unterminated block is flushed as reasoning rather than lost", async () => { diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index b2fc6d3876f..00cbf4a28fd 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -291,7 +291,7 @@ describe("combo request cloning", () => { expect(concrete).toEqual({ model: "a/m1", input: [{ role: "user", content: "hi" }], - reasoning: { effort: "high" }, + reasoning: { effort: "high", summary: "auto" }, }); expect(raw).toEqual({ model: "combo/free", input: [{ role: "user", content: "hi" }] }); expect(concrete.input).not.toBe(raw.input); @@ -430,15 +430,15 @@ describe("combo request cloning", () => { */ test("a combo default above the target ladder is downgraded, not dropped (#3108)", () => { expect(concreteComboRequestBody({ model: "combo/x" }, target, "max", ["low", "medium", "high"]).reasoning) - .toEqual({ effort: "high" }); + .toEqual({ effort: "high", summary: "auto" }); expect(concreteComboRequestBody({ model: "combo/x" }, target, "high", ["low", "medium"]).reasoning) - .toEqual({ effort: "medium" }); + .toEqual({ effort: "medium", summary: "auto" }); // Exact support is still passed through untouched. expect(concreteComboRequestBody({ model: "combo/x" }, target, "max", ["high", "max"]).reasoning) - .toEqual({ effort: "max" }); + .toEqual({ effort: "max", summary: "auto" }); // Never raises: a request below everything supported takes the lowest rung, not a higher one. expect(concreteComboRequestBody({ model: "combo/x" }, target, "low", ["high", "max"]).reasoning) - .toEqual({ effort: "high" }); + .toEqual({ effort: "high", summary: "auto" }); // A caller-supplied effort still wins over the combo default. expect(concreteComboRequestBody( { model: "combo/x", reasoning: { effort: "low" } }, target, "max", ["low", "medium", "high"], diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8cc5bec1b87..5d035b125bf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "inline-think-boundaries.test.ts": "adapters/openai", + "reasoning-effort-summary-default.test.ts": "responses", "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", diff --git a/tests/providers/deepseek-reasoning-replay-gaps.test.ts b/tests/providers/deepseek-reasoning-replay-gaps.test.ts index 2ccdd1e641f..74a926fb4a1 100644 --- a/tests/providers/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/providers/deepseek-reasoning-replay-gaps.test.ts @@ -151,6 +151,7 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(assistantIndex).toBeGreaterThanOrEqual(0); expect(assistantIndex).toBeLessThan(taskIndex); expect(messages[assistantIndex]!["reasoning_content"]).toBe(" "); + expect(messages[assistantIndex]!.content).toBe(""); }); test("GAP A: reasoning item arriving AFTER its function_call is attached to its turn", () => { diff --git a/tests/providers/model-rename-migration.test.ts b/tests/providers/model-rename-migration.test.ts index 704fb4b7874..924cecd5967 100644 --- a/tests/providers/model-rename-migration.test.ts +++ b/tests/providers/model-rename-migration.test.ts @@ -48,6 +48,7 @@ function staleConfig(): OcxConfig { modelDefaultReasoningEfforts: { "qwen3.8-max-preview": "xhigh" }, preserveReasoningContentModels: ["glm-5.2", "qwen3.8-max-preview", "qwen3.7-max"], thinkingBudgetModels: ["qwen3.8-max-preview", "qwen3.7-max"], + inlineThinkTagModels: ["qwen3.8-max-preview", "qwen3.7-max"], retainModels: ["qwen3.8-max-preview"], }, }, @@ -71,6 +72,7 @@ describe("registry model rename migration (#1610)", () => { expect(prov.modelDefaultReasoningEfforts?.["qwen3.8-max"]).toBe("xhigh"); expect(prov.preserveReasoningContentModels).toEqual(["glm-5.2", "qwen3.8-max", "qwen3.7-max"]); expect(prov.thinkingBudgetModels).toEqual(["qwen3.8-max", "qwen3.7-max"]); + expect(prov.inlineThinkTagModels).toEqual(["qwen3.8-max", "qwen3.7-max"]); expect(prov.retainModels).toEqual(["qwen3.8-max"]); expect(warnings.some(w => w.includes("qwen3.8-max"))).toBe(true); }); @@ -98,6 +100,7 @@ describe("registry model rename migration (#1610)", () => { prov.modelDefaultReasoningEfforts = {}; prov.preserveReasoningContentModels = ["qwen3.8-max"]; prov.thinkingBudgetModels = ["qwen3.7-max"]; + prov.inlineThinkTagModels = ["qwen3.8-max"]; prov.retainModels = ["qwen3.8-max"]; clean.disabledModels = ["other/model"]; diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index 99ecc9411bc..77d8029de59 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -65,6 +65,14 @@ function resolve( } describe("resolved static model policy parity", () => { + test("inline tag opt-in inherits only matching transport defaults and preserves explicit empty lists", () => { + const entry = registry({ inlineThinkTagModels: [MODEL] }); + expect(resolve(provider(), entry).provider.inlineThinkTagModels).toEqual([MODEL]); + expect(resolve(provider({ inlineThinkTagModels: [] }), entry).provider.inlineThinkTagModels).toEqual([]); + expect(resolve(provider({ inlineThinkTagModels: ["other"] }), entry).provider.inlineThinkTagModels).toEqual(["other"]); + expect(resolve(provider(), entry, false).provider.inlineThinkTagModels).toBeUndefined(); + expect(routedProviderConfig("fixture-provider", provider({ inlineThinkTagModels: [MODEL] })).inlineThinkTagModels).toEqual([MODEL]); + }); test("representative registry maps stay byte-equivalent to the current route merge", () => { const entry = PROVIDER_REGISTRY.find(candidate => { if (candidate.allowBaseUrlOverride || /\{[^}]*\}/.test(candidate.baseUrl)) return false; diff --git a/tests/responses/reasoning-effort-summary-default.test.ts b/tests/responses/reasoning-effort-summary-default.test.ts index 9dcad9a1c1c..10f57d60dbb 100644 --- a/tests/responses/reasoning-effort-summary-default.test.ts +++ b/tests/responses/reasoning-effort-summary-default.test.ts @@ -4,6 +4,38 @@ import { concreteComboRequestBody } from "../../src/combos/request"; import type { OcxComboTarget } from "../../src/types"; describe("reasoning effort preserves visible thinking when summary is omitted", () => { + test.each(["unknown", "off", ""])("invalid effort %j does not enable visibility", effort => { + const parsed = parseRequest({ model: "test", input: [], reasoning: { effort } }); + expect(parsed.options.reasoning).toBeUndefined(); + expect(parsed.options.hideThinkingSummary).toBe(true); + }); + test.each([{}, [], 1, null].map(effort => ({ effort })))("non-string effort %j is rejected by the wire schema", ({ effort }) => { + expect(() => parseRequest({ model: "test", input: [], reasoning: { effort } })).toThrow("responses parse error"); + }); + test.each(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"])("validated effort %s enables raw visibility", effort => { + const parsed = parseRequest({ model: "test", input: [], reasoning: { effort } }); + expect(parsed.options.reasoning).toBe(effort === "ultra" ? "max" : effort); + expect(parsed.options.hideThinkingSummary).toBeUndefined(); + }); + test.each([undefined, "none", "off", "invalid"])("explicit auto is independent of effort %s", effort => { + expect(parseRequest({ model: "test", input: [], reasoning: { effort, summary: "auto" } }) + .options.hideThinkingSummary).toBeUndefined(); + }); + test("combo preserves caller summaries, none/minimal, fallback, and adaptive boundaries", () => { + const target = { provider: "test", model: "model" }; + for (const summary of ["none", "auto"]) { + expect(concreteComboRequestBody({ reasoning: { summary } }, target, "high", ["high"]).reasoning) + .toEqual({ effort: "high", summary }); + } + for (const effort of ["none", "minimal", "medium"]) { + expect(concreteComboRequestBody({ reasoning: { effort } }, target, "high", ["high"]).reasoning) + .toEqual({ effort }); + } + expect(concreteComboRequestBody({ reasoning: { effort: "medium", summary: "none" } }, target, "high", ["high"], "strict", "force").reasoning) + .toEqual({ effort: "high", summary: "none" }); + expect(concreteComboRequestBody({ reasoning: { effort: "high", summary: "none" } }, target, "high", undefined, "adaptive").reasoning) + .toEqual({ summary: "none" }); + }); test("reasoning with active effort does not default to hideThinkingSummary", () => { const parsed = parseRequest({ model: "test-model",