From 2d39546fa938591dc2acc44f5387c92f365bc8a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 22:11:37 +0000 Subject: [PATCH 01/15] feat(record): capture and replay OpenAI/OpenRouter stream usage incl. cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing a streaming OpenAI-compatible chat completion dropped the final usage frame — the `chat.completion.chunk` with an empty `choices` array and a populated `usage` — because the collapser skipped every chunk without choices. Recorded fixtures kept content / reasoning / tool calls / timings but no token counts, so replay could only serve the ceil(len/4) estimate or a hand-authored `response.usage`. OpenRouter's provider-reported `cost` was never captured at all, making consumer billing paths untestable from a tape (the gap #269 closed for fal's `x-fal-billable-units`). Record: `collapseOpenAISSE` captures the last non-null `usage` object into `CollapseResult.usage`; the non-streaming recorder captures the completion envelope's `usage`. Persist: the recorder writes it to `response.usage`, passing through the standard token fields plus `cost`, `cost_details`, `prompt_tokens_details`, `completion_tokens_details`, `is_byok`, and unmodelled provider extras such as `native_tokens_*`. Non-numeric / unknown-shaped fields are dropped so a recorded fixture always passes load-time validation. A stream that reported no usage records no `usage` key, keeping pre-existing fixtures byte-identical. Replay: recorded counts already win over estimation via `resolveUsage`; OpenRouter shaping now also passes forward-compat `usage` keys through verbatim instead of dropping them, and `ResponseOverrides.usage` accepts extra keys. Closes #368 --- CHANGELOG.md | 8 + docs/record-replay/index.html | 37 ++ .../openai-stream-usage-record-replay.test.ts | 481 ++++++++++++++++++ src/openrouter-chat.ts | 36 ++ src/recorder.ts | 98 +++- src/stream-collapse.ts | 32 ++ src/types.ts | 16 + 7 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/openai-stream-usage-record-replay.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8016a060..8df70b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- **Recorded OpenAI/OpenRouter token usage — including OpenRouter `usage.cost` (#368).** Collapsing a streaming OpenAI-compatible chat completion previously dropped the final usage frame (the `chat.completion.chunk` with an empty `choices` array and a populated `usage`), because the collapser skipped every chunk without choices. A recorded fixture therefore kept content / reasoning / tool calls / timings but no token counts at all, and replay could only ever serve the `ceil(length / 4)` estimate or a hand-authored `response.usage` override. OpenRouter's provider-reported `cost` was never captured, so an app that bills from real provider cost could not e2e-test its wallet/ledger path from a tape (the same gap #269 closed for fal's `x-fal-billable-units`). + - **Record:** `collapseOpenAISSE` now captures the last non-null `usage` object on the stream (`CollapseResult.usage`), and the non-streaming recorder captures the completion envelope's `usage`. + - **Persist:** the recorder writes it to the fixture's `response.usage`, passing through the standard token fields plus `cost`, `cost_details`, `prompt_tokens_details`, `completion_tokens_details`, `is_byok`, and unmodelled provider extras such as OpenRouter's `native_tokens_*`. Non-numeric / unknown-shaped fields are dropped so a recorded fixture always passes load-time validation. **Back-compatible:** a stream that reported no usage records no `usage` key and the fixture stays byte-identical to before. + - **Replay:** recorded counts win over estimation (existing `resolveUsage` precedence), and OpenRouter-shaped responses emit the recorded `cost` / breakdowns on both the final streaming usage chunk and the non-streaming envelope. `ResponseOverrides.usage` accepts forward-compat extra keys, and OpenRouter shaping now passes any such key through verbatim rather than dropping it. + - **Note:** capturing cost requires the recorded request to actually elicit a usage frame — `stream_options: { include_usage: true }` on OpenAI-compatible streams (OpenRouter sends it regardless), or a non-streaming response. Plain OpenAI (`/v1/...`) replays continue to emit token counts only; `cost` is OpenRouter-shaped output. + ### Changed - `POST /__aimock/reset` is now the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/journal` is unaffected. diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index 92ba9f05..d9e6c62d 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -449,6 +449,43 @@

Recording Block Order

faithfully block order is reconstructable on each provider's wire.

+

Recording Token Usage & Cost

+

+ OpenAI-compatible recordings (including OpenRouter) keep + the provider's reported usage on the fixture. For a non-streaming call that + is the envelope's usage object; for a stream it is the final + chat.completion.chunk — the one with an empty choices array — + that OpenAI emits when the request sets + stream_options: { include_usage: true } (OpenRouter emits it either way). +

+
{
+  "match": { "userMessage": "summarize this" },
+  "response": {
+    "content": "…",
+    "usage": {
+      "prompt_tokens": 1234,
+      "completion_tokens": 567,
+      "total_tokens": 1801,
+      "cost": 0.0042
+    }
+  }
+}
+

+ On replay those counts are served verbatim instead of aimock's + ceil(length / 4) estimate, and OpenRouter-shaped responses re-emit + usage.cost — so a test can assert a wallet or ledger deduction against the + amount the provider actually charged. Extra provider fields (cost_details, + prompt_tokens_details, completion_tokens_details, + native_tokens_*, …) round-trip too. +

+

+ Record with usage enabled to get cost. If the recorded request did not + ask for usage — and the provider therefore never sent a usage frame — the fixture is + written without a usage key and replay falls back to estimated token counts, + exactly as before. You can always hand-author response.usage on a fixture + instead. +

+

Header Forwarding

When proxying to upstream providers, aimock forwards the original request's headers except diff --git a/src/__tests__/openai-stream-usage-record-replay.test.ts b/src/__tests__/openai-stream-usage-record-replay.test.ts new file mode 100644 index 00000000..6177f240 --- /dev/null +++ b/src/__tests__/openai-stream-usage-record-replay.test.ts @@ -0,0 +1,481 @@ +/** + * Record & replay of provider-reported OpenAI/OpenRouter stream usage (#368). + * + * Before this, collapsing an SSE chat completion dropped the final usage frame + * (empty `choices` → `continue`), so a recorded fixture could only ever replay + * ESTIMATED token counts (`ceil(len/4)`) and OpenRouter's `usage.cost` was never + * captured at all — making consumer billing paths untestable from a tape. + * + * These tests pin the full round trip: capture (collapser) → persist (recorder) + * → replay (OpenRouter-shaped usage chunk / completion envelope). + */ + +import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collapseOpenAISSE } from "../stream-collapse.js"; +import { createServer, type ServerInstance } from "../server.js"; +import { validateFixtures } from "../fixture-loader.js"; +import type { Fixture, FixtureFile, SSEChunk, TextResponse } from "../types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function httpPost(url: string, body: object): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + url, + { method: "POST", headers: { "Content-Type": "application/json" } }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ status: res.statusCode!, body: Buffer.concat(chunks).toString() }), + ); + }, + ); + req.on("error", reject); + req.write(JSON.stringify(body)); + req.end(); + }); +} + +/** Split an SSE body into data-frame JSON objects (skips comments + [DONE]). */ +function parseSSE(body: string): SSEChunk[] { + return body + .split("\n\n") + .map((block) => block.split("\n").find((l) => l.startsWith("data: "))) + .filter((l): l is string => !!l && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice(6)) as SSEChunk); +} + +const OR = "/api/v1/chat/completions"; + +/** Stand-in upstream that replies with a fixed body + content-type. */ +function createUpstream( + servers: http.Server[], + body: string, + contentType: string, +): Promise { + return new Promise((resolve) => { + const srv = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": contentType }); + res.end(body); + }); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as { port: number }; + servers.push(srv); + resolve(`http://127.0.0.1:${addr.port}`); + }); + }); +} + +/** The single fixture the recorder wrote into `dir`. */ +function readRecordedFixture(dir: string): Fixture { + const files = readdirSync(dir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const file = JSON.parse(readFileSync(join(dir, files[0]), "utf-8")) as FixtureFile; + expect(file.fixtures).toHaveLength(1); + return file.fixtures[0]; +} + +/** + * A real OpenRouter streaming body: content deltas, a finish chunk, then the + * final EMPTY-`choices` usage frame carrying token counts AND `cost`. + */ +const OPENROUTER_SSE = [ + `data: ${JSON.stringify({ + id: "gen-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }], + })}`, + "", + `data: ${JSON.stringify({ + id: "gen-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: " world" } }], + })}`, + "", + `data: ${JSON.stringify({ + id: "gen-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}`, + "", + `data: ${JSON.stringify({ + id: "gen-1", + object: "chat.completion.chunk", + choices: [], + usage: { + prompt_tokens: 1234, + completion_tokens: 567, + total_tokens: 1801, + cost: 0.0042, + cost_details: { + upstream_inference_cost: 0.004, + upstream_inference_prompt_cost: 0.001, + upstream_inference_completions_cost: 0.003, + }, + prompt_tokens_details: { cached_tokens: 12 }, + completion_tokens_details: { reasoning_tokens: 34 }, + is_byok: false, + native_tokens_prompt: 1200, + native_tokens_completion: 550, + }, + })}`, + "", + "data: [DONE]", + "", +].join("\n"); + +let instance: ServerInstance | null = null; +let servers: http.Server[] = []; +let tmpDir: string | null = null; + +afterEach(async () => { + if (instance) { + await new Promise((resolve) => instance!.server.close(() => resolve())); + instance = null; + } + for (const s of servers) { + await new Promise((resolve) => s.close(() => resolve())); + } + servers = []; + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } +}); + +// --------------------------------------------------------------------------- +// 1. Capture — collapseOpenAISSE +// --------------------------------------------------------------------------- + +describe("collapseOpenAISSE usage capture (#368)", () => { + it("captures the final empty-choices usage frame verbatim, including cost", () => { + const result = collapseOpenAISSE(OPENROUTER_SSE); + expect(result.content).toBe("Hello world"); + expect(result.usage).toEqual({ + prompt_tokens: 1234, + completion_tokens: 567, + total_tokens: 1801, + cost: 0.0042, + cost_details: { + upstream_inference_cost: 0.004, + upstream_inference_prompt_cost: 0.001, + upstream_inference_completions_cost: 0.003, + }, + prompt_tokens_details: { cached_tokens: 12 }, + completion_tokens_details: { reasoning_tokens: 34 }, + is_byok: false, + native_tokens_prompt: 1200, + native_tokens_completion: 550, + }); + }); + + it("captures usage alongside tool calls", () => { + const body = [ + `data: ${JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: "call_1", function: { name: "f", arguments: "{}" } }], + }, + }, + ], + })}`, + "", + `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 7, completion_tokens: 3 } })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + + const result = collapseOpenAISSE(body); + expect(result.toolCalls).toHaveLength(1); + expect(result.usage).toEqual({ prompt_tokens: 7, completion_tokens: 3 }); + }); + + it("keeps the LAST usage frame when a provider emits more than one", () => { + const body = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "x" } }], usage: { cost: 1 } })}`, + "", + `data: ${JSON.stringify({ choices: [], usage: { cost: 2 } })}`, + "", + ].join("\n"); + expect(collapseOpenAISSE(body).usage).toEqual({ cost: 2 }); + }); + + it("leaves usage undefined when the stream carries none (byte-identical to pre-#368)", () => { + const body = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "hi" } }] })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const result = collapseOpenAISSE(body); + expect(result).toEqual({ content: "hi" }); + }); + + it("does not mistake a transcription stream's usage for chat usage", () => { + const result = collapseOpenAISSE( + `data: ${JSON.stringify({ + type: "transcript.text.done", + text: "hello", + usage: { type: "duration", seconds: 3 }, + })}\n\n`, + ); + expect(result.transcription?.usage).toEqual({ type: "duration", seconds: 3 }); + expect(result.usage).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Persist — the recorder writes response.usage +// --------------------------------------------------------------------------- + +describe("recorder persists provider usage (#368)", () => { + it("writes the streamed usage (incl. cost) onto the recorded fixture", async () => { + const upstreamUrl = await createUpstream(servers, OPENROUTER_SSE, "text/event-stream"); + tmpDir = mkdtempSync(join(tmpdir(), "aimock-usage-")); + instance = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const res = await httpPost(`${instance.url}/v1/chat/completions`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + expect(res.status).toBe(200); + + const fixture = readRecordedFixture(tmpDir); + const response = fixture.response as TextResponse; + expect(response.content).toBe("Hello world"); + expect(response.usage).toEqual({ + prompt_tokens: 1234, + completion_tokens: 567, + total_tokens: 1801, + cost: 0.0042, + cost_details: { + upstream_inference_cost: 0.004, + upstream_inference_prompt_cost: 0.001, + upstream_inference_completions_cost: 0.003, + }, + prompt_tokens_details: { cached_tokens: 12 }, + completion_tokens_details: { reasoning_tokens: 34 }, + is_byok: false, + native_tokens_prompt: 1200, + native_tokens_completion: 550, + }); + // A fixture the recorder writes must always pass load-time validation. + expect(validateFixtures([fixture]).filter((r) => r.severity === "error")).toEqual([]); + }); + + it("writes usage from a NON-streaming completion envelope too", async () => { + const upstreamUrl = await createUpstream( + servers, + JSON.stringify({ + id: "gen-2", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "hey" } }], + usage: { prompt_tokens: 11, completion_tokens: 2, total_tokens: 13, cost: 0.5 }, + }), + "application/json", + ); + tmpDir = mkdtempSync(join(tmpdir(), "aimock-usage-")); + instance = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const res = await httpPost(`${instance.url}/v1/chat/completions`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + + const response = readRecordedFixture(tmpDir).response as TextResponse; + expect(response.content).toBe("hey"); + expect(response.usage).toEqual({ + prompt_tokens: 11, + completion_tokens: 2, + total_tokens: 13, + cost: 0.5, + }); + }); + + it("omits usage entirely when upstream reported none", async () => { + const noUsageSSE = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "plain" } }] })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const upstreamUrl = await createUpstream(servers, noUsageSSE, "text/event-stream"); + tmpDir = mkdtempSync(join(tmpdir(), "aimock-usage-")); + instance = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + await httpPost(`${instance.url}/v1/chat/completions`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + }); + + const response = readRecordedFixture(tmpDir).response as TextResponse; + expect(response).toEqual({ content: "plain" }); + }); + + it("drops non-numeric usage fields so the recorded fixture still validates", async () => { + const oddSSE = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "x" } }] })}`, + "", + `data: ${JSON.stringify({ + choices: [], + usage: { + prompt_tokens: 3, + // Shapes the fixture contract does not accept — must not be persisted. + model: "some/model", + buckets: [1, 2], + nested: { note: "text", tokens: 5 }, + cost: null, + }, + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const upstreamUrl = await createUpstream(servers, oddSSE, "text/event-stream"); + tmpDir = mkdtempSync(join(tmpdir(), "aimock-usage-")); + instance = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + await httpPost(`${instance.url}/v1/chat/completions`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + + const fixture = readRecordedFixture(tmpDir); + expect((fixture.response as TextResponse).usage).toEqual({ prompt_tokens: 3 }); + expect(validateFixtures([fixture]).filter((r) => r.severity === "error")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Replay — recorded usage reaches the wire +// --------------------------------------------------------------------------- + +describe("replay of recorded usage (#368)", () => { + const recorded: Fixture = { + match: { userMessage: "hi" }, + response: { + content: "Hello world", + usage: { + prompt_tokens: 1234, + completion_tokens: 567, + total_tokens: 1801, + cost: 0.0042, + cost_details: { + upstream_inference_cost: 0.004, + upstream_inference_prompt_cost: 0.001, + upstream_inference_completions_cost: 0.003, + }, + prompt_tokens_details: { cached_tokens: 12 }, + completion_tokens_details: { reasoning_tokens: 34 }, + native_tokens_prompt: 1200, + }, + }, + }; + + it("emits the recorded token counts and cost on the final stream usage chunk", async () => { + instance = await createServer([recorded]); + const res = await httpPost(`${instance.url}${OR}`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + + const usageChunk = parseSSE(res.body).find((c) => c.usage !== undefined); + expect(usageChunk).toBeDefined(); + // Real recorded counts, NOT the ceil(len/4) estimate. + expect(usageChunk!.usage!.prompt_tokens).toBe(1234); + expect(usageChunk!.usage!.completion_tokens).toBe(567); + expect(usageChunk!.usage!.total_tokens).toBe(1801); + expect(usageChunk!.usage!.cost).toBe(0.0042); + expect(usageChunk!.usage!.cost_details).toEqual({ + upstream_inference_cost: 0.004, + upstream_inference_prompt_cost: 0.001, + upstream_inference_completions_cost: 0.003, + }); + expect(usageChunk!.usage!.prompt_tokens_details).toEqual({ cached_tokens: 12 }); + expect(usageChunk!.usage!.completion_tokens_details).toEqual({ reasoning_tokens: 34 }); + // Unmodelled provider fields pass through verbatim. + expect(usageChunk!.usage!.native_tokens_prompt).toBe(1200); + }); + + it("emits the recorded usage on the non-streaming completion envelope", async () => { + instance = await createServer([recorded]); + const res = await httpPost(`${instance.url}${OR}`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + }); + const json = JSON.parse(res.body) as { usage: Record }; + expect(json.usage.prompt_tokens).toBe(1234); + expect(json.usage.completion_tokens).toBe(567); + expect(json.usage.total_tokens).toBe(1801); + expect(json.usage.cost).toBe(0.0042); + expect(json.usage.native_tokens_prompt).toBe(1200); + }); + + it("round-trips record → replay end to end (cost survives the tape)", async () => { + const upstreamUrl = await createUpstream(servers, OPENROUTER_SSE, "text/event-stream"); + tmpDir = mkdtempSync(join(tmpdir(), "aimock-usage-")); + const recorder = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + await httpPost(`${recorder.url}/v1/chat/completions`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + await new Promise((resolve) => recorder.server.close(() => resolve())); + + instance = await createServer([readRecordedFixture(tmpDir)]); + const res = await httpPost(`${instance.url}${OR}`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + const usageChunk = parseSSE(res.body).find((c) => c.usage !== undefined); + expect(usageChunk!.usage!.cost).toBe(0.0042); + expect(usageChunk!.usage!.prompt_tokens).toBe(1234); + expect(usageChunk!.usage!.completion_tokens).toBe(567); + }); + + it("still estimates tokens for a fixture without recorded usage", async () => { + instance = await createServer([{ match: { userMessage: "hi" }, response: { content: "yo" } }]); + const res = await httpPost(`${instance.url}${OR}`, { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + }); + const json = JSON.parse(res.body) as { usage: Record }; + expect(json.usage.completion_tokens).toBe(1); + expect(json.usage.cost).toBeUndefined(); + }); +}); diff --git a/src/openrouter-chat.ts b/src/openrouter-chat.ts index 57433fd7..5f91d449 100644 --- a/src/openrouter-chat.ts +++ b/src/openrouter-chat.ts @@ -92,6 +92,29 @@ export interface OpenRouterShaping { usageExtras: OpenRouterUsageExtras; } +/** + * `usage` keys that are already accounted for elsewhere and must therefore NOT + * be re-emitted through the forward-compat passthrough below: the canonical + * token counts (and their per-provider aliases), which `resolveUsage` folds into + * `prompt_tokens` / `completion_tokens` / `total_tokens` on the base usage + * object, plus every field this function shapes explicitly. + */ +const SHAPED_OR_CANONICAL_USAGE_KEYS = new Set([ + "prompt_tokens", + "completion_tokens", + "total_tokens", + "input_tokens", + "output_tokens", + "promptTokenCount", + "candidatesTokenCount", + "totalTokenCount", + "cost", + "cost_details", + "prompt_tokens_details", + "completion_tokens_details", + "is_byok", +]); + /** * Resolve the shaping context from a fixture's response overrides and the * winning model slug. `provider` defaults to the winning slug's author; @@ -134,6 +157,19 @@ export function resolveOpenRouterShaping( if (u?.is_byok !== undefined) { usageExtras.is_byok = u.is_byok; } + // Forward-compat passthrough (#368): a fixture's `usage` may carry provider + // fields aimock does not model — typically written by the recorder straight + // from a real upstream usage frame (OpenRouter `native_tokens_prompt`, + // `native_tokens_completion`, …). Emit them verbatim rather than silently + // dropping them, so a recorded usage object round-trips on replay. The + // canonical token counts and every field shaped above are excluded: those are + // already resolved onto the base usage object (see helpers.ts `resolveUsage`) + // or handled with their own required-member defaults. + for (const [key, value] of Object.entries(u ?? {})) { + if (value === undefined) continue; + if (SHAPED_OR_CANONICAL_USAGE_KEYS.has(key)) continue; + usageExtras[key] = value; + } return { provider: overrides?.provider ?? deriveOpenRouterProvider(winningModel), ...(overrides?.nativeFinishReason !== undefined && { diff --git a/src/recorder.ts b/src/recorder.ts index 05ed6c52..151566bd 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -12,6 +12,7 @@ import type { RecordConfig, RecordedTimings, RecordProviderKey, + ResponseOverrides, ToolCall, } from "./types.js"; import { getLastMessageByRole, getTextContent, currentTurnHasToolResult } from "./router.js"; @@ -691,6 +692,15 @@ export async function proxyAndRecord( `Harmony tokens present but unparseable — content preserved verbatim${collapsed.harmonyNote ? ` (${collapsed.harmonyNote})` : ""}`, ); } + // Provider-reported usage captured from the stream's final usage frame + // (#368). Persisted on the text / toolCall fixture shapes so replay serves + // the REAL token counts instead of the `ceil(len/4)` estimate, and so + // OpenRouter's `usage.cost` round-trips to the replayed usage chunk / + // completion envelope. Omitted when the stream carried no usage, keeping + // every pre-#368 recorded fixture byte-identical. The transcription and + // audio shapes carry their own usage slots and are left untouched. + const collapsedUsage = sanitizeRecordedUsage(collapsed.usage); + const usageSpread = collapsedUsage ? { usage: collapsedUsage } : {}; // Audio from streamed inlineData (e.g. Gemini SSE with audio parts). // A single Gemini turn can interleave audio with a functionCall and/or // text/thought parts; preserve those companion modalities so the tool call @@ -753,6 +763,7 @@ export async function proxyAndRecord( ...reasoningSignatureSpread, ...redactedThinkingSpread, ...webSearchesSpread, + ...usageSpread, }; } else { const reasoningSpread = collapsed.reasoning ? { reasoning: collapsed.reasoning } : {}; @@ -802,6 +813,7 @@ export async function proxyAndRecord( ...reasoningSignatureSpread, ...redactedThinkingSpread, ...webSearchesSpread, + ...usageSpread, }; } else { fixtureResponse = { @@ -810,6 +822,7 @@ export async function proxyAndRecord( ...reasoningSignatureSpread, ...redactedThinkingSpread, ...webSearchesSpread, + ...usageSpread, }; } } else { @@ -819,6 +832,7 @@ export async function proxyAndRecord( ...reasoningSignatureSpread, ...redactedThinkingSpread, ...webSearchesSpread, + ...usageSpread, }; } } @@ -1512,6 +1526,69 @@ function toToolCallArguments(raw: unknown): string { return JSON.stringify(raw); } +/** + * `usage` sub-objects that carry numeric breakdowns rather than a scalar, and + * the inner fields aimock documents for each. Mirrors the load-time validator in + * fixture-loader.ts so anything this recorder writes loads cleanly. + */ +const USAGE_OBJECT_FIELDS: Record = { + cost_details: [ + "upstream_inference_cost", + "upstream_inference_prompt_cost", + "upstream_inference_completions_cost", + ], + prompt_tokens_details: ["cached_tokens", "cache_write_tokens", "audio_tokens"], + completion_tokens_details: ["reasoning_tokens"], +}; + +/** + * Sanitize a provider-reported `usage` object into the fixture + * `response.usage` override shape (#368). + * + * The recorder persists real upstream usage so replay can serve the provider's + * ACTUAL token counts (instead of the `ceil(len/4)` estimate) and OpenRouter's + * `cost` — which previously never survived recording at all. Upstream usage is + * provider-specific and forward-extending, so this passes fields through rather + * than whitelisting them, but only in the shapes the fixture contract accepts: + * + * - numeric scalars (`prompt_tokens`, `cost`, `native_tokens_prompt`, …) — kept + * - `is_byok` boolean — kept + * - the documented breakdown objects — kept, with non-numeric inner fields + * dropped (the load-time validator rejects those, and a fixture the recorder + * writes must always load) + * - anything else (strings, arrays, nulls, unknown objects) — DROPPED, because + * `fixture-loader.ts` errors on a non-numeric extra `usage` key and a recorded + * fixture that fails validation is worse than one missing an exotic field + * + * Returns `undefined` when nothing survives, so the caller omits `usage` + * entirely and keeps pre-#368 fixtures byte-identical. + */ +function sanitizeRecordedUsage(raw: unknown): ResponseOverrides["usage"] | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const out: Record = {}; + for (const [key, val] of Object.entries(raw as Record)) { + if (val === undefined || val === null) continue; + if (key in USAGE_OBJECT_FIELDS) { + if (typeof val !== "object" || val === null || Array.isArray(val)) continue; + const inner: Record = {}; + for (const [innerKey, innerVal] of Object.entries(val as Record)) { + // Documented inner fields must be numeric (validator-enforced); undocumented + // ones are unvalidated, so pass them through only when numeric too — keeping + // the recorded object uniformly safe to re-load. + if (typeof innerVal === "number" && Number.isFinite(innerVal)) inner[innerKey] = innerVal; + } + if (Object.keys(inner).length > 0) out[key] = inner; + continue; + } + if (key === "is_byok") { + if (typeof val === "boolean") out[key] = val; + continue; + } + if (typeof val === "number" && Number.isFinite(val)) out[key] = val; + } + return Object.keys(out).length > 0 ? (out as ResponseOverrides["usage"]) : undefined; +} + /** * Detect the response format from the parsed upstream JSON and convert * it into an aimock FixtureResponse. @@ -1760,6 +1837,13 @@ function buildFixtureResponse( ? message.reasoning : undefined; + // Provider-reported usage from the non-streaming envelope (#368), at + // parity with the collapsed-stream path: replay then serves the real token + // counts rather than the `ceil(len/4)` estimate, and OpenRouter's + // `usage.cost` survives recording. Omitted when upstream sent no usage. + const openaiUsage = sanitizeRecordedUsage(obj.usage); + const usageSpread = openaiUsage ? { usage: openaiUsage } : {}; + if (hasToolCalls) { const toolCalls: ToolCall[] = (message.tool_calls as Array>).map( (tc) => { @@ -1776,19 +1860,29 @@ function buildFixtureResponse( content: message.content as string, toolCalls, ...(openaiReasoning ? { reasoning: openaiReasoning } : {}), + ...usageSpread, }; } - return { toolCalls, ...(openaiReasoning ? { reasoning: openaiReasoning } : {}) }; + return { + toolCalls, + ...(openaiReasoning ? { reasoning: openaiReasoning } : {}), + ...usageSpread, + }; } // Text content only if (hasContent) { return { content: message.content as string, ...(openaiReasoning ? { reasoning: openaiReasoning } : {}), + ...usageSpread, }; } // Recognized OpenAI shape but empty content (e.g. content filtering, zero max_tokens) - return { content: "", ...(openaiReasoning ? { reasoning: openaiReasoning } : {}) }; + return { + content: "", + ...(openaiReasoning ? { reasoning: openaiReasoning } : {}), + ...usageSpread, + }; } } diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 88918d1e..38fdc973 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -122,6 +122,22 @@ export interface CollapseResult { */ redactedThinking?: string[]; webSearches?: string[]; + /** + * The provider-reported `usage` object from the stream's final usage frame, + * captured VERBATIM (#368). OpenAI-compatible streams (including OpenRouter) + * close with a `chat.completion.chunk` whose `choices` is empty and whose + * `usage` carries the real token counts — plus, on OpenRouter, + * `cost` / `cost_details` / `native_tokens_*`. Collapsing dropped that frame, + * so a recorded fixture could only ever replay estimated (`ceil(len/4)`) token + * counts and never a provider-reported cost. Last-usage-wins: a stream that + * emits several usage frames keeps the final one. Absent when the upstream + * stream carried no usage (e.g. `stream_options.include_usage` unset). + * + * Kept as a loose record because the field set is provider-specific and + * forward-extending; the recorder sanitizes it into the fixture + * `response.usage` override shape before persisting. + */ + usage?: Record; toolCalls?: ToolCall[]; droppedChunks?: number; firstDroppedSample?: string; @@ -372,6 +388,8 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { let transcriptUsage: Record | undefined; let reasoning = ""; const webSearchQueries: string[] = []; + // Provider-reported usage from the stream's final usage frame (#368). + let usage: Record | undefined; let droppedChunks = 0; let firstDroppedSample: string | undefined; let harmonyUnparsed = false; @@ -465,6 +483,17 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { continue; } + // Final usage frame (#368). OpenAI-compatible providers close an + // include_usage stream with a `chat.completion.chunk` carrying EMPTY + // `choices` and a populated `usage` — the only frame with the real token + // counts, and on OpenRouter the only one with `cost`. Capture it BEFORE the + // empty-`choices` guard below, which would otherwise skip the frame + // entirely. Some providers also attach `usage` to the finish chunk (non-empty + // choices), so this runs for every chat chunk, last-usage-wins. + if (parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)) { + usage = parsed.usage as Record; + } + const choices = parsed.choices as Array> | undefined; if (!choices || choices.length === 0) continue; @@ -605,6 +634,8 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { ...(reasoning ? { reasoning } : {}), // webSearches parity with the text-only return branch. ...(webSearchQueries.length > 0 ? { webSearches: webSearchQueries } : {}), + // Provider-reported usage parity with the text-only return branch (#368). + ...(usage ? { usage } : {}), ...(droppedChunks > 0 ? { droppedChunks } : {}), ...(firstDroppedSample ? { firstDroppedSample } : {}), ...(harmonyUnparsed ? { harmonyUnparsed: true } : {}), @@ -626,6 +657,7 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { content, ...(reasoning ? { reasoning } : {}), ...(webSearchQueries.length > 0 ? { webSearches: webSearchQueries } : {}), + ...(usage ? { usage } : {}), ...(droppedChunks > 0 ? { droppedChunks } : {}), ...(firstDroppedSample ? { firstDroppedSample } : {}), ...(harmonyUnparsed ? { harmonyUnparsed: true } : {}), diff --git a/src/types.ts b/src/types.ts index eb049746..c8dcd985 100644 --- a/src/types.ts +++ b/src/types.ts @@ -232,6 +232,16 @@ export interface ResponseOverrides { * overridden (avoids asserting a fake BYOK state by default). */ is_byok?: boolean; + /** + * Forward-compat escape hatch for provider usage fields aimock does not + * model (OpenRouter `native_tokens_prompt` / `native_tokens_completion`, + * future breakdowns, …). The recorder persists such fields when an upstream + * usage frame carries them, and OpenRouter-shaped replays pass any extra key + * through verbatim onto the emitted `usage` object. The load-time validator + * still requires extra scalars to be numbers (see fixture-loader.ts), so a + * typo'd field is caught rather than silently emitted as a string. + */ + [key: string]: unknown; }; systemFingerprint?: string; finishReason?: string; @@ -790,6 +800,12 @@ export interface OpenRouterUsageExtras { }; completion_tokens_details?: { reasoning_tokens: number; [key: string]: unknown }; is_byok?: boolean; + /** + * Provider usage fields aimock does not model, passed through verbatim from a + * fixture's `response.usage` (typically written by the recorder from a real + * upstream usage frame — e.g. OpenRouter `native_tokens_prompt`). + */ + [key: string]: unknown; } export interface SSEChunk { From 6c3785124634a869f86c67586c99935e17a8ef45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 00:48:12 +0000 Subject: [PATCH 02/15] fix(record): route the openrouter provider key to the OpenAI SSE collapser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collapseStreamingResponse`'s provider switch had no case for `openrouter`, even though it is a first-class RecordProviderKey that server.ts sets on every `/api/v1/chat/completions` request. Recording a streaming OpenRouter completion fell to the `default` arm and logged [stream-collapse] unknown SSE provider "openrouter", falling back to OpenAI SSE format on every recorded stream. The collapse was already correct — the fallback IS the OpenAI collapser and OpenRouter speaks the OpenAI SSE wire format — so this is a diagnostics fix with no behavior change. The warning claimed aimock did not recognize a provider it ships first-class support for, which is actively misleading while debugging a recording (including the #368 cost-capture flow). --- CHANGELOG.md | 4 ++++ src/__tests__/stream-collapse.test.ts | 27 ++++++++++++++++++++++++++- src/stream-collapse.ts | 8 ++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df70b8f..1ff686cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - **Replay:** recorded counts win over estimation (existing `resolveUsage` precedence), and OpenRouter-shaped responses emit the recorded `cost` / breakdowns on both the final streaming usage chunk and the non-streaming envelope. `ResponseOverrides.usage` accepts forward-compat extra keys, and OpenRouter shaping now passes any such key through verbatim rather than dropping it. - **Note:** capturing cost requires the recorded request to actually elicit a usage frame — `stream_options: { include_usage: true }` on OpenAI-compatible streams (OpenRouter sends it regardless), or a non-streaming response. Plain OpenAI (`/v1/...`) replays continue to emit token counts only; `cost` is OpenRouter-shaped output. +### Fixed + +- **`openrouter` is no longer treated as an unknown SSE provider when recording.** `collapseStreamingResponse`'s provider switch had cases for `openai` / `azure` / `anthropic` / `gemini` / `cohere` / `bedrock` but none for `openrouter`, even though it is a first-class `RecordProviderKey` that the server sets on every `/api/v1/chat/completions` request. Recording a streaming OpenRouter completion therefore hit the `default` arm and logged `[stream-collapse] unknown SSE provider "openrouter", falling back to OpenAI SSE format` on **every** recorded stream. The collapse itself was already correct (the fallback is the OpenAI collapser, and OpenRouter speaks the OpenAI SSE wire format), so this is a log-noise/diagnostics fix with no behavior change — but the warning claimed aimock did not recognize a provider it ships first-class support for, which is actively misleading while debugging a recording. + ### Changed - `POST /__aimock/reset` is now the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/journal` is unaffected. diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index 0cc50b8e..2250ab92 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { collapseOpenAISSE, collapseAnthropicSSE, @@ -751,6 +751,31 @@ describe("collapseStreamingResponse", () => { expect(result!.content).toBe("buf-hi"); }); + it("routes the openrouter provider key to the OpenAI collapser without warning", () => { + // OpenRouter is a first-class RecordProviderKey (set by every + // /api/v1/chat/completions record) and speaks the OpenAI SSE wire format. + // It must be a recognized case, not the unknown-provider fallback, which + // logged a misleading warning on every recorded OpenRouter stream. + const openrouterSse = [ + 'data: {"choices":[{"delta":{"content":"hi"}}]}', + "", + 'data: {"choices":[],"usage":{"prompt_tokens":4,"completion_tokens":1,"cost":0.001}}', + "", + "data: [DONE]", + "", + ].join("\n"); + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const result = collapseStreamingResponse( + "text/event-stream", + "openrouter", + openrouterSse, + logger as unknown as Parameters[3], + ); + expect(result?.content).toBe("hi"); + expect(result?.usage).toEqual({ prompt_tokens: 4, completion_tokens: 1, cost: 0.001 }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + it("unknown SSE provider key falls back to OpenAI SSE format", () => { const openaiSse = 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\ndata: [DONE]\n\n'; // "unknown-provider" is not in RecordProviderKey; "as never" lets us test the runtime default branch diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 38fdc973..b3bfb2ce 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -1895,8 +1895,16 @@ export function collapseStreamingResponse( if (ct.includes("text/event-stream")) { const str = typeof body === "string" ? body : body.toString("utf8"); switch (providerKey) { + // OpenRouter belongs here with OpenAI/Azure: it IS the OpenAI SSE wire + // format (it is the OpenAI-compatible gateway), and `openrouter` is a + // first-class RecordProviderKey set by every `/api/v1/chat/completions` + // record. Without this case it fell to the `default` arm, which collapsed + // it correctly but logged `unknown SSE provider "openrouter"` on EVERY + // recorded OpenRouter stream — telling users aimock does not recognize a + // provider it ships first-class support for. case "openai": case "azure": + case "openrouter": return collapseOpenAISSE(str); case "anthropic": return collapseAnthropicSSE(str); From 51503417e4f1ca1917c390dfec067e046ab96cf2 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 09:51:08 -0700 Subject: [PATCH 03/15] fix: own-key usage-field classification in sanitizeRecordedUsage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizeRecordedUsage classified usage keys with `key in USAGE_OBJECT_FIELDS`, which walks the prototype chain. A usage key named like an Object.prototype member (toString, valueOf, constructor, ...) was misclassified: a numeric one was dropped, and an object-valued one was emitted under a bogus key that fixture-loader's validateFixtures then rejects — breaking the recorder->loader parity that guarantees a recorded fixture always validates. USAGE_OBJECT_FIELDS also declared per-field inner-name arrays that the code never used (the inner loop keeps every finite-numeric inner field), implying an allowlist that was not enforced. Replace it with a ReadonlySet of the three object-field names and classify via own-key `.has()`, mirroring the validator's own `new Set(Object.keys(...))` + escape-hatch behavior. This removes the dead arrays and fixes the prototype-chain misclassification in one change, keeping sanitizer output a subset of what the validator accepts. Adds src/__tests__/recorder-usage-sanitize.test.ts exercising the real exported function and asserting recorder->validator parity. --- src/__tests__/recorder-usage-sanitize.test.ts | 96 +++++++++++++++++++ src/recorder.ts | 29 +++--- 2 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/recorder-usage-sanitize.test.ts diff --git a/src/__tests__/recorder-usage-sanitize.test.ts b/src/__tests__/recorder-usage-sanitize.test.ts new file mode 100644 index 00000000..a7966a08 --- /dev/null +++ b/src/__tests__/recorder-usage-sanitize.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeRecordedUsage } from "../recorder.js"; +import { validateFixtures } from "../fixture-loader.js"; +import type { Fixture } from "../types.js"; + +// --------------------------------------------------------------------------- +// sanitizeRecordedUsage (#369): field classification must be OWN-KEY, and the +// object-field data structure must not imply a dead inner-field allowlist. +// +// The sanitizer's output must always be a SUBSET of what validateFixtures +// accepts, so "a recorded fixture always validates" holds. We assert that +// parity directly by feeding sanitized usage through validateFixtures. +// --------------------------------------------------------------------------- + +/** Build a minimal fixture whose response.usage is the sanitized override. */ +function fixtureWithUsage(usage: unknown): Fixture[] { + return [ + { + match: {}, + response: { content: "ok", usage: usage as never }, + }, + ] as unknown as Fixture[]; +} + +function usageErrors(usage: unknown): string[] { + return validateFixtures(fixtureWithUsage(usage)) + .filter((r) => r.severity === "error") + .map((r) => r.message); +} + +describe("sanitizeRecordedUsage — own-key classification (F1)", () => { + it("keeps a numeric prototype-named scalar and real fields alongside it", () => { + // `toString` collides with Object.prototype: a `key in USAGE_OBJECT_FIELDS` + // check misclassifies it as an object-field and drops the numeric value. + const out = sanitizeRecordedUsage({ toString: 5, prompt_tokens: 10 }); + expect(out).toEqual({ toString: 5, prompt_tokens: 10 }); + // And the validator accepts what the sanitizer produced (parity). + expect(usageErrors(out)).toEqual([]); + }); + + it("drops an object under a prototype-named key instead of emitting it (validator would reject it)", () => { + // `valueOf` collides with Object.prototype: `key in USAGE_OBJECT_FIELDS` is + // true, so an object value is emitted under `valueOf` — which the validator + // rejects ("usage.valueOf must be a number"), breaking recorder→loader parity. + const out = sanitizeRecordedUsage({ + valueOf: { reasoning_tokens: 3 }, + completion_tokens: 7, + }); + // `toEqual` compares OWN enumerable keys, so this proves no `valueOf` key + // was emitted (a `not.toHaveProperty` check would wrongly match the + // inherited Object.prototype.valueOf). + expect(out).toEqual({ completion_tokens: 7 }); + expect(usageErrors(out)).toEqual([]); + }); + + it("classifies the real object fields as objects, not scalars", () => { + const out = sanitizeRecordedUsage({ + prompt_tokens: 4, + prompt_tokens_details: { cached_tokens: 2 }, + }); + expect(out).toEqual({ + prompt_tokens: 4, + prompt_tokens_details: { cached_tokens: 2 }, + }); + expect(usageErrors(out)).toEqual([]); + }); +}); + +describe("sanitizeRecordedUsage — object-field passthrough (F3 characterization)", () => { + it("keeps numeric inner fields (documented and undocumented) — the design's forward-compat passthrough", () => { + // The object-field structure carries only the three field NAMES; the inner + // arrays were dead. All finite-numeric inner fields survive, matching the + // validator's index-signature escape hatch for forward-compat inner keys. + const out = sanitizeRecordedUsage({ + cost_details: { + upstream_inference_cost: 1.5, // documented + some_future_inner_cost: 0.25, // undocumented but numeric + }, + }); + expect(out).toEqual({ + cost_details: { + upstream_inference_cost: 1.5, + some_future_inner_cost: 0.25, + }, + }); + expect(usageErrors(out)).toEqual([]); + }); + + it("drops non-numeric inner fields", () => { + const out = sanitizeRecordedUsage({ + completion_tokens_details: { reasoning_tokens: 9, label: "nope" }, + }); + expect(out).toEqual({ completion_tokens_details: { reasoning_tokens: 9 } }); + expect(usageErrors(out)).toEqual([]); + }); +}); diff --git a/src/recorder.ts b/src/recorder.ts index 151566bd..43c5f6d6 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -1527,19 +1527,20 @@ function toToolCallArguments(raw: unknown): string { } /** - * `usage` sub-objects that carry numeric breakdowns rather than a scalar, and - * the inner fields aimock documents for each. Mirrors the load-time validator in - * fixture-loader.ts so anything this recorder writes loads cleanly. + * `usage` sub-objects that carry numeric breakdowns rather than a scalar. The + * inner fields are NOT allowlisted here on purpose: the recorder keeps every + * finite-numeric inner field (documented or forward-compat), matching the + * load-time validator in fixture-loader.ts, whose index-signature escape hatch + * accepts undocumented numeric inner keys too. A `Set` (own-key membership via + * `.has()`, no prototype-chain walk) mirrors that validator's + * `new Set(Object.keys(...))` classification so anything this recorder writes + * loads cleanly. */ -const USAGE_OBJECT_FIELDS: Record = { - cost_details: [ - "upstream_inference_cost", - "upstream_inference_prompt_cost", - "upstream_inference_completions_cost", - ], - prompt_tokens_details: ["cached_tokens", "cache_write_tokens", "audio_tokens"], - completion_tokens_details: ["reasoning_tokens"], -}; +const USAGE_OBJECT_FIELDS: ReadonlySet = new Set([ + "cost_details", + "prompt_tokens_details", + "completion_tokens_details", +]); /** * Sanitize a provider-reported `usage` object into the fixture @@ -1563,12 +1564,12 @@ const USAGE_OBJECT_FIELDS: Record = { * Returns `undefined` when nothing survives, so the caller omits `usage` * entirely and keeps pre-#368 fixtures byte-identical. */ -function sanitizeRecordedUsage(raw: unknown): ResponseOverrides["usage"] | undefined { +export function sanitizeRecordedUsage(raw: unknown): ResponseOverrides["usage"] | undefined { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; const out: Record = {}; for (const [key, val] of Object.entries(raw as Record)) { if (val === undefined || val === null) continue; - if (key in USAGE_OBJECT_FIELDS) { + if (USAGE_OBJECT_FIELDS.has(key)) { if (typeof val !== "object" || val === null || Array.isArray(val)) continue; const inner: Record = {}; for (const [innerKey, innerVal] of Object.entries(val as Record)) { From cb0b998e151fa8d166681e3b1e57bbd826378bc9 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 09:49:04 -0700 Subject: [PATCH 04/15] fix(record): ignore empty usage frames so a trailing usage:{} cannot clobber token counts (#369) --- src/__tests__/stream-collapse.test.ts | 21 +++++++++++++++++++++ src/stream-collapse.ts | 10 ++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index 2250ab92..0843fb3e 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -226,6 +226,27 @@ describe("collapseOpenAISSE", () => { expect(result.toolCalls![0].name).toBe("lookup"); expect(result.toolCalls![0].arguments).toBe('{"q":"test"}'); }); + + it("does not let a trailing empty usage frame clobber a populated one (#369 F4)", () => { + const body = [ + `data: ${JSON.stringify({ + choices: [], + usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 }, + })}`, + "", + `data: ${JSON.stringify({ choices: [], usage: {} })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + + const result = collapseOpenAISSE(body); + expect(result.usage).toEqual({ + prompt_tokens: 11, + completion_tokens: 5, + total_tokens: 16, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index b3bfb2ce..db5a1a4c 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -489,8 +489,14 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { // counts, and on OpenRouter the only one with `cost`. Capture it BEFORE the // empty-`choices` guard below, which would otherwise skip the frame // entirely. Some providers also attach `usage` to the finish chunk (non-empty - // choices), so this runs for every chat chunk, last-usage-wins. - if (parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)) { + // choices), so this runs for every chat chunk, last-NON-EMPTY-usage-wins: + // a trailing bare `usage: {}` frame must not clobber a good capture (#369). + if ( + parsed.usage && + typeof parsed.usage === "object" && + !Array.isArray(parsed.usage) && + Object.keys(parsed.usage).length > 0 + ) { usage = parsed.usage as Record; } From bdfcfbd022783b82be8a85740419e22ee190a168 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 09:50:18 -0700 Subject: [PATCH 05/15] fix(openrouter): make usage passthrough prototype-safe The forward-compat usage passthrough assigned every un-shaped key onto usageExtras verbatim. A JSON-parsed usage frame (fixtures mirror upstream provider responses) can carry a real own __proto__/constructor/prototype key; a plain usageExtras[key] = value for one of those hits the prototype setter and corrupts the emitted object's prototype chain. Skip those keys in the passthrough loop. Legitimate un-shaped keys still pass through. --- .../openrouter-passthrough-proto.test.ts | 65 +++++++++++++++++++ src/openrouter-chat.ts | 11 ++++ 2 files changed, 76 insertions(+) create mode 100644 src/__tests__/openrouter-passthrough-proto.test.ts diff --git a/src/__tests__/openrouter-passthrough-proto.test.ts b/src/__tests__/openrouter-passthrough-proto.test.ts new file mode 100644 index 00000000..797d562c --- /dev/null +++ b/src/__tests__/openrouter-passthrough-proto.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { resolveOpenRouterShaping } from "../openrouter-chat.js"; +import type { ResponseOverrides } from "../types.js"; + +// --------------------------------------------------------------------------- +// Prototype-pollution guard for the forward-compat usage passthrough (#369 CR). +// +// The passthrough loop in resolveOpenRouterShaping copies every un-shaped usage +// key onto the emitted `usageExtras`. Recorded fixtures are attacker-adjacent — +// they come from upstream provider responses — and JSON.parse materializes a +// `"__proto__"` (or `"constructor"` / `"prototype"`) key as a REAL own, +// enumerable property. A plain `usageExtras[key] = value` assignment for such a +// key hits the prototype setter, corrupting the emitted object's prototype +// chain rather than emitting a data field. The passthrough must be +// prototype-safe: skip those keys while letting legitimate un-shaped keys +// (e.g. `native_tokens_prompt`) through verbatim. +// --------------------------------------------------------------------------- + +/** + * Build a usage override whose extras include a `__proto__` (and `constructor`) + * OWN key. A source-level object literal cannot express this — `{ __proto__: … }` + * sets the prototype instead of an own key — so parse it from JSON, exactly as + * the recorder would load a captured upstream usage frame. + */ +function usageWithProtoKeys(): ResponseOverrides { + const usage = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":{"polluted":true},"native_tokens_prompt":1200}', + ) as ResponseOverrides["usage"]; + return { usage }; +} + +describe("openrouter usage passthrough — prototype safety", () => { + it("does not let a __proto__ usage key corrupt the emitted object's prototype", () => { + const shaping = resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); + const extras = shaping.usageExtras; + + // The emitted object's prototype chain must be intact — no bogus inherited prop. + expect(Object.getPrototypeOf(extras)).toBe(Object.prototype); + expect("polluted" in extras).toBe(false); + expect((extras as Record).polluted).toBeUndefined(); + }); + + it("does not emit __proto__ / constructor / prototype as own keys", () => { + const shaping = resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); + const extras = shaping.usageExtras; + + expect(Object.prototype.hasOwnProperty.call(extras, "__proto__")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(extras, "constructor")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(extras, "prototype")).toBe(false); + }); + + it("does not pollute a bystander plain object after shaping", () => { + resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); + const bystander: Record = {}; + expect("polluted" in bystander).toBe(false); + expect(bystander.polluted).toBeUndefined(); + }); + + it("still passes legitimate un-shaped usage keys through verbatim", () => { + const shaping = resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); + const extras = shaping.usageExtras as Record; + expect(extras.native_tokens_prompt).toBe(1200); + expect(Object.prototype.hasOwnProperty.call(extras, "native_tokens_prompt")).toBe(true); + }); +}); diff --git a/src/openrouter-chat.ts b/src/openrouter-chat.ts index 5f91d449..cbd99d24 100644 --- a/src/openrouter-chat.ts +++ b/src/openrouter-chat.ts @@ -115,6 +115,16 @@ const SHAPED_OR_CANONICAL_USAGE_KEYS = new Set([ "is_byok", ]); +/** + * Keys that must never flow through the passthrough assignment below: a + * JSON-parsed usage frame (fixtures are attacker-adjacent — they mirror upstream + * provider responses) can carry a real own `__proto__`/`constructor`/`prototype` + * key, and a plain `usageExtras[key] = value` for one of those hits the + * prototype setter, corrupting the emitted object's prototype chain instead of + * emitting a data field. Real providers never send these, so skipping is safe. + */ +const UNSAFE_PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]); + /** * Resolve the shaping context from a fixture's response overrides and the * winning model slug. `provider` defaults to the winning slug's author; @@ -168,6 +178,7 @@ export function resolveOpenRouterShaping( for (const [key, value] of Object.entries(u ?? {})) { if (value === undefined) continue; if (SHAPED_OR_CANONICAL_USAGE_KEYS.has(key)) continue; + if (UNSAFE_PROTO_KEYS.has(key)) continue; usageExtras[key] = value; } return { From f3e40e74a537a83f22a14adc7a05821cb1152ca2 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 09:49:26 -0700 Subject: [PATCH 06/15] test(record): register round-trip recorder with drained cleanup The round-trip record->replay test created a local recorder server and closed it inline, but never registered it with the afterEach-drained `servers` array. An assertion throw before the explicit close would leak the listening socket. Push `recorder.server` onto `servers` so it is torn down even on early throw, matching the idiom every other test in the file uses. --- src/__tests__/openai-stream-usage-record-replay.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/__tests__/openai-stream-usage-record-replay.test.ts b/src/__tests__/openai-stream-usage-record-replay.test.ts index 6177f240..aaecad9b 100644 --- a/src/__tests__/openai-stream-usage-record-replay.test.ts +++ b/src/__tests__/openai-stream-usage-record-replay.test.ts @@ -447,6 +447,9 @@ describe("replay of recorded usage (#368)", () => { port: 0, record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, }); + // Register with the afterEach-drained cleanup so an early assertion throw + // before the explicit close below can't leak the listening socket. + servers.push(recorder.server); await httpPost(`${recorder.url}/v1/chat/completions`, { model: "openai/gpt-4o", messages: [{ role: "user", content: "hi" }], From 45b7f7ca165f01a45ce7a8a0066c79b4cae40a0e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 09:51:25 -0700 Subject: [PATCH 07/15] docs(record): correct OpenRouter SSE/usage doc accuracy - record-replay page: OpenAI SSE row now lists OpenRouter alongside OpenAI/Azure (collapseOpenAISSE groups all three) - ResponseOverrides: replace stale "all 7" wording with the 7 common fields plus the 2 OpenRouter-only fields (provider, nativeFinishReason) - CHANGELOG: distinguish persist-time drop from the separate load-time validator so usage-field handling no longer reads as contradictory --- CHANGELOG.md | 2 +- docs/record-replay/index.html | 2 +- src/types.ts | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff686cf..9385f7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Recorded OpenAI/OpenRouter token usage — including OpenRouter `usage.cost` (#368).** Collapsing a streaming OpenAI-compatible chat completion previously dropped the final usage frame (the `chat.completion.chunk` with an empty `choices` array and a populated `usage`), because the collapser skipped every chunk without choices. A recorded fixture therefore kept content / reasoning / tool calls / timings but no token counts at all, and replay could only ever serve the `ceil(length / 4)` estimate or a hand-authored `response.usage` override. OpenRouter's provider-reported `cost` was never captured, so an app that bills from real provider cost could not e2e-test its wallet/ledger path from a tape (the same gap #269 closed for fal's `x-fal-billable-units`). - **Record:** `collapseOpenAISSE` now captures the last non-null `usage` object on the stream (`CollapseResult.usage`), and the non-streaming recorder captures the completion envelope's `usage`. - - **Persist:** the recorder writes it to the fixture's `response.usage`, passing through the standard token fields plus `cost`, `cost_details`, `prompt_tokens_details`, `completion_tokens_details`, `is_byok`, and unmodelled provider extras such as OpenRouter's `native_tokens_*`. Non-numeric / unknown-shaped fields are dropped so a recorded fixture always passes load-time validation. **Back-compatible:** a stream that reported no usage records no `usage` key and the fixture stays byte-identical to before. + - **Persist:** the recorder writes it to the fixture's `response.usage`, passing through the standard token fields plus `cost`, `cost_details`, `prompt_tokens_details`, `completion_tokens_details`, `is_byok`, and unmodelled provider extras such as OpenRouter's `native_tokens_*`. At persist time the recorder drops non-numeric / unknown-shaped fields, so a recorded fixture always passes the load-time validator — a separate stage that independently rejects a non-numeric extra `usage` scalar in a hand-authored fixture (see `fixture-loader.ts`). **Back-compatible:** a stream that reported no usage records no `usage` key and the fixture stays byte-identical to before. - **Replay:** recorded counts win over estimation (existing `resolveUsage` precedence), and OpenRouter-shaped responses emit the recorded `cost` / breakdowns on both the final streaming usage chunk and the non-streaming envelope. `ResponseOverrides.usage` accepts forward-compat extra keys, and OpenRouter shaping now passes any such key through verbatim rather than dropping it. - **Note:** capturing cost requires the recorded request to actually elicit a usage frame — `stream_options: { include_usage: true }` on OpenAI-compatible streams (OpenRouter sends it regardless), or a non-streaming response. Plain OpenAI (`/v1/...`) replays continue to emit token counts only; `cost` is OpenRouter-shaped output. diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index d9e6c62d..b07eb978 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -395,7 +395,7 @@

