@copilotkit/aimock 1.36.1 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -2
  3. package/dist/agui-recorder.cjs +62 -0
  4. package/dist/agui-recorder.cjs.map +1 -1
  5. package/dist/agui-recorder.d.cts +23 -1
  6. package/dist/agui-recorder.d.cts.map +1 -1
  7. package/dist/agui-recorder.d.ts +23 -1
  8. package/dist/agui-recorder.d.ts.map +1 -1
  9. package/dist/agui-recorder.js +60 -1
  10. package/dist/agui-recorder.js.map +1 -1
  11. package/dist/agui-types.d.cts +13 -0
  12. package/dist/agui-types.d.cts.map +1 -1
  13. package/dist/agui-types.d.ts +13 -0
  14. package/dist/agui-types.d.ts.map +1 -1
  15. package/dist/fixture-loader.cjs +0 -0
  16. package/dist/fixture-loader.cjs.map +1 -1
  17. package/dist/fixture-loader.d.cts.map +1 -1
  18. package/dist/fixture-loader.d.ts.map +1 -1
  19. package/dist/fixture-loader.js +0 -0
  20. package/dist/fixture-loader.js.map +1 -1
  21. package/dist/index.cjs +5 -0
  22. package/dist/index.d.cts +3 -3
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +3 -3
  25. package/dist/recorder.cjs +12 -4
  26. package/dist/recorder.cjs.map +1 -1
  27. package/dist/recorder.d.cts.map +1 -1
  28. package/dist/recorder.d.ts.map +1 -1
  29. package/dist/recorder.js +12 -4
  30. package/dist/recorder.js.map +1 -1
  31. package/dist/router.cjs +59 -25
  32. package/dist/router.cjs.map +1 -1
  33. package/dist/router.d.cts +20 -0
  34. package/dist/router.d.cts.map +1 -1
  35. package/dist/router.d.ts +20 -0
  36. package/dist/router.d.ts.map +1 -1
  37. package/dist/router.js +59 -25
  38. package/dist/router.js.map +1 -1
  39. package/dist/stream-collapse.cjs +120 -27
  40. package/dist/stream-collapse.cjs.map +1 -1
  41. package/dist/stream-collapse.d.cts +31 -8
  42. package/dist/stream-collapse.d.cts.map +1 -1
  43. package/dist/stream-collapse.d.ts +31 -8
  44. package/dist/stream-collapse.d.ts.map +1 -1
  45. package/dist/stream-collapse.js +119 -28
  46. package/dist/stream-collapse.js.map +1 -1
  47. package/dist/types.d.cts +13 -0
  48. package/dist/types.d.cts.map +1 -1
  49. package/dist/types.d.ts +13 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/ws-gemini-live.d.ts +2 -2
  52. package/dist/ws-realtime.d.ts +2 -2
  53. package/package.json +2 -2
  54. package/skills/write-fixtures/SKILL.md +40 -31
