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 0000000000..960e278485 --- /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/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 78828bdfb5..575311f8a4 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` @@ -212,11 +217,16 @@ 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, 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. ## `anthropic` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f2582895f7..eddd75946f 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/src/adapters/command-code-tool-text.ts b/src/adapters/command-code-tool-text.ts index 74e33542d9..dccc591814 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 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. */ @@ -81,6 +87,21 @@ function tryJson(value: string): unknown { try { return JSON.parse(value); } catch { return undefined; } } +/** + * 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 looseEnvelopeName(text: string, declared: CommandCodeDeclaredTools | undefined): string | undefined { + const trimmed = text.trim(); + 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) ? fn[1]! : undefined; +} + 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; @@ -251,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[] = []; @@ -283,6 +307,13 @@ export class CommandCodeToolTextFilter { for (const [key, block] of this.activeProbes) { this.queueOperations++; if (key === exceptKey) 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"; @@ -310,8 +341,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") { @@ -320,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) { @@ -332,24 +399,8 @@ 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; - } - } - } - if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key); + this.probeBlockText(block, text); + if (block.state !== "probing") this.activeProbes.delete(key); return [...boundaryEvents, ...preceding, ...this.limitPending()]; } @@ -411,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"; @@ -420,6 +472,16 @@ 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: 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); + continue; + } block.state = "queued"; block.markupParts = []; this.activeProbes.delete(block.id); @@ -472,6 +534,16 @@ export class CommandCodeToolTextFilter { events.push({ type: "tool_call_end" }); salvaged = true; lastTextBlock = undefined; + } else if (restore && !block.interrupted && markup === undefined + && 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 + // 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 +573,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/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index a28290fe60..84ff3b4bc5 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,7 +776,7 @@ function sanitizeSchema( } Object.assign(out, normalized); } - return out; + return completeArrayItems(out, state); } export function sanitizeGeminiToolParametersWithReport( diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b96b64a03..28173b2b04 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 3a46c3ebba..3dfee8acdc 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, @@ -328,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, @@ -335,6 +359,12 @@ function duplicatedSerializedToolCallRanges( context?: TextContext, ): { start: number; end: number }[] { if (structuredCalls.length === 0) return []; + const repeated = repeatedCallIn(text, context); + if (repeated) { + // Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed. + const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated)); + 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 => { @@ -359,6 +389,32 @@ 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, @@ -397,9 +453,68 @@ 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 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[], + 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 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[], + serializedText: string, +): StructuredToolCallReference[] { + const repeated = repeatedCallIn(serializedText); + if (!repeated) 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 = candidates[index]!.reduction; + 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, @@ -407,8 +522,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/src/cli/effort.ts b/src/cli/effort.ts index 1daed4c679..709e3e4764 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/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 6c44162ea4..59347607f5 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/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 6b91a3f860..c84ec3d4f2 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/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index d96f660504..42c0bbda40 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/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index 13b2a1c31d..5693306cc9 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-and-adapters.md b/structure/providers-and-adapters.md index b5b10648e2..25076e841a 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 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/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 46cb706423..3677b03e3d 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/structure/providers/google.md b/structure/providers/google.md index 5582a356d9..2253b15747 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -88,7 +88,12 @@ 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. 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/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 4d553d3d90..7d0030de0b 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/adapters/google/google-tool-schema.test.ts b/tests/adapters/google/google-tool-schema.test.ts index 776a513867..486f078c56 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; @@ -12,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({ @@ -452,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( @@ -469,18 +490,145 @@ 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", () => { + 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, 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(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: {} }); }); + + 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" }); + }); }); 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 7faf3a8572..bc44648654 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,290 @@ 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 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("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}`; + 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("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({ @@ -106,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('');", diff --git a/tests/cli/cli-effort-slug.test.ts b/tests/cli/cli-effort-slug.test.ts new file mode 100644 index 0000000000..38761d2643 --- /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(); + }); +}); + diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 6df91ab4e5..b5698848aa 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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b4b1c78cf7..ce99be13d8 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" } 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 0000000000..7ca96b075a --- /dev/null +++ b/tests/providers/command-code-tool-text-prose-split.test.ts @@ -0,0 +1,205 @@ +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"] } }; + +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")); +} + +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 }]])) }; +} + +/** 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; + + 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("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(""); + 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([]); + }); +}); diff --git a/tests/providers/deepseek-quota-currency.test.ts b/tests/providers/deepseek-quota-currency.test.ts new file mode 100644 index 0000000000..0dd8001f4e --- /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/mimo-token-plan-capacity.test.ts b/tests/providers/mimo-token-plan-capacity.test.ts new file mode 100644 index 0000000000..cecd298396 --- /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"]); + }); +}); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 0c9c8b210c..228be2e729 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); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 2bb927063a..7ad56d082f 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 0000000000..f24f374902 --- /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 b4a29268ae..f3d96d61b0 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 4faf3866c1..263a1c049d 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", diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts index c9b876a184..46804f2d48 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));