From 546059a6a9c8e1dcac183a92f7c394a0de7da620 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:10:21 +0900 Subject: [PATCH 01/15] fix(quota): label DeepSeek balance with the selected row's currency (#5692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CNY-billed account showed 'API balance ($76.88)'. The symbol now follows the balance_infos row that was picked: USD keeps $, CNY uses ¥, other codes prefix the amount, and a row without a currency keeps the legacy $. --- src/providers/quota/vendor-probes-key.ts | 10 ++- .../providers/deepseek-quota-currency.test.ts | 85 +++++++++++++++++++ tests/providers/provider-quota.test.ts | 2 +- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/providers/deepseek-quota-currency.test.ts diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 6c44162ea43..59347607f5e 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -371,9 +371,15 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): const toppedUp = toFiniteNumber(preferred.topped_up_balance); const balance = totalBalance ?? grantedBalance ?? toppedUp; if (balance === undefined || balance < 0) return null; + // The rows are currency-scoped, so the symbol has to follow the row that was + // picked: the two glyph currencies keep their sign, any other ISO code + // prefixes the amount, and a row without one keeps the legacy dollar. + const currency = String(preferred.currency ?? "").trim().toUpperCase(); + const sign = currency === "CNY" ? "¥" : currency === "" || currency === "USD" ? "$" : `${currency} `; + const amount = (value: number) => `${sign}${value.toFixed(2)}`; const label = grantedBalance !== undefined && grantedBalance > 0 - ? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)` - : `API balance ($${balance.toFixed(2)})`; + ? `API balance (${amount(balance)} total, ${amount(grantedBalance)} granted)` + : `API balance (${amount(balance)})`; return report(provider, "deepseek:balance", { customWindows: [{ label, percent: 0 }], updatedAt: Date.now(), diff --git a/tests/providers/deepseek-quota-currency.test.ts b/tests/providers/deepseek-quota-currency.test.ts new file mode 100644 index 00000000000..0dd8001f4ef --- /dev/null +++ b/tests/providers/deepseek-quota-currency.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../src/providers/quota"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +function deepSeekConfig(): OcxConfig { + return { + defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.deepseek.com", + apiKey: "deepseek-secret", + }, + }, + } as OcxConfig; +} + +/** Answer the DeepSeek balance probe with exactly these `balance_infos` rows. */ +async function balanceLabel(rows: unknown[]): Promise { + globalThis.fetch = (async () => new Response(JSON.stringify({ + is_available: true, + balance_infos: rows, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(deepSeekConfig(), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("deepseek:balance"); + return result.reports[0]?.quota.customWindows?.[0]?.label; +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-deepseek-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(opencodexHome); +}); + +describe("DeepSeek quota balance currency", () => { + test("a CNY-only row renders the yuan sign", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "76.88" }])) + .toBe("API balance (¥76.88)"); + }); + + test("a CNY row with a granted balance renders both amounts in yuan", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "76.88", granted_balance: "12.5" }])) + .toBe("API balance (¥76.88 total, ¥12.50 granted)"); + }); + + test("a USD row keeps the dollar sign", async () => { + expect(await balanceLabel([{ currency: "USD", total_balance: "6" }])) + .toBe("API balance ($6.00)"); + }); + + test("any other currency prefixes the upper-cased code", async () => { + expect(await balanceLabel([{ currency: "eur", total_balance: "5" }])) + .toBe("API balance (EUR 5.00)"); + }); + + test("a row without a currency keeps the legacy dollar sign", async () => { + expect(await balanceLabel([{ total_balance: "5" }])) + .toBe("API balance ($5.00)"); + }); + + test("a USD row is still preferred over a CNY row", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "10" }, { currency: "USD", total_balance: "7" }])) + .toBe("API balance ($7.00)"); + }); +}); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 0c9c8b210c4..228be2e729b 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1130,7 +1130,7 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("deepseek:balance"); expect(result.reports[0]?.quota.customWindows).toEqual([{ - label: "API balance ($6.00 total, $4.00 granted)", + label: "API balance (¥6.00 total, ¥4.00 granted)", percent: 0, }]); expect(seen).toHaveLength(1); From d26625c00c428de5e497538b6ad28ca92e4319ad Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:13:05 +0900 Subject: [PATCH 02/15] fix(registry): publish MiMo token-plan context, output and modality facts (#5695) The mimo token-plan entry declared no model-level capacity, so V2.6 rows reached clients without a context window, output cap or input modalities. Xiaomi's model pages list 1M context and 128K output for all four roster ids, image input for V2.6 Pro/Flash and V2.5, and text only for V2.5 Pro. Video/audio have no catalog vocabulary and are not claimed; noVisionModels is unchanged. --- src/providers/registry/entries-extended.ts | 18 +++- .../catalog-vision-sidecar-modalities.test.ts | 2 +- .../mimo-token-plan-capacity.test.ts | 84 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/providers/mimo-token-plan-capacity.test.ts diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index d96f6605043..42c0bbda406 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1218,11 +1218,23 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://xiaomimimo.com", - // Token-plan roster per Xiaomi's token-plan model list (V2.6 Pro and Flash). No jawcodeBundle, - // so no plan-specific facts are claimed; usage estimates still come from the model-level vendor - // price fallback (the pay-as-you-go equivalent), exactly as they did for V2.5. + // Token-plan roster per Xiaomi's token-plan model list (V2.6 Pro and Flash). Model-level facts + // come from Xiaomi's model pages (mimo.mi.com/models/en-US/, fetched 2026-09-24): 1M context, + // 128K max output; V2.6 Pro/Flash and V2.5 take text/image/video/audio, V2.5 Pro text only. The + // catalog vocabulary has no video or audio, so only text/image are claimed. The token plan speaks + // the same API format as pay-as-you-go, so these are model facts rather than plan facts. No + // jawcodeBundle: pricing and entitlement stay unclaimed, and usage estimates still come from the + // model-level vendor price fallback, exactly as they did for V2.5. defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"], + modelContextWindows: { "mimo-v2.6-pro": 1_048_576, "mimo-v2.6-flash": 1_048_576, "mimo-v2.5-pro": 1_048_576, "mimo-v2.5": 1_048_576 }, + modelMaxOutputTokens: { "mimo-v2.6-pro": 131_072, "mimo-v2.6-flash": 131_072, "mimo-v2.5-pro": 131_072, "mimo-v2.5": 131_072 }, + modelInputModalities: { + "mimo-v2.6-pro": ["text", "image"], + "mimo-v2.6-flash": ["text", "image"], + "mimo-v2.5": ["text", "image"], + "mimo-v2.5-pro": ["text"], + }, // The gateway validates the ladder strictly and rejects anything above `high`. reasoningEfforts: ["low", "medium", "high"], reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 6df91ab4e50..b5698848aad 100644 --- a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts +++ b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts @@ -107,7 +107,7 @@ describe("vision-sidecar catalog modalities", () => { expect(applyProviderConfigHints("mimo", canonical, { id: "mimo-v2.5", provider: "mimo", - }).inputModalities).toBeUndefined(); + }).inputModalities).toEqual(["text", "image"]); // native, from the registry's modelInputModalities const customDestination: OcxProviderConfig = { adapter: "openai-chat", diff --git a/tests/providers/mimo-token-plan-capacity.test.ts b/tests/providers/mimo-token-plan-capacity.test.ts new file mode 100644 index 00000000000..cecd298396c --- /dev/null +++ b/tests/providers/mimo-token-plan-capacity.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { applyProviderConfigHints } from "../../src/codex/catalog"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { resolveModelPolicy } from "../../src/providers/resolved-model-policy"; +import { routeModel } from "../../src/router"; +import type { OcxConfig } from "../../src/types"; + +// Xiaomi's model pages (mimo.mi.com/models/en-US/, fetched 2026-09-24): 1M context, +// 128K max output, and text/image/video/audio input for V2.6 Pro/Flash and V2.5 (V2.5 Pro +// documents text only). The catalog vocabulary has no video or audio entry. +const CONTEXT_WINDOW = 1_048_576; +const MAX_OUTPUT = 131_072; +const ROSTER = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"]; + +const entry = () => getProviderRegistryEntry("mimo")!; + +function policy(modelId: string) { + return resolveModelPolicy({ + providerName: "mimo", + modelId, + provider: { adapter: "openai-chat", baseUrl: entry().baseUrl, authMode: "key" }, + registryEntry: entry(), + transportMatchedRegistry: true, + effectiveAuth: { authMode: "key" }, + }); +} + +describe("MiMo token-plan capacity facts", () => { + test("the registry entry carries the vendor window, output and modality maps", () => { + const mimo = entry(); + expect(mimo.modelContextWindows).toEqual(Object.fromEntries(ROSTER.map(id => [id, CONTEXT_WINDOW]))); + expect(mimo.modelMaxOutputTokens).toEqual(Object.fromEntries(ROSTER.map(id => [id, MAX_OUTPUT]))); + expect(mimo.modelInputModalities).toEqual({ + "mimo-v2.6-pro": ["text", "image"], + "mimo-v2.6-flash": ["text", "image"], + "mimo-v2.5": ["text", "image"], + "mimo-v2.5-pro": ["text"], + }); + // Every map key is on the roster, and no plan-specific entitlement is claimed. + for (const id of ROSTER) expect(mimo.models, id).toContain(id); + expect(mimo.jawcodeBundle).toBeUndefined(); + }); + + test("the seed carries the facts, so a saved token-plan config inherits them", () => { + const seed = providerConfigSeed(entry()); + expect(seed.modelContextWindows).toEqual(Object.fromEntries(ROSTER.map(id => [id, CONTEXT_WINDOW]))); + expect(seed.modelMaxOutputTokens).toEqual(Object.fromEntries(ROSTER.map(id => [id, MAX_OUTPUT]))); + expect(seed.modelInputModalities).toEqual(entry().modelInputModalities!); + expect(seed.noVisionModels).toEqual(["mimo-v2.5-pro"]); + }); + + test("a routed V2.6 Pro row reports the vendor window, output and image input", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "mimo", + providers: { mimo: { ...providerConfigSeed(entry()), apiKey: "k", liveModels: true } }, + }; + const route = routeModel(config, "mimo/mimo-v2.6-pro"); + const row = applyProviderConfigHints("mimo", route.provider, { provider: "mimo", id: route.modelId }); + expect(row.contextWindow).toBe(CONTEXT_WINDOW); + expect(row.maxOutputTokens).toBe(MAX_OUTPUT); + expect(row.inputModalities).toEqual(["text", "image"]); + // V2.6 Flash is the sibling the same pages cover. + expect(applyProviderConfigHints("mimo", route.provider, { provider: "mimo", id: "mimo-v2.6-flash" }).inputModalities) + .toEqual(["text", "image"]); + }); + + test("the token-plan policy reports V2.5 Pro as text-only and V2.5 as image-capable", () => { + for (const id of ROSTER) { + const resolved = policy(id); + expect(resolved.model.contextWindow, id).toBe(CONTEXT_WINDOW); + expect(resolved.model.maxOutputTokens, id).toBe(MAX_OUTPUT); + // The maps are model facts, not plan prices: no per-model reasoning or tier claim is added. + expect(resolved.model.reasoningEfforts, id).toEqual(["low", "medium", "high"]); + } + const v25 = policy("mimo-v2.5"); + expect(v25.model.inputModalities).toEqual(["text", "image"]); + // The claim is read from the registry, not from a vendor-free default. + expect(v25.provenance.model.inputModalities).toBe("registry"); + // V2.5 Pro documents text-only input upstream, so the modality map must not widen it. + expect(policy("mimo-v2.5-pro").model.inputModalities).toEqual(["text"]); + }); +}); From 2043f969e4b575e18597501c643597b45261a225 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:13:05 +0900 Subject: [PATCH 03/15] fix(google): give array tool parameters without items a string item schema (#5689) A tool parameter declared as {type: array} with no items reached Gemini unchanged and could be rejected. The sanitizer now materializes items {type: string} for any array it emits without items (missing, tuple, or invalid source items). Valid item schemas are unchanged, the budget-exhausted path is untouched, and no loss category is recorded. --- src/adapters/google-tool-schema.ts | 4 + structure/providers/google.md | 4 +- .../google/google-tool-schema.test.ts | 80 ++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index a28290fe602..6100d884114 100644 --- a/src/adapters/google-tool-schema.ts +++ b/src/adapters/google-tool-schema.ts @@ -753,6 +753,10 @@ function sanitizeSchema( } Object.assign(out, normalized); } + // Gemini rejects an array declaration with no `items` (#5689). A string item keeps the declaration + // valid. It narrows an unconstrained item rather than widening a constraint, so the loss report, + // which counts widened or dropped constraints, does not record it. + if (out.type === "array" && !Object.hasOwn(out, "items")) out.items = { type: "string" }; return out; } diff --git a/structure/providers/google.md b/structure/providers/google.md index 5582a356d9f..07ecc155907 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -88,7 +88,9 @@ Every sanitizer branch that widens or drops an accepted-value constraint has a c including type unions and unsupported types, conditional and tuple constraints, reference-overlay replacement, and root object coercion. Lossless normalization does not set `lossy`: accepted type case folding, duplicate enum/required removal, nullable-union collapse, and string-const conversion -preserve the accepted value set. Annotation-only fields such as title, default, examples, comments, +preserve the accepted value set; an array left without `items` is emitted with `items: { type: "string" }` +because Gemini rejects an array declaration without an item type; that narrows an unconstrained item +rather than widening a constraint, so it does not set `lossy` either. Annotation-only fields such as title, default, examples, comments, deprecated, read-only/write-only, external documentation and examples are omitted without loss. Local-reference siblings use 2020-12-style conjunctive semantics for loss accounting, while the wire transform retains its implemented overlay-wins merge; enum reports compare that intersection diff --git a/tests/adapters/google/google-tool-schema.test.ts b/tests/adapters/google/google-tool-schema.test.ts index 776a5138670..52387b055a0 100644 --- a/tests/adapters/google/google-tool-schema.test.ts +++ b/tests/adapters/google/google-tool-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { sanitizeGeminiToolParameters } from "../../../src/adapters/google-tool-schema"; +import { + sanitizeGeminiToolParameters, + sanitizeGeminiToolParametersWithReport, +} from "../../../src/adapters/google-tool-schema"; function countSchemaNodes(value: unknown): number { if (!value || typeof value !== "object" || Array.isArray(value)) return 0; @@ -483,4 +486,79 @@ describe("sanitizeGeminiToolParameters", () => { expect(sanitizeGeminiToolParameters(undefined)).toEqual({ type: "object", properties: {} }); expect(sanitizeGeminiToolParameters("nope")).toEqual({ type: "object", properties: {} }); }); + + test("materializes items on an array left without them (issue #5689)", () => { + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + required: ["values"], + properties: { values: { type: "array" } }, + }, { endpointClass: "ai-studio" }); + const values = (result.parameters.properties as Record>).values; + expect(values.items).toEqual({ type: "string" }); + expect(values).toEqual({ type: "array", items: { type: "string" } }); + // Gemini needs the item type present; adding it widens nothing, so `lossy` stays false. + expect(result.lossReport.lossy).toBe(false); + expect(result.lossReport.categories).toEqual({}); + }); + + test("materializes items for an array nested in array items", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { grid: { type: "array", items: { type: "array" } } }, + }); + const grid = (out.properties as Record>).grid; + expect(grid).toEqual({ type: "array", items: { type: "array", items: { type: "string" } } }); + }); + + test("materializes items when a tuple's prefix list is dropped", () => { + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties: { pair: { type: "array", items: [{ type: "string" }, { type: "number" }] } }, + }, { endpointClass: "ai-studio" }); + const pair = (result.parameters.properties as Record>).pair; + expect(pair).toEqual({ type: "array", items: { type: "string" } }); + expect(result.lossReport.categories).toEqual({ "tuple-prefix-dropped": 1 }); + }); + + test("materializes items for an array collapsed from a nullable anyOf", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { ids: { anyOf: [{ type: "array" }, { type: "null" }] } }, + }); + expect((out.properties as Record>).ids).toEqual({ + type: "array", + items: { type: "string" }, + nullable: true, + }); + }); + + test("leaves valid array items unchanged", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { + list: { type: "array", items: { type: "integer", description: "kept" }, minItems: 1 }, + enumList: { type: "array", items: { enum: ["a", "b"] } }, + }, + }); + const props = out.properties as Record>; + expect(props.list).toEqual({ type: "array", items: { type: "integer", description: "kept" } }); + expect(props.enumList).toEqual({ type: "array", items: { enum: ["a", "b"] } }); + }); + + test("does not add items to a non-array property", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { + text: { type: "string" }, + widened: {}, + nested: { type: "object", properties: { inner: { type: "array" } } }, + }, + }); + const props = out.properties as Record>; + expect(Object.hasOwn(props.text, "items")).toBe(false); + expect(Object.hasOwn(props.widened, "items")).toBe(false); + expect(Object.hasOwn(props.nested, "items")).toBe(false); + const inner = (props.nested.properties as Record>).inner; + expect(inner.items).toEqual({ type: "string" }); + }); }); From 49d7e065b5c3d94f0a5eaaaa151b8c3e2d54e946 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:13:42 +0900 Subject: [PATCH 04/15] fix(openai-chat): reconcile repeated MiMo tool-call echoes (carries #5693) MiMo 2.6 Pro over OpenCode Go can echo two identical bare blocks in assistant text beside one structured call whose input repeats the body twice. The pair is now suppressed when exactly one structured call agrees with its function and input, and a doubled input (direct or newline joined, input as the only key) is reduced to one copy. Ambiguous or mismatched markup stays visible. Rebuilt on the blockAt/freeformBody reader from #5725, so the comparison also holds for the canonical MiMo layout with a newline after the function header. Co-authored-by: Vadevious --- .../src/content/docs/reference/adapters.md | 5 + .../serialized-tool-call-content.ts | 36 ++++ .../ADR-5548-serialized-tool-call-content.md | 2 +- structure/providers/chat-compat.md | 6 + ...-chat-serialized-tool-call-content.test.ts | 182 ++++++++++++++++++ .../responses-chat-tool-call-content.test.ts | 22 ++- 6 files changed, 248 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 78828bdfb52..87a818ca589 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -62,6 +62,11 @@ transport; it does not infer subscription attribution from the inbound protocol. collects `usage`. Providers listed in `reasoningDetailsModels` (MiniMax M-series) instead read structured `delta.reasoning_details` segments, whose `text` arrives as cumulative snapshots and is prefix-diffed, and replay preserved reasoning as a `reasoning_details` array. +- Suppresses bare `` text when it duplicates a structured call, and collapses two + immediately adjacent identical blocks when exactly one structured call agrees with their function + and input. A doubled `input` is reduced to one copy, joined either directly or by one newline, + and only when the arguments object holds no key besides `input`. Trailing whitespace after the + pair is suppressed; mismatched or example markup remains visible. - ClinePass uses the live-verified gateway format `reasoning: { enabled: true, effort }` (or `{ enabled: false }` when reasoning is disabled); its public API docs do not currently specify this request shape. The adapter preserves requested `low`, `medium`, `high`, `xhigh`, and `max` diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 3a46c3ebbac..8986adb94aa 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -100,6 +100,17 @@ function callsIn(text: string, context: TextContext = { fence: null, lineStart: return calls; } +/** + * The first block, and only when the text after it is exactly one repetition of that same block + * (trailing whitespace allowed). The returned range covers the pair and any trailing whitespace. + */ +function repeatedCallIn(text: string, context?: TextContext): SerializedToolCall | undefined { + const first = callsIn(text, context)[0]; + if (!first) return undefined; + if (text.slice(first.end).trimEnd() !== text.slice(first.start, first.end).trimEnd()) return undefined; + 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, @@ -335,6 +346,16 @@ function duplicatedSerializedToolCallRanges( context?: TextContext, ): { start: number; end: number }[] { if (structuredCalls.length === 0) return []; + const repeated = repeatedCallIn(text, context); + if (repeated) { + const body = freeformBody(repeated.body); + // Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed. + const matching = structuredCalls.filter(structured => { + const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined; + return input !== undefined && freeformBody(input) === body; + }); + return matching.length === 1 ? [{ start: repeated.start, end: repeated.end }] : []; + } return callsIn(text, context).filter(call => { const body = freeformBody(call.body); return structuredCalls.some(structured => { @@ -365,6 +386,21 @@ export function repairArgumentsDuplicatedBesideSerializedCall( functionNames: ReadonlySet, serializedText: string, ): string { + const repeated = repeatedCallIn(serializedText); + if (repeated && functionNames.has(repeated.name)) { + const body = freeformBody(repeated.body); + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + && Object.keys(parsed).length === 1 + && ((parsed as Record).input === body + body + || (parsed as Record).input === body + "\n" + body)) { + return JSON.stringify({ input: body }); + } + } catch { + // A malformed concatenation may still match the prefix repair below. + } + } try { JSON.parse(argumentsText); return argumentsText; diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index 13b2a1c31d0..5693306cc97 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -9,5 +9,5 @@ - Alternatives considered: Drop all tool-call-looking content, add a provider-specific switch, or reconcile serialized blocks with structured calls at the Chat adapter boundary. - Choice: Hold only a possible complete markup block and suppress or repair it only when the function name and duplicated body agree with a structured call in the same response. - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. -- Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. +- Consequences: Matching calls no longer appear twice; an exact pair of immediately adjacent identical blocks with one doubled structured input is reduced to one call; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for proven duplicate shapes. - Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 46cb7064238..3677b03e3db 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -334,6 +334,12 @@ of a line does the first `` close it, so a body can still carry lite 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. +Two immediately adjacent identical bare blocks, with optional trailing whitespace after the pair, +are suppressed only when exactly one structured call matches their function name and carries their +body as `input`, either as one copy or as two copies joined directly or by one newline. Reducing a +doubled `input` requires an arguments object with no keys besides `input`; extra keys leave it +unchanged. Unrelated structured calls do not prevent suppression, and other repeated shapes remain +unchanged. Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures drain all held text, including matching serialized blocks, because pending tools are not dispatched. The held bytes use the shared translator budget. The streaming hold is bounded (`ingestStreaming`): once a closed block is followed by more than 8 KiB of prose with no block open after it, or held text plus queued events would pass 4 MiB, everything held is released in order with nothing suppressed, so an unmatched block no longer delays the rest of the answer to the end of the turn. A duplicate is the tail of the content, so its reconciliation is unaffected; past either bound the stream prefers delivery (the pre-#5548 raw markup) over suppression. Buffered responses keep the unbounded `ingest` because their structured calls are already known (`tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts`). For a model opted into inline `` splitting, diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 7faf3a8572b..2e31d673561 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -31,6 +31,188 @@ test("buffered Chat responses reconcile matching serialized and structured tool }); }); +test("buffered Chat responses reconcile two identical echoed blocks and doubled input", async () => { + const script = "const names = []; text(names);"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress two echoed blocks when structured input is already single", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress two echoed blocks with a trailing newline", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block + "\n", + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input: script }) } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses repair two echoed blocks with newline-joined input", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + "\n" + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress a repeated echo beside an unrelated structured call", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [ + { id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input: script }) } }, + { id: "call_other", function: { name: "other", arguments: JSON.stringify({ input: "other" }) } }, + ], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script }) }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "other" }) }, + ]); +}); + +test("buffered Chat responses preserve repeated markup when two structured calls match", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [ + { id: "call_one", function: { name: "exec", arguments: argumentsText } }, + { id: "call_two", function: { name: "exec", arguments: argumentsText } }, + ], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + +test("buffered Chat responses preserve repeated markup when the structured input differs", async () => { + const script = "text('example');"; + const content = `${script}`.repeat(2); + const argumentsText = JSON.stringify({ input: script + "text('other');" }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: argumentsText } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.find(event => event.type === "text_delta")).toEqual({ type: "text_delta", text: content }); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: argumentsText, + }); +}); + +test("buffered Chat responses reduce a doubled input behind the MiMo wrapping newline", async () => { + // The canonical MiMo layout puts one template newline after the function header, so the block + // body and the doubled structured input only agree once both sides are freeform-normalized. + const script = "text('ok');"; + const block = `\n${script}\n`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + test("buffered Chat responses preserve serialized markup for a different function", async () => { const content = "literal example"; const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts index c9b876a184c..46804f2d481 100644 --- a/tests/responses/responses-chat-tool-call-content.test.ts +++ b/tests/responses/responses-chat-tool-call-content.test.ts @@ -10,12 +10,16 @@ afterEach(() => { releaseSpendHome = undefined; }); -test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a structured call", async () => { +async function checkEchoedToolCall( + repeated: boolean, + trailingNewline = false, + newlineJoinedInput = false, +): Promise { const savedFetch = globalThis.fetch; const script = "const result = await tools.exec_command({cmd: \"pwd\"});\ntext(result.output);"; const leaked = `${script}\n`; const commentary = "I'll run it now.\n"; - const content = commentary + leaked; + const content = commentary + leaked + (repeated ? leaked : "") + (trailingNewline ? "\n" : ""); const split = commentary.length + 5; const frames = [ { choices: [{ delta: { content: content.slice(0, split) } }] }, @@ -26,7 +30,12 @@ test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a stru tool_calls: [{ index: 0, id: "call_exec", - function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + function: { + name: "exec", + arguments: repeated + ? JSON.stringify({ input: script + (newlineJoinedInput ? "\n" : "") + script }) + : script + JSON.stringify({ input: script }), + }, }], }, }], @@ -85,4 +94,9 @@ test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a stru } finally { globalThis.fetch = savedFetch; } -}); +} + +test("/v1/responses suppresses one echoed block", () => checkEchoedToolCall(false)); +test("/v1/responses suppresses two echoed blocks with doubled input", () => checkEchoedToolCall(true)); +test("/v1/responses suppresses trailing newline and repairs newline-joined doubled input", () => + checkEchoedToolCall(true, true, true)); From e6f3878154857352ade176f7c86c5130b8316951 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:13:58 +0900 Subject: [PATCH 05/15] fix(xai): give grok-4.7-build-fast grok-4.7's documented metadata (#5576) The discovered grok-4.7-build-fast id fell back to a 128K window and a generic effort ladder. xAI documents Grok 4.7 Fast as the same model on faster infrastructure (Cursor and Grok Build only), so the id now carries grok-4.7's 500K window, low..xhigh ladder with a high default, image input, and the reasoning-model stop/penalty/reasoning-replay lists. The wire pin, service tier and lineup seed stay unclaimed until probed. --- src/providers/registry/entries-core.ts | 15 ++++- structure/providers/xai-grok.md | 6 ++ .../provider-registry-parity.test.ts | 2 +- .../xai/grok-47-build-fast-metadata.test.ts | 59 +++++++++++++++++++ tests/providers/xai/xai-no-stop.test.ts | 1 + tests/providers/xai/xai-transport.test.ts | 2 + 6 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/providers/xai/grok-47-build-fast-metadata.test.ts diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 6b91a3f860f..c84ec3d4f20 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -267,6 +267,12 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. models: XAI_MODELS, + // grok-4.7-build-fast arrives only through OAuth discovery. We read it as the Grok Build id of + // what xAI documents as Grok 4.7 Fast: "the same model served on faster infrastructure", + // offered in Cursor and Grok Build only, not on the public xAI API (docs.x.ai/developers/grok-4-7, + // fetched 2026-09-24). It therefore inherits grok-4.7's documented facts in the lists below. + // Its wire pin and service tier stay unclaimed until probed, which is why it is absent from + // XAI_MODELS, modelWireDefaults and modelSupportsServiceTier. // Live 2026-09-20: Chat Completions rejects `stop` on grok-4.6 // (`400 invalid-argument "Model grok-4.6 does not support parameter stop."`). // xAI documents `stop` as unsupported for reasoning models. Claude Code @@ -276,6 +282,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Live 2026-09-23: grok-4.7 answers the same 400. noStopModels: [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -300,6 +307,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Non-reasoning ids keep caller penalties. noPenaltyModels: [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -364,6 +372,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // (they are already listed in noVisionModels below). modelInputModalities: { "grok-4.7": ["text", "image"], + "grok-4.7-build-fast": ["text", "image"], "grok-4.6": ["text", "image"], "grok-4.5": ["text", "image"], "grok-4.3": ["text", "image"], @@ -376,7 +385,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching). // Models that never emit reasoning simply have no thinking parts to replay (no-op). - preserveReasoningContentModels: ["grok-4.7", "grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], + preserveReasoningContentModels: ["grok-4.7", "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh). // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning; // multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI @@ -385,15 +394,17 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // 2026-09-23 live probe accepted low..xhigh and rejected max on both wires; // devlog/_plan/260923_grok47_parity/010_probe-evidence.md. "grok-4.7": ["low", "medium", "high", "xhigh"], + "grok-4.7-build-fast": ["low", "medium", "high", "xhigh"], "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.5": ["low", "medium", "high"], "grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"], }, - modelDefaultReasoningEfforts: { "grok-4.7": "high", "grok-4.6": "high" }, + modelDefaultReasoningEfforts: { "grok-4.7": "high", "grok-4.7-build-fast": "high", "grok-4.6": "high" }, modelContextWindows: { // 500k confirmed by context_length_exceeded: // devlog/_plan/260923_grok47_parity/010_probe-evidence.md. "grok-4.7": 500_000, + "grok-4.7-build-fast": 500_000, "grok-4.6": 500_000, "grok-4.5": 500_000, "grok-4.3": 1_000_000, diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 4d553d3d90d..7d0030de0b7 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -61,6 +61,12 @@ the Responses wire. Claude Code auto-mode always sends `stop_sequences`; forward classifier mark Grok temporarily unavailable. Regression coverage: `tests/providers/xai/xai-no-stop.test.ts`. +`grok-4.7-build-fast` joins these lists, `preserveReasoningContentModels` and the grok-4.7 +context/effort/vision rows, because xAI documents Grok 4.7 Fast as the same model on faster +infrastructure (Cursor and Grok Build only, not the public xAI API); it stays out of the lineup +seed, `modelWireDefaults` and `modelSupportsServiceTier` until a live probe. Regression coverage: +`tests/providers/xai/grok-47-build-fast-metadata.test.ts`. + ### Policy-refusal 403 xAI sometimes refuses a turn with HTTP 403 and a bare refusal sentence (`I can't help with that diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 2bb927063a8..7ad56d082fd 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1316,7 +1316,7 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.7"]).toEqual(["low", "medium", "high", "xhigh"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.6"]).toEqual(["low", "medium", "high", "xhigh"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.5"]).toEqual(["low", "medium", "high"]); - expect(OAUTH_PROVIDERS.xai.providerConfig.modelDefaultReasoningEfforts).toEqual({ "grok-4.7": "high", "grok-4.6": "high" }); + expect(OAUTH_PROVIDERS.xai.providerConfig.modelDefaultReasoningEfforts).toEqual({ "grok-4.7": "high", "grok-4.7-build-fast": "high", "grok-4.6": "high" }); expect(OAUTH_PROVIDERS.xai.providerConfig.modelInputModalities?.["grok-4.7"]).toEqual(["text", "image"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEffortMap).toBeUndefined(); expect(OAUTH_PROVIDERS.xai.providerConfig.noVisionModels).toContain("grok-build-0.1"); diff --git a/tests/providers/xai/grok-47-build-fast-metadata.test.ts b/tests/providers/xai/grok-47-build-fast-metadata.test.ts new file mode 100644 index 00000000000..f24f3749024 --- /dev/null +++ b/tests/providers/xai/grok-47-build-fast-metadata.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { XAI_MODELS } from "../../../src/providers/registry/model-seeds"; +import type { ProviderRegistryEntry } from "../../../src/providers/registry/types"; + +// xAI documents Grok 4.7 Fast as "the same model served on faster infrastructure", listed for +// Cursor and Grok Build and not available on the public xAI API +// (docs.x.ai/developers/grok-4-7, fetched 2026-09-24). The discovered OAuth id inherits +// grok-4.7's documented facts; its wire pin and service tier stay unclaimed until probed. +const BASE = "grok-4.7"; +const BUILD_FAST = "grok-4.7-build-fast"; + +function xai(): ProviderRegistryEntry { + const entry = getProviderRegistryEntry("xai"); + if (!entry) throw new Error("xai registry entry missing"); + return entry; +} + +// Each assertion reads the base value from the registry instead of restating it: a later +// grok-4.7 correction has to move the Fast row with it, and a restated literal would hide that. +const MAPS = [ + ["modelContextWindows", (entry: ProviderRegistryEntry) => entry.modelContextWindows], + ["modelReasoningEfforts", (entry: ProviderRegistryEntry) => entry.modelReasoningEfforts], + ["modelDefaultReasoningEfforts", (entry: ProviderRegistryEntry) => entry.modelDefaultReasoningEfforts], + ["modelInputModalities", (entry: ProviderRegistryEntry) => entry.modelInputModalities], +] as const; + +const LISTS = ["noStopModels", "noPenaltyModels", "preserveReasoningContentModels"] as const; + +describe("xai grok-4.7-build-fast metadata", () => { + for (const [field, read] of MAPS) { + test(`${field} carries the grok-4.7 value`, () => { + const map = read(xai()); + expect(map?.[BASE]).toBeDefined(); + expect(map?.[BUILD_FAST]).toEqual(map?.[BASE]); + }); + } + + for (const field of LISTS) { + test(`${field} seeds the id directly after grok-4.7`, () => { + const list = xai()[field] ?? []; + expect(list).toContain(BASE); + expect(list.indexOf(BUILD_FAST)).toBe(list.indexOf(BASE) + 1); + }); + } + + test("claims no lineup slot, wire pin or service tier", () => { + const entry = xai(); + // Live discovery owns the lineup, so the seed lists stay free of a Cursor/Grok-Build-only id. + expect(XAI_MODELS).toContain(BASE); + expect(XAI_MODELS).not.toContain(BUILD_FAST); + expect(entry.models ?? []).not.toContain(BUILD_FAST); + // Non-vacuous negatives: both claims exist for grok-4.7, and only there. + expect(entry.modelWireDefaults?.[BASE]).toBeDefined(); + expect(entry.modelWireDefaults?.[BUILD_FAST]).toBeUndefined(); + expect(entry.modelSupportsServiceTier?.[BASE]).toBe(true); + expect(entry.modelSupportsServiceTier?.[BUILD_FAST]).toBeUndefined(); + }); +}); diff --git a/tests/providers/xai/xai-no-stop.test.ts b/tests/providers/xai/xai-no-stop.test.ts index b4a29268aef..f3d96d61b03 100644 --- a/tests/providers/xai/xai-no-stop.test.ts +++ b/tests/providers/xai/xai-no-stop.test.ts @@ -8,6 +8,7 @@ import { withTestTranslatorBudget } from "../../helpers/translator-budget"; const XAI_NO_STOP_MODELS = [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", diff --git a/tests/providers/xai/xai-transport.test.ts b/tests/providers/xai/xai-transport.test.ts index 4faf3866c1d..263a1c049df 100644 --- a/tests/providers/xai/xai-transport.test.ts +++ b/tests/providers/xai/xai-transport.test.ts @@ -635,6 +635,7 @@ describe("xAI reasoning_content cache preservation", () => { const entry = getProviderRegistryEntry("xai"); expect(entry?.preserveReasoningContentModels).toEqual([ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -826,6 +827,7 @@ describe("xAI reasoning_content cache preservation", () => { describe("xAI reasoning models reject penalty parameters", () => { const REASONING = [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", From 1613f35da0a1af169562e49b7a694ff797c1dd62 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:17:41 +0900 Subject: [PATCH 06/15] fix(cli): resolve Codex catalog slugs in ocx effort model (#5096) ocx effort model command-code/deepseek-deepseek-v4.1-flash, the slug the Codex catalog publishes, reported an empty ladder because the selector was split at the first slash and looked up literally. The model part now decodes through the router's known-id slug codec before the ladder, wire map and noReasoningModels lookups, so the slug and the exact id command-code/deepseek/deepseek-v4.1-flash report the same ladder. Output names the resolved id and adds requestedModel / 'Resolved from' when it differs. Unresolvable ids behave as before; no ladder rows change. --- src/cli/effort.ts | 22 +++-- tests/cli/cli-effort-slug.test.ts | 141 ++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 tests/cli/cli-effort-slug.test.ts diff --git a/src/cli/effort.ts b/src/cli/effort.ts index 1daed4c6792..709e3e47647 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -7,6 +7,8 @@ import { mapReasoningEffort, reasoningEffortMapFor, } from "../reasoning-effort"; +import { decodeRoutedModelId } from "../providers/slug-codec"; +import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig } from "../types"; import { @@ -290,19 +292,26 @@ function inspectModelEffort(modelTarget: string, wantsJson: boolean): void { ); } - const isReasoningDisabled = modelInList(provider.noReasoningModels, modelId); - const efforts = configuredReasoningEfforts(provider, modelId); - const wireMap = reasoningEffortMapFor(provider, modelId); + // A Codex-facing slug encodes the inner "/" of a namespaced native id + // (`command-code/deepseek-deepseek-v4.1-flash` for `deepseek/deepseek-v4.1-flash`), so the + // literal id only resolves against the ladder through the decode the router already uses. + const known = knownModelIdsForProvider(providerName, provider, config); + const resolvedModelId = known.includes(modelId) ? modelId : decodeRoutedModelId(modelId, known); + + const isReasoningDisabled = modelInList(provider.noReasoningModels, resolvedModelId); + const efforts = configuredReasoningEfforts(provider, resolvedModelId); + const wireMap = reasoningEffortMapFor(provider, resolvedModelId); // Derive sample ladder directly from canonical CODEX_REASONING_LEVELS (#3528 review) const mappedExamples: Record = {}; for (const { effort } of CODEX_REASONING_LEVELS) { - mappedExamples[effort] = mapReasoningEffort(provider, modelId, effort); + mappedExamples[effort] = mapReasoningEffort(provider, resolvedModelId, effort); } const result = { provider: providerName, - model: modelId, + model: resolvedModelId, + ...(resolvedModelId !== modelId ? { requestedModel: modelId } : {}), reasoningDisabled: isReasoningDisabled, supportedEfforts: efforts ?? null, wireMap: wireMap ?? null, @@ -310,7 +319,8 @@ function inspectModelEffort(modelTarget: string, wantsJson: boolean): void { }; const lines = [ - `Reasoning effort configuration for ${providerName}/${modelId}:`, + `Reasoning effort configuration for ${providerName}/${resolvedModelId}:`, + ...(resolvedModelId !== modelId ? [` Resolved from: ${modelId}`] : []), ` Reasoning disabled: ${isReasoningDisabled ? "yes (noReasoningModels)" : "no"}`, ` Supported ladder: ${efforts ? efforts.join(", ") : "(default / unconstrained)"}`, ` Wire mapping overrides: ${wireMap ? JSON.stringify(wireMap) : "(standard provider mapping)"}`, diff --git a/tests/cli/cli-effort-slug.test.ts b/tests/cli/cli-effort-slug.test.ts new file mode 100644 index 00000000000..38761d26437 --- /dev/null +++ b/tests/cli/cli-effort-slug.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleEffortCommand } from "../../src/cli/effort"; +import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../../src/providers/command-code-efforts"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { encodeRoutedModelId } from "../../src/providers/slug-codec"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Codex-facing slug and native id for the Command Code routes named in #5096. The slug form is + * the one the Codex catalog shows (inner "/" encoded by `encodeRoutedModelId`), and the native + * form is the key the provider ladder is registered under. + */ +const ROUTED_MODELS = [ + { native: "deepseek/deepseek-v4.1-flash", codexSlug: "deepseek-deepseek-v4.1-flash" }, + { native: "z-ai/glm-5.3-flashx", codexSlug: "z-ai-glm-5.3-flashx" }, + { native: "google/gemini-3.8-flash", codexSlug: "google-gemini-3.8-flash" }, +] as const; + +const COMMAND_CODE = "command-code"; + +/** Ladder read from the SSOT rather than restated, so no tier is invented here. */ +function ladderFor(nativeId: string): string[] { + const ladder = COMMAND_CODE_MODEL_REASONING_EFFORTS[nativeId]; + if (!ladder) throw new Error(`Command Code reasoning ladder missing for ${nativeId}`); + return ladder; +} + +let tempHome: string | null = null; +const savedHome = process.env.OPENCODEX_HOME; +let logOrig = console.log; +let errorOrig = console.error; + +beforeEach(() => { + logOrig = console.log; + errorOrig = console.error; + tempHome = mkdtempSync(join(tmpdir(), "ocx-effort-slug-test-")); + process.env.OPENCODEX_HOME = tempHome; + // Transport fields mirror the registry entry, and the ladder is the registry's own table: + // this is the row a signed-in Command Code provider resolves to. + const initialConfig: OcxConfig = { + port: 10100, + defaultProvider: COMMAND_CODE, + providers: { + [COMMAND_CODE]: { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + }, + }, + } as unknown as OcxConfig; + writeFileSync(join(tempHome, "config.json"), JSON.stringify(initialConfig, null, 2), "utf8"); +}); + +afterEach(() => { + console.log = logOrig; + console.error = errorOrig; + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) { + removeTreeWithRetry(tempHome); + tempHome = null; + } +}); + +async function inspect(target: string, json: boolean): Promise<{ code: number; out: string; errors: string[] }> { + const logs: string[] = []; + const errors: string[] = []; + console.log = (...parts: unknown[]) => { logs.push(parts.map(String).join(" ")); }; + console.error = (...parts: unknown[]) => { errors.push(parts.map(String).join(" ")); }; + const argv = json ? ["model", target, "--json"] : ["model", target]; + const code = await handleEffortCommand(argv, {}); + return { code, out: logs.join("\n"), errors }; +} + +async function inspectJson(target: string): Promise> { + const { code, out, errors } = await inspect(target, true); + expect(errors).toEqual([]); + expect(code).toBe(0); + return JSON.parse(out) as Record; +} + +describe("ocx effort model routed-slug resolution", () => { + test("the #5096 slug literals are what the codec encodes these native ids to", () => { + for (const { native, codexSlug } of ROUTED_MODELS) { + expect(encodeRoutedModelId(native)).toBe(codexSlug); + } + }); + + test("the configured Command Code row matches the registry transport and ladder", () => { + const entry = getProviderRegistryEntry(COMMAND_CODE); + expect(entry?.adapter).toBe("command-code"); + expect(entry?.baseUrl).toBe("https://api.commandcode.ai"); + expect(entry?.modelReasoningEfforts).toEqual(COMMAND_CODE_MODEL_REASONING_EFFORTS); + }); + + for (const { native, codexSlug } of ROUTED_MODELS) { + test(`${COMMAND_CODE}/${codexSlug} resolves to ${native} and reports its registry ladder`, async () => { + const result = await inspectJson(`${COMMAND_CODE}/${codexSlug}`); + expect(result.model).toBe(native); + expect(result.requestedModel).toBe(codexSlug); + expect(result.supportedEfforts).toEqual(ladderFor(native)); + }); + + test(`${COMMAND_CODE}/${native} reports the same ladder without a resolution note`, async () => { + const result = await inspectJson(`${COMMAND_CODE}/${native}`); + expect(result.model).toBe(native); + expect(result.requestedModel).toBeUndefined(); + expect(result.supportedEfforts).toEqual(ladderFor(native)); + }); + } + + test("text output names the resolved id and the slug it came from", async () => { + const { native, codexSlug } = ROUTED_MODELS[0]; + const { code, out } = await inspect(`${COMMAND_CODE}/${codexSlug}`, false); + expect(code).toBe(0); + expect(out).toContain(`Reasoning effort configuration for ${COMMAND_CODE}/${native}:`); + expect(out).toContain(` Resolved from: ${codexSlug}`); + expect(out).toContain(`Supported ladder: ${ladderFor(native).join(", ")}`); + }); + + test("a native id in text output carries no resolution note", async () => { + const { native } = ROUTED_MODELS[0]; + const { code, out } = await inspect(`${COMMAND_CODE}/${native}`, false); + expect(code).toBe(0); + expect(out).toContain(`Reasoning effort configuration for ${COMMAND_CODE}/${native}:`); + expect(out).not.toContain("Resolved from:"); + }); + + test("an unresolvable id keeps today's output: no ladder, no requestedModel", async () => { + const result = await inspectJson(`${COMMAND_CODE}/not-a-real-model`); + expect(result.model).toBe("not-a-real-model"); + expect(result.requestedModel).toBeUndefined(); + expect(result.supportedEfforts).toBeNull(); + }); +}); + From 89771f76adf438c780dc9d694b82f5bc6199f25d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:19:50 +0900 Subject: [PATCH 07/15] fix(command-code): keep MiMo tool-call markup after prose off the text channel (#5698) The Command Code tool-text filter only held a text block that opened with . MiMo's gateway echo can arrive after ordinary prose in the same delta, and interleaved reasoning interrupted held blocks, so the raw envelope reached the client while the native call also ran. - A delta is split at the marker: prose keeps its streamed or queued path and the markup starts a fresh probe block. Leading whitespace still uses the existing probe. - Held blocks are no longer interrupted by interleaved events; the queued byte bound still flushes an envelope that never resolves. - An envelope the strict parser rejects but that opens and closes around a declared function is dropped on the duplicate and clean-finish paths. Markup that parses but does not fit its schema is still released as text. Reimplemented from the reporter's validated patch in the issue. Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> --- src/adapters/command-code-tool-text.ts | 141 +++++++++++++-- ...command-code-tool-text-prose-split.test.ts | 168 ++++++++++++++++++ 2 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 tests/providers/command-code-tool-text-prose-split.test.ts diff --git a/src/adapters/command-code-tool-text.ts b/src/adapters/command-code-tool-text.ts index 74e33542d97..83554eaa39b 100644 --- a/src/adapters/command-code-tool-text.ts +++ b/src/adapters/command-code-tool-text.ts @@ -22,6 +22,12 @@ import { validatesRestoredValue } from "./command-code-restored-schema"; * A text block that opens with `` is therefore held instead of streamed. It is dropped * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when * it names a declared tool with arguments that fit its schema. Other markup is released unchanged. + * MiMo can also append the markup after ordinary prose inside one text block; the stream filter + * splits such a delta at the marker and holds the markup part the same way (#5698; a marker split + * across deltas after prose is still released as text). + * A malformed envelope that still opens and closes around a declared function name, but that the + * strict parser rejects, is dropped instead of released on both the duplicate and the clean-finish + * path, so the echo never reaches the client. * Later text waits behind unresolved markup within the same byte bound. */ @@ -81,6 +87,20 @@ function tryJson(value: string): unknown { try { return JSON.parse(value); } catch { return undefined; } } +/** + * Whether a block reads as a complete envelope echo even though the strict parser rejected it: + * malformed parameter tags, a missing ``, garbage inside the body. Opening and + * closing as an envelope with a known function name is enough to keep it off the client; the + * native duplicate call, when the gateway emits one, carries the canonical execution. + */ +function isLooseEnvelope(text: string, declared: CommandCodeDeclaredTools | undefined): boolean { + const trimmed = text.trim(); + if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("")) return false; + if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return false; + const fn = /\s]+)>/.exec(trimmed); + return fn !== null && (declared?.has(fn[1]!) ?? false); +} + function deepEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false; @@ -283,6 +303,13 @@ export class CommandCodeToolTextFilter { for (const [key, block] of this.activeProbes) { this.queueOperations++; if (key === exceptKey) continue; + // Held blocks open with the marker by construction (the probe guarantees it). Interrupting + // them on interleaved events (reasoning deltas, other blocks, the native call itself) cleared + // complete and malformed envelopes alike and released the echoed call as text. Held blocks + // therefore stay held until settle, in arrival order; the queued-byte bound (makeRoom) still + // caps memory and wait, flushing everything as text if a held envelope never resolves while + // the stream keeps producing. + if (block.state === "held") continue; this.activeProbes.delete(key); block.interrupted = true; block.state = "queued"; @@ -310,8 +337,44 @@ export class CommandCodeToolTextFilter { block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) }; this.blocks.set(key, block); } - if (block.state === "streaming" && this.head === this.pending.length) { - return [...boundaryEvents, { type: "text_delta", text }]; + // MiMo can append tool-call markup after ordinary prose inside one text block. The probe below + // only recognizes a block that opens with the marker, so a marker arriving after prose would + // reach the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose, then + // "..." echoed by the gateway as one text delta). Split the delta at + // the marker: prose keeps its queued or streamed path, the markup starts a fresh probe block + // and follows the normal hold-and-restore route. A probing block that has consumed nothing but + // whitespace keeps its probe instead, because that probe already holds the marker. + if (block.state !== "held") { + const markerIndex = text.indexOf(TOOL_CALL_MARKER); + const whitespaceLead = markerIndex > 0 && block.state === "probing" && block.probe === "" + && text.slice(0, markerIndex).trim() === ""; + // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the + // block is ordinary text and the marker must still start a fresh probe block. + if (!whitespaceLead && (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing"))) { + const prose = markerIndex > 0 ? text.slice(0, markerIndex) : ""; + const marked = markerIndex > 0 ? text.slice(markerIndex) : text; + let proseEvents: AdapterEvent[] = []; + if (prose) { + if (block.state === "streaming" && this.head === this.pending.length) { + proseEvents = [{ type: "text_delta", text: prose }]; + } else { + if (block.state === "dropped" || block.state === "streaming") { + block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() }; + this.blocks.set(key, block); + } + proseEvents = this.queueProseDelta(block, prose); + this.probeBlockText(block, prose); + } + } + if (block.state === "queued") block.state = "streaming"; + this.activeProbes.delete(key); + const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) }; + this.blocks.set(key, probeBlock); + return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)]; + } + if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) { + return [...boundaryEvents, { type: "text_delta", text }]; + } } // Once a duplicate is dropped, later text is a new chunk at its own wire position. if (block.state === "dropped" || block.state === "streaming") { @@ -332,23 +395,7 @@ export class CommandCodeToolTextFilter { this.queueOperations++; } this.queuedBytes += bytes; - if (block.state === "probing") { - for (const char of text) { - if (!block.probe && char.trim() === "") continue; - block.probe += char; - if (!TOOL_CALL_MARKER.startsWith(block.probe)) { - block.state = "queued"; - block.markupParts = []; - break; - } - if (block.probe === TOOL_CALL_MARKER) { - block.state = "held"; - block.probe = ""; - this.held.push(block); - break; - } - } - } + this.probeBlockText(block, text); if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key); return [...boundaryEvents, ...preceding, ...this.limitPending()]; } @@ -420,6 +467,14 @@ export class CommandCodeToolTextFilter { } block.candidates.delete(id); if (block.candidates.size === 0) { + // A malformed envelope cannot match a native input, but it is still an envelope: drop it + // rather than releasing the echo as text (the native call carries the execution). + if (markup === undefined && isLooseEnvelope(block.markupParts.join(""), this.declared)) { + this.drop(block); + block.state = "dropped"; + this.activeProbes.delete(block.id); + continue; + } block.state = "queued"; block.markupParts = []; this.activeProbes.delete(block.id); @@ -472,6 +527,16 @@ export class CommandCodeToolTextFilter { events.push({ type: "tool_call_end" }); salvaged = true; lastTextBlock = undefined; + } else if (restore && !block.interrupted && markup === undefined + && isLooseEnvelope(block.markupParts.join(""), this.declared)) { + // A malformed envelope is still an envelope: the native duplicate (observed in every + // capture) carries the call, so the echo is dropped rather than rendered as text. + // Releasing it would put the raw markup back on screen; restoring it could execute a + // second time alongside the native call. The parser must have rejected the text, so an + // envelope that parses but does not fit its schema keeps the release-as-text contract. + this.drop(block); + block.state = "dropped"; + lastTextBlock = undefined; } else { block.state = "queued"; block.markupParts = []; @@ -501,6 +566,44 @@ export class CommandCodeToolTextFilter { block.bytes += bytes; } + /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */ + private probeBlockText(block: TextBlock, text: string): void { + if (block.state !== "probing") return; + for (const char of text) { + if (!block.probe && char.trim() === "") continue; + block.probe += char; + if (!TOOL_CALL_MARKER.startsWith(block.probe)) { + block.state = "queued"; + block.markupParts = []; + break; + } + if (block.probe === TOOL_CALL_MARKER) { + block.state = "held"; + block.probe = ""; + this.held.push(block); + break; + } + } + } + + /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */ + private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] { + const preceding = this.makeRoom(encoder.encode(prose).byteLength); + this.retain(block, prose); + const bytes = encoder.encode(prose).byteLength; + const tail = this.pending.at(-1); + if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) { + tail.parts.push(prose); + tail.bytes += bytes; + this.queueOperations++; + } else { + this.pending.push({ kind: "chunk", block, parts: [prose], bytes }); + this.queueOperations++; + } + this.queuedBytes += bytes; + return preceding; + } + private drop(block: TextBlock): void { this.budget.releaseRetained(block.bytes, { kind: "live_transient" }); this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes); diff --git a/tests/providers/command-code-tool-text-prose-split.test.ts b/tests/providers/command-code-tool-text-prose-split.test.ts new file mode 100644 index 00000000000..ff54558148e --- /dev/null +++ b/tests/providers/command-code-tool-text-prose-split.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { CommandCodeToolTextFilter } from "../../src/adapters/command-code-tool-text"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const provider: OcxProviderConfig = { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + apiKey: "secret-command-key", +}; + +const JS = "const r = await tools.exec_command({cmd:\"sed -n '1,40p' src/a.ts\"});\ntext(r.output);"; +const MARKUP = "" + JS + ""; + +// Captured 2026-09-23 from xiaomi/mimo-v2.6-pro through the live proxy: the echoed envelope lost the +// parameter key, its ">" and its closing tag, so the body runs to and cannot parse. +const MALFORMED = [ + " text(r.output)); ", +].join("\n"); + +const EXEC_TOOL = { name: "exec", description: "Run JavaScript", freeform: true, + parameters: { type: "object", properties: { input: { type: "string", description: "Raw freeform input for this tool." } }, required: ["input"] } }; + +function ndjson(events: unknown[]): Response { + return new Response(events.map(event => JSON.stringify(event)).join("\n")); +} + +function parsed(): OcxParsedRequest { + return { + modelId: "xiaomi/mimo-v2.6-flash", + stream: true, + context: { systemPrompt: ["system"], messages: [{ role: "user", content: "go", timestamp: 1 }], tools: [EXEC_TOOL] }, + options: { maxOutputTokens: 100 }, + }; +} + +/** Run events through one adapter instance the way the server does: buildRequest, fetchResponse, parseStream. */ +async function adapterEvents(events: unknown[]): Promise { + const adapter = createCommandCodeAdapter({ ...provider, fetch: (async () => ndjson(events)) as typeof fetch } as OcxProviderConfig); + const request = await adapter.buildRequest(parsed()); + const response = await adapter.fetchResponse!(request); + const out: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, createTestTranslatorBudget())) out.push(event); + return out; +} + +const texts = (events: AdapterEvent[]) => events.filter(event => event.type === "text_delta").map(event => (event as { text: string }).text).join(""); +const calls = (events: AdapterEvent[]) => { + const out: Array<{ id: string; name: string; args: string }> = []; + for (const event of events) { + if (event.type === "tool_call_start") out.push({ id: event.id, name: event.name, args: "" }); + if (event.type === "tool_call_delta") out[out.length - 1]!.args += event.arguments; + } + return out; +}; +const done = (events: AdapterEvent[]) => events.find(event => event.type === "done") as { stopReason?: string } | undefined; + +const textBlock = (text: string) => [ + { type: "text-start", id: "t" }, + { type: "text-delta", id: "t", text }, + { type: "text-end", id: "t" }, +]; + +/** A filter that declares only the freeform exec tool, plus its budget for leak assertions. */ +function execFilter() { + const budget = createTestTranslatorBudget(); + return { budget, filter: new CommandCodeToolTextFilter(budget, new Map([["exec", { freeform: true, schema: EXEC_TOOL.parameters }]])) }; +} + +describe("Command Code markup echoed after prose in one text block", () => { + const PROSE = "Running it now.\n"; + const proseMarkup = PROSE + MARKUP; + + test("drops the markup and keeps the prose when the native call carries the same input", async () => { + const events = await adapterEvents([ + { type: "tool-input-start", id: "call_c1", toolName: "exec" }, + ...textBlock(proseMarkup), + { type: "tool-call", toolCallId: "call_c1", toolName: "exec", input: JS, dynamic: true, invalid: true }, + { type: "finish", rawFinishReason: "tool_calls" }, + ]); + expect(texts(events)).toBe(PROSE); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("restores the trailing markup as a call on a clean finish", async () => { + const events = await adapterEvents([...textBlock(proseMarkup), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(PROSE); + const [call] = calls(events); + expect(call).toMatchObject({ name: "exec", args: JSON.stringify({ input: JS }) }); + expect(call!.id).toMatch(/^call_ocx_[0-9a-f]{32}$/); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("holds a marker that opens a fresh block after streamed prose", () => { + const { budget, filter } = execFilter(); + expect(filter.textDelta("t", "Running it now.")).toEqual([{ type: "text_delta", text: "Running it now." }]); + // The streamed block used to pass the marker straight through instead of holding it. + expect(filter.textDelta("t", MARKUP)).toEqual([]); + const finished = filter.finish(); + expect(finished.salvaged).toBe(true); + expect(finished.events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end"]); + expect(texts(finished.events)).toBe(""); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("keeps markup behind a newline on the ordinary hold path", async () => { + const events = await adapterEvents([...textBlock("\n" + MARKUP), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(""); + expect(calls(events)).toMatchObject([{ name: "exec", args: JSON.stringify({ input: JS }) }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("keeps a held block through an interleaved reasoning event", () => { + const { budget, filter } = execFilter(); + const thinking: AdapterEvent = { type: "thinking_delta", thinking: "about to call exec" }; + expect(filter.textStart("t")).toEqual([]); + expect(filter.textDelta("t", MARKUP)).toEqual([]); + // Reasoning used to break the held block open and put the echoed call on screen as text. + expect(filter.enqueueEvent(thinking, "about to call exec")).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + const finished = filter.finish(); + expect(finished.salvaged).toBe(true); + expect(finished.events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end", "thinking_delta"]); + expect(texts(finished.events)).toBe(""); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); + +describe("Command Code malformed envelope echo", () => { + test("drops a malformed envelope when the native call arrives for it", async () => { + const events = await adapterEvents([ + { type: "tool-input-start", id: "call_c1", toolName: "exec" }, + ...textBlock(MALFORMED), + { type: "tool-call", toolCallId: "call_c1", toolName: "exec", input: JS, dynamic: true, invalid: true }, + { type: "finish", rawFinishReason: "tool_calls" }, + ]); + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("drops a malformed envelope on a clean finish instead of restoring it", async () => { + const events = await adapterEvents([...textBlock(MALFORMED), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([]); + expect(done(events)?.stopReason).toBe("stop"); + }); + + test("releases a marker pair with no function name as text", async () => { + const junk = "junk"; + const events = await adapterEvents([...textBlock(junk), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(junk); + expect(calls(events)).toEqual([]); + }); + + test("releases a never-closing envelope as text", async () => { + const partial = "abc"; + const events = await adapterEvents([...textBlock(partial), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(partial); + expect(calls(events)).toEqual([]); + }); +}); From d2ba0f0d24dd5f9ecf6fe3d18a872ad46b54c229 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:21:16 +0900 Subject: [PATCH 08/15] docs(command-code): describe the prose split, held envelopes and loose-envelope drop (#5698) --- docs-site/src/content/docs/reference/adapters.md | 14 +++++++++----- structure/providers-and-adapters.md | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 87a818ca589..8cc659a4426 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -217,11 +217,15 @@ only on `/provider/v1/messages`; the pin applies only while the provider points endpoint. It supports forwarding `prompt_cache_key`; this is separate from the OAuth adapter's session header and does not guarantee a provider cache hit. The OAuth `command-code` preset streams `/alpha/generate` as NDJSON. MiMo tool-call -markup echoed by the gateway as text is removed when it duplicates a real call. After a -clean stop or tool-call finish, a complete declared-tool call with no native counterpart -is restored as a real call; an interrupted or failed turn leaves the markup as text. A -freeform call echoed without its `` close counts as complete once -`` arrives. This applies to every MiMo model Command Code serves. +markup echoed by the gateway as text is removed when it duplicates a real call, including +markup the gateway appends after ordinary prose in the same chunk; a marker split across +chunks is still shown as text. Reasoning or other events arriving in between no longer +release a held envelope. After a clean stop or tool-call finish, a complete declared-tool +call with no native counterpart is restored as a real call; an interrupted or failed turn +leaves the markup as text. A call the parser cannot read is dropped rather than printed +when it still opens, closes, and names a declared tool. A freeform call echoed without its +`` close counts as complete once `` arrives. This applies to every +MiMo model Command Code serves. ## `anthropic` diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b5b10648e2e..dd5d11d87ee 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -55,7 +55,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | -| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Native, reasoning, and other intervening events release an open markup block as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. | +| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose in the same delta is split off at the marker and held like a block that opens with ``; a marker split across deltas after prose is still released as text. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped on the duplicate and clean-finish paths; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the split, the interleaved-event hold, and both drop paths. | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | From 58b8b65dcffd5531aabf8c86c085258a3b51bddb Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:21:16 +0900 Subject: [PATCH 09/15] test(layout): register L3 regression files; add the L3 lane plan --- .../260924_l3_provider_adapters/010_plan.md | 122 ++++++++++++++++++ scripts/test-layout/layout.json | 5 + tests/fixtures/test-layout-expected.json | 7 +- 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260924_l3_provider_adapters/010_plan.md diff --git a/devlog/_plan/260924_l3_provider_adapters/010_plan.md b/devlog/_plan/260924_l3_provider_adapters/010_plan.md new file mode 100644 index 00000000000..960e278485f --- /dev/null +++ b/devlog/_plan/260924_l3_provider_adapters/010_plan.md @@ -0,0 +1,122 @@ +# L3 provider adapters — diff-level plan (wp1) + +Lane L3 bundles seven independent provider-adapter fixes into one PR against `dev` +(branch `codex/260924-l3-provider-adapters`, base `be0b5294e5`). Each item has its own +writer scope, and the lane lead registers every new test file in +`scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. + +## Items and diffs + +### 1. #5692 DeepSeek quota currency symbol +- `src/providers/quota/vendor-probes-key.ts` `fetchDeepSeekQuota`: read `preferred.currency`, + map USD→`$`, CNY→`¥`, anything else → `" "` prefix (trimmed, upper-cased; empty/missing → `$` + keeps legacy behaviour only when the row has no currency). Both label branches use it. +- Test: new sibling `tests/providers/deepseek-quota-currency.test.ts` (provider-quota.test.ts sits at + its 3763-line cap): CNY-only row → `API balance (¥76.88)`; USD row → `$`; CNY with granted → both + amounts use `¥`; unknown currency (e.g. EUR) → code prefix. + +### 2. #5689 Google array without items +- `src/adapters/google-tool-schema.ts` `sanitizeSchema`: after the `items` block, when + `out.type === "array"` and `out.items` is absent (source had no items, tuple items dropped, invalid + items widened, or budget ran out), set `out.items = { type: "string" }` and count a loss + (`invalid-schema-widened`) only when the source had no usable items. Valid `items` untouched; + nullable arrays keep `nullable`. Also covers `anyOf`-normalized arrays (apply after anyOf merge). +- Tests in `tests/adapters/google/google-tool-schema.test.ts` (486 lines, uncapped): issue repro + `{type:object, required:[values], properties:{values:{type:array}}}`; nested array; tuple items; + existing valid items byte-identical; non-array unaffected. + +### 3. #5695 mimo token-plan capacity facts +- `src/providers/registry/entries-extended.ts` `mimo` entry: add + `modelContextWindows` (all four ids 1_048_576), `modelMaxOutputTokens` (all four 131_072), + `modelInputModalities` (v2.6-pro, v2.6-flash, v2.5: `["text","image"]`; v2.5-pro: `["text"]`). + Source: mimo.mi.com/models/en-US/ (fetched 2026-09-24: 1M context, 128K output; v2.6-pro/flash + and v2.5 input Text/Image/Video/Audio, v2.5-pro Text). Video/audio are not representable in the + catalog's modality vocabulary, so only text/image are claimed. Keep `noVisionModels` and + `preserveCustomDestination`; update the comment. +- Test: registry/catalog assertion in a sibling test file (e.g. `tests/providers/mimo-token-plan-capacity.test.ts`). + +### 4. Carry #5693 (Vadevious) on #5725 +- `src/adapters/openai-chat/serialized-tool-call-content.ts`: add `repeatedCallIn` built on the + current `callsIn`/`blockAt`; in `duplicatedSerializedToolCallRanges` suppress the adjacent identical + pair only when exactly one structured call matches (compare with `freeformBody`); in + `repairArgumentsDuplicatedBesideSerializedCall` reduce a doubled `input` (direct or newline joined) + when `input` is the only key. +- Tests: port the PR's tests into `tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts` + and `tests/responses/responses-chat-tool-call-content.test.ts`; docs: adapters.md bullet, + `structure/providers/chat-compat.md` paragraph, ADR-5548 consequences line. +- Commit trailer: `Co-authored-by: Vadevious `. + +### 5. #5698 command-code filter (marciodps) +- `src/adapters/command-code-tool-text.ts` per the reporter's final patch, with fixes: + mid-prose marker split in `textDelta` (not when the prefix is only whitespace on a probing block + with an empty probe — the existing probe already holds `"\n"`); shared + `probeBlockText` / `queueProseDelta` helpers; `breakOpenBlocks` skips held blocks; + `isLooseEnvelope` (null-safe `exec` result) used in `matchNative` and `settle` to drop malformed + envelopes naming a declared tool. +- Tests: new sibling `tests/providers/command-code-tool-text-prose-split.test.ts`: prose+markup in + one delta with native duplicate (dropped); prose+markup clean finish (restored); marker at index 0 + after streaming prose; leading-whitespace markup still held; interleaved reasoning keeps held + block; captured malformed `junk` does not throw; never-closing + partial markup released as text. +- Commit trailer: `Co-authored-by: marciodps ` (or their commit email if public). + +### 6. #5096 remainder: `ocx effort model` slug resolution +- `src/cli/effort.ts` `inspectModelEffort`: after splitting provider/model, when the model is not a + known id, decode it with `decodeRoutedModelId(model, knownModelIdsForProvider(provider, prov, config))` + (router.ts / slug-codec.ts). Report `model` as the resolved native id and add `requestedModel` when it + differs. Unresolvable ids keep today's behaviour. +- Tests: new sibling `tests/cli/cli-effort-slug.test.ts`: `command-code/deepseek-deepseek-v4.1-flash` + and `command-code/deepseek/deepseek-v4.1-flash` report the same ladder as + `COMMAND_CODE_MODEL_REASONING_EFFORTS["deepseek/deepseek-v4.1-flash"]`; same for GLM 5.3 FlashX and + Gemini 3.8 Flash (the ids named in the latest issue comment). Ladders are read from the SSOT, not + restated, so no tier is invented. + +### 7. #5576 grok-4.7-build-fast +- `src/providers/registry/entries-core.ts` xAI entry: add `grok-4.7-build-fast` to + `modelContextWindows` (500_000), `modelReasoningEfforts` (low..xhigh), `modelDefaultReasoningEfforts` + (high), `modelInputModalities` (text,image). Not added to `XAI_MODELS`: xAI documents Grok 4.7 Fast as + "the same model served on faster infrastructure… not available on the public xAI API" + (docs.x.ai/developers/grok-4-7, fetched 2026-09-24). No service-tier claim. +- Test: new sibling `tests/providers/xai/grok-47-build-fast-metadata.test.ts` asserting the four facts + equal grok-4.7's. + +## Out of scope +#5421; the "per-model maps lost on restart" half of #5576; the opencode-go `openai-chat` wiring from +#5698 (reported against #5499 in the PR body). + +## Verification +`bun run typecheck`; each focused test file above plus existing neighbours +(`tests/providers/command-code-tool-text.test.ts`, `tests/providers/provider-quota.test.ts`, +`tests/adapters/google/google-tool-schema*.test.ts`, `tests/cli/cli-effort.test.ts`, xAI and catalog +parity suites); `bun run test:changed`; `bun run privacy:scan`; `bun run structure:check`. + + +## Audit fold (A, round 1 verdict FAIL → amendments) + +1. Item 2 materializes `items: {type:"string"}` without adding a loss category (representation fix, keeps + `lossy:false` contracts). Budget-exhausted return stays untouched (`google-tool-schema.test.ts:479-481`). + Existing tests that pin an array output without items (contract test ~578-587, tuple case ~303-322) update + their expected `parameters` only; category sets stay. `structure/providers/google.md` gains one sentence. +2. Item 1 also rewrites `tests/providers/provider-quota.test.ts:1131-1136` (CNY row) to `¥`, line-neutral + (file at its 3763 cap). +3. Item 7: add `grok-4.7-build-fast` to `modelContextWindows`, `modelReasoningEfforts`, + `modelDefaultReasoningEfforts`, `modelInputModalities`, plus the reasoning-model parameter lists xAI documents + for reasoning models (`noStopModels`, `noPenaltyModels`, `preserveReasoningContentModels`). Not added: + `modelWireDefaults` and `modelSupportsServiceTier` (live-probed on grok-4.7 only), `XAI_MODELS`. Update exact + literals: `provider-registry-parity.test.ts:1319`, `xai-no-stop.test.ts:47`, `xai-transport.test.ts:634,844`. + `structure/providers/xai-grok.md` gains a line. +4. Item 6: the decoded id replaces `modelId` before `modelInList` / `configuredReasoningEfforts` / + `reasoningEffortMapFor`. +5. Item 4: compare with `freeformBody` on both sides, tail must be exactly one repetition, add a leading-newline + case. Rebuttal: the newline-joined doubled-input repair stays — #5693 commit 7854ac8 added it with its own + regression test and the PR body documents it. +6. Item 5 source is marciodps' third follow-up comment on #5698 (2026-09-23T19:33Z), full patch vs 2.64.0. + Preserve the marker-free fast path (`command-code-tool-text.test.ts:388`), the queue-visit bound (`:324`), + and whitespace-probe salvage. +7. Lead registers every new test file in both layout maps. + + +Round 2 verdict PASS. Residuals: item 2 drops the original "budget ran out" clause, and the two contract +tests assert only loss reports, so no expectation needs editing there; item 7 keeps grok-4.7-build-fast on +the provider-default wire, to be re-checked on first live discovery. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f2582895f74..eddd75946fd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,11 @@ } }, "explicit": { + "deepseek-quota-currency.test.ts": "providers", + "mimo-token-plan-capacity.test.ts": "providers", + "command-code-tool-text-prose-split.test.ts": "providers", + "cli-effort-slug.test.ts": "cli", + "grok-47-build-fast-metadata.test.ts": "providers/xai", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b4b1c78cf70..ce99be13d82 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1603,5 +1603,10 @@ "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", + "deepseek-quota-currency.test.ts": "providers", + "mimo-token-plan-capacity.test.ts": "providers", + "command-code-tool-text-prose-split.test.ts": "providers", + "cli-effort-slug.test.ts": "cli", + "grok-47-build-fast-metadata.test.ts": "providers/xai" } From 2601fa2403f257a5fefc0d8110d95c4487eaa41d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:44:00 +0900 Subject: [PATCH 10/15] fix(google): charge synthesized array items to the schema node budget (#5689) Review follow-up: the materialized items schema was added after traversal without consuming a node, so many bare array leaves could exceed the 1,024-node bound. Synthesis now reserves one node and is skipped, with node-budget-widened reported, once the budget is spent. --- src/adapters/google-tool-schema.ts | 13 +++++++++++-- tests/adapters/google/google-tool-schema.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index 6100d884114..2a6c3d7136d 100644 --- a/src/adapters/google-tool-schema.ts +++ b/src/adapters/google-tool-schema.ts @@ -755,8 +755,17 @@ function sanitizeSchema( } // Gemini rejects an array declaration with no `items` (#5689). A string item keeps the declaration // valid. It narrows an unconstrained item rather than widening a constraint, so the loss report, - // which counts widened or dropped constraints, does not record it. - if (out.type === "array" && !Object.hasOwn(out, "items")) out.items = { type: "string" }; + // which counts widened or dropped constraints, does not record it. The synthesized node is part + // of the emitted tree, so it charges the node budget like any other: a wide enough fan-out of + // `items`-less arrays otherwise pushed the output past MAX_SCHEMA_NODES. + if (out.type === "array" && !Object.hasOwn(out, "items")) { + if (state.remainingNodes <= 0) { + reportBudgetExhausted(state); + } else { + state.remainingNodes -= 1; + out.items = { type: "string" }; + } + } return out; } diff --git a/tests/adapters/google/google-tool-schema.test.ts b/tests/adapters/google/google-tool-schema.test.ts index 52387b055a0..f96e249ee50 100644 --- a/tests/adapters/google/google-tool-schema.test.ts +++ b/tests/adapters/google/google-tool-schema.test.ts @@ -482,6 +482,22 @@ describe("sanitizeGeminiToolParameters", () => { expect(countSchemaNodes(out)).toBe(1_024); }); + test("charges synthesized array items to the node budget", () => { + const names = Array.from({ length: 2_000 }, (_, index) => `field_${index}`); + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties: Object.fromEntries(names.map(name => [name, { type: "array" }])), + }, { endpointClass: "ai-studio" }); + + // Every retained array leaf costs two nodes: the leaf and the `items` synthesized for it. + expect(countSchemaNodes(result.parameters)).toBeLessThanOrEqual(1_024); + const properties = result.parameters.properties as Record>; + const retained = Object.keys(properties); + expect(retained).toHaveLength(512); + expect(retained.filter(name => properties[name].items !== undefined)).toHaveLength(511); + expect(result.lossReport.categories["node-budget-widened"]).toBeGreaterThan(0); + }); + test("falls back to an object schema for non-object input", () => { expect(sanitizeGeminiToolParameters(undefined)).toEqual({ type: "object", properties: {} }); expect(sanitizeGeminiToolParameters("nope")).toEqual({ type: "object", properties: {} }); From 82cd8a222bb76a437ba61bff71dac6c54855abc0 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:49:50 +0900 Subject: [PATCH 11/15] fix(openai-chat): reduce a doubled echo input only when one call qualifies Review follow-up to the #5693 carry: the doubled-input repair ran per structured call, so two qualifying calls were both rewritten while the pair itself stayed visible as ambiguous. Both flush sites now reconcile a response's calls as one batch, and the reduction applies only when exactly one call qualifies. --- src/adapters/openai-chat.ts | 18 ++-- .../serialized-tool-call-content.ts | 95 +++++++++++++++---- ...-chat-serialized-tool-call-content.test.ts | 49 ++++++++++ 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b96b64a03d..28173b2b04d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -45,7 +45,7 @@ import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; -import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; +import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; @@ -337,8 +337,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd return yield* terminateWithError(unnamedToolCallEvent(pendingUsage)); } } - // Held serialized markup is released only now, reconciled against the calls it may duplicate. - const references = calls.map(call => reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current())); + // Held markup is released only now, as one batch per response: the doubled-input repair needs every call. + const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args })), toolCallContent.current()); calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); yield* toolCallContent.drain(references); for (const call of calls) { @@ -772,7 +772,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); const contentEnd = events.length; const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join(""); - const references: ReturnType[] = []; + // Each call holds the delta event it emitted, so the batch repair sets its arguments later. + const structuredCalls: { wireName: string; restoredName: string; argumentsText: string; delta: Extract }[] = []; const rawToolCalls = msg.tool_calls; if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) { @@ -795,12 +796,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } - references.push(reconcileStructuredToolCall(name, toolNames.restore(name), args, answerText)); - events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); - events.push({ type: "tool_call_delta", arguments: references.at(-1)!.argumentsText }); - events.push({ type: "tool_call_end" }); + const delta: Extract = { type: "tool_call_delta", arguments: args }; + structuredCalls.push({ wireName: name, restoredName: toolNames.restore(name), argumentsText: args, delta }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }, delta, { type: "tool_call_end" }); } } + const references = reconcileStructuredToolCalls(structuredCalls, answerText); + structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; }); reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); const stopReason = stopReasonFor(choice.finish_reason); events.push({ diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 8986adb94aa..46ba34ee7db 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -380,27 +380,38 @@ export function stripDuplicatedSerializedToolCalls( return result + text.slice(cursor); } +/** + * The reduced arguments when the freeform body of the repeated block is written twice in the + * single string "input" field, or undefined for any other shape. Only the batch reconciler may + * apply it: the reduction rewrites executable arguments, so it needs a uniqueness proof. + */ +function doubledInputReduction( + argumentsText: string, + functionNames: ReadonlySet, + repeated: SerializedToolCall, +): string | undefined { + if (!functionNames.has(repeated.name)) return undefined; + const body = freeformBody(repeated.body); + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + && Object.keys(parsed).length === 1 + && ((parsed as Record).input === body + body + || (parsed as Record).input === body + "\n" + body)) { + return JSON.stringify({ input: body }); + } + } catch { + // A malformed concatenation is handled by the prefix repair instead. + } + return undefined; +} + /** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */ export function repairArgumentsDuplicatedBesideSerializedCall( argumentsText: string, functionNames: ReadonlySet, serializedText: string, ): string { - const repeated = repeatedCallIn(serializedText); - if (repeated && functionNames.has(repeated.name)) { - const body = freeformBody(repeated.body); - try { - const parsed = JSON.parse(argumentsText) as unknown; - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - && Object.keys(parsed).length === 1 - && ((parsed as Record).input === body + body - || (parsed as Record).input === body + "\n" + body)) { - return JSON.stringify({ input: body }); - } - } catch { - // A malformed concatenation may still match the prefix repair below. - } - } try { JSON.parse(argumentsText); return argumentsText; @@ -433,9 +444,60 @@ export function repairArgumentsDuplicatedBesideSerializedCall( return argumentsText; } +/** One structured call as the reconciler sees it, before its arguments meet the visible text. */ +export interface StructuredToolCallInput { + wireName: string; + restoredName: string; + argumentsText: string; +} + +/** + * Repairs the arguments of every structured call in one response against the visible text that + * response carried, and returns them in input order. The per-call prefix repair stands alone, + * because the markup it proves is matched against that call's own repaired input. The + * doubled-input reduction is applied only when exactly ONE call qualifies: it rewrites executable + * arguments, and two qualifying calls leave the repeated block ambiguous, so the uniqueness proof + * has to cover the whole batch rather than one call at a time. + */ +export function reconcileStructuredToolCalls( + calls: readonly StructuredToolCallInput[], + serializedText: string, +): StructuredToolCallReference[] { + const references = calls.map(call => { + const names = new Set([call.wireName, call.restoredName]); + return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(call.argumentsText, names, serializedText) }; + }); + return reduceUnambiguousDoubledInput(references, serializedText); +} + +/** + * Applies the doubled-input reduction across the batch. The doubled shape is valid JSON, so the + * prefix repair returns it untouched, and the reduction only ever rewrites a call the repair left + * alone. A repeated block with no uniquely qualifying call keeps every argument as sent, which is + * also what leaves the markup visible: the range matcher then finds either no agreeing call or + * several, and suppresses neither. + */ +function reduceUnambiguousDoubledInput( + references: readonly StructuredToolCallReference[], + serializedText: string, +): StructuredToolCallReference[] { + const repeated = repeatedCallIn(serializedText); + if (!repeated) return [...references]; + const reductions = references.map(reference => + doubledInputReduction(reference.argumentsText, reference.names, repeated)); + if (reductions.filter(reduction => reduction !== undefined).length !== 1) return [...references]; + return references.map((reference, index) => { + const reduction = reductions[index]; + return reduction === undefined ? reference : { names: reference.names, argumentsText: reduction }; + }); +} + /** * One structured call as the reconciler sees it: both the wire name and its restored client name * identify it, and its arguments are repaired against the visible text the same response carried. + * A single call is its own batch, so the doubled-input reduction still applies here; a caller + * holding several calls of one response must pass them together to + * `reconcileStructuredToolCalls` so the reduction sees all of them. */ export function reconcileStructuredToolCall( wireName: string, @@ -443,8 +505,7 @@ export function reconcileStructuredToolCall( argumentsText: string, serializedText: string, ): StructuredToolCallReference { - const names = new Set([wireName, restoredName]); - return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(argumentsText, names, serializedText) }; + return reconcileStructuredToolCalls([{ wireName, restoredName, argumentsText }], serializedText)[0]!; } /** diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 2e31d673561..54815b2f6fa 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -213,6 +213,55 @@ test("buffered Chat responses reduce a doubled input behind the MiMo wrapping ne }); }); +test("buffered Chat responses keep doubled input when two structured calls qualify", async () => { + // Two qualifying calls leave the repeated block ambiguous, so rewriting either argument would + // execute something the response never proved. Both keep their input and the markup stays visible. + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script + script }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [ + { id: "call_one", function: { name: "exec", arguments: argumentsText } }, + { id: "call_two", function: { name: "exec", arguments: argumentsText } }, + ] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + +test("streamed Chat responses keep doubled input when two structured calls qualify", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script + script }); + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 40) } }] }, + { choices: [{ delta: { content: content.slice(40) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_one", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: { tool_calls: [{ index: 1, id: "call_two", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + test("buffered Chat responses preserve serialized markup for a different function", async () => { const content = "literal example"; const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ From c08c0f75f844afe8fbde3149009d2866cabb61fa Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:55:10 +0900 Subject: [PATCH 12/15] fix(command-code): drop a malformed echo only for its own native call; track only probing blocks Review follow-ups to #5698: a malformed envelope was dropped when any native call exhausted its candidates, even one for another tool; it now needs a native call for the function it declares, otherwise it is released as text. Held blocks are no longer kept in activeProbes just to be skipped on every event. --- .../src/content/docs/reference/adapters.md | 3 +- src/adapters/command-code-tool-text.ts | 57 +++++++++++-------- structure/providers-and-adapters.md | 2 +- ...command-code-tool-text-prose-split.test.ts | 37 ++++++++++++ 4 files changed, 72 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8cc659a4426..575311f8a4e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -223,7 +223,8 @@ chunks is still shown as text. Reasoning or other events arriving in between no release a held envelope. After a clean stop or tool-call finish, a complete declared-tool call with no native counterpart is restored as a real call; an interrupted or failed turn leaves the markup as text. A call the parser cannot read is dropped rather than printed -when it still opens, closes, and names a declared tool. A freeform call echoed without its +when it still opens, closes, and names a declared tool, and either the real call for that +tool arrives or the turn finishes cleanly. A freeform call echoed without its `` close counts as complete once `` arrives. This applies to every MiMo model Command Code serves. diff --git a/src/adapters/command-code-tool-text.ts b/src/adapters/command-code-tool-text.ts index 83554eaa39b..dccc591814b 100644 --- a/src/adapters/command-code-tool-text.ts +++ b/src/adapters/command-code-tool-text.ts @@ -26,8 +26,8 @@ import { validatesRestoredValue } from "./command-code-restored-schema"; * splits such a delta at the marker and holds the markup part the same way (#5698; a marker split * across deltas after prose is still released as text). * A malformed envelope that still opens and closes around a declared function name, but that the - * strict parser rejects, is dropped instead of released on both the duplicate and the clean-finish - * path, so the echo never reaches the client. + * strict parser rejects, is dropped instead of released when the native call for that same function + * arrives, and on the clean-finish path, so the echo never reaches the client. * Later text waits behind unresolved markup within the same byte bound. */ @@ -88,17 +88,18 @@ function tryJson(value: string): unknown { } /** - * Whether a block reads as a complete envelope echo even though the strict parser rejected it: - * malformed parameter tags, a missing ``, garbage inside the body. Opening and - * closing as an envelope with a known function name is enough to keep it off the client; the - * native duplicate call, when the gateway emits one, carries the canonical execution. + * The declared function name of an envelope echo the strict parser rejected, or undefined when the + * text is anything else: malformed parameter tags, a missing ``, garbage inside the body. + * Opening and closing as an envelope with a known function name is enough to keep it off the client + * once the native duplicate call for that same name — which carries the canonical execution — + * arrives; a native call for another tool says nothing about this envelope. */ -function isLooseEnvelope(text: string, declared: CommandCodeDeclaredTools | undefined): boolean { +function looseEnvelopeName(text: string, declared: CommandCodeDeclaredTools | undefined): string | undefined { const trimmed = text.trim(); - if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("")) return false; - if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return false; + if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("")) return undefined; + if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return undefined; const fn = /\s]+)>/.exec(trimmed); - return fn !== null && (declared?.has(fn[1]!) ?? false); + return fn !== null && (declared?.has(fn[1]!) ?? false) ? fn[1]! : undefined; } function deepEqual(left: unknown, right: unknown): boolean { @@ -271,7 +272,10 @@ const encoder = new TextEncoder(); export class CommandCodeToolTextFilter { private readonly openInputs = new Map(); private readonly blocks = new Map(); - /** Only nonempty blocks still deciding whether their text is markup need boundary visits. */ + /** + * Only blocks still deciding whether their text is markup (state "probing") need boundary visits; + * a held block is deliberately absent, so no interleaved event can interrupt it. + */ private readonly activeProbes = new Map(); /** Held blocks in arrival order, including ended ones awaiting a verdict. */ private held: TextBlock[] = []; @@ -303,13 +307,13 @@ export class CommandCodeToolTextFilter { for (const [key, block] of this.activeProbes) { this.queueOperations++; if (key === exceptKey) continue; - // Held blocks open with the marker by construction (the probe guarantees it). Interrupting - // them on interleaved events (reasoning deltas, other blocks, the native call itself) cleared - // complete and malformed envelopes alike and released the echoed call as text. Held blocks - // therefore stay held until settle, in arrival order; the queued-byte bound (makeRoom) still - // caps memory and wait, flushing everything as text if a held envelope never resolves while - // the stream keeps producing. - if (block.state === "held") continue; + // Guard only: a held block is not tracked here (textDelta adds probing blocks and drops them + // from the map on the transition to held). It must stay held until settle, in arrival order — + // interrupting it on interleaved events (reasoning deltas, other blocks, the native call + // itself) cleared complete and malformed envelopes alike and released the echoed call as text. + // The queued-byte bound (makeRoom) still caps memory and wait, flushing everything as text if a + // held envelope never resolves while the stream keeps producing. + if (block.state !== "probing") continue; this.activeProbes.delete(key); block.interrupted = true; block.state = "queued"; @@ -383,7 +387,7 @@ export class CommandCodeToolTextFilter { } const preceding = this.makeRoom(encoder.encode(text).byteLength); this.retain(block, text); - if (block.state === "probing" || block.state === "held") this.activeProbes.set(key, block); + if (block.state === "probing") this.activeProbes.set(key, block); const bytes = encoder.encode(text).byteLength; const tail = this.pending.at(-1); if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) { @@ -396,7 +400,7 @@ export class CommandCodeToolTextFilter { } this.queuedBytes += bytes; this.probeBlockText(block, text); - if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key); + if (block.state !== "probing") this.activeProbes.delete(key); return [...boundaryEvents, ...preceding, ...this.limitPending()]; } @@ -458,7 +462,8 @@ export class CommandCodeToolTextFilter { remaining.push(block); continue; } - const markup = parseToolCallMarkup(block.markupParts.join("")); + const text = block.markupParts.join(""); + const markup = parseToolCallMarkup(text); if (markup && markup.name === name && markupMatchesInput(markup, input)) { this.drop(block); block.state = "dropped"; @@ -467,9 +472,11 @@ export class CommandCodeToolTextFilter { } block.candidates.delete(id); if (block.candidates.size === 0) { - // A malformed envelope cannot match a native input, but it is still an envelope: drop it - // rather than releasing the echo as text (the native call carries the execution). - if (markup === undefined && isLooseEnvelope(block.markupParts.join(""), this.declared)) { + // A malformed envelope cannot match a native input, but it is still an envelope: when the + // native call is for the function it declares, that call carries the execution, so drop the + // echo rather than releasing it as text. A native call for any other tool proves nothing + // about this envelope, so it keeps the release-as-text path below. + if (markup === undefined && looseEnvelopeName(text, this.declared) === name) { this.drop(block); block.state = "dropped"; this.activeProbes.delete(block.id); @@ -528,7 +535,7 @@ export class CommandCodeToolTextFilter { salvaged = true; lastTextBlock = undefined; } else if (restore && !block.interrupted && markup === undefined - && isLooseEnvelope(block.markupParts.join(""), this.declared)) { + && looseEnvelopeName(block.markupParts.join(""), this.declared) !== undefined) { // A malformed envelope is still an envelope: the native duplicate (observed in every // capture) carries the call, so the echo is dropped rather than rendered as text. // Releasing it would put the raw markup back on screen; restoring it could execute a diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index dd5d11d87ee..25076e841a7 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -55,7 +55,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | -| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose in the same delta is split off at the marker and held like a block that opens with ``; a marker split across deltas after prose is still released as text. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped on the duplicate and clean-finish paths; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the split, the interleaved-event hold, and both drop paths. | +| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose in the same delta is split off at the marker and held like a block that opens with ``; a marker split across deltas after prose is still released as text. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the split, the interleaved-event hold, and both drop paths. | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | diff --git a/tests/providers/command-code-tool-text-prose-split.test.ts b/tests/providers/command-code-tool-text-prose-split.test.ts index ff54558148e..7ca96b075a5 100644 --- a/tests/providers/command-code-tool-text-prose-split.test.ts +++ b/tests/providers/command-code-tool-text-prose-split.test.ts @@ -26,6 +26,9 @@ const MALFORMED = [ const EXEC_TOOL = { name: "exec", description: "Run JavaScript", freeform: true, parameters: { type: "object", properties: { input: { type: "string", description: "Raw freeform input for this tool." } }, required: ["input"] } }; +const READ_TOOL = { name: "read", description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }; + function ndjson(events: unknown[]): Response { return new Response(events.map(event => JSON.stringify(event)).join("\n")); } @@ -72,6 +75,15 @@ function execFilter() { return { budget, filter: new CommandCodeToolTextFilter(budget, new Map([["exec", { freeform: true, schema: EXEC_TOOL.parameters }]])) }; } +/** A filter that also declares a second tool, for native calls that are not the envelope's. */ +function twoToolFilter() { + const budget = createTestTranslatorBudget(); + return { budget, filter: new CommandCodeToolTextFilter(budget, new Map([ + ["exec", { freeform: true, schema: EXEC_TOOL.parameters }], + ["read", { freeform: false, schema: READ_TOOL.parameters }], + ])) }; +} + describe("Command Code markup echoed after prose in one text block", () => { const PROSE = "Running it now.\n"; const proseMarkup = PROSE + MARKUP; @@ -145,6 +157,31 @@ describe("Command Code malformed envelope echo", () => { expect(done(events)?.stopReason).toBe("tool_calls"); }); + test("releases a malformed envelope when the native call is for another tool", () => { + const { budget, filter } = twoToolFilter(); + const args = JSON.stringify({ path: "src/a.ts" }); + expect(filter.textDelta("t", MALFORMED)).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + // A read call proves nothing about an exec envelope, so it must not consume the echo the way a + // matching exec call does: the text is released rather than dropped, still ahead of the call. + const events = [...filter.nativeCall("call_r1", "read", args), ...filter.releaseAll()]; + expect(texts(events)).toBe(MALFORMED); + expect(calls(events)).toEqual([{ id: "call_r1", name: "read", args }]); + expect(events.findIndex(event => event.type === "text_delta")) + .toBeLessThan(events.findIndex(event => event.type === "tool_call_start")); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("drops a malformed envelope when the native call names it", () => { + const { budget, filter } = twoToolFilter(); + expect(filter.textDelta("t", MALFORMED)).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + const events = [...filter.nativeCall("call_c1", "exec", JS), ...filter.releaseAll()]; + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(budget.snapshot().currentBytes).toBe(0); + }); + test("drops a malformed envelope on a clean finish instead of restoring it", async () => { const events = await adapterEvents([...textBlock(MALFORMED), { type: "finish", rawFinishReason: "stop" }]); expect(texts(events)).toBe(""); From 38990078721174411aa2a2dbd868d878b03d2b06 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:19:01 +0900 Subject: [PATCH 13/15] fix(openai-chat): count an already-agreeing call as a competing echo explanation Review follow-up: with a doubled call A and a call B whose input already equals the repeated block, A was still reduced because only doubled shapes were counted. Both now count as explanations, and the reduction applies only when there is exactly one. --- .../serialized-tool-call-content.ts | 47 ++++++++++------ ...-chat-serialized-tool-call-content.test.ts | 53 +++++++++++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 46ba34ee7db..3dfee8acdcd 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -339,6 +339,19 @@ function freeformBody(value: string): string { return value.replace(/^\r?\n/, "").trimEnd(); } +/** + * Whether a structured call's freeform input already equals the body of `repeated`. Such a call + * explains the repeated pair on its own, which is what competes with a doubled call in the same + * batch: both readings account for the two blocks, and the response never says which one it meant. + */ +function agreesWithRepeatedBlock( + structured: StructuredToolCallReference, + repeated: SerializedToolCall, +): boolean { + const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined; + return input !== undefined && freeformBody(input) === freeformBody(repeated.body); +} + /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ function duplicatedSerializedToolCallRanges( text: string, @@ -348,12 +361,8 @@ function duplicatedSerializedToolCallRanges( if (structuredCalls.length === 0) return []; const repeated = repeatedCallIn(text, context); if (repeated) { - const body = freeformBody(repeated.body); // Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed. - const matching = structuredCalls.filter(structured => { - const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined; - return input !== undefined && freeformBody(input) === body; - }); + const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated)); return matching.length === 1 ? [{ start: repeated.start, end: repeated.end }] : []; } return callsIn(text, context).filter(call => { @@ -455,9 +464,10 @@ export interface StructuredToolCallInput { * Repairs the arguments of every structured call in one response against the visible text that * response carried, and returns them in input order. The per-call prefix repair stands alone, * because the markup it proves is matched against that call's own repaired input. The - * doubled-input reduction is applied only when exactly ONE call qualifies: it rewrites executable - * arguments, and two qualifying calls leave the repeated block ambiguous, so the uniqueness proof - * has to cover the whole batch rather than one call at a time. + * doubled-input reduction is applied only when exactly ONE call in the batch qualifies: it either + * carries the doubled shape or already agrees with the repeated block body. It rewrites executable + * arguments, and a second qualifying call leaves the block ambiguous, so the uniqueness proof has + * to cover the whole batch rather than one call at a time. */ export function reconcileStructuredToolCalls( calls: readonly StructuredToolCallInput[], @@ -473,9 +483,12 @@ export function reconcileStructuredToolCalls( /** * Applies the doubled-input reduction across the batch. The doubled shape is valid JSON, so the * prefix repair returns it untouched, and the reduction only ever rewrites a call the repair left - * alone. A repeated block with no uniquely qualifying call keeps every argument as sent, which is - * also what leaves the markup visible: the range matcher then finds either no agreeing call or - * several, and suppresses neither. + * alone. A call whose input already equals the repeated body is a competing explanation, not a + * bystander: both readings account for the pair and the response never picks one, so a batch with + * two qualifying calls keeps every argument exactly as sent. The reduction then rewrites nothing, + * and the markup is left to the range matcher, which suppresses the pair only when exactly one + * call already agrees. A lone qualifying call is always the doubled one, because a call that + * already agrees leaves nothing to reduce. */ function reduceUnambiguousDoubledInput( references: readonly StructuredToolCallReference[], @@ -483,11 +496,15 @@ function reduceUnambiguousDoubledInput( ): StructuredToolCallReference[] { const repeated = repeatedCallIn(serializedText); if (!repeated) return [...references]; - const reductions = references.map(reference => - doubledInputReduction(reference.argumentsText, reference.names, repeated)); - if (reductions.filter(reduction => reduction !== undefined).length !== 1) return [...references]; + const candidates = references.map(reference => ({ + reduction: doubledInputReduction(reference.argumentsText, reference.names, repeated), + explains: agreesWithRepeatedBlock(reference, repeated), + })); + if (candidates.filter(candidate => candidate.explains || candidate.reduction !== undefined).length !== 1) { + return [...references]; + } return references.map((reference, index) => { - const reduction = reductions[index]; + const reduction = candidates[index]!.reduction; return reduction === undefined ? reference : { names: reference.names, argumentsText: reduction }; }); } diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 54815b2f6fa..55fcc07722b 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -237,6 +237,33 @@ test("buffered Chat responses keep doubled input when two structured calls quali ]); }); +test("buffered Chat responses keep a doubled input when another call already agrees with the blocks", async () => { + // The single-input call explains the repeated pair on its own, so the doubled call beside it is a + // competing reading rather than the unique one, and neither argument is rewritten. Exactly one + // call still agrees with the blocks, so the range matcher suppresses the pair: the markup is + // settled, the doubled input is not. + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const doubledArguments = `{"input":"${script}${script}"}`; + const singleArguments = `{"input":"${script}"}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [ + { id: "call_doubled", function: { name: "exec", arguments: doubledArguments } }, + { id: "call_single", function: { name: "exec", arguments: singleArguments } }, + ] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: doubledArguments }, + { type: "tool_call_delta", arguments: singleArguments }, + ]); +}); + test("streamed Chat responses keep doubled input when two structured calls qualify", async () => { const script = "text('ok');"; const block = `${script}`; @@ -262,6 +289,32 @@ test("streamed Chat responses keep doubled input when two structured calls quali ]); }); +test("streamed Chat responses keep doubled input when another call already agrees with the blocks", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const doubledArguments = `{"input":"${script}${script}"}`; + const singleArguments = `{"input":"${script}"}`; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 40) } }] }, + { choices: [{ delta: { content: content.slice(40) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_doubled", function: { name: "exec", arguments: doubledArguments } }] } }] }, + { choices: [{ delta: { tool_calls: [{ index: 1, id: "call_single", function: { name: "exec", arguments: singleArguments } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: doubledArguments }, + { type: "tool_call_delta", arguments: singleArguments }, + ]); +}); + test("buffered Chat responses preserve serialized markup for a different function", async () => { const content = "literal example"; const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ From e66626cb959d0f5134927d021e24bfc6dc3e938b Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:32:47 +0900 Subject: [PATCH 14/15] fix(google): omit an array the node budget cannot complete instead of emitting it bare Review follow-up to #5689: when the budget ran out at an array, the retained array could still be emitted without items, which Gemini rejects for the whole request. Every sanitizeSchema exit now completes an array's items or returns BUDGET_EXHAUSTED so the caller omits it, cascading to a parent that lost its own items. Non-array schemas keep the existing budget behaviour. --- src/adapters/google-tool-schema.ts | 42 ++++++----- structure/providers/google.md | 5 +- .../google/google-tool-schema.test.ts | 72 ++++++++++++++++--- 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index 2a6c3d7136d..84ff3b4bc50 100644 --- a/src/adapters/google-tool-schema.ts +++ b/src/adapters/google-tool-schema.ts @@ -604,6 +604,28 @@ function sanitizeProperties( return properties; } +/** + * Gemini rejects an array declaration that carries no `items` (#5689), so no return from + * `sanitizeSchema` may leave an array incomplete. A string item keeps the declaration valid: it + * narrows an unconstrained item rather than widening a constraint, so the loss report, which counts + * widened or dropped constraints, does not record it. The synthesized node is part of the emitted + * tree and charges the node budget like any other, because a wide enough fan-out of `items`-less + * arrays otherwise pushed the output past MAX_SCHEMA_NODES. When the budget cannot pay for that + * item, the array is omitted (`BUDGET_EXHAUSTED`) for its caller to drop instead of being emitted + * bare, which Gemini would reject for the whole request. A parent whose own `items` came back + * exhausted reaches this same rule, so an incomplete array is never nested in a retained one. + */ +function completeArrayItems(out: Schema, state: SanitizeState): SanitizeResult { + if (out.type !== "array" || Object.hasOwn(out, "items")) return out; + if (state.remainingNodes <= 0) { + reportBudgetExhausted(state); + return BUDGET_EXHAUSTED; + } + state.remainingNodes -= 1; + out.items = { type: "string" }; + return out; +} + function sanitizeSchema( node: unknown, defs: Map, @@ -725,7 +747,8 @@ function sanitizeSchema( if (state.remainingNodes <= 0) { if (Object.hasOwn(node, "items") || Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state); - return out; + // An array that the budget stopped before its `items` traversal takes the same rule. + return completeArrayItems(out, state); } if (Array.isArray(node.items)) { @@ -739,7 +762,7 @@ function sanitizeSchema( if (state.remainingNodes <= 0) { if (Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state); - return out; + return completeArrayItems(out, state); } if (Object.hasOwn(node, "anyOf")) { const normalized = normalizeAnyOf(node.anyOf, defs, depth, refDepth, state); @@ -753,20 +776,7 @@ function sanitizeSchema( } Object.assign(out, normalized); } - // Gemini rejects an array declaration with no `items` (#5689). A string item keeps the declaration - // valid. It narrows an unconstrained item rather than widening a constraint, so the loss report, - // which counts widened or dropped constraints, does not record it. The synthesized node is part - // of the emitted tree, so it charges the node budget like any other: a wide enough fan-out of - // `items`-less arrays otherwise pushed the output past MAX_SCHEMA_NODES. - if (out.type === "array" && !Object.hasOwn(out, "items")) { - if (state.remainingNodes <= 0) { - reportBudgetExhausted(state); - } else { - state.remainingNodes -= 1; - out.items = { type: "string" }; - } - } - return out; + return completeArrayItems(out, state); } export function sanitizeGeminiToolParametersWithReport( diff --git a/structure/providers/google.md b/structure/providers/google.md index 07ecc155907..2253b157472 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -90,7 +90,10 @@ replacement, and root object coercion. Lossless normalization does not set `loss case folding, duplicate enum/required removal, nullable-union collapse, and string-const conversion preserve the accepted value set; an array left without `items` is emitted with `items: { type: "string" }` because Gemini rejects an array declaration without an item type; that narrows an unconstrained item -rather than widening a constraint, so it does not set `lossy` either. Annotation-only fields such as title, default, examples, comments, +rather than widening a constraint, so it does not set `lossy` either. The synthesized item is itself +part of the emitted tree and charges the 1,024-node allowance, so an array the budget can no longer +complete is omitted — along with any parent that lost its own `items` to the same rule — and records +`node-budget-widened` instead of emitting a declaration Gemini would reject. Annotation-only fields such as title, default, examples, comments, deprecated, read-only/write-only, external documentation and examples are omitted without loss. Local-reference siblings use 2020-12-style conjunctive semantics for loss accounting, while the wire transform retains its implemented overlay-wins merge; enum reports compare that intersection diff --git a/tests/adapters/google/google-tool-schema.test.ts b/tests/adapters/google/google-tool-schema.test.ts index f96e249ee50..486f078c564 100644 --- a/tests/adapters/google/google-tool-schema.test.ts +++ b/tests/adapters/google/google-tool-schema.test.ts @@ -15,6 +15,24 @@ function countSchemaNodes(value: unknown): number { return count; } +/** + * Gemini rejects an array declaration that carries no `items` (#5689), which fails the whole tool + * request. Returns the path of every emitted array that lacks them, walking the same places as + * `countSchemaNodes`. + */ +function findArraysWithoutItems(value: unknown, path = "root"): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const schema = value as Record; + const offenders = schema.type === "array" && schema.items === undefined ? [path] : []; + if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) { + for (const [name, child] of Object.entries(schema.properties)) { + offenders.push(...findArraysWithoutItems(child, `${path}.${name}`)); + } + } + offenders.push(...findArraysWithoutItems(schema.items, `${path}.items`)); + return offenders; +} + describe("sanitizeGeminiToolParameters", () => { test("drops JSON-Schema keywords outside Google's documented function-schema subset", () => { const out = sanitizeGeminiToolParameters({ @@ -455,7 +473,7 @@ describe("sanitizeGeminiToolParameters", () => { expect(choice).toEqual({ description: "kept" }); }); - test("does not read items after earlier traversal exhausts the budget", () => { + test("omits an array left without items after earlier traversal exhausts the budget", () => { const container: Record = { type: "array", properties: Object.fromEntries(Array.from( @@ -472,14 +490,17 @@ describe("sanitizeGeminiToolParameters", () => { }, }); - const out = sanitizeGeminiToolParameters({ + const result = sanitizeGeminiToolParametersWithReport({ type: "object", properties: { container }, - }); - const sanitized = (out.properties as Record>).container; + }, { endpointClass: "ai-studio" }); + const properties = result.parameters.properties as Record>; + // The traversal stops before the `items` keyword is read, and the array it can no longer + // complete is omitted rather than emitted without them. expect(readItems).toBe(false); - expect(sanitized.items).toBeUndefined(); - expect(countSchemaNodes(out)).toBe(1_024); + expect(findArraysWithoutItems(result.parameters)).toEqual([]); + expect(properties.container).toBeUndefined(); + expect(result.lossReport.categories["node-budget-widened"]).toBe(1); }); test("charges synthesized array items to the node budget", () => { @@ -489,15 +510,48 @@ describe("sanitizeGeminiToolParameters", () => { properties: Object.fromEntries(names.map(name => [name, { type: "array" }])), }, { endpointClass: "ai-studio" }); - // Every retained array leaf costs two nodes: the leaf and the `items` synthesized for it. + // Every retained array leaf costs two nodes: the leaf and the `items` synthesized for it, and no + // retained leaf may be an array the budget left without them. expect(countSchemaNodes(result.parameters)).toBeLessThanOrEqual(1_024); + expect(findArraysWithoutItems(result.parameters)).toEqual([]); const properties = result.parameters.properties as Record>; const retained = Object.keys(properties); - expect(retained).toHaveLength(512); - expect(retained.filter(name => properties[name].items !== undefined)).toHaveLength(511); + expect(retained).toHaveLength(511); + expect(retained.map(name => properties[name])).toEqual( + retained.map(() => ({ type: "array", items: { type: "string" } })), + ); expect(result.lossReport.categories["node-budget-widened"]).toBeGreaterThan(0); }); + test("omits a nested array whose items cannot be completed inside the node budget", () => { + // Each `grid` costs three nodes: the outer array, its inner array, and the item synthesized for + // the inner one. The one-node `pad` property puts the boundary mid-grid, so the last grid can + // complete neither level: the inner array is omitted for want of an item node and the outer one + // follows it, rather than the inner array being nested into a retained outer array without them. + const properties: Record = { pad: { type: "string" } }; + for (let index = 0; index < 2_000; index++) { + properties[`grid_${index}`] = { type: "array", items: { type: "array" } }; + } + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties, + }, { endpointClass: "ai-studio" }); + + expect(countSchemaNodes(result.parameters)).toBeLessThanOrEqual(1_024); + // The inner array cannot pay for its item node, so it is omitted; the outer array that lost its + // `items` this way is omitted with it instead of being retained bare. + expect(findArraysWithoutItems(result.parameters)).toEqual([]); + const retained = result.parameters.properties as Record>; + expect(Object.keys(retained)).toHaveLength(341); + expect(retained.pad).toEqual({ type: "string" }); + expect(retained.grid_339).toEqual({ + type: "array", + items: { type: "array", items: { type: "string" } }, + }); + expect(retained.grid_340).toBeUndefined(); + expect(result.lossReport.categories["node-budget-widened"]).toBe(1); + }); + test("falls back to an object schema for non-object input", () => { expect(sanitizeGeminiToolParameters(undefined)).toEqual({ type: "object", properties: {} }); expect(sanitizeGeminiToolParameters("nope")).toEqual({ type: "object", properties: {} }); From d1ed6e32eba4b15f14eea55e40369f06c17f1877 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:57:28 +0900 Subject: [PATCH 15/15] test(openai-chat): pin fenced repeated echoes as visible and unrepaired Review follow-up: a repeated pair inside a Markdown fence opened in an earlier chunk keeps its doubled input and stays visible, and a fenced echo does not repair the argument prefix beside it. The buffer never holds a complete block inside a fence, so the reducer and drain share the same starting context; these tests guard that. --- ...-chat-serialized-tool-call-content.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 55fcc07722b..bc44648654c 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -390,6 +390,44 @@ describe("MiMo echo variants (#5724)", () => { } }); + test("a repeated block pair inside a Markdown fence keeps its doubled input and stays visible", async () => { + // The fence opener lands in the first streamed chunk, so the buffer carries an open fence + // when the identical pair arrives. Fenced markup is user-visible, so neither the arguments + // nor the text may change: the reduction and the suppression scan must read the same context. + const block = `${script}`; + const content = `Look at this example here\n\`\`\`\n${block}${block}`; + for (const events of [await streamed(content, script + script), await buffered(content, script + script)]) { + expect(visible(events)).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script + script }) }, + ]); + } + }); + + test("a fenced echo does not repair the malformed argument prefix beside it", async () => { + // The prefix repair reads the same held text through its own `callsIn` scan, so the fenced + // pair must not prove an echo there either: the arguments the gateway sent stay untouched. + const block = `${script}`; + const argumentsText = script + JSON.stringify({ input: script }); + const content = `Look at this example here\n\`\`\`\n${block}${block}`; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 30) } }] }, + { choices: [{ delta: { content: content.slice(30) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(visible(events)).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + ]); + }); + test("a closed block whose body carries literal tool-call tags is still matched whole", async () => { for (const input of [ "text('');",