Stream Collapsing

OpenAI SSE - OpenAI, Azure + OpenAI, Azure, OpenRouter text/event-stream diff --git a/src/types.ts b/src/types.ts index c8dcd985..fc84ac5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,9 +177,12 @@ export interface FixtureMatch { * When total_tokens (or provider equivalent) is omitted, it is auto-computed * from the component fields. * - * Provider support: OpenAI Chat (all 7), Responses API (5: no role, - * systemFingerprint), Claude (5: no created, systemFingerprint), - * Gemini (2: only finishReason, usage). + * Provider support: seven fields are common (id, created, model, usage, + * systemFingerprint, finishReason, role); provider and nativeFinishReason are + * OpenRouter-only and ignored elsewhere. OpenAI Chat honors the seven common + * fields; OpenRouter additionally honors provider/nativeFinishReason; Responses + * API (5: no role, systemFingerprint); Claude (5: no created, + * systemFingerprint); Gemini (2: only finishReason, usage). */ export interface ResponseOverrides { id?: string; From dca6a94b65be97b6f1491e112c9daef10164c650 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:05:13 -0700 Subject: [PATCH 08/15] test(openrouter): remove vacuous bystander prototype-pollution test The 'does not pollute a bystander' case asserted a property that cannot fail: resolveOpenRouterShaping copies keys via plain assignment (usageExtras[key] = value), which for a '__proto__' key reparents only usageExtras itself and never mutates Object.prototype, so an unrelated plain object can never be polluted regardless of the UNSAFE_PROTO_KEYS guard. The test passed even against a guard-removed implementation, providing zero regression protection. The guard's real, observable effect is fully covered by the two genuine sibling tests, both of which go red without the guard: the emitted object's prototype chain stays intact (no inherited 'polluted') and __proto__/constructor/prototype never become own keys. --- src/__tests__/openrouter-passthrough-proto.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/__tests__/openrouter-passthrough-proto.test.ts b/src/__tests__/openrouter-passthrough-proto.test.ts index 797d562c..06fb3efa 100644 --- a/src/__tests__/openrouter-passthrough-proto.test.ts +++ b/src/__tests__/openrouter-passthrough-proto.test.ts @@ -49,13 +49,6 @@ describe("openrouter usage passthrough — prototype safety", () => { expect(Object.prototype.hasOwnProperty.call(extras, "prototype")).toBe(false); }); - it("does not pollute a bystander plain object after shaping", () => { - resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); - const bystander: Record = {}; - expect("polluted" in bystander).toBe(false); - expect(bystander.polluted).toBeUndefined(); - }); - it("still passes legitimate un-shaped usage keys through verbatim", () => { const shaping = resolveOpenRouterShaping(usageWithProtoKeys(), "openai/gpt-4o"); const extras = shaping.usageExtras as Record; From a64adcfd7930e784ec8525b74f4d813788fba08d Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:06:55 -0700 Subject: [PATCH 09/15] docs: correct usage/cost record-replay docs and comments (#369) - record-replay page: stream-collapse summary now mentions the captured final usage frame and ordered blocks; usage/cost example annotated so cost is only claimed to round-trip on OpenRouter-endpoint replay. - CHANGELOG: reword the persist bullet so the modelled non-numeric fields (cost_details/*_details objects, is_byok boolean) are described as kept by shape and the drop rule applies only to off-shape/non-finite fields, matching sanitizeRecordedUsage + the fixture-loader validator. - recorder.ts comments: state that AudioResponse has no usage slot (audio branch cannot carry usage) and that stream/non-stream usage parity is OpenAI-compatible-only. --- CHANGELOG.md | 2 +- docs/record-replay/index.html | 25 ++++++++++++++++++------- src/recorder.ts | 17 ++++++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9385f7f0..42cb229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Recorded OpenAI/OpenRouter token usage — including OpenRouter `usage.cost` (#368).** Collapsing a streaming OpenAI-compatible chat completion previously dropped the final usage frame (the `chat.completion.chunk` with an empty `choices` array and a populated `usage`), because the collapser skipped every chunk without choices. A recorded fixture therefore kept content / reasoning / tool calls / timings but no token counts at all, and replay could only ever serve the `ceil(length / 4)` estimate or a hand-authored `response.usage` override. OpenRouter's provider-reported `cost` was never captured, so an app that bills from real provider cost could not e2e-test its wallet/ledger path from a tape (the same gap #269 closed for fal's `x-fal-billable-units`). - **Record:** `collapseOpenAISSE` now captures the last non-null `usage` object on the stream (`CollapseResult.usage`), and the non-streaming recorder captures the completion envelope's `usage`. - - **Persist:** the recorder writes it to the fixture's `response.usage`, passing through the standard token fields plus `cost`, `cost_details`, `prompt_tokens_details`, `completion_tokens_details`, `is_byok`, and unmodelled provider extras such as OpenRouter's `native_tokens_*`. At persist time the recorder drops non-numeric / unknown-shaped fields, so a recorded fixture always passes the load-time validator — a separate stage that independently rejects a non-numeric extra `usage` scalar in a hand-authored fixture (see `fixture-loader.ts`). **Back-compatible:** a stream that reported no usage records no `usage` key and the fixture stays byte-identical to before. + - **Persist:** the recorder writes it to the fixture's `response.usage` via `sanitizeRecordedUsage`, which keeps each field by its documented shape: the numeric token scalars (`cost` among them) and any unmodelled scalar extra such as OpenRouter's `native_tokens_*`, but only when finite; the `cost_details` / `prompt_tokens_details` / `completion_tokens_details` objects, keeping their finite-numeric inner scalars; and the `is_byok` boolean. What it drops is anything that fits none of those shapes — an unmodelled extra that is not a finite number, or a modelled field of the wrong type — so a recorded fixture always passes the load-time validator (a separate stage in `fixture-loader.ts` that type-checks each documented field per its type and independently rejects, for example, a non-numeric `usage` scalar in a hand-authored fixture). **Back-compatible:** a stream that reported no usage records no `usage` key and the fixture stays byte-identical to before. - **Replay:** recorded counts win over estimation (existing `resolveUsage` precedence), and OpenRouter-shaped responses emit the recorded `cost` / breakdowns on both the final streaming usage chunk and the non-streaming envelope. `ResponseOverrides.usage` accepts forward-compat extra keys, and OpenRouter shaping now passes any such key through verbatim rather than dropping it. - **Note:** capturing cost requires the recorded request to actually elicit a usage frame — `stream_options: { include_usage: true }` on OpenAI-compatible streams (OpenRouter sends it regardless), or a non-streaming response. Plain OpenAI (`/v1/...`) replays continue to emit token counts only; `cost` is OpenRouter-shaped output. diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index b07eb978..5179fd1a 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -426,8 +426,14 @@

Stream Collapsing

- The collapse extracts text content and tool calls from streaming chunks and produces a - simple { content } or { toolCalls } fixture response. + The collapse extracts text content and tool calls from streaming chunks — plus, for + OpenAI-compatible streams, the provider-reported token usage from the final + usage frame — and produces a fixture response such as { content }, + { toolCalls }, or { content, toolCalls, usage }. Genuinely + tool-first or interleaved streams additionally gain an ordered + blocks array. See + Recording Block Order and + Recording Token Usage & Cost below.

Recording Block Order

@@ -472,11 +478,16 @@

Recording Token Usage & Cost

}

On replay those counts are served verbatim instead of aimock's - ceil(length / 4) estimate, and OpenRouter-shaped responses re-emit - usage.cost — so a test can assert a wallet or ledger deduction against the - amount the provider actually charged. Extra provider fields (cost_details, - prompt_tokens_details, completion_tokens_details, - native_tokens_*, …) round-trip too. + ceil(length / 4) estimate. The usage.cost shown above is + OpenRouter-shaped output: it is re-emitted only when the fixture is replayed through + aimock's OpenRouter endpoint + (/api/v1/chat/completions), so a test can assert a wallet or ledger deduction + against the amount the provider actually charged. On a plain OpenAI (/v1/…) + replay the cost key is still recorded and validated on the fixture but is + not served back (token counts round-trip while cost stays inert), so + copy this example into an OpenRouter fixture if you need the cost to appear. Extra + provider fields (cost_details, prompt_tokens_details, + completion_tokens_details, native_tokens_*, …) round-trip too.

Record with usage enabled to get cost. If the recorded request did not diff --git a/src/recorder.ts b/src/recorder.ts index 43c5f6d6..661f01e7 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -697,8 +697,13 @@ export async function proxyAndRecord( // the REAL token counts instead of the `ceil(len/4)` estimate, and so // OpenRouter's `usage.cost` round-trips to the replayed usage chunk / // completion envelope. Omitted when the stream carried no usage, keeping - // every pre-#368 recorded fixture byte-identical. The transcription and - // audio shapes carry their own usage slots and are left untouched. + // every pre-#368 recorded fixture byte-identical. Transcription fixtures + // carry their own `usage` slot, so the transcription branch below is left + // untouched. The audio shape (`AudioResponse` in types.ts) has NO usage + // field, so the audio branch cannot carry `usage` at all — inert today (a + // collapse never yields both `audioB64` and a stream usage frame), but a + // future collapser that set both would drop the usage on the audio path + // with no compiler signal. const collapsedUsage = sanitizeRecordedUsage(collapsed.usage); const usageSpread = collapsedUsage ? { usage: collapsedUsage } : {}; // Audio from streamed inlineData (e.g. Gemini SSE with audio parts). @@ -1839,9 +1844,11 @@ function buildFixtureResponse( : undefined; // Provider-reported usage from the non-streaming envelope (#368), at - // parity with the collapsed-stream path: replay then serves the real token - // counts rather than the `ceil(len/4)` estimate, and OpenRouter's - // `usage.cost` survives recording. Omitted when upstream sent no usage. + // parity with the collapsed-stream path (both capture usage for + // OpenAI-compatible providers only — only `collapseOpenAISSE` sets + // `CollapseResult.usage`): replay then serves the real token counts rather + // than the `ceil(len/4)` estimate, and OpenRouter's `usage.cost` survives + // recording. Omitted when upstream sent no usage. const openaiUsage = sanitizeRecordedUsage(obj.usage); const usageSpread = openaiUsage ? { usage: openaiUsage } : {}; From a1c359d2fea492a499982bd1f1ca7e818324cced Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:15:22 -0700 Subject: [PATCH 10/15] test(openrouter): exercise the prototype leg of the proto-pollution guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proto-safety fixture carried __proto__, constructor, and native_tokens_prompt but never a prototype key, so the assertion that prototype is not passed through onto usageExtras was vacuously green — an impl regression dropping "prototype" from UNSAFE_PROTO_KEYS would still pass. Add a prototype own key to the JSON-parsed usage fixture so the guard's prototype leg is actually driven. --- src/__tests__/openrouter-passthrough-proto.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/__tests__/openrouter-passthrough-proto.test.ts b/src/__tests__/openrouter-passthrough-proto.test.ts index 06fb3efa..c24187ff 100644 --- a/src/__tests__/openrouter-passthrough-proto.test.ts +++ b/src/__tests__/openrouter-passthrough-proto.test.ts @@ -17,14 +17,15 @@ import type { ResponseOverrides } from "../types.js"; // --------------------------------------------------------------------------- /** - * Build a usage override whose extras include a `__proto__` (and `constructor`) - * OWN key. A source-level object literal cannot express this — `{ __proto__: … }` - * sets the prototype instead of an own key — so parse it from JSON, exactly as - * the recorder would load a captured upstream usage frame. + * Build a usage override whose extras include a `__proto__`, `constructor`, and + * `prototype` OWN key. A source-level object literal cannot express the first + * of these — `{ __proto__: … }` sets the prototype instead of an own key — so + * parse it from JSON, exactly as the recorder would load a captured upstream + * usage frame. All three keys exercise a distinct leg of `UNSAFE_PROTO_KEYS`. */ function usageWithProtoKeys(): ResponseOverrides { const usage = JSON.parse( - '{"__proto__":{"polluted":true},"constructor":{"polluted":true},"native_tokens_prompt":1200}', + '{"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true},"native_tokens_prompt":1200}', ) as ResponseOverrides["usage"]; return { usage }; } From cc4d574546fdbbc3f8007f52186b94b0a0423476 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:14:40 -0700 Subject: [PATCH 11/15] docs: clarify recorded usage is orthogonal to response shape (#369) --- docs/record-replay/index.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index 5179fd1a..f8d2e318 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -426,11 +426,13 @@

Stream Collapsing

- The collapse extracts text content and tool calls from streaming chunks — plus, for - OpenAI-compatible streams, the provider-reported token usage from the final - usage frame — and produces a fixture response such as { content }, - { toolCalls }, or { content, toolCalls, usage }. Genuinely - tool-first or interleaved streams additionally gain an ordered + The collapse extracts text content and tool calls from streaming chunks and produces a + fixture response such as { content }, { toolCalls }, or + { content, toolCalls }. For OpenAI-compatible streams it also captures the + provider-reported token usage from the final usage frame and attaches it to + whichever of those shapes results — usage is recorded independently of + whether the response carries content, tool calls, or both. Genuinely tool-first or + interleaved streams additionally gain an ordered blocks array. See Recording Block Order and Recording Token Usage & Cost below. From 7965cefef87f45533b96dc44c4222c025860ab08 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:55:10 -0700 Subject: [PATCH 12/15] fix(record): account unkeyable Bedrock tool_use starts as dropped chunks --- src/__tests__/stream-collapse.test.ts | 37 ++++++++++++++++ src/stream-collapse.ts | 62 +++++++++++++++++++-------- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index 0843fb3e..7c015aa1 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -4373,3 +4373,40 @@ describe("stream block-order instrumentation (#274)", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Stream-collapse integrity: uncorrelated tool_use-start accounting (Bedrock) +// --------------------------------------------------------------------------- + +describe("collapseBedrockEventStream uncorrelated tool_use start accounting", () => { + // A Bedrock native tool_use content_block_start with an undefined block index + // cannot be keyed, so it must be accounted as a dropped chunk (matching the + // sibling arg-delta path), never silently lose the tool call's identity. + it("native: tool_use content_block_start with no index is accounted, not silently lost", () => { + const frame = encodeEventStreamMessage("chunk", { + type: "content_block_start", + // No `index` — the tool call's identity cannot be keyed. + content_block: { type: "tool_use", id: "toolu_x", name: "get_weather", input: {} }, + }); + const result = collapseBedrockEventStream(Buffer.from(frame)); + // Either captured or counted as dropped — but NOT silently vanished. + const captured = (result.toolCalls?.length ?? 0) > 0; + const accounted = (result.droppedChunks ?? 0) > 0; + expect(captured || accounted).toBe(true); + // Sibling accounting behavior: an unkeyable tool_use start is a dropped chunk. + expect(result.droppedChunks).toBe(1); + }); + + // The Converse contentBlockStart path shares the same `index !== undefined` + // guard and must account an unkeyable toolUse start too. + it("converse: toolUse contentBlockStart with no index is accounted, not silently lost", () => { + const frame = encodeEventStreamMessage("contentBlockStart", { + // No contentBlockIndex anywhere — the tool call cannot be keyed. + contentBlockStart: { + start: { toolUse: { toolUseId: "tu_1", name: "get_weather" } }, + }, + }); + const result = collapseBedrockEventStream(Buffer.from(frame)); + expect(result.droppedChunks).toBe(1); + }); +}); diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index db5a1a4c..70de81f6 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -1541,14 +1541,29 @@ export function collapseBedrockEventStream(rawBody: Buffer): CollapseResult { if (redactedData !== undefined) { redactedThinking.push(redactedData); } - if (block?.type === "tool_use" && index !== undefined) { - const created = { - id: (block.id as string) ?? "", - name: (block.name as string) ?? "", - arguments: "", - }; - toolCallMap.set(index, created); - orderAtoms.push({ kind: "toolCall", ref: created }); + if (block?.type === "tool_use") { + if (index !== undefined) { + const created = { + id: (block.id as string) ?? "", + name: (block.name as string) ?? "", + arguments: "", + }; + toolCallMap.set(index, created); + orderAtoms.push({ kind: "toolCall", ref: created }); + } else { + // A tool_use start with no block index cannot be keyed, so no later + // input_json_delta can ever correlate to it and the tool call's + // identity is silently lost. Account for it as a dropped chunk — + // matching the sibling uncorrelated arg-delta path below — rather + // than vanishing without a trace. + droppedChunks++; + if (droppedChunks === 1) { + firstDroppedSample = `tool_use content_block_start with no index — tool call identity lost: ${surrogateSafeSlice( + frameStr, + 200, + )}`; + } + } } continue; } @@ -1561,15 +1576,28 @@ export function collapseBedrockEventStream(rawBody: Buffer): CollapseResult { | number | undefined; const start = blockStart.start as Record | undefined; - if (start?.toolUse && index !== undefined) { - const toolUse = start.toolUse as Record; - const created = { - id: (toolUse.toolUseId as string) ?? "", - name: (toolUse.name as string) ?? "", - arguments: "", - }; - toolCallMap.set(index, created); - orderAtoms.push({ kind: "toolCall", ref: created }); + if (start?.toolUse) { + if (index !== undefined) { + const toolUse = start.toolUse as Record; + const created = { + id: (toolUse.toolUseId as string) ?? "", + name: (toolUse.name as string) ?? "", + arguments: "", + }; + toolCallMap.set(index, created); + orderAtoms.push({ kind: "toolCall", ref: created }); + } else { + // Same accounting as the native path: an unkeyable toolUse start + // would lose its identity, so count it as a dropped chunk rather + // than dropping it silently. + droppedChunks++; + if (droppedChunks === 1) { + firstDroppedSample = `contentBlockStart toolUse with no index — tool call identity lost: ${surrogateSafeSlice( + frameStr, + 200, + )}`; + } + } } } From 8dadd3b1dca3cde47a95364c2b6b097f20952d65 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:55:47 -0700 Subject: [PATCH 13/15] fix(record): advance Cohere lastStartKey only after a tool_calls payload --- src/__tests__/stream-collapse.test.ts | 40 +++++++++++++++++++++++++++ src/stream-collapse.ts | 10 +++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index 7c015aa1..1ae868b9 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -4410,3 +4410,43 @@ describe("collapseBedrockEventStream uncorrelated tool_use start accounting", () expect(result.droppedChunks).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// Stream-collapse integrity: Cohere tool-call-start correlation ordering +// --------------------------------------------------------------------------- + +describe("collapseCohereSSE tool-call-start correlation ordering", () => { + // A payload-less tool-call-start must NOT advance lastStartKey; otherwise a + // following index-less tool-call-delta is stolen away from the prior valid + // start and miscounted as a dropped chunk. + it("payload-less tool-call-start does not steal correlation from a prior start", () => { + const body = [ + `event: tool-call-start`, + `data: ${JSON.stringify({ + type: "tool-call-start", + index: 0, + delta: { + message: { + tool_calls: { id: "call_1", type: "function", function: { name: "fn", arguments: "" } }, + }, + }, + })}`, + "", + // A start event carrying NO tool_calls payload and NO index. + `event: tool-call-start`, + `data: ${JSON.stringify({ type: "tool-call-start" })}`, + "", + // An index-less delta that should correlate to the prior valid start (0). + `event: tool-call-delta`, + `data: ${JSON.stringify({ + type: "tool-call-delta", + delta: { message: { tool_calls: { function: { arguments: '{"x":1}' } } } }, + })}`, + "", + ].join("\n"); + const result = collapseCohereSSE(body); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].arguments).toBe('{"x":1}'); + expect(result.droppedChunks).toBeUndefined(); + }); +}); diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 70de81f6..1979dd81 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -1234,9 +1234,6 @@ export function collapseCohereSSE(rawBody: string): CollapseResult { } else { index = nextSyntheticIndex++; } - // Track the most-recent start key (real OR synthetic) so a following - // index-less delta correlates to whichever call just opened. - lastStartKey = index; const delta = parsed.delta as Record | undefined; const message = delta?.message as Record | undefined; const toolCalls = message?.tool_calls as Record | undefined; @@ -1248,6 +1245,13 @@ export function collapseCohereSSE(rawBody: string): CollapseResult { arguments: "", }; toolCallMap.set(index, created); + // Track the most-recent start key (real OR synthetic) so a following + // index-less delta correlates to whichever call just opened. Advance it + // ONLY after confirming this start actually carried a tool_calls payload + // and created an entry; a payload-less start must not steal correlation + // from a prior valid start (which would miscount the next index-less + // delta as a dropped chunk). + lastStartKey = index; // Record the tool atom at the position its tool-call-start arrived; it // references `created` so later tool-call-delta args fill it in place. orderAtoms.push({ kind: "toolCall", ref: created }); From 20477649c10b1eb988f3cefc42f9b122279eedd1 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:56:32 -0700 Subject: [PATCH 14/15] fix(record): correlate index-and-id-less OpenAI tool deltas to last-open call --- src/__tests__/stream-collapse.test.ts | 57 +++++++++++++++++++++++++++ src/stream-collapse.ts | 16 ++++++++ 2 files changed, 73 insertions(+) diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index 1ae868b9..eb3106fc 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -4450,3 +4450,60 @@ describe("collapseCohereSSE tool-call-start correlation ordering", () => { expect(result.droppedChunks).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Stream-collapse integrity: OpenAI index-and-id-less tool-call correlation +// --------------------------------------------------------------------------- + +describe("collapseOpenAISSE index-and-id-less tool-call correlation", () => { + // Arg deltas that omit BOTH index and id must fall back to the last-open + // tool-call key so one call's arguments streamed across multiple such deltas + // concatenate into one tool call instead of fragmenting. + it("arg deltas lacking both index and id concatenate into one tool call", () => { + const body = [ + `data: ${JSON.stringify({ + choices: [{ delta: { tool_calls: [{ function: { name: "foo", arguments: '{"a":' } }] } }], + })}`, + "", + `data: ${JSON.stringify({ + choices: [{ delta: { tool_calls: [{ function: { arguments: "1}" } }] } }], + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const result = collapseOpenAISSE(body); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("foo"); + expect(result.toolCalls![0].arguments).toBe('{"a":1}'); + }); + + // Distinct calls still separate when each carries its own id, and a trailing + // bare delta correlates to whichever call opened most recently. + it("bare delta correlates to the last-open call, not a merge of all calls", () => { + const body = [ + `data: ${JSON.stringify({ + choices: [ + { delta: { tool_calls: [{ id: "a", function: { name: "fa", arguments: '{"x":1}' } }] } }, + ], + })}`, + "", + `data: ${JSON.stringify({ + choices: [ + { delta: { tool_calls: [{ id: "b", function: { name: "fb", arguments: '{"y":' } }] } }, + ], + })}`, + "", + `data: ${JSON.stringify({ + choices: [{ delta: { tool_calls: [{ function: { arguments: "2}" } }] } }], + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const result = collapseOpenAISSE(body); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls![0]).toMatchObject({ name: "fa", arguments: '{"x":1}' }); + expect(result.toolCalls![1]).toMatchObject({ name: "fb", arguments: '{"y":2}' }); + }); +}); diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 1979dd81..85336c8c 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -404,6 +404,14 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { // it (they are small per-stream counters), so synthetic keys never collide. let nextSyntheticIndex = 1_000_000; const idKeyMap = new Map(); + // The key of the most-recently-opened tool call (real, id-correlated, or + // synthetic). A delta that omits BOTH `index` and `id` carries no identity of + // its own; without a fallback each such fragment would mint a fresh synthetic + // key, splitting one call's arguments streamed across multiple bare deltas + // into separate tool-call entries with no drop/truncation signal. Correlate + // it to the last-open call instead (real OpenAI always streams `index`, so + // this only affects the degenerate index-and-id-less case). + let lastToolCallKey: number | undefined; // Cross-channel order atoms (#274), in stream arrival order. A toolCall atom // references the same accumulator object stored in toolCallMap, so later arg // deltas mutate the block in place. @@ -540,9 +548,17 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { index = nextSyntheticIndex++; idKeyMap.set(rawId, index); } + } else if (lastToolCallKey !== undefined) { + // No `index` AND no `id`: correlate to the last-open tool call so a + // call's arguments streamed across multiple bare deltas concatenate + // into one entry instead of fragmenting under fresh synthetic keys. + index = lastToolCallKey; } else { index = nextSyntheticIndex++; } + // Remember this key so a following index-and-id-less delta correlates + // to whichever call most recently opened. + lastToolCallKey = index; if (!toolCallMap.has(index)) { const created = { From 2bde1954bad3459e77351114f9c32c4789bba825 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 12 Aug 2026 10:56:58 -0700 Subject: [PATCH 15/15] fix(record): guard transcription usage against array and empty-object values --- src/__tests__/stream-collapse.test.ts | 32 +++++++++++++++++++++++++++ src/stream-collapse.ts | 10 ++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index eb3106fc..d00a01fa 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -4507,3 +4507,35 @@ describe("collapseOpenAISSE index-and-id-less tool-call correlation", () => { expect(result.toolCalls![1]).toMatchObject({ name: "fb", arguments: '{"y":2}' }); }); }); + +// --------------------------------------------------------------------------- +// Stream-collapse integrity: transcription usage capture guards +// --------------------------------------------------------------------------- + +describe("collapseOpenAISSE transcription usage capture guards", () => { + // An array-valued usage is a type lie once cast to a Record — never capture it. + it("array-valued usage is not captured", () => { + const result = collapseOpenAISSE( + 'data: {"type":"transcript.text.done","text":"hi","usage":[]}\n\n', + ); + expect(result.transcription).toBeDefined(); + expect(result.transcription!.usage).toBeUndefined(); + }); + + // An empty usage object carries no information — never capture it. + it("empty usage object is not captured", () => { + const result = collapseOpenAISSE( + 'data: {"type":"transcript.text.done","text":"hi","usage":{}}\n\n', + ); + expect(result.transcription).toBeDefined(); + expect(result.transcription!.usage).toBeUndefined(); + }); + + // A real, non-empty usage object is still captured. + it("non-empty usage object is still captured", () => { + const result = collapseOpenAISSE( + 'data: {"type":"transcript.text.done","text":"hi","usage":{"total_tokens":5}}\n\n', + ); + expect(result.transcription!.usage).toEqual({ total_tokens: 5 }); + }); +}); diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 85336c8c..142ceedd 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -455,7 +455,15 @@ export function collapseOpenAISSE(rawBody: string): CollapseResult { ) .map((language) => ({ code: language.code as string })); } - if (parsed.usage && typeof parsed.usage === "object") { + // Only capture a real usage OBJECT. `typeof === "object"` alone admits an + // array (a type lie once cast to Record) and an empty `{}` (meaningless); + // guard against both, exactly like any chat usage capture would. + if ( + parsed.usage && + typeof parsed.usage === "object" && + !Array.isArray(parsed.usage) && + Object.keys(parsed.usage).length > 0 + ) { transcriptUsage = parsed.usage as Record; } continue;