diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c7535203297..9080eac0ec7 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[]` | 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. | @@ -242,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 7117b4679ea..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", @@ -1087,6 +1089,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..2bf7a481740 --- /dev/null +++ b/src/adapters/inline-think-tags.ts @@ -0,0 +1,240 @@ +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.drain(); + } + if (this.state === "scanning") { + this.replaceCarry("preBuffer", this.preBuffer + text); + 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", ""); + 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"; + 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 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); + // 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); + } 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[] = []; + 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; + } +} + +/** 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); + try { + return [...splitter.feed(content), ...splitter.flush()]; + } finally { + splitter.dispose(); + } +} 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/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/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/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/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/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..956a62d0996 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" @@ -230,6 +230,8 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", "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 51bb36f5245..914c2e9cae4 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 = 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; if (data.service_tier !== undefined) options.serviceTier = data.service_tier; 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/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 1c7465bde6e..d7a90a9741e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -46,6 +46,32 @@ 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`. 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 (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, 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 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 [Google provider contract](google.md#google-tool-schema-loss-reporting). @@ -351,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 @@ -365,6 +394,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/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 new file mode 100644 index 00000000000..d2bd917f60c --- /dev/null +++ b/tests/adapters/openai/openai-chat-inline-think-tags.test.ts @@ -0,0 +1,150 @@ +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"; + +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.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"), + )); + + 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("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("\n\nUse 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"), + )); + + 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/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 1fd592857c0..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", @@ -913,6 +915,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/deepseek-reasoning-replay-gaps.test.ts b/tests/providers/deepseek-reasoning-replay-gaps.test.ts index 4e5c6ea3cae..74a926fb4a1 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,30 @@ 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(" "); + expect(messages[assistantIndex]!.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 +312,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); 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); 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 new file mode 100644 index 00000000000..10f57d60dbb --- /dev/null +++ b/tests/responses/reasoning-effort-summary-default.test.ts @@ -0,0 +1,85 @@ +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.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", + 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" }); + }); +});