diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6dc0dbdbac3..fb3a73ed01c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -857,10 +857,12 @@ when a stable upstream session is required. **MiMo tool-call echoes.** On OpenCode Go and other Chat Completions routes, a bare `` block is hidden when it duplicates one structured call to the same tool -with the same effective input. If the input differs or several calls could explain +with the same effective input. This includes MiMo wrappers such as +`` with matching closing IDs, including echoes appended to ordinary prose. +Code examples remain visible. If the input differs or several calls could explain the block, the markup remains visible. For `mimo-v2` and dotted MiMo V2 model IDs such as `mimo-v2.6-pro`, if the gateway instead sends exactly one empty `{}` call for a declared -freeform tool and puts its input in a standalone bare block, opencodex restores that +freeform tool and puts its input in a standalone bare or identified block, opencodex restores that input to the call and hides the block. Hyphenated IDs such as `mimo-v2-pro` and `mimo-v2-omni` are outside this recovery rule. Prose, quoted examples, ordinary functions, and ambiguous responses remain unchanged. This also handles diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1c6e4ff4d1b..543b3922cd5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -119,6 +119,7 @@ "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", + "openai-chat-nonce-tool-call-content.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 48a7bca52e2..9b0a2b03803 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -1,10 +1,9 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; import type { AdapterEvent } from "../../types"; import { repairFreeformToolInput } from "../../responses/apply-patch-envelope"; +import { contextAfter, deferredContinuationBreak, splitAtPossibleSerializedToolCall, MAX_TAG_LENGTH, TAG_SUFFIX, type TextContext } from "./serialized-tool-call-markup"; +export { splitAtPossibleSerializedToolCall } from "./serialized-tool-call-markup"; -const OPEN_TAG = ""; -const CLOSE_TAG = ""; -const FUNCTION_TAG = "\s*\r\n]+)>/y; +const BLOCK_HEADER = new RegExp(`\\s*\\r\\n]+)>`, "y"); /** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */ -const NEXT_BLOCK_HEADER = /\n\s*\r\n]+>/g; +const NEXT_BLOCK_HEADER = new RegExp(`\\n\\s*\\r\\n]+>`, "g"); const FUNCTION_CLOSE = ""; const PARAMETER_CLOSE = ""; @@ -77,22 +70,23 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined { BLOCK_HEADER.lastIndex = offset; const header = BLOCK_HEADER.exec(text); if (!header) return undefined; + const closeTag = ``; const bodyStart = offset + header[0].length; NEXT_BLOCK_HEADER.lastIndex = bodyStart; const next = NEXT_BLOCK_HEADER.exec(text); const limit = next ? next.index + 1 : text.length; let unclosed: SerializedToolCall | undefined; - for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed); - close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) { + for (let close = text.indexOf(closeTag, bodyStart); close >= 0 && (close < limit || !unclosed); + close = text.indexOf(closeTag, close + closeTag.length)) { let bodyEnd = trimmedEnd(text, bodyStart, close); const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE); if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length); if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length; const call = { - name: header[1]!.trim(), + name: header[2]!.trim(), body: text.slice(bodyStart, bodyEnd), start: offset, - end: close + CLOSE_TAG.length, + end: close + closeTag.length, }; if (closed) return call; if (close < limit) unclosed ??= call; @@ -128,94 +122,6 @@ function repeatedCallIn(text: string, context?: TextContext): SerializedToolCall return { ...first, end: text.length }; } -/** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */ -export function splitAtPossibleSerializedToolCall( - text: string, - initialContext: TextContext = { fence: null, lineStart: true }, - final = false, -): { - emit: string; - defer: string; - hasOpenTag: boolean; - context: TextContext; -} { - const context = { ...initialContext }; - const split = (at: number, hasOpenTag = false) => ({ - emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context, - }); - for (let index = 0; index < text.length; index++) { - if (context.lineStart) { - const rest = text.slice(index); - const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest); - if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0] - && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) { - if (!final && !rest.includes("\n")) return split(index); - context.fence = context.fence ? null : fence[1]!; - index += fence[0].length - 1; - context.lineStart = false; - continue; - } - if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index); - // Only bare control markup qualifies. Prose, quotes, indented examples and - // fenced code stay user-visible even when their body matches a real call. - if (!context.fence && !context.inlineTicks) { - if (rest.startsWith(OPEN_TAG)) { - const header = rest.slice(OPEN_TAG.length).trimStart(); - if (/^\r\n]+>/.test(header)) return split(index, true); - if (!final && (FUNCTION_TAG.startsWith(header) - || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) { - return split(index); - } - } else if (!final && OPEN_TAG.startsWith(rest)) return split(index); - } - } - if (!context.fence && text[index] === "`") { - let end = index + 1; - while (text[end] === "`") end++; - if (!final && end === text.length) return split(index); - const ticks = end - index; - if (!context.inlineTicks) context.inlineTicks = ticks; - else if (context.inlineTicks === ticks) context.inlineTicks = undefined; - index = end - 1; - } - context.lineStart = text[index] === "\n"; - } - return split(text.length); -} - -/** - * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so - * the scan runs through the whole text instead of stopping at the first opening tag; a block ends - * mid-line, which is exactly what the neutral spelling reports too. - */ -function contextAfter(text: string, context: TextContext): TextContext { - if (text.length === 0) return context; - return splitAtPossibleSerializedToolCall(text.replaceAll(OPEN_TAG, ""), context, true).context; -} - -/** - * A deferred header/fence can itself be long. While its unresolved portion only grows, a - * chunk-local check suffices; the first resolving character goes back through the full splitter. - * Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules. - */ -function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined { - if (text.startsWith(OPEN_TAG)) { - if (/^\s*$/.test(text)) return /\S/; - if (/^\s*\r\n]*$/.test(text)) return /[>\r\n]/; - } - if (!context.inlineTicks && context.lineStart) { - const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text); - if (fence) { - if (!context.fence) return /\n/; - // Closing-fence ticks may grow only before its optional whitespace suffix. - if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/; - return /[^ \t\r]/; - } - } - if (/^`+$/.test(text)) return /[^`]/; - return undefined; -} - /** * Holds possible duplicate text within the shared translator budget until the dispatch outcome is * known. While a block candidate is open, any other event (reasoning) is queued at its position in @@ -291,19 +197,22 @@ export class SerializedToolCallContentBuffer { /** Scan new text plus a fixed overlap, never the retained body (which may be a large rope). */ private observeDelimiters(delta: string): void { const scan = this.delimiterSuffix + delta; - const closer = scan.lastIndexOf(CLOSE_TAG); - const afterCloser = closer + CLOSE_TAG.length; - if (closer >= 0) { - this.sawCloser = true; - this.openAfterCloser = false; - this.trailingProseChars = 0; - } - if (scan.lastIndexOf(OPEN_TAG) >= (closer >= 0 ? afterCloser : 0)) { - this.openAfterCloser = true; + const tags = scan.matchAll(new RegExp(`<(/?)tool_call${TAG_SUFFIX}>`, "g")); + let afterCloser = -1; + for (const tag of tags) { + if (tag.index! + tag[0].length <= this.delimiterSuffix.length) continue; + if (tag[1]) { + this.sawCloser = true; + this.openAfterCloser = false; + this.trailingProseChars = 0; + afterCloser = tag.index! + tag[0].length; + } else { + this.openAfterCloser = true; + } } - const tail = closer >= 0 ? scan.slice(afterCloser) : delta; + const tail = afterCloser >= 0 ? scan.slice(afterCloser) : delta; if (this.sawCloser) this.trailingProseChars += tail.replace(/\s+/g, "").length; - this.delimiterSuffix = scan.slice(-(Math.max(OPEN_TAG.length, CLOSE_TAG.length) - 1)); + this.delimiterSuffix = scan.slice(-(MAX_TAG_LENGTH - 1)); } /** Returns immediately safe text and retains only the suffix that still needs reconciliation. */ @@ -609,7 +518,7 @@ export function reconcileStructuredToolCalls( const body = hasParameterOpener ? freeformBody(wrappedBody.slice("\s*\r\n]+>/.test(block.body) + && !new RegExp(`(?:^|\\n)\\s*\\r\\n]+>`).test(block.body) && !(hasParameterOpener && /^/.test(wrappedBody)) && !body.includes("")) { references[0] = { ...references[0]!, argumentsText: JSON.stringify({ input: body }), diff --git a/src/adapters/openai-chat/serialized-tool-call-markup.ts b/src/adapters/openai-chat/serialized-tool-call-markup.ts new file mode 100644 index 00000000000..d8ce4b0f1b0 --- /dev/null +++ b/src/adapters/openai-chat/serialized-tool-call-markup.ts @@ -0,0 +1,107 @@ +const OPEN_TAG = ""; +const FUNCTION_TAG = "`); +const OPEN_PREFIX = /^ ({ + emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context, + }); + for (let index = 0; index < text.length; index++) { + if (context.lineStart) { + const rest = text.slice(index); + const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest); + if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0] + && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) { + if (!final && !rest.includes("\n")) return split(index); + context.fence = context.fence ? null : fence[1]!; + index += fence[0].length - 1; + context.lineStart = false; + continue; + } + if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index); + context.literalLine = /[ \t>]/.test(text[index]!); + } + // Plain wrappers retain their bare-line rule. Nonced wrappers also occur after prose; + // quotes, indentation and Markdown code remain literal, even beside a matching call. + if (!context.fence && !context.inlineTicks && !context.literalLine && text[index] === "<") { + const rest = text.slice(index); + const opening = OPENING.exec(rest)?.[0]; + if (opening && (context.lineStart || opening !== OPEN_TAG)) { + const header = rest.slice(opening.length).trimStart(); + if (/^\r\n]+>/.test(header)) return split(index, true); + if (!final && (FUNCTION_TAG.startsWith(header) + || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) { + return split(index); + } + } else if (!final && (OPEN_TAG.startsWith(rest) || OPEN_PREFIX.test(rest))) return split(index); + } + if (!context.fence && text[index] === "`") { + let end = index + 1; + while (text[end] === "`") end++; + if (!final && end === text.length) return split(index); + const ticks = end - index; + if (!context.inlineTicks) context.inlineTicks = ticks; + else if (context.inlineTicks === ticks) context.inlineTicks = undefined; + index = end - 1; + } + context.lineStart = text[index] === "\n"; + } + return split(text.length); +} + +/** + * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so + * the scan runs through the whole text instead of stopping at the first opening tag; a block ends + * mid-line, which is exactly what the neutral spelling reports too. + */ +export function contextAfter(text: string, context: TextContext): TextContext { + if (text.length === 0) return context; + return splitAtPossibleSerializedToolCall(text.replace(new RegExp(``, "g"), ""), context, true).context; +} + +/** + * A deferred header/fence can itself be long. While its unresolved portion only grows, a + * chunk-local check suffices; the first resolving character goes back through the full splitter. + * Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules. + */ +export function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined { + const opening = OPENING.exec(text)?.[0]; + if (opening) { + const header = text.slice(opening.length); + if (/^\s*$/.test(header)) return /\S/; + if (/^\s*\r\n]*$/.test(header)) return /[>\r\n]/; + } + if (!context.inlineTicks && context.lineStart) { + const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text); + if (fence) { + if (!context.fence) return /\n/; + // Closing-fence ticks may grow only before its optional whitespace suffix. + if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/; + return /[^ \t\r]/; + } + } + if (/^`+$/.test(text)) return /[^`]/; + return undefined; +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 58965fc812e..53b61844ea9 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -202,7 +202,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `serialized-tool-call-markup.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. Prompt-cache TTL: an omitted `cacheRetention` resolves through `src/adapters/anthropic/cache-retention.ts` to 1h (`ttl: "1h"`) for first-party `api.anthropic.com` (API key and OAuth pools) and to 5m for Anthropic-compatible endpoints, which do not document the 1h TTL; an explicit value wins everywhere. The native Messages passthrough upgrades caller breakpoints without a TTL to 1h under the same rule unless the caller pinned any breakpoint to another TTL. Usage carries `cache_creation.ephemeral_1h_input_tokens` as `cacheCreation1hInputTokens`, which `src/usage/cost.ts` prices at 1.6x the 5m write rate (2x base input). `tests/adapters/adapter-usage.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts` and `tests/claude-integration/claude-translated-cache-ttl.test.ts` pin the defaults. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 082ac326a82..16ca3d79992 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -325,8 +325,8 @@ the request body is byte-for-byte unchanged in this area and OpenRouter retains Some Chat gateways expose one model-produced call twice: as a complete `…` content block and as a structured `tool_calls` -entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bare blocks at the -start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged. +entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bare line-start blocks outside Markdown. +Its scanner `src/adapters/openai-chat/serialized-tool-call-markup.ts` also recognizes `` after ordinary prose, requiring identical closing IDs (1–64 hex characters); code, quotes and indentation stay literal. Recognition alone never dispatches a call. It holds a possible serialized block, resumes ordinary text delivery when the header cannot match, and removes the block only when its function name and freeform body match a structured call's parsed `input` or exact raw arguments from a declared @@ -336,16 +336,16 @@ unwrap an alternate field, and JSON text that changes on dispatch is not treated Malformed JSON on an ordinary function is not raw input. A block may close a freeform body with a stray `` and may omit ``, and one newline after the function header is template layout, so MiMo's echoes of those shapes match too -(#5724). Blocks are read by delimiter scan in linear time: the first `` preceded by -`` closes the block, and only when none appears before the next block header at the start -of a line does the first `` close it, so a body can still carry literal tool-call tags. +(#5724). Blocks are scanned in linear time; closing tags must match the opener’s exact ID (`` for bare blocks). +Before the next line-start header, the first matching close preceded by `` wins; otherwise the first matching close before that header is the unclosed fallback. +If no matching close precedes that header, scanning continues beyond it but accepts only a matching close preceded by ``. Bodies can still carry literal tags. If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. MiMo V2 Chat IDs of `mimo-v2` or `mimo-v2.*` can send `{}` for a freeform call and put -its input in one bare block; hyphenated IDs (`mimo-v2-pro`, `mimo-v2-omni`) are excluded. With no other text, one call and an exact wire-tool match, the adapter restores `input` and removes the block. +its input in one bare or identified block; hyphenated IDs (`mimo-v2-pro`, `mimo-v2-omni`) are excluded. With no other text, one call and an exact wire-tool match, the adapter restores `input` and removes the block. Prose, fences, ordinary functions, multiple calls/blocks, and nonempty arguments remain inert. A malformed `${input}\n`; +const reference = { names: new Set(["exec"]), argumentsText: JSON.stringify({ input }), freeformTool: { name: "exec" } }; + +for (const prefix of ["", "Working now. "]) { + test(`every stream split reconciles an identified echo after ${JSON.stringify(prefix)}`, () => { + const text = prefix + block; + for (let split = 0; split <= text.length; split++) { + const budget = createTestTranslatorBudget(); + const buffer = new SerializedToolCallContentBuffer(budget); + const visible = buffer.ingest(text.slice(0, split)) + buffer.ingest(text.slice(split)) + buffer.flush([reference]); + expect(visible).toBe(prefix); + expect(budget.snapshot().currentBytes).toBe(0); + buffer.dispose(); + } + }); +} + +test.each(["`", "```\n", "> ", " ", " > "])("literal Markdown prefix %j is preserved", prefix => { + const text = prefix + block + (prefix === "`" ? "`" : prefix === "```\n" ? "\n```" : ""); + for (let split = 0; split <= text.length; split++) { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + expect(buffer.ingest(text.slice(0, split)) + buffer.ingest(text.slice(split)) + buffer.flush([reference])).toBe(text); + buffer.dispose(); + } +}); + +test("wrong nonce, wrong function, wrong input and incomplete blocks stay visible", () => { + for (const text of [block.replace(``, ""), + block.replace("function=exec", "function=other"), block.replace(input, "text(43);"), + block.replace(``, ""), block.replaceAll(nonce, "z".repeat(40)), + block.replaceAll(nonce, "a".repeat(65))]) { + expect(stripDuplicatedSerializedToolCalls(text, [reference])).toBe(text); + } +}); + +test("invalid identified wrappers stay visible in streaming responses", async () => { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter({ + adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "test", + })); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { + messages: [{ role: "user", content: "Run the tool", timestamp: 0 }], + tools: [{ name: "exec", description: "JavaScript", freeform: true, parameters: { + type: "object", properties: { input: { type: "string" } }, + } }], + } }); + for (const text of [ + block.replace(``, ""), + block.replaceAll(nonce, "a".repeat(65)), + ]) { + const wire = [...text] + .map(content => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`) + .join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(wire))) events.push(event); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(text); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + } +}); + +test("failed dispatch preserves held content and releases its budget", () => { + const budget = createTestTranslatorBudget(); + const buffer = new SerializedToolCallContentBuffer(budget); + expect(buffer.ingest(block)).toBe(""); + expect(buffer.flush([])).toBe(block); + expect(budget.snapshot().currentBytes).toBe(0); + buffer.dispose(); +}); + +test("identified closing tags retain the streaming prose bound at every split", () => { + const end = ``; + for (let split = 0; split <= end.length; split++) { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + const start = block.slice(0, -end.length); + expect(buffer.ingestStreaming(start + end.slice(0, split))).toEqual([]); + expect(buffer.ingestStreaming(end.slice(split))).toEqual([]); + expect(buffer.ingestStreaming("x".repeat(8192))).toEqual([]); + const released = buffer.ingestStreaming("y"); + expect(released.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(block + "x".repeat(8192) + "y"); + buffer.dispose(); + } +}); + +for (const stream of [false, true]) for (const empty of [false, true]) { + test(`MiMo adapter dispatches exactly once and hides only the echo (stream=${stream}, empty=${empty})`, async () => { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "test" })); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream, options: {}, context: { + messages: [{ role: "user", content: "Run the tool", timestamp: 0 }], + tools: [{ name: "exec", description: "JavaScript", freeform: true, parameters: { type: "object", properties: { input: { type: "string" } } } }], + } }); + const call = { id: "call_one", function: { name: "exec", arguments: empty ? "{}" : JSON.stringify({ input }) } }; + const prefix = empty ? "" : "Working. "; + const content = prefix + (empty ? block : block + block); + const events: AdapterEvent[] = []; + if (!stream) events.push(...await adapter.parseResponse!(Response.json({ choices: [{ message: { content, tool_calls: [call] }, finish_reason: "tool_calls" }] }), createTestTranslatorBudget())); + else { + const frames = [...content].map(frameContent => ({ choices: [{ delta: { content: frameContent } }] })); + const wire = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, ...call }] } }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] })}\n\ndata: [DONE]\n\n`; + for await (const event of adapter.parseStream(new Response(wire))) events.push(event); + } + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(prefix); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1); + expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")).toBe(JSON.stringify({ input })); + }); +} diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-scaling.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-scaling.test.ts index c47a6b20b85..b80d0ebe2b3 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-scaling.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-scaling.test.ts @@ -11,8 +11,8 @@ function scanWork(count: number, closed: boolean): number { const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); buffer.ingestStreaming(closed ? BLOCK : OPEN); let work = 0; - const original = String.prototype.lastIndexOf; - const scan = spyOn(String.prototype, "lastIndexOf").mockImplementation(function (this: string, ...args) { + const original = String.prototype.matchAll; + const scan = spyOn(String.prototype, "matchAll").mockImplementation(function (this: string, ...args) { work += this.length; return original.apply(this, args); }); @@ -32,9 +32,9 @@ describe("serialized tool content incremental delimiter work", () => { const small = scanWork(2048, closed); const large = scanWork(4096, closed); expect(small).toBeGreaterThan(0); - // Count actual search input, not elapsed time or an implementation-maintained counter. - expect(large).toBeLessThanOrEqual(2 * small + 24); - expect(large).toBeLessThanOrEqual(2 * 4096 * (3 + 11)); + // Count actual search input, allowing only the fixed tag-overlap warmup cost. + expect(large).toBeLessThanOrEqual(2 * small + 6400); + expect(large).toBeLessThanOrEqual(4096 * 100); }); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b7c535715ab..e51ba227e55 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -192,6 +192,7 @@ "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "codex-account-clear-paused.test.ts": "codex-integration", "responses-skills-snapshot.test.ts": "responses", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", + "openai-chat-nonce-tool-call-content.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", "jev-stats.test.ts": "usage",