@@ -1 +1 @@
1
- {"version":3,"file":"router.js","names":[],"sources":["../src/router.ts"],"sourcesContent":["import type {\n ChatCompletionRequest,\n ChatMessage,\n ContentPart,\n Fixture,\n FixtureMatch,\n} from \"./types.js\";\nimport {\n isImageResponse,\n isAudioResponse,\n isTranscriptionResponse,\n isVideoResponse,\n isJSONResponse,\n isErrorResponse,\n} from \"./helpers.js\";\n\nexport function getLastMessageByRole(messages: ChatMessage[], role: string): ChatMessage | null {\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === role) return messages[i];\n }\n return null;\n}\n\n/**\n * Concatenate the text content of every `system` role message in order.\n * Hosts that build a system context from multiple sources (persona, agent\n * context entries, tool guidance) often emit several system messages in one\n * request; this joins SEPARATE system messages with newlines so a substring\n * matcher sees the whole context as one body.\n *\n * Empty handling is symmetric with {@link getTextContent}: a system message\n * with no extractable text (`null`) contributes nothing, while a message that\n * extracts to an empty string is a present-but-empty body. We skip only the\n * `null` (no-text) case so a genuinely empty system message does not inject a\n * stray newline; this matches getTextContent treating \"no text\" and \"empty\n * text\" consistently.\n */\nexport function getSystemText(messages: ChatMessage[]): string {\n const parts: string[] = [];\n for (const m of messages) {\n if (m.role !== \"system\") continue;\n const text = getTextContent(m.content);\n if (text === null) continue;\n parts.push(text);\n }\n return parts.join(\"\\n\");\n}\n\n/**\n * Extract the text content from a message's content field.\n * Handles both plain string content and array-of-parts content\n * (e.g. `[{type: \"text\", text: \"...\"}]` as sent by some SDKs).\n *\n * Multi-part text is joined with `\"\"` (the parts form one logical body split\n * across segments). Empty handling is symmetric with the string path: a string\n * `\"\"` returns `\"\"`, and an array containing at least one text part whose\n * combined text is empty likewise returns `\"\"` (NOT `null`). `null` is reserved\n * for \"no text content at all\" — null content, or an array with no text parts —\n * so callers can distinguish \"absent\" from \"present but empty\" the same way for\n * both content shapes.\n */\nexport function getTextContent(content: string | ContentPart[] | null): string | null {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const texts = content\n .filter((p) => p.type === \"text\" && typeof p.text === \"string\")\n .map((p) => p.text as string);\n return texts.length > 0 ? texts.join(\"\") : null;\n }\n return null;\n}\n\n/**\n * Result of {@link matchFixtureDiagnostic}: the matched fixture (or `null`) plus\n * the number of fixtures that matched the request SHAPE (every predicate above\n * the sequenceIndex/turnIndex gates) but were rejected ONLY by the\n * sequenceIndex/turnIndex count state.\n *\n * `skippedBySequenceOrTurn > 0` with `fixture === null` distinguishes a\n * \"sequence/turn exhausted\" miss (candidate fixtures existed but their count\n * gate had moved on) from a true \"no fixture had a matching shape\" miss — used\n * to disambiguate the strict-mode 503 message.\n */\nexport interface MatchFixtureDiagnostic {\n fixture: Fixture | null;\n skippedBySequenceOrTurn: number;\n /**\n * `true` when the served fixture was selected by relaxed content-anchored\n * matching even though its `turnIndex` is defined and does NOT equal the\n * request's assistant-message count — i.e. the legacy strict turnIndex gate\n * (now opt-in via `AIMOCK_STRICT_TURN_INDEX`) WOULD HAVE rejected it. Absent /\n * falsy on canonical-position matches, non-turnIndexed matches, and misses.\n * Additive optional field — existing handler destructures are unaffected.\n */\n turnIndexRelaxed?: boolean;\n /**\n * How the served fixture was selected: `\"turnIndex\"` when its `turnIndex`\n * sits exactly at the current assistant count (canonical position),\n * `\"content\"` otherwise (a non-turnIndexed match or a relaxed off-by-N\n * match). Absent on misses. Additive optional field.\n */\n matchedBy?: \"content\" | \"turnIndex\";\n}\n\n/**\n * Optional matcher tuning.\n *\n * `strictTurnIndex` restores the legacy behaviour where `turnIndex` must equal\n * the request's assistant-message count exactly (a hard reject gate). It is set\n * by the record path, where a miss proxies upstream to capture a fresh turn; an\n * earlier-turn fixture must not shadow a longer request or the new turn would\n * never be recorded. Replay (the default, `false`) treats `turnIndex` as a\n * non-fatal disambiguator instead — see {@link selectByTurnIndex}.\n */\nexport interface MatchOptions {\n strictTurnIndex?: boolean;\n /**\n * Optional sink for the one-shot relaxed-turnIndex divergence warning. Handlers\n * pass their `defaults.logger`; the structural `{ warn }` shape avoids an\n * import cycle with logger.ts and keeps the matcher decoupled. When omitted no\n * warning is emitted (the diagnostic fields are still populated). The Logger's\n * own level gate keeps a passing programmatic run (silent default) quiet.\n */\n logger?: { warn(...args: unknown[]): void };\n}\n\n/**\n * Process-level opt-out: when `AIMOCK_STRICT_TURN_INDEX=1` (or `true`) is set,\n * REPLAY selection restores the legacy hard turnIndex gate — a content-matching\n * fixture whose `turnIndex` is defined and `!== assistantCount` is rejected,\n * reproducing origin/main semantics. Follows the `AIMOCK_ALLOW_PRIVATE_URLS`\n * precedent for parsing/precedence. Read per-call (not cached) so tests can flip\n * it. Does NOT affect the record path, which is already strict regardless.\n */\nfunction strictTurnIndexEnv(): boolean {\n const v = process.env.AIMOCK_STRICT_TURN_INDEX;\n return v === \"1\" || v === \"true\";\n}\n\n/**\n * Process-level set of fixtures for which the relaxed-turnIndex divergence\n * warning has already fired, so each divergent fixture warns at most ONCE per\n * process (throttle). Keyed by the selected fixture's OBJECT IDENTITY: the\n * `Fixture` references in the server's fixtures array are stable across replays\n * (the array is held by reference and only fully replaced on a fixtures reset),\n * so identity uniquely distinguishes divergent fixtures and warns each exactly\n * once. A `WeakSet` was chosen over the previous `JSON.stringify(match)` key for\n * two reasons: (1) stringifying the match DROPS `predicate` functions and\n * serialises any RegExp matcher to `{}`, so two distinct fixtures differing only\n * by a predicate/regex collided to one key and the second's warning was silently\n * suppressed; and (2) a string `Set` only grows, accumulating an entry per\n * divergent shape on a long-lived server, whereas a `WeakSet` auto-evicts when a\n * fixture object is released (e.g. after a fixtures reset drops the references).\n * `let` because `WeakSet` has no `.clear()`, so the test hook reassigns a fresh\n * one.\n */\nlet warnedRelaxedFixtures = new WeakSet<Fixture>();\n\n/**\n * Test-only hook to clear the throttle state between cases. Not part of the\n * public contract. `WeakSet` has no `.clear()`, so reassign a fresh instance.\n */\nexport function _resetTurnIndexRelaxWarnings(): void {\n warnedRelaxedFixtures = new WeakSet<Fixture>();\n}\n\n/**\n * Build the {@link MatchOptions} a request handler must pass to\n * {@link matchFixtureDiagnostic} / {@link matchFixture}, derived from whether\n * the handler is about to record on a miss.\n *\n * EVERY record-capable handler (OpenAI chat, Anthropic messages, Responses,\n * Gemini, Bedrock, Bedrock-Converse, Cohere, Ollama, …) must build its match\n * options through THIS helper rather than hand-rolling `{ strictTurnIndex }` at\n * the call site. Recording proxies upstream on a miss to capture a fresh turn;\n * if `strictTurnIndex` is left false during recording, an earlier-turn fixture\n * can content-shadow a longer request, the `if (!fixture)` record branch never\n * fires, and the new turn is SILENTLY never recorded. Funnelling the decision\n * through one helper makes that wiring impossible for a future handler to miss:\n * pass `recording = true` whenever the handler's own record gate is satisfied\n * (i.e. it will call `proxyAndRecord` on a miss), `false` otherwise.\n */\nexport function recordMatchOptions(\n recording: boolean,\n logger?: { warn(...args: unknown[]): void },\n): MatchOptions {\n return { strictTurnIndex: recording, logger };\n}\n\n/**\n * Match a fixture against a request, additionally reporting WHY a `null` result\n * occurred. Shares the exact predicate loop with {@link matchFixture}; a fixture\n * that passes every shape predicate but fails ONLY the sequenceIndex or\n * turnIndex gate increments `skippedBySequenceOrTurn`. {@link matchFixture} is a\n * thin wrapper that returns the `.fixture` field, so existing callers are\n * unaffected.\n */\nexport function matchFixtureDiagnostic(\n fixtures: Fixture[],\n req: ChatCompletionRequest,\n matchCounts?: Map<Fixture, number>,\n requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest,\n options?: MatchOptions,\n): MatchFixtureDiagnostic {\n // Apply transform once before matching — used for stripping dynamic data\n const effective = requestTransform ? requestTransform(req) : req;\n const useExactMatch = !!requestTransform;\n // In record mode the server proxies to the upstream on a miss, so a fixture\n // already captured for an EARLIER turn must NOT shadow a longer (later-turn)\n // request — otherwise the new turn would never be proxied and recorded.\n // There turnIndex stays a strict hard gate. Replay (the default) instead\n // treats turnIndex as a non-fatal disambiguator so a canonical multi-bubble\n // run isn't falsely rejected for an off-by-N assistant count.\n // Strict turnIndex is in force when the record path requests it OR the\n // process-level AIMOCK_STRICT_TURN_INDEX opt-out is set (which restores the\n // legacy hard gate for replay too). Record mode passes `true` explicitly; the\n // env only matters when the caller left it `false`/unset (replay).\n const strictTurnIndex = (options?.strictTurnIndex ?? false) || strictTurnIndexEnv();\n\n let skippedBySequenceOrTurn = 0;\n // Every fixture whose content / shape predicates (and sequenceIndex gate)\n // pass. turnIndex is applied afterwards as a non-fatal disambiguator.\n const contentMatches: Fixture[] = [];\n\n for (const fixture of fixtures) {\n const { match } = fixture;\n\n // predicate — if present, must return true (receives original request)\n if (match.predicate !== undefined) {\n if (!match.predicate(req)) continue;\n }\n\n // endpoint — bidirectional filtering:\n // 1. If fixture has endpoint set, only match requests of that type\n // 2. If request has _endpointType but fixture doesn't, skip fixtures\n // whose response type is incompatible (prevents generic chat fixtures\n // from matching image/speech/video requests and causing 500s)\n const reqEndpoint = effective._endpointType as string | undefined;\n if (match.endpoint !== undefined) {\n if (match.endpoint !== reqEndpoint) continue;\n } else if (\n reqEndpoint &&\n reqEndpoint !== \"chat\" &&\n reqEndpoint !== \"embedding\" &&\n !reqEndpoint.startsWith(\"realtime\")\n ) {\n // Fixture has no endpoint restriction but request is multimedia —\n // only match if the response type is compatible.\n // Function responses cannot be checked statically, so treat them as compatible.\n const r = fixture.response;\n if (typeof r !== \"function\") {\n const compatible =\n (reqEndpoint === \"image\" && isImageResponse(r)) ||\n (reqEndpoint === \"speech\" && isAudioResponse(r)) ||\n (reqEndpoint === \"elevenlabs-tts\" && isAudioResponse(r)) ||\n (reqEndpoint === \"audio-gen\" && isAudioResponse(r)) ||\n (reqEndpoint === \"fal-audio\" && isAudioResponse(r)) ||\n (reqEndpoint === \"fal\" && (isJSONResponse(r) || isErrorResponse(r))) ||\n (reqEndpoint === \"transcription\" && isTranscriptionResponse(r)) ||\n (reqEndpoint === \"translation\" && isTranscriptionResponse(r)) ||\n (reqEndpoint === \"video\" && isVideoResponse(r));\n if (!compatible) continue;\n }\n }\n\n // context — opt-in exact match against the request's _context field.\n // If fixture specifies a context, only match requests with that exact context.\n // If fixture omits context, match any request regardless of _context.\n if (match.context !== undefined) {\n if (effective._context !== match.context) continue;\n }\n\n // userMessage — case-sensitive match against the last user message content.\n // String matching is intentionally case-sensitive so fixture authors can\n // rely on exact string values. This differs from the case-insensitive\n // matchesPattern() in helpers.ts, which is used for search/rerank/moderation\n // where exact casing rarely matters.\n if (match.userMessage !== undefined) {\n const msg = getLastMessageByRole(effective.messages, \"user\");\n const text = msg ? getTextContent(msg.content) : null;\n if (!text) continue;\n if (typeof match.userMessage === \"string\") {\n if (useExactMatch) {\n if (text !== match.userMessage) continue;\n } else {\n if (!text.includes(match.userMessage)) continue;\n }\n } else {\n match.userMessage.lastIndex = 0;\n if (!match.userMessage.test(text)) continue;\n }\n }\n\n // systemMessage — case-sensitive substring, regexp, or array-of-substrings\n // match against the joined text of every system message in the request.\n // Use to gate a fixture on host-supplied context (e.g. agent-context\n // entries) so that when the calling app changes that context the fixture\n // stops matching and the request falls through to the next fixture or\n // upstream proxy.\n //\n // Array form (string[]) requires ALL substrings to be present — useful\n // when the gate must combine multiple non-adjacent tokens (e.g. a default\n // name AND a default activity list whose positions in the serialised\n // context JSON aren't stable).\n if (match.systemMessage !== undefined) {\n const sm = match.systemMessage;\n // Empty array is treated as \"no constraint\" → matches unconditionally,\n // INCLUDING requests with no system text at all. This is the documented\n // contract (same permissive behaviour as not setting systemMessage), so\n // it must be honored BEFORE the no-system-text guard below — otherwise a\n // request without a system message would be wrongly skipped. Validation\n // rejects [] at load time for JSON fixtures; programmatic callers that\n // pass [] get this permissive behaviour.\n if (Array.isArray(sm) && sm.length === 0) {\n // no constraint — fall through to the next predicate\n } else {\n const text = getSystemText(effective.messages);\n if (!text) continue;\n if (Array.isArray(sm)) {\n let allPresent = true;\n for (const needle of sm) {\n if (!text.includes(needle)) {\n allPresent = false;\n break;\n }\n }\n if (!allPresent) continue;\n } else if (typeof sm === \"string\") {\n if (useExactMatch) {\n if (text !== sm) continue;\n } else {\n if (!text.includes(sm)) continue;\n }\n } else {\n sm.lastIndex = 0;\n if (!sm.test(text)) continue;\n }\n }\n }\n\n // toolCallId — a toolCallId fixture answers the model's response to a tool\n // result, which by API contract only happens when the conversation's LAST\n // message is a tool result. If a newer user (or other) turn follows the\n // tool message, the stale tool_call_id must not shadow userMessage matchers.\n if (match.toolCallId !== undefined) {\n const last = effective.messages[effective.messages.length - 1];\n if (!last || last.role !== \"tool\" || last.tool_call_id !== match.toolCallId) continue;\n }\n\n // toolName — match against any tool definition by function.name\n if (match.toolName !== undefined) {\n const tools = effective.tools ?? [];\n const found = tools.some((t) => t.function.name === match.toolName);\n if (!found) continue;\n }\n\n // inputText — case-sensitive match against the embedding input text.\n // Same rationale as userMessage above: fixture authors specify exact strings.\n if (match.inputText !== undefined) {\n const embeddingInput = effective.embeddingInput;\n if (!embeddingInput) continue;\n if (typeof match.inputText === \"string\") {\n if (useExactMatch) {\n if (embeddingInput !== match.inputText) continue;\n } else {\n if (!embeddingInput.includes(match.inputText)) continue;\n }\n } else {\n match.inputText.lastIndex = 0;\n if (!match.inputText.test(embeddingInput)) continue;\n }\n }\n\n // responseFormat — exact string match against request response_format.type\n if (match.responseFormat !== undefined) {\n const reqType = effective.response_format?.type;\n if (reqType !== match.responseFormat) continue;\n }\n\n // model — exact match or prefix + dash-digit boundary for strings (so that\n // \"claude-opus-4\" matches \"claude-opus-4-20250514\" but \"gpt-4\" does NOT\n // match \"gpt-4o\" and \"gpt-4o\" does NOT match \"gpt-4o-mini\"), regexp unchanged\n if (match.model !== undefined) {\n if (typeof match.model === \"string\") {\n if (effective.model !== match.model) {\n if (!effective.model?.startsWith(match.model)) continue;\n const rest = effective.model.slice(match.model.length);\n if (!/^-\\d/.test(rest)) continue;\n }\n } else {\n match.model.lastIndex = 0;\n if (!match.model.test(effective.model ?? \"\")) continue;\n }\n }\n\n // hasToolResult — request-SHAPE predicate: does the conversation contain a\n // tool result message? Must be evaluated with the other shape predicates\n // ABOVE the sequence/turn state gates so that a fixture whose shape never\n // matched is not miscounted as \"skipped by sequence/turn state\".\n if (match.hasToolResult !== undefined) {\n const hasTool = effective.messages.some((m) => m.role === \"tool\");\n if (hasTool !== match.hasToolResult) continue;\n }\n\n // At this point every SHAPE / CONTENT predicate above has passed, so this\n // fixture is a genuine CONTENT match for the request. The sequenceIndex and\n // turnIndex constraints below are POSITION state, not request shape.\n //\n // sequenceIndex remains a hard, stateful gate: it consumes sequenced\n // siblings one call at a time (and an exhausted index intentionally falls\n // through to a later fixture). A fixture that matched the shape but fails\n // ONLY the sequenceIndex gate is a \"candidate skipped by sequence/turn\n // state\", counted separately so callers can disambiguate the strict-mode\n // 503 message.\n if (match.sequenceIndex !== undefined && matchCounts !== undefined) {\n const count = matchCounts.get(fixture) ?? 0;\n if (count !== match.sequenceIndex) {\n skippedBySequenceOrTurn++;\n continue;\n }\n }\n\n // turnIndex is normally NOT a hard gate (replay). Multi-step agents emit\n // several assistant bubbles per logical turn, so a canonical run's assistant\n // count routinely differs from a fixture's hardcoded turnIndex even when the\n // request content matches exactly. Rejecting a uniquely content-matching\n // fixture on absolute position produced false \"empty assistant response\"\n // misses. Instead we collect every content match and use turnIndex only as a\n // non-fatal DISAMBIGUATOR to choose AMONG several content-matching fixtures\n // (see selectByTurnIndex below). Content that does not match any fixture\n // still matches nothing — only the position gate is relaxed.\n //\n // Under strictTurnIndex (record mode) turnIndex stays a hard, exact gate so\n // an earlier-turn capture can't shadow a longer request; the miss then\n // proxies upstream and records the new turn.\n if (strictTurnIndex && match.turnIndex !== undefined) {\n const assistantCount = effective.messages.filter((m) => m.role === \"assistant\").length;\n if (assistantCount !== match.turnIndex) {\n skippedBySequenceOrTurn++;\n continue;\n }\n }\n\n contentMatches.push(fixture);\n }\n\n if (contentMatches.length === 0) {\n return { fixture: null, skippedBySequenceOrTurn };\n }\n\n const assistantCount = effective.messages.filter((m) => m.role === \"assistant\").length;\n const { fixture: selected, byUniquePosition } = selectByTurnIndex(contentMatches, assistantCount);\n\n // Divergence predicate: the served fixture carries a turnIndex that does NOT\n // sit at the current assistant position. Under strict matching this fixture\n // would have been rejected at the gate above, so serving it here is the (rare,\n // off-by-N) relaxed behaviour change PR #276 introduced. Computed from values\n // already in hand — no second matching pass.\n const selectedTurn = selected.match.turnIndex;\n const turnIndexRelaxed = selectedTurn !== undefined && selectedTurn !== assistantCount;\n // `matchedBy` reports \"turnIndex\" ONLY when the selection was genuinely decided\n // by a UNIQUE positional criterion (a single candidate whose turnIndex sits\n // exactly at the current assistant count). A canonical-position fixture that\n // tied with another at-position candidate, or that lost the exact-turn\n // tie-break to an earlier fallback, was decided by REGISTRATION ORDER, not by\n // position — those are \"content\". `selectByTurnIndex` reports which it was.\n const matchedBy: \"content\" | \"turnIndex\" = byUniquePosition ? \"turnIndex\" : \"content\";\n\n if (turnIndexRelaxed && options?.logger) {\n // Throttle: warn at most once per divergent fixture per process. Keyed by\n // the fixture's OBJECT IDENTITY so distinct fixtures whose match serialises\n // identically (predicate/regex collisions) each warn, and entries auto-evict\n // when the fixture is released (see warnedRelaxedFixtures above).\n if (!warnedRelaxedFixtures.has(selected)) {\n warnedRelaxedFixtures.add(selected);\n // Human-readable description for the message only (NOT the throttle key,\n // which is the fixture's object identity). `JSON.stringify(match)` is\n // unfit here: it DROPS `predicate` functions entirely and collapses any\n // RegExp matcher to `{}`, so a predicate/regex fixture's warning read\n // \"served fixture {}\" / \"{\"userMessage\":{}}\". `describeMatch` instead\n // summarizes the present matcher KEYS (annotating predicate/regex values)\n // so the warned fixture is identifiable.\n const idx = fixtures.indexOf(selected);\n const desc = describeMatch(selected.match, idx);\n options.logger.warn(\n `turnIndex relaxed: served fixture ${desc} at assistantCount=${assistantCount} ` +\n `(scripted turnIndex=${selectedTurn}); set AIMOCK_STRICT_TURN_INDEX=1 to restore strict matching`,\n );\n }\n }\n\n return { fixture: selected, skippedBySequenceOrTurn, turnIndexRelaxed, matchedBy };\n}\n\n/**\n * Build a stable, human-readable identifier for a fixture's match shape for the\n * relaxed-turnIndex warning. The previous `JSON.stringify(match)` was unfit: it\n * DROPS `predicate` functions (non-serialisable) and serialises any RegExp\n * matcher to `{}`, so a predicate- or regex-gated fixture's warning collapsed to\n * an uninformative \"served fixture {}\" / `{\"userMessage\":{}}` blob.\n *\n * Instead we list the PRESENT matcher keys in declaration order, annotating each\n * by VALUE KIND so predicates and regexes survive: `predicate(fn)`,\n * `userMessage(regex)`, `userMessage(\"hello\")`, `turnIndex=0`, etc. The\n * fixture's array `index` (when known, i.e. `>= 0`) is prefixed as the stable\n * positional identifier — the `Fixture` type carries no `id`/`name`, so its\n * registration index is the only stable handle. String/number values are shown\n * inline (truncated) so a content match remains recognisable; the whole string\n * is capped to keep the log line bounded.\n */\nfunction describeMatch(match: FixtureMatch, index: number): string {\n const parts: string[] = [];\n for (const [key, value] of Object.entries(match)) {\n if (value === undefined) continue;\n if (typeof value === \"function\") {\n parts.push(`${key}(fn)`);\n } else if (value instanceof RegExp) {\n parts.push(`${key}(${value})`);\n } else if (typeof value === \"string\") {\n const v = value.length > 40 ? `${value.slice(0, 40)}…` : value;\n parts.push(`${key}(${JSON.stringify(v)})`);\n } else if (Array.isArray(value)) {\n parts.push(`${key}(${value.length} item${value.length === 1 ? \"\" : \"s\"})`);\n } else {\n parts.push(`${key}=${String(value)}`);\n }\n }\n const keys = parts.length > 0 ? parts.join(\", \") : \"no matchers\";\n const prefix = index >= 0 ? `#${index} ` : \"\";\n return `${prefix}{ ${keys} }`.slice(0, 160);\n}\n\n/**\n * Choose one fixture from a set that all CONTENT-matched the same request,\n * using `turnIndex` purely as a position disambiguator (never as a reject\n * gate).\n *\n * The selection rule is applied UNIFORMLY regardless of candidate count (a\n * single candidate is NOT special-cased), so the same request never flips its\n * answer just because an unrelated content-matching fixture was registered.\n * Within every tier ties are broken by REGISTRATION ORDER — the\n * earliest-registered eligible candidate wins — preserving the historical\n * greedy \"first matching fixture wins\" contract.\n *\n * 1. Prefer the turnIndexed candidate whose `turnIndex` is closest to\n * `assistantCount` WITHOUT exceeding it (the highest `turnIndex <=\n * assistantCount`). A behind-the-count scripted turn (turnIndex <\n * assistantCount) beats a plain fallback — an explicit position is a\n * stronger signal than an unpositioned default. A negative `turnIndex` such\n * as -1 is a valid at/behind position (the seed is `-Infinity`, never a `-1`\n * sentinel that would mis-skip it). Earlier registration breaks ties among\n * equal turnIndexes.\n * 2. EXACT-turn tie-break: when the best at/behind scripted turn sits at the\n * EXACT current position (`turnIndex === assistantCount`) a plain fallback\n * also answers \"right now\", so the two are equally eligible and REGISTRATION\n * ORDER decides — a later-registered `turnIndex:0` does NOT override an\n * earlier-registered fallback, and vice-versa.\n * 3. Otherwise every turnIndexed candidate is still AHEAD of the conversation.\n * An explicit future turn must NOT answer an earlier point, so a plain\n * fallback (eligible at every position) is the better answer — applied\n * uniformly, INCLUDING when the fallback is the sole partner of a single\n * future-turn fixture (the single/multi asymmetry this fixes).\n * 4. Otherwise (pure script, every candidate turnIndexed and all ahead) the\n * script genuinely has no earlier answer, so serve the lowest `turnIndex`\n * candidate — the false-red-kill for a lone scripted turn whose run has\n * FEWER assistant bubbles than its `turnIndex`; registration order breaks\n * ties.\n *\n * A future-turn fixture therefore NEVER answers an earlier-point request when an\n * eligible alternative (a fallback, or an at/behind scripted turn) exists — the\n * future-turn guard is enforced uniformly for single and multiple candidates.\n *\n * Returns the selected fixture alongside `byUniquePosition`: `true` ONLY when the\n * choice was decided by a UNIQUE positional criterion — the served fixture's\n * `turnIndex` sits EXACTLY at `assistantCount`, no earlier fallback overrode it\n * (tier 2), and no other candidate shared that exact position (so registration\n * order did not break a tie). `matchFixtureDiagnostic` maps this to\n * `matchedBy === \"turnIndex\"`; every other selection path (tie-break,\n * registration order, behind/ahead scripted turn, fallback) is `\"content\"`.\n */\nfunction selectByTurnIndex(\n candidates: Fixture[],\n assistantCount: number,\n): { fixture: Fixture; byUniquePosition: boolean } {\n // The first non-turnIndexed candidate is the registration-order-first plain\n // fallback (eligible at every position). Tracked by index so the exact-turn\n // tie-break can compare registration order against the chosen scripted turn.\n const fallbackIdx = candidates.findIndex((f) => f.match.turnIndex === undefined);\n\n // Tier 1: closest scripted turn at/before the current count. Strict `>`\n // preserves registration order on equal turnIndexes; `-Infinity` seed so a\n // negative turnIndex is a legitimate at/behind candidate, not a sentinel skip.\n let bestIdx = -1;\n let bestTurn = -Infinity;\n for (let i = 0; i < candidates.length; i++) {\n const t = candidates[i].match.turnIndex;\n if (t === undefined) continue;\n if (t <= assistantCount && t > bestTurn) {\n bestIdx = i;\n bestTurn = t;\n }\n }\n\n if (bestIdx !== -1) {\n // Tier 2: exact-turn tie with a fallback → earlier registration wins. A\n // fallback won the tie, so position did NOT uniquely decide → content.\n if (bestTurn === assistantCount && fallbackIdx !== -1 && fallbackIdx < bestIdx) {\n return { fixture: candidates[fallbackIdx], byUniquePosition: false };\n }\n // A UNIQUE positional decision requires the chosen turn to sit EXACTLY at\n // the current count AND to be the only candidate at that exact position —\n // otherwise registration order, not position, broke the tie.\n const atExactPosition =\n bestTurn === assistantCount &&\n candidates.filter((f) => f.match.turnIndex === assistantCount).length === 1;\n return { fixture: candidates[bestIdx], byUniquePosition: atExactPosition };\n }\n\n // Tier 3: every scripted turn is ahead. A plain fallback answers this earlier\n // point; first-registered fallback wins.\n if (fallbackIdx !== -1) return { fixture: candidates[fallbackIdx], byUniquePosition: false };\n\n // Tier 4: pure script, all turnIndexed and all ahead. Serve the lowest\n // scripted turn; registration order breaks ties (first of the lowest wins).\n let lowest = candidates[0];\n for (const f of candidates) {\n if ((f.match.turnIndex as number) < (lowest.match.turnIndex as number)) lowest = f;\n }\n return { fixture: lowest, byUniquePosition: false };\n}\n\n/**\n * Match a fixture against a request, returning the fixture or `null`. Thin\n * wrapper over {@link matchFixtureDiagnostic} that discards the skip diagnostic\n * — preserves the historical signature for all existing callers.\n */\nexport function matchFixture(\n fixtures: Fixture[],\n req: ChatCompletionRequest,\n matchCounts?: Map<Fixture, number>,\n requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest,\n options?: MatchOptions,\n): Fixture | null {\n return matchFixtureDiagnostic(fixtures, req, matchCounts, requestTransform, options).fixture;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"router.js","names":[],"sources":["../src/router.ts"],"sourcesContent":["import type {\n ChatCompletionRequest,\n ChatMessage,\n ContentPart,\n Fixture,\n FixtureMatch,\n} from \"./types.js\";\nimport {\n isImageResponse,\n isAudioResponse,\n isTranscriptionResponse,\n isVideoResponse,\n isJSONResponse,\n isErrorResponse,\n} from \"./helpers.js\";\n\nexport function getLastMessageByRole(messages: ChatMessage[], role: string): ChatMessage | null {\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === role) return messages[i];\n }\n return null;\n}\n\n/**\n * Concatenate the text content of every `system` role message in order.\n * Hosts that build a system context from multiple sources (persona, agent\n * context entries, tool guidance) often emit several system messages in one\n * request; this joins SEPARATE system messages with newlines so a substring\n * matcher sees the whole context as one body.\n *\n * Empty handling is symmetric with {@link getTextContent}: a system message\n * with no extractable text (`null`) contributes nothing, while a message that\n * extracts to an empty string is a present-but-empty body. We skip only the\n * `null` (no-text) case so a genuinely empty system message does not inject a\n * stray newline; this matches getTextContent treating \"no text\" and \"empty\n * text\" consistently.\n */\nexport function getSystemText(messages: ChatMessage[]): string {\n const parts: string[] = [];\n for (const m of messages) {\n if (m.role !== \"system\") continue;\n const text = getTextContent(m.content);\n if (text === null) continue;\n parts.push(text);\n }\n return parts.join(\"\\n\");\n}\n\n/**\n * Extract the text content from a message's content field.\n * Handles both plain string content and array-of-parts content\n * (e.g. `[{type: \"text\", text: \"...\"}]` as sent by some SDKs).\n *\n * Multi-part text is joined with `\"\"` (the parts form one logical body split\n * across segments). Empty handling is symmetric with the string path: a string\n * `\"\"` returns `\"\"`, and an array containing at least one text part whose\n * combined text is empty likewise returns `\"\"` (NOT `null`). `null` is reserved\n * for \"no text content at all\" — null content, or an array with no text parts —\n * so callers can distinguish \"absent\" from \"present but empty\" the same way for\n * both content shapes.\n */\nexport function getTextContent(content: string | ContentPart[] | null): string | null {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const texts = content\n .filter((p) => p.type === \"text\" && typeof p.text === \"string\")\n .map((p) => p.text as string);\n return texts.length > 0 ? texts.join(\"\") : null;\n }\n return null;\n}\n\n/**\n * Text of the last user message, for `userMessage` fixture matching. Normally\n * this is the text of the final `user` message. But some SDKs serialise a\n * single multimodal user turn (prompt text + attachment) into TWO consecutive\n * user messages — a text-only one FOLLOWED by an attachment-only one, e.g.\n * `[{role:\"user\", content:\"describe this\"}, {role:\"user\", content:[{type:\"image_url\",...}]}]`\n * (observed with Microsoft Agent Framework's `agent_framework_openai` image\n * path). The trailing attachment-only message has NO extractable text, so the\n * naive \"last user message\" lookup returns `null` and — because no fixture can\n * key on empty text — every such multimodal fixture misses. We therefore skip\n * trailing user messages that carry no text and use the nearest preceding user\n * message that does. This is deliberately narrow: it only skips text-LESS\n * (`null`) user messages — a message whose text extracts to an explicit empty\n * string `\"\"` is a present-but-empty body and IS returned (so a fixture can key\n * on it), while a genuine multi-message user turn (each message HAS text) is\n * unaffected and still matched on its final message. Returns the nearest\n * preceding user message whose text is non-null (including `\"\"`); returns `null`\n * only when no user message carries any text part at all.\n */\nexport function getLastUserText(messages: ChatMessage[]): string | null {\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role !== \"user\") continue;\n const text = getTextContent(messages[i].content);\n if (text !== null) return text;\n }\n return null;\n}\n\n/**\n * Test a matcher RegExp against a string WITHOUT mutating the caller-supplied\n * regex. `RegExp.prototype.test` advances `lastIndex` as a side effect on\n * `/g` and `/y` regexes; fixtures hold caller-owned RegExp objects, so testing\n * them in place would clobber the caller's positional state (and a `/g` regex\n * reused across match calls would match intermittently). We test a fresh clone\n * (same source + flags) so the caller's object is never touched and every test\n * starts from index 0.\n */\nfunction regexTest(re: RegExp, text: string): boolean {\n return new RegExp(re.source, re.flags).test(text);\n}\n\n/**\n * Result of {@link matchFixtureDiagnostic}: the matched fixture (or `null`) plus\n * the number of fixtures that matched the request SHAPE (every predicate above\n * the sequenceIndex/turnIndex gates) but were rejected ONLY by the\n * sequenceIndex/turnIndex count state.\n *\n * `skippedBySequenceOrTurn > 0` with `fixture === null` distinguishes a\n * \"sequence/turn exhausted\" miss (candidate fixtures existed but their count\n * gate had moved on) from a true \"no fixture had a matching shape\" miss — used\n * to disambiguate the strict-mode 503 message.\n */\nexport interface MatchFixtureDiagnostic {\n fixture: Fixture | null;\n skippedBySequenceOrTurn: number;\n /**\n * `true` when the served fixture was selected by relaxed content-anchored\n * matching even though its `turnIndex` is defined and does NOT equal the\n * request's assistant-message count — i.e. the legacy strict turnIndex gate\n * (now opt-in via `AIMOCK_STRICT_TURN_INDEX`) WOULD HAVE rejected it. Absent /\n * falsy on canonical-position matches, non-turnIndexed matches, and misses.\n * Additive optional field — existing handler destructures are unaffected.\n */\n turnIndexRelaxed?: boolean;\n /**\n * How the served fixture was selected: `\"turnIndex\"` when its `turnIndex`\n * sits exactly at the current assistant count (canonical position),\n * `\"content\"` otherwise (a non-turnIndexed match or a relaxed off-by-N\n * match). Absent on misses. Additive optional field.\n */\n matchedBy?: \"content\" | \"turnIndex\";\n}\n\n/**\n * Optional matcher tuning.\n *\n * `strictTurnIndex` restores the legacy behaviour where `turnIndex` must equal\n * the request's assistant-message count exactly (a hard reject gate). It is set\n * by the record path, where a miss proxies upstream to capture a fresh turn; an\n * earlier-turn fixture must not shadow a longer request or the new turn would\n * never be recorded. Replay (the default, `false`) treats `turnIndex` as a\n * non-fatal disambiguator instead — see {@link selectByTurnIndex}.\n */\nexport interface MatchOptions {\n strictTurnIndex?: boolean;\n /**\n * Optional sink for the one-shot relaxed-turnIndex divergence warning. Handlers\n * pass their `defaults.logger`; the structural `{ warn }` shape avoids an\n * import cycle with logger.ts and keeps the matcher decoupled. When omitted no\n * warning is emitted (the diagnostic fields are still populated). The Logger's\n * own level gate keeps a passing programmatic run (silent default) quiet.\n */\n logger?: { warn(...args: unknown[]): void };\n}\n\n/**\n * Process-level opt-out: when `AIMOCK_STRICT_TURN_INDEX=1` (or `true`) is set,\n * REPLAY selection restores the legacy hard turnIndex gate — a content-matching\n * fixture whose `turnIndex` is defined and `!== assistantCount` is rejected,\n * reproducing origin/main semantics. Follows the `AIMOCK_ALLOW_PRIVATE_URLS`\n * precedent for parsing/precedence. Read per-call (not cached) so tests can flip\n * it. Does NOT affect the record path, which is already strict regardless.\n */\nfunction strictTurnIndexEnv(): boolean {\n const v = process.env.AIMOCK_STRICT_TURN_INDEX;\n return v === \"1\" || v === \"true\";\n}\n\n/**\n * Process-level set of fixtures for which the relaxed-turnIndex divergence\n * warning has already fired, so each divergent fixture warns at most ONCE per\n * process (throttle). Keyed by the selected fixture's OBJECT IDENTITY: the\n * `Fixture` references in the server's fixtures array are stable across replays\n * (the array is held by reference and only fully replaced on a fixtures reset),\n * so identity uniquely distinguishes divergent fixtures and warns each exactly\n * once. A `WeakSet` was chosen over the previous `JSON.stringify(match)` key for\n * two reasons: (1) stringifying the match DROPS `predicate` functions and\n * serialises any RegExp matcher to `{}`, so two distinct fixtures differing only\n * by a predicate/regex collided to one key and the second's warning was silently\n * suppressed; and (2) a string `Set` only grows, accumulating an entry per\n * divergent shape on a long-lived server, whereas a `WeakSet` auto-evicts when a\n * fixture object is released (e.g. after a fixtures reset drops the references).\n * `let` because `WeakSet` has no `.clear()`, so the test hook reassigns a fresh\n * one.\n */\nlet warnedRelaxedFixtures = new WeakSet<Fixture>();\n\n/**\n * Test-only hook to clear the throttle state between cases. Not part of the\n * public contract. `WeakSet` has no `.clear()`, so reassign a fresh instance.\n */\nexport function _resetTurnIndexRelaxWarnings(): void {\n warnedRelaxedFixtures = new WeakSet<Fixture>();\n}\n\n/**\n * Build the {@link MatchOptions} a request handler must pass to\n * {@link matchFixtureDiagnostic} / {@link matchFixture}, derived from whether\n * the handler is about to record on a miss.\n *\n * EVERY record-capable handler (OpenAI chat, Anthropic messages, Responses,\n * Gemini, Bedrock, Bedrock-Converse, Cohere, Ollama, …) must build its match\n * options through THIS helper rather than hand-rolling `{ strictTurnIndex }` at\n * the call site. Recording proxies upstream on a miss to capture a fresh turn;\n * if `strictTurnIndex` is left false during recording, an earlier-turn fixture\n * can content-shadow a longer request, the `if (!fixture)` record branch never\n * fires, and the new turn is SILENTLY never recorded. Funnelling the decision\n * through one helper makes that wiring impossible for a future handler to miss:\n * pass `recording = true` whenever the handler's own record gate is satisfied\n * (i.e. it will call `proxyAndRecord` on a miss), `false` otherwise.\n */\nexport function recordMatchOptions(\n recording: boolean,\n logger?: { warn(...args: unknown[]): void },\n): MatchOptions {\n return { strictTurnIndex: recording, logger };\n}\n\n/**\n * Match a fixture against a request, additionally reporting WHY a `null` result\n * occurred. Shares the exact predicate loop with {@link matchFixture}; a fixture\n * that passes every shape predicate but fails ONLY the sequenceIndex or\n * turnIndex gate increments `skippedBySequenceOrTurn`. {@link matchFixture} is a\n * thin wrapper that returns the `.fixture` field, so existing callers are\n * unaffected.\n */\nexport function matchFixtureDiagnostic(\n fixtures: Fixture[],\n req: ChatCompletionRequest,\n matchCounts?: Map<Fixture, number>,\n requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest,\n options?: MatchOptions,\n): MatchFixtureDiagnostic {\n // Apply transform once before matching — used for stripping dynamic data\n const effective = requestTransform ? requestTransform(req) : req;\n const useExactMatch = !!requestTransform;\n // In record mode the server proxies to the upstream on a miss, so a fixture\n // already captured for an EARLIER turn must NOT shadow a longer (later-turn)\n // request — otherwise the new turn would never be proxied and recorded.\n // There turnIndex stays a strict hard gate. Replay (the default) instead\n // treats turnIndex as a non-fatal disambiguator so a canonical multi-bubble\n // run isn't falsely rejected for an off-by-N assistant count.\n // Strict turnIndex is in force when the record path requests it OR the\n // process-level AIMOCK_STRICT_TURN_INDEX opt-out is set (which restores the\n // legacy hard gate for replay too). Record mode passes `true` explicitly; the\n // env only matters when the caller left it `false`/unset (replay).\n const strictTurnIndex = (options?.strictTurnIndex ?? false) || strictTurnIndexEnv();\n\n let skippedBySequenceOrTurn = 0;\n // Every fixture whose content / shape predicates (and sequenceIndex gate)\n // pass. turnIndex is applied afterwards as a non-fatal disambiguator.\n const contentMatches: Fixture[] = [];\n\n for (const fixture of fixtures) {\n const { match } = fixture;\n\n // predicate — if present, must return true (receives original request)\n if (match.predicate !== undefined) {\n if (!match.predicate(req)) continue;\n }\n\n // endpoint — bidirectional filtering:\n // 1. If fixture has endpoint set, only match requests of that type\n // 2. If request has _endpointType but fixture doesn't, skip fixtures\n // whose response type is incompatible (prevents generic chat fixtures\n // from matching image/speech/video requests and causing 500s)\n const reqEndpoint = effective._endpointType as string | undefined;\n if (match.endpoint !== undefined) {\n if (match.endpoint !== reqEndpoint) continue;\n } else if (\n reqEndpoint &&\n reqEndpoint !== \"chat\" &&\n reqEndpoint !== \"embedding\" &&\n !reqEndpoint.startsWith(\"realtime\")\n ) {\n // Fixture has no endpoint restriction but request is multimedia —\n // only match if the response type is compatible.\n // Function responses cannot be checked statically, so treat them as compatible.\n const r = fixture.response;\n if (typeof r !== \"function\") {\n const compatible =\n (reqEndpoint === \"image\" && isImageResponse(r)) ||\n (reqEndpoint === \"speech\" && isAudioResponse(r)) ||\n (reqEndpoint === \"elevenlabs-tts\" && isAudioResponse(r)) ||\n (reqEndpoint === \"audio-gen\" && isAudioResponse(r)) ||\n (reqEndpoint === \"fal-audio\" && isAudioResponse(r)) ||\n (reqEndpoint === \"fal\" && (isJSONResponse(r) || isErrorResponse(r))) ||\n (reqEndpoint === \"transcription\" && isTranscriptionResponse(r)) ||\n (reqEndpoint === \"translation\" && isTranscriptionResponse(r)) ||\n (reqEndpoint === \"video\" && isVideoResponse(r));\n if (!compatible) continue;\n }\n }\n\n // context — opt-in exact match against the request's _context field.\n // If fixture specifies a context, only match requests with that exact context.\n // If fixture omits context, match any request regardless of _context.\n if (match.context !== undefined) {\n if (effective._context !== match.context) continue;\n }\n\n // userMessage — case-sensitive match against the last user message content.\n // String matching is intentionally case-sensitive so fixture authors can\n // rely on exact string values. This differs from the case-insensitive\n // matchesPattern() in helpers.ts, which is used for search/rerank/moderation\n // where exact casing rarely matters.\n if (match.userMessage !== undefined) {\n // Use the last user message that actually carries text — see\n // getLastUserText for why a trailing attachment-only user message\n // (multimodal serialisation split) must not shadow the real prompt.\n const text = getLastUserText(effective.messages);\n // `text === null` means no user message carried any text (e.g. a pure\n // attachment turn) — skip. An explicit empty-string body (`\"\"`) is a\n // present-but-empty user message and must be allowed through so a fixture\n // keyed on empty text can match it (see getLastUserText).\n if (text === null) continue;\n if (typeof match.userMessage === \"string\") {\n if (useExactMatch) {\n if (text !== match.userMessage) continue;\n } else {\n if (!text.includes(match.userMessage)) continue;\n }\n } else {\n if (!regexTest(match.userMessage, text)) continue;\n }\n }\n\n // systemMessage — case-sensitive substring, regexp, or array-of-substrings\n // match against the joined text of every system message in the request.\n // Use to gate a fixture on host-supplied context (e.g. agent-context\n // entries) so that when the calling app changes that context the fixture\n // stops matching and the request falls through to the next fixture or\n // upstream proxy.\n //\n // Array form (string[]) requires ALL substrings to be present — useful\n // when the gate must combine multiple non-adjacent tokens (e.g. a default\n // name AND a default activity list whose positions in the serialised\n // context JSON aren't stable).\n if (match.systemMessage !== undefined) {\n const sm = match.systemMessage;\n // Empty array is treated as \"no constraint\" → matches unconditionally,\n // INCLUDING requests with no system text at all. This is the documented\n // contract (same permissive behaviour as not setting systemMessage), so\n // it must be honored BEFORE the no-system-text guard below — otherwise a\n // request without a system message would be wrongly skipped. Validation\n // rejects [] at load time for JSON fixtures; programmatic callers that\n // pass [] get this permissive behaviour.\n if (Array.isArray(sm) && sm.length === 0) {\n // no constraint — fall through to the next predicate\n } else {\n const text = getSystemText(effective.messages);\n // Deliberately `!text` (not `text === null`): unlike userMessage/inputText,\n // getSystemText returns `\"\"` for BOTH \"a present but empty system message\"\n // AND \"no system message at all\", so it exposes no absent-vs-empty\n // distinction at the request level. Allowing `\"\"` through would make a\n // `systemMessage: \"\"` / `/^$/` fixture a catch-all firing on every\n // no-system-message request. There is no shipped-fixture demand for\n // matching an empty system prompt, so we keep the falsy guard here. If a\n // real need arises, add a getSystemText sibling that returns `null` when\n // absent and `\"\"` when present-empty, then switch to `text === null`.\n if (!text) continue;\n if (Array.isArray(sm)) {\n let allPresent = true;\n for (const needle of sm) {\n if (!text.includes(needle)) {\n allPresent = false;\n break;\n }\n }\n if (!allPresent) continue;\n } else if (typeof sm === \"string\") {\n if (useExactMatch) {\n if (text !== sm) continue;\n } else {\n if (!text.includes(sm)) continue;\n }\n } else {\n if (!regexTest(sm, text)) continue;\n }\n }\n }\n\n // toolCallId — a toolCallId fixture answers the model's response to a tool\n // result, which by API contract only happens when the conversation's LAST\n // message is a tool result. If a newer user (or other) turn follows the\n // tool message, the stale tool_call_id must not shadow userMessage matchers.\n if (match.toolCallId !== undefined) {\n const last = effective.messages[effective.messages.length - 1];\n if (!last || last.role !== \"tool\" || last.tool_call_id !== match.toolCallId) continue;\n }\n\n // toolResultContains — substring gate on the LAST message's text content\n // when that message is a tool result. Same last-message rule as toolCallId\n // (a tool-result fixture answers the model's next call after a tool round),\n // but discriminates on the result PAYLOAD instead of the call id — needed\n // when approve/cancel resumes share the same toolCallId and differ only\n // inside the tool-result JSON.\n if (match.toolResultContains !== undefined) {\n const last = effective.messages[effective.messages.length - 1];\n if (!last || last.role !== \"tool\") continue;\n const text = getTextContent(last.content);\n if (text === null || !text.includes(match.toolResultContains)) continue;\n }\n\n // toolName — match against any tool definition by function.name\n if (match.toolName !== undefined) {\n const tools = effective.tools ?? [];\n const found = tools.some((t) => t.function.name === match.toolName);\n if (!found) continue;\n }\n\n // inputText — case-sensitive match against the embedding input text.\n // Same rationale as userMessage above: fixture authors specify exact strings.\n if (match.inputText !== undefined) {\n const embeddingInput = effective.embeddingInput;\n // `undefined` means the request carried no embedding input (a non-embedding\n // request) — skip. An explicit empty string (`\"\"`) is a genuinely-empty\n // embedding input and must be allowed through so `inputText: \"\"` can match.\n if (embeddingInput === undefined) continue;\n if (typeof match.inputText === \"string\") {\n if (useExactMatch) {\n if (embeddingInput !== match.inputText) continue;\n } else {\n if (!embeddingInput.includes(match.inputText)) continue;\n }\n } else {\n if (!regexTest(match.inputText, embeddingInput)) continue;\n }\n }\n\n // responseFormat — exact string match against request response_format.type\n if (match.responseFormat !== undefined) {\n const reqType = effective.response_format?.type;\n if (reqType !== match.responseFormat) continue;\n }\n\n // model — exact match or prefix + dash-digit boundary for strings (so that\n // \"claude-opus-4\" matches \"claude-opus-4-20250514\" but \"gpt-4\" does NOT\n // match \"gpt-4o\" and \"gpt-4o\" does NOT match \"gpt-4o-mini\"), regexp unchanged\n if (match.model !== undefined) {\n if (typeof match.model === \"string\") {\n if (effective.model !== match.model) {\n if (!effective.model?.startsWith(match.model)) continue;\n const rest = effective.model.slice(match.model.length);\n if (!/^-\\d/.test(rest)) continue;\n }\n } else {\n if (!regexTest(match.model, effective.model ?? \"\")) continue;\n }\n }\n\n // hasToolResult — request-SHAPE predicate: does the conversation contain a\n // tool result message? Must be evaluated with the other shape predicates\n // ABOVE the sequence/turn state gates so that a fixture whose shape never\n // matched is not miscounted as \"skipped by sequence/turn state\".\n if (match.hasToolResult !== undefined) {\n const hasTool = effective.messages.some((m) => m.role === \"tool\");\n if (hasTool !== match.hasToolResult) continue;\n }\n\n // At this point every SHAPE / CONTENT predicate above has passed, so this\n // fixture is a genuine CONTENT match for the request. The sequenceIndex and\n // turnIndex constraints below are POSITION state, not request shape.\n //\n // sequenceIndex remains a hard, stateful gate: it consumes sequenced\n // siblings one call at a time (and an exhausted index intentionally falls\n // through to a later fixture). A fixture that matched the shape but fails\n // ONLY the sequenceIndex gate is a \"candidate skipped by sequence/turn\n // state\", counted separately so callers can disambiguate the strict-mode\n // 503 message.\n if (match.sequenceIndex !== undefined && matchCounts !== undefined) {\n const count = matchCounts.get(fixture) ?? 0;\n if (count !== match.sequenceIndex) {\n skippedBySequenceOrTurn++;\n continue;\n }\n }\n\n // turnIndex is normally NOT a hard gate (replay). Multi-step agents emit\n // several assistant bubbles per logical turn, so a canonical run's assistant\n // count routinely differs from a fixture's hardcoded turnIndex even when the\n // request content matches exactly. Rejecting a uniquely content-matching\n // fixture on absolute position produced false \"empty assistant response\"\n // misses. Instead we collect every content match and use turnIndex only as a\n // non-fatal DISAMBIGUATOR to choose AMONG several content-matching fixtures\n // (see selectByTurnIndex below). Content that does not match any fixture\n // still matches nothing — only the position gate is relaxed.\n //\n // Under strictTurnIndex (record mode) turnIndex stays a hard, exact gate so\n // an earlier-turn capture can't shadow a longer request; the miss then\n // proxies upstream and records the new turn.\n if (strictTurnIndex && match.turnIndex !== undefined) {\n const assistantCount = effective.messages.filter((m) => m.role === \"assistant\").length;\n if (assistantCount !== match.turnIndex) {\n skippedBySequenceOrTurn++;\n continue;\n }\n }\n\n contentMatches.push(fixture);\n }\n\n if (contentMatches.length === 0) {\n return { fixture: null, skippedBySequenceOrTurn };\n }\n\n const assistantCount = effective.messages.filter((m) => m.role === \"assistant\").length;\n const { fixture: selected, byUniquePosition } = selectByTurnIndex(contentMatches, assistantCount);\n\n // Divergence predicate: the served fixture carries a turnIndex that does NOT\n // sit at the current assistant position. Under strict matching this fixture\n // would have been rejected at the gate above, so serving it here is the (rare,\n // off-by-N) relaxed behaviour change PR #276 introduced. Computed from values\n // already in hand — no second matching pass.\n const selectedTurn = selected.match.turnIndex;\n const turnIndexRelaxed = selectedTurn !== undefined && selectedTurn !== assistantCount;\n // `matchedBy` reports \"turnIndex\" ONLY when the selection was genuinely decided\n // by a UNIQUE positional criterion (a single candidate whose turnIndex sits\n // exactly at the current assistant count). A canonical-position fixture that\n // tied with another at-position candidate, or that lost the exact-turn\n // tie-break to an earlier fallback, was decided by REGISTRATION ORDER, not by\n // position — those are \"content\". `selectByTurnIndex` reports which it was.\n const matchedBy: \"content\" | \"turnIndex\" = byUniquePosition ? \"turnIndex\" : \"content\";\n\n if (turnIndexRelaxed && options?.logger) {\n // Throttle: warn at most once per divergent fixture per process. Keyed by\n // the fixture's OBJECT IDENTITY so distinct fixtures whose match serialises\n // identically (predicate/regex collisions) each warn, and entries auto-evict\n // when the fixture is released (see warnedRelaxedFixtures above).\n if (!warnedRelaxedFixtures.has(selected)) {\n warnedRelaxedFixtures.add(selected);\n // Human-readable description for the message only (NOT the throttle key,\n // which is the fixture's object identity). `JSON.stringify(match)` is\n // unfit here: it DROPS `predicate` functions entirely and collapses any\n // RegExp matcher to `{}`, so a predicate/regex fixture's warning read\n // \"served fixture {}\" / \"{\"userMessage\":{}}\". `describeMatch` instead\n // summarizes the present matcher KEYS (annotating predicate/regex values)\n // so the warned fixture is identifiable.\n const idx = fixtures.indexOf(selected);\n const desc = describeMatch(selected.match, idx);\n options.logger.warn(\n `turnIndex relaxed: served fixture ${desc} at assistantCount=${assistantCount} ` +\n `(scripted turnIndex=${selectedTurn}); set AIMOCK_STRICT_TURN_INDEX=1 to restore strict matching`,\n );\n }\n }\n\n return { fixture: selected, skippedBySequenceOrTurn, turnIndexRelaxed, matchedBy };\n}\n\n/**\n * Build a stable, human-readable identifier for a fixture's match shape for the\n * relaxed-turnIndex warning. The previous `JSON.stringify(match)` was unfit: it\n * DROPS `predicate` functions (non-serialisable) and serialises any RegExp\n * matcher to `{}`, so a predicate- or regex-gated fixture's warning collapsed to\n * an uninformative \"served fixture {}\" / `{\"userMessage\":{}}` blob.\n *\n * Instead we list the PRESENT matcher keys in declaration order, annotating each\n * by VALUE KIND so predicates and regexes survive: `predicate(fn)`,\n * `userMessage(regex)`, `userMessage(\"hello\")`, `turnIndex=0`, etc. The\n * fixture's array `index` (when known, i.e. `>= 0`) is prefixed as the stable\n * positional identifier — the `Fixture` type carries no `id`/`name`, so its\n * registration index is the only stable handle. String/number values are shown\n * inline (truncated) so a content match remains recognisable; the whole string\n * is capped to keep the log line bounded.\n */\nfunction describeMatch(match: FixtureMatch, index: number): string {\n const parts: string[] = [];\n for (const [key, value] of Object.entries(match)) {\n if (value === undefined) continue;\n if (typeof value === \"function\") {\n parts.push(`${key}(fn)`);\n } else if (value instanceof RegExp) {\n parts.push(`${key}(${value})`);\n } else if (typeof value === \"string\") {\n const v = value.length > 40 ? `${value.slice(0, 40)}…` : value;\n parts.push(`${key}(${JSON.stringify(v)})`);\n } else if (Array.isArray(value)) {\n parts.push(`${key}(${value.length} item${value.length === 1 ? \"\" : \"s\"})`);\n } else {\n parts.push(`${key}=${String(value)}`);\n }\n }\n const keys = parts.length > 0 ? parts.join(\", \") : \"no matchers\";\n const prefix = index >= 0 ? `#${index} ` : \"\";\n return `${prefix}{ ${keys} }`.slice(0, 160);\n}\n\n/**\n * Choose one fixture from a set that all CONTENT-matched the same request,\n * using `turnIndex` purely as a position disambiguator (never as a reject\n * gate).\n *\n * The selection rule is applied UNIFORMLY regardless of candidate count (a\n * single candidate is NOT special-cased), so the same request never flips its\n * answer just because an unrelated content-matching fixture was registered.\n * Within every tier ties are broken by REGISTRATION ORDER — the\n * earliest-registered eligible candidate wins — preserving the historical\n * greedy \"first matching fixture wins\" contract.\n *\n * 1. Prefer the turnIndexed candidate whose `turnIndex` is closest to\n * `assistantCount` WITHOUT exceeding it (the highest `turnIndex <=\n * assistantCount`). A behind-the-count scripted turn (turnIndex <\n * assistantCount) beats a plain fallback — an explicit position is a\n * stronger signal than an unpositioned default. A negative `turnIndex` such\n * as -1 is a valid at/behind position (the seed is `-Infinity`, never a `-1`\n * sentinel that would mis-skip it). Earlier registration breaks ties among\n * equal turnIndexes.\n * 2. EXACT-turn tie-break: when the best at/behind scripted turn sits at the\n * EXACT current position (`turnIndex === assistantCount`) a plain fallback\n * also answers \"right now\", so the two are equally eligible and REGISTRATION\n * ORDER decides — a later-registered `turnIndex:0` does NOT override an\n * earlier-registered fallback, and vice-versa.\n * 3. Otherwise every turnIndexed candidate is still AHEAD of the conversation.\n * An explicit future turn must NOT answer an earlier point, so a plain\n * fallback (eligible at every position) is the better answer — applied\n * uniformly, INCLUDING when the fallback is the sole partner of a single\n * future-turn fixture (the single/multi asymmetry this fixes).\n * 4. Otherwise (pure script, every candidate turnIndexed and all ahead) the\n * script genuinely has no earlier answer, so serve the lowest `turnIndex`\n * candidate — the false-red-kill for a lone scripted turn whose run has\n * FEWER assistant bubbles than its `turnIndex`; registration order breaks\n * ties.\n *\n * A future-turn fixture therefore NEVER answers an earlier-point request when an\n * eligible alternative (a fallback, or an at/behind scripted turn) exists — the\n * future-turn guard is enforced uniformly for single and multiple candidates.\n *\n * Returns the selected fixture alongside `byUniquePosition`: `true` ONLY when the\n * choice was decided by a UNIQUE positional criterion — the served fixture's\n * `turnIndex` sits EXACTLY at `assistantCount`, no earlier fallback overrode it\n * (tier 2), and no other candidate shared that exact position (so registration\n * order did not break a tie). `matchFixtureDiagnostic` maps this to\n * `matchedBy === \"turnIndex\"`; every other selection path (tie-break,\n * registration order, behind/ahead scripted turn, fallback) is `\"content\"`.\n */\nfunction selectByTurnIndex(\n candidates: Fixture[],\n assistantCount: number,\n): { fixture: Fixture; byUniquePosition: boolean } {\n // The first non-turnIndexed candidate is the registration-order-first plain\n // fallback (eligible at every position). Tracked by index so the exact-turn\n // tie-break can compare registration order against the chosen scripted turn.\n const fallbackIdx = candidates.findIndex((f) => f.match.turnIndex === undefined);\n\n // Tier 1: closest scripted turn at/before the current count. Strict `>`\n // preserves registration order on equal turnIndexes; `-Infinity` seed so a\n // negative turnIndex is a legitimate at/behind candidate, not a sentinel skip.\n let bestIdx = -1;\n let bestTurn = -Infinity;\n for (let i = 0; i < candidates.length; i++) {\n const t = candidates[i].match.turnIndex;\n if (t === undefined) continue;\n if (t <= assistantCount && t > bestTurn) {\n bestIdx = i;\n bestTurn = t;\n }\n }\n\n if (bestIdx !== -1) {\n // Tier 2: exact-turn tie with a fallback → earlier registration wins. A\n // fallback won the tie, so position did NOT uniquely decide → content.\n if (bestTurn === assistantCount && fallbackIdx !== -1 && fallbackIdx < bestIdx) {\n return { fixture: candidates[fallbackIdx], byUniquePosition: false };\n }\n // A UNIQUE positional decision requires the chosen turn to sit EXACTLY at\n // the current count AND to be the only candidate at that exact position —\n // otherwise registration order, not position, broke the tie.\n const atExactPosition =\n bestTurn === assistantCount &&\n candidates.filter((f) => f.match.turnIndex === assistantCount).length === 1;\n return { fixture: candidates[bestIdx], byUniquePosition: atExactPosition };\n }\n\n // Tier 3: every scripted turn is ahead. A plain fallback answers this earlier\n // point; first-registered fallback wins.\n if (fallbackIdx !== -1) return { fixture: candidates[fallbackIdx], byUniquePosition: false };\n\n // Tier 4: pure script, all turnIndexed and all ahead. Serve the lowest\n // scripted turn; registration order breaks ties (first of the lowest wins).\n let lowest = candidates[0];\n for (const f of candidates) {\n if ((f.match.turnIndex as number) < (lowest.match.turnIndex as number)) lowest = f;\n }\n return { fixture: lowest, byUniquePosition: false };\n}\n\n/**\n * Match a fixture against a request, returning the fixture or `null`. Thin\n * wrapper over {@link matchFixtureDiagnostic} that discards the skip diagnostic\n * — preserves the historical signature for all existing callers.\n */\nexport function matchFixture(\n fixtures: Fixture[],\n req: ChatCompletionRequest,\n matchCounts?: Map<Fixture, number>,\n requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest,\n options?: MatchOptions,\n): Fixture | null {\n return matchFixtureDiagnostic(fixtures, req, matchCounts, requestTransform, options).fixture;\n}\n"],"mappings":"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"}
@@ -12,6 +12,75 @@ let node_zlib = require("node:zlib");
12
12
  * text followed by tool calls.
