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[]` | `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 `<think>` / `<thinking>` / `<reasoning>` 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. |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,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
233 changes: 233 additions & 0 deletions src/adapters/inline-think-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
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;
private sawAnswerText = false;

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 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 });
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) { 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));
}
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;
}
112 changes: 0 additions & 112 deletions src/adapters/kiro-thinking.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/adapters/kiro/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 => {
Expand Down
Loading
Loading