Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<think>` / `<thinking>` / `<reasoning>` 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. |
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
240 changes: 240 additions & 0 deletions src/adapters/inline-think-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { AdapterEvent } from "../types";
import { modelInList } from "../types";
import type { TranslatorBudget } from "../lib/translator-budget";

type ThinkingTag = "<thinking>" | "<think>" | "<reasoning>";
type ParserState = "pre" | "thinking" | "scanning" | "streaming";

const OPEN_TAGS: ThinkingTag[] = ["<thinking>", "<think>", "<reasoning>"];
const MAX_OPEN_TAG = Math.max(...OPEN_TAGS.map(t => t.length));
const MAX_CLOSE_TAG = Math.max(...OPEN_TAGS.map(t => `</${t.slice(1)}`.length));

function closeTagFor(openTag: ThinkingTag): string {
return `</${openTag.slice(1)}`;
}

function isPossibleOpenTagPrefix(text: string): boolean {
return OPEN_TAGS.some(tag => 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 `<think>` 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();
}
}
112 changes: 0 additions & 112 deletions src/adapters/kiro-thinking.ts

This file was deleted.

Loading
Loading