13
13
  */
14
14
  /**
15
+ * Hard ceiling (UTF-16 code units) for any SINGLE string a collapser
16
+ * accumulates across deltas (`content`, `reasoning`, a tool call's
17
+ * `arguments`, `audioB64`, `argsStr`, and the coalesced text of `orderAtoms`).
18
+ *
19
+ * V8's maximum string length on 64-bit is 2^29 - 1 (~536.8M UTF-16 code
20
+ * units); appending past it throws `RangeError: Invalid string length`. The
21
+ * proxy path bounds the raw BYTE buffer under `PROXY_BUFFER_HARD_CEILING`
22
+ * (256 MiB) before collapse, but the collapser is ALSO reachable directly
23
+ * (exported from index.ts) and — even on the proxy path — a single accumulator
24
+ * can approach the byte budget in code units, so it needs its own guard rather
25
+ * than relying on the byte cap alone. 256Mi code units is comfortably below
26
+ * V8's limit while leaving huge headroom for any real completion (whose
27
+ * accumulated content can never exceed the total streamed body).
28
+ *
29
+ * When an append would cross the ceiling the accumulator keeps the ceiling's
30
+ * worth of characters, drops the overflow, and marks itself truncated — never
31
+ * throwing — mirroring the proxy buffer's "bound then mark truncated, never
32
+ * fail the client" discipline.
33
+ */
34
+ const MAX_COLLAPSE_STRING_LENGTH = 256 * 1024 * 1024;
35
+ /**
36
+ * Test-only override of {@link MAX_COLLAPSE_STRING_LENGTH}. Lets the cap suite
37
+ * exercise the truncation path with a tiny budget instead of building a
38
+ * multi-hundred-MB string. `undefined` (the default) uses the real ceiling.
39
+ * NEVER set from production code.
40
+ */
41
+ let collapseStringLimitOverride;
42
+ /** @internal test-only — see {@link collapseStringLimitOverride}. */
43
+ function setCollapseStringLimitForTests(value) {
44
+ collapseStringLimitOverride = value;
45
+ }
46
+ /** Effective accumulated-string ceiling, honoring any active test-only override. */
47
+ function effectiveCollapseStringLimit() {
48
+ return collapseStringLimitOverride ?? MAX_COLLAPSE_STRING_LENGTH;
49
+ }
50
+ /**
51
+ * Guard a raw collapse-input body against V8's max string length BEFORE any
52
+ * per-delta accumulation begins. Every collapser accumulates `content` /
53
+ * `reasoning` / a tool call's `arguments` / `audioB64` / `argsStr` (and the
54
+ * coalesced text of `orderAtoms`) from fragments of `body`; the sum of any one
55
+ * of those accumulators can never exceed the total input length, so bounding
56
+ * the INPUT under the ceiling bounds EVERY accumulator at once — no per-`+=`
57
+ * site can then build a string past the limit and throw
58
+ * `RangeError: Invalid string length` (the ~1/sec prod crash).
59
+ *
60
+ * Returns the body UNCHANGED when it is within the ceiling. When it exceeds the
61
+ * ceiling this returns a hard-truncated prefix (never throwing) — the collapse
62
+ * result the caller builds from it is marked incomplete via `truncated: true`
63
+ * (see {@link isCollapseInputTruncated}). This mirrors the proxy buffer's
64
+ * "bound then mark truncated, never fail the client" discipline: on the proxy
65
+ * path a body this large has already tripped the byte cap and skipped recording,
66
+ * so this is a defense-in-depth backstop that also covers the direct (exported)
67
+ * collapse callers where no byte cap ran.
68
+ */
69
+ function guardCollapseBody(body) {
70
+ const limit = effectiveCollapseStringLimit();
71
+ if (body.length <= limit) return body;
72
+ return body.slice(0, limit);
73
+ }
74
+ /**
75
+ * True when a body exceeded the accumulated-string ceiling and had to be
76
+ * truncated by {@link guardCollapseBody}. Collapsers stamp `truncated: true` on
77
+ * their result in this case so the recorder skips journaling a partial fixture,
78
+ * exactly like a transport-truncated stream.
79
+ */
80
+ function isCollapseInputTruncated(body) {
81
+ return body.length > effectiveCollapseStringLimit();
82
+ }
83
+ /**
15
84
  * Normalize a tool call's accumulated `arguments` into valid JSON exactly like
16
85
  * the flat-`toolCalls` recorder path: an empty / whitespace-only / missing
17
86
  * value becomes `"{}"`, never `""`. Mirrors `recorder.ts` `toToolCallArguments`
@@ -166,8 +235,9 @@ function extractSSEData(lines) {
166
235
  * data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"Hello"}}]}\n\n
167
236
  * data: [DONE]\n\n
168
237
  */
