Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## [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` 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.

### 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.
Expand Down
54 changes: 52 additions & 2 deletions docs/record-replay/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ <h2>Stream Collapsing</h2>
<tbody>
<tr>
<td>OpenAI SSE</td>
<td>OpenAI, Azure</td>
<td>OpenAI, Azure, OpenRouter</td>
<td><code>text/event-stream</code></td>
</tr>
<tr>
Expand Down Expand Up @@ -427,7 +427,15 @@ <h2>Stream Collapsing</h2>
</table>
<p>
The collapse extracts text content and tool calls from streaming chunks and produces a
simple <code>{ content }</code> or <code>{ toolCalls }</code> fixture response.
fixture response such as <code>{ content }</code>, <code>{ toolCalls }</code>, or
<code>{ content, toolCalls }</code>. For OpenAI-compatible streams it also captures the
provider-reported token <code>usage</code> from the final usage frame and attaches it to
whichever of those shapes results &mdash; <code>usage</code> is recorded independently of
whether the response carries content, tool calls, or both. Genuinely tool-first or
interleaved streams additionally gain an ordered
<a href="/fixtures#ordered-blocks"><code>blocks</code></a> array. See
<a href="#recording-block-order">Recording Block Order</a> and
<a href="#recording-usage">Recording Token Usage &amp; Cost</a> below.
</p>

<h2 id="recording-block-order">Recording Block Order</h2>
Expand All @@ -449,6 +457,48 @@ <h2 id="recording-block-order">Recording Block Order</h2>
faithfully block order is reconstructable on each provider's wire.
</p>

<h2 id="recording-usage">Recording Token Usage &amp; Cost</h2>
<p>
OpenAI-compatible recordings (including <a href="/openrouter-chat">OpenRouter</a>) keep
the provider's reported <code>usage</code> on the fixture. For a non-streaming call that
is the envelope's <code>usage</code> object; for a stream it is the final
<code>chat.completion.chunk</code> — the one with an empty <code>choices</code> array —
that OpenAI emits when the request sets
<code>stream_options: { include_usage: true }</code> (OpenRouter emits it either way).
</p>
<pre><code>{
"match": { "userMessage": "summarize this" },
"response": {
"content": "…",
"usage": {
"prompt_tokens": 1234,
"completion_tokens": 567,
"total_tokens": 1801,
"cost": 0.0042
}
}
}</code></pre>
<p>
On replay those counts are served verbatim instead of aimock's
<code>ceil(length / 4)</code> estimate. The <code>usage.cost</code> shown above is
OpenRouter-shaped output: it is re-emitted only when the fixture is replayed through
aimock's <a href="/openrouter-chat">OpenRouter</a> endpoint
(<code>/api/v1/chat/completions</code>), so a test can assert a wallet or ledger deduction
against the amount the provider actually charged. On a plain OpenAI (<code>/v1/…</code>)
replay the <code>cost</code> key is still recorded and validated on the fixture but is
<em>not</em> served back (token counts round-trip while <code>cost</code> stays inert), so
copy this example into an OpenRouter fixture if you need the cost to appear. Extra
provider fields (<code>cost_details</code>, <code>prompt_tokens_details</code>,
<code>completion_tokens_details</code>, <code>native_tokens_*</code>, …) round-trip too.
</p>
<p>
<strong>Record with usage enabled to get cost.</strong> If the recorded request did not
ask for usage — and the provider therefore never sent a usage frame — the fixture is
written without a <code>usage</code> key and replay falls back to estimated token counts,
exactly as before. You can always hand-author <code>response.usage</code> on a fixture
instead.
</p>

<h2>Header Forwarding</h2>
<p>
When proxying to upstream providers, aimock forwards the original request's headers except
Expand Down
Loading
Loading