169
- function collapseOpenAISSE(body) {
170
- const lines = splitSSEEvents(body);
238
+ function collapseOpenAISSE(rawBody) {
239
+ const inputTruncated = isCollapseInputTruncated(rawBody);
240
+ const lines = splitSSEEvents(guardCollapseBody(rawBody));
171
241
  let content = "";
172
242
  let reasoning = "";
173
243
  const webSearchQueries = [];
@@ -288,7 +358,8 @@ function collapseOpenAISSE(body) {
288
358
  ...droppedChunks > 0 ? { droppedChunks } : {},
289
359
  ...firstDroppedSample ? { firstDroppedSample } : {},
290
360
  ...harmonyUnparsed ? { harmonyUnparsed: true } : {},
291
- ...harmonyNote ? { harmonyNote } : {}
361
+ ...harmonyNote ? { harmonyNote } : {},
362
+ ...inputTruncated ? { truncated: true } : {}
292
363
  };
293
364
  }
294
365
  return {
@@ -298,7 +369,8 @@ function collapseOpenAISSE(body) {
298
369
  ...droppedChunks > 0 ? { droppedChunks } : {},
299
370
  ...firstDroppedSample ? { firstDroppedSample } : {},
300
371
  ...harmonyUnparsed ? { harmonyUnparsed: true } : {},
301
- ...harmonyNote ? { harmonyNote } : {}
372
+ ...harmonyNote ? { harmonyNote } : {},
373
+ ...inputTruncated ? { truncated: true } : {}
302
374
  };
303
375
  }
304
376
  /**
@@ -308,8 +380,9 @@ function collapseOpenAISSE(body) {
308
380
  * event: message_start\ndata: {...}\n\n
309
381
  * event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"Hello"}}\n\n
310
382
  */
311
- function collapseAnthropicSSE(body) {
312
- const blocks = splitSSEEvents(body);
383
+ function collapseAnthropicSSE(rawBody) {
384
+ const inputTruncated = isCollapseInputTruncated(rawBody);
385
+ const blocks = splitSSEEvents(guardCollapseBody(rawBody));
313
386
  let content = "";
314
387
  let reasoning = "";
315
388
  let reasoningSignature;
@@ -399,7 +472,8 @@ function collapseAnthropicSSE(body) {
399
472
  ...reasoningSignature ? { reasoningSignature } : {},
400
473
  ...redactedThinking.length > 0 ? { redactedThinking } : {},
401
474
  ...droppedChunks > 0 ? { droppedChunks } : {},
402
- ...firstDroppedSample ? { firstDroppedSample } : {}
475
+ ...firstDroppedSample ? { firstDroppedSample } : {},
476
+ ...inputTruncated ? { truncated: true } : {}
403
477
  };
404
478
  }
405
479
  return {
@@ -408,7 +482,8 @@ function collapseAnthropicSSE(body) {
408
482
  ...reasoningSignature ? { reasoningSignature } : {},
409
483
  ...redactedThinking.length > 0 ? { redactedThinking } : {},
410
484
  ...droppedChunks > 0 ? { droppedChunks } : {},
411
- ...firstDroppedSample ? { firstDroppedSample } : {}
485
+ ...firstDroppedSample ? { firstDroppedSample } : {},
486
+ ...inputTruncated ? { truncated: true } : {}
412
487
  };
413
488
  }
414
489
  /**
@@ -417,8 +492,9 @@ function collapseAnthropicSSE(body) {
417
492
  * Format (data-only, no event prefix, no [DONE]):
418
493
  * data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}\n\n
419
494
  */
420
- function collapseGeminiSSE(body) {
421
- const lines = splitSSEEvents(body);
495
+ function collapseGeminiSSE(rawBody) {
496
+ const inputTruncated = isCollapseInputTruncated(rawBody);
497
+ const lines = splitSSEEvents(guardCollapseBody(rawBody));
422
498
  let content = "";
423
499
  let reasoning = "";
424
500
  let droppedChunks = 0;
@@ -480,7 +556,8 @@ function collapseGeminiSSE(body) {
480
556
  ...reasoning ? { reasoning } : {},
481
557
  ...normalizedToolCalls.length > 0 ? { toolCalls: normalizedToolCalls } : {},
482
558
  ...droppedChunks > 0 ? { droppedChunks } : {},
483
- ...firstDroppedSample ? { firstDroppedSample } : {}
559
+ ...firstDroppedSample ? { firstDroppedSample } : {},
560
+ ...inputTruncated ? { truncated: true } : {}
484
561
  };
485
562
  if (toolCalls.length > 0) {
486
563
  const blocks = buildOrderedBlocks(orderAtoms);
@@ -490,14 +567,16 @@ function collapseGeminiSSE(body) {
490
567
  toolCalls: normalizedToolCalls,
491
568
  ...reasoning ? { reasoning } : {},
492
569
  ...droppedChunks > 0 ? { droppedChunks } : {},
493
- ...firstDroppedSample ? { firstDroppedSample } : {}
570
+ ...firstDroppedSample ? { firstDroppedSample } : {},
571
+ ...inputTruncated ? { truncated: true } : {}
494
572
  };
495
573
  }
496
574
  return {
497
575
  content,
498
576
  ...reasoning ? { reasoning } : {},
499
577
  ...droppedChunks > 0 ? { droppedChunks } : {},
500
- ...firstDroppedSample ? { firstDroppedSample } : {}
578
+ ...firstDroppedSample ? { firstDroppedSample } : {},
579
+ ...inputTruncated ? { truncated: true } : {}
501
580
  };
502
581
  }
503
582
  /**
@@ -514,8 +593,9 @@ function collapseGeminiSSE(body) {
514
593
  * content is run through the same fail-safe {@link parseHarmonyContent} gate to
515
594
  * capture structured tool calls / reasoning instead of leaking raw tokens.
516
595
  */
517
- function collapseOllamaNDJSON(body) {
518
- const lines = body.split("\n").filter((l) => l.trim().length > 0);
596
+ function collapseOllamaNDJSON(rawBody) {
597
+ const inputTruncated = isCollapseInputTruncated(rawBody);
598
+ const lines = guardCollapseBody(rawBody).split("\n").filter((l) => l.trim().length > 0);
519
599
  let content = "";
520
600
  let reasoning = "";
521
601
  let droppedChunks = 0;
@@ -589,7 +669,8 @@ function collapseOllamaNDJSON(body) {
589
669
  ...droppedChunks > 0 ? { droppedChunks } : {},
590
670
  ...firstDroppedSample ? { firstDroppedSample } : {},
591
671
  ...harmonyUnparsed ? { harmonyUnparsed: true } : {},
592
- ...harmonyNote ? { harmonyNote } : {}
672
+ ...harmonyNote ? { harmonyNote } : {},
673
+ ...inputTruncated ? { truncated: true } : {}
593
674
  };
594
675
  }
595
676
  return {
@@ -598,7 +679,8 @@ function collapseOllamaNDJSON(body) {
598
679
  ...droppedChunks > 0 ? { droppedChunks } : {},
599
680
  ...firstDroppedSample ? { firstDroppedSample } : {},
600
681
  ...harmonyUnparsed ? { harmonyUnparsed: true } : {},
601
- ...harmonyNote ? { harmonyNote } : {}
682
+ ...harmonyNote ? { harmonyNote } : {},
683
+ ...inputTruncated ? { truncated: true } : {}
602
684
  };
603
685
  }
604
686
  /**
@@ -607,8 +689,9 @@ function collapseOllamaNDJSON(body) {
607
689
  * Format:
608
690
  * event: content-delta\ndata: {"type":"content-delta","delta":{"message":{"content":{"text":"Hello"}}}}\n\n
609
691
  */
610
- function collapseCohereSSE(body) {
611
- const blocks = splitSSEEvents(body);
692
+ function collapseCohereSSE(rawBody) {
693
+ const inputTruncated = isCollapseInputTruncated(rawBody);
694
+ const blocks = splitSSEEvents(guardCollapseBody(rawBody));
612
695
  let content = "";
613
696
  let reasoning = "";
614
697
  let droppedChunks = 0;
@@ -697,14 +780,16 @@ function collapseCohereSSE(body) {
697
780
  toolCalls: orderedBlocks ? orderedToolCalls : indexSortedToolCalls,
698
781
  ...reasoning ? { reasoning } : {},
699
782
  ...droppedChunks > 0 ? { droppedChunks } : {},
700
- ...firstDroppedSample ? { firstDroppedSample } : {}
783
+ ...firstDroppedSample ? { firstDroppedSample } : {},
784
+ ...inputTruncated ? { truncated: true } : {}
701
785
  };
702
786
  }
703
787
  return {
704
788
  content,
705
789
  ...reasoning ? { reasoning } : {},
706
790
  ...droppedChunks > 0 ? { droppedChunks } : {},
707
- ...firstDroppedSample ? { firstDroppedSample } : {}
791
+ ...firstDroppedSample ? { firstDroppedSample } : {},
792
+ ...inputTruncated ? { truncated: true } : {}
708
793
  };
709
794
  }
710
795
  /**
@@ -798,8 +883,11 @@ function decodeEventStreamFrames(buf) {
798
883
  * Each frame contains a JSON payload with event types like:
799
884
  * contentBlockDelta, contentBlockStart, etc.
800
885
  */
801
- function collapseBedrockEventStream(body) {
802
- const { frames, truncated } = decodeEventStreamFrames(body);
886
+ function collapseBedrockEventStream(rawBody) {
887
+ const limit = effectiveCollapseStringLimit();
888
+ const inputTruncated = rawBody.byteLength > limit;
889
+ const { frames, truncated: frameTruncated } = decodeEventStreamFrames(inputTruncated ? rawBody.subarray(0, limit) : rawBody);
890
+ const truncated = frameTruncated || inputTruncated;
803
891
  let content = "";
804
892
  let reasoning = "";
805
893
  let reasoningSignature;
@@ -954,8 +1042,9 @@ function collapseBedrockEventStream(body) {
954
1042
  * delta) are still accepted for backward compatibility with previously
955
1043
  * recorded fixtures.
956
1044
  */
957
- function collapseGeminiInteractionsSSE(body) {
958
- const lines = splitSSEEvents(body);
1045
+ function collapseGeminiInteractionsSSE(rawBody) {
1046
+ const inputTruncated = isCollapseInputTruncated(rawBody);
1047
+ const lines = splitSSEEvents(guardCollapseBody(rawBody));
959
1048
  let content = "";
960
1049
  let reasoning = "";
961
1050
  let droppedChunks = 0;
@@ -1036,13 +1125,15 @@ function collapseGeminiInteractionsSSE(body) {
1036
1125
  toolCalls,
1037
1126
  ...reasoning ? { reasoning } : {},
1038
1127
  ...droppedChunks > 0 ? { droppedChunks } : {},
1039
- ...firstDroppedSample ? { firstDroppedSample } : {}
1128
+ ...firstDroppedSample ? { firstDroppedSample } : {},
1129
+ ...inputTruncated ? { truncated: true } : {}
1040
1130
  };
1041
1131
  return {
1042
1132
  content,
1043
1133
  ...reasoning ? { reasoning } : {},
1044
1134
  ...droppedChunks > 0 ? { droppedChunks } : {},
1045
- ...firstDroppedSample ? { firstDroppedSample } : {}
1135
+ ...firstDroppedSample ? { firstDroppedSample } : {},
1136
+ ...inputTruncated ? { truncated: true } : {}
1046
1137
  };
1047
1138
  }
1048
1139
  /**
@@ -1074,6 +1165,7 @@ function collapseStreamingResponse(contentType, providerKey, body, logger) {
1074
1165
  }
1075
1166
 
1076
1167
  //#endregion
1168
+ exports.MAX_COLLAPSE_STRING_LENGTH = MAX_COLLAPSE_STRING_LENGTH;
1077
1169
  exports.capturedRedactedData = capturedRedactedData;
1078
1170
  exports.collapseAnthropicSSE = collapseAnthropicSSE;
1079
1171
  exports.collapseBedrockEventStream = collapseBedrockEventStream;
@@ -1083,4 +1175,5 @@ exports.collapseGeminiSSE = collapseGeminiSSE;
1083
1175
  exports.collapseOllamaNDJSON = collapseOllamaNDJSON;
1084
1176
  exports.collapseOpenAISSE = collapseOpenAISSE;
1085
1177
  exports.collapseStreamingResponse = collapseStreamingResponse;
1178
+ exports.setCollapseStringLimitForTests = setCollapseStringLimitForTests;
1086
1179
  //# sourceMappingURL=stream-collapse.cjs.map