Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2d39546
feat(record): capture and replay OpenAI/OpenRouter stream usage incl.…
claude Aug 11, 2026
6c37851
fix(record): route the openrouter provider key to the OpenAI SSE coll…
claude Aug 12, 2026
5150341
fix: own-key usage-field classification in sanitizeRecordedUsage
jpr5 Aug 12, 2026
cb0b998
fix(record): ignore empty usage frames so a trailing usage:{} cannot …
jpr5 Aug 12, 2026
bdfcfbd
fix(openrouter): make usage passthrough prototype-safe
jpr5 Aug 12, 2026
f3e40e7
test(record): register round-trip recorder with drained cleanup
jpr5 Aug 12, 2026
45b7f7c
docs(record): correct OpenRouter SSE/usage doc accuracy
jpr5 Aug 12, 2026
dca6a94
test(openrouter): remove vacuous bystander prototype-pollution test
jpr5 Aug 12, 2026
a64adcf
docs: correct usage/cost record-replay docs and comments (#369)
jpr5 Aug 12, 2026
a1c359d
test(openrouter): exercise the prototype leg of the proto-pollution g…
jpr5 Aug 12, 2026
cc4d574
docs: clarify recorded usage is orthogonal to response shape (#369)
jpr5 Aug 12, 2026
7965cef
fix(record): account unkeyable Bedrock tool_use starts as dropped chunks
jpr5 Aug 12, 2026
8dadd3b
fix(record): advance Cohere lastStartKey only after a tool_calls payload
jpr5 Aug 12, 2026
2047764
fix(record): correlate index-and-id-less OpenAI tool deltas to last-o…
jpr5 Aug 12, 2026
2bde195
fix(record): guard transcription usage against array and empty-object…
jpr5 Aug 12, 2026
30d73ac
Merge origin/main into claude/pr-review-n6v600
jpr5 Sep 8, 2026
3d665a4
fix(test): type the recorded-fixture helper as the on-disk entry it r…
jpr5 Sep 8, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

- **AG-UI subagent lifecycle events, and `subagentRunId` attribution mirrored where canonical declares it.** Upstream `@ag-ui/core` added `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` / `SUBAGENT_ERROR` and threaded an optional `subagentRunId` through the events a subagent can emit; aimock's AG-UI types carried none of it. The three literals now join `AGUIEventType` and the `AGUIEvent` union, backed by `AGUISubagentStartedEvent` (required `subagentRunId` and `name`, optional `description` / `parentSubagentRunId`, plus `parentToolCallId` / `parentMessageId` so an agents-as-tools subagent can be correlated to the call that spawned it without inspecting `rawEvent.metadata`), `AGUISubagentFinishedEvent` (optional `result`, plus an `AGUISubagentFinishedOutcome` of `{ type: "success" }` or `{ type: "suspended"; interruptIds?: string[] }` — `AGUIRunFinishedOutcome` one level down, where absent still means success, `interruptIds` names only the run-level interrupts that subagent directly owns, and unlike `RUN_FINISHED.outcome` the field postdates the valueless-field cleanup so it never tolerates `null`), and `AGUISubagentErrorEvent` (`message`, optional `code`). `subagentRunId` itself is declared **per event, not on a shared base type**, because that is how canonical declares it: optional on 24 event interfaces, required on the three subagent events, and deliberately absent from `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR`, `MESSAGES_SNAPSHOT` and the deprecated `THINKING_*` family. Putting it once on `AGUIBaseEvent` would have cleared the drift report while granting the field to events canonical does not give it. The same optional field is mirrored onto `AGUIMessage` and `AGUIInterrupt`. Canonical `types.ts` declares it INDEPENDENTLY on `BaseMessageSchema`, `ToolMessageSchema`, `ActivityMessageSchema` and `ReasoningMessageSchema` — the latter three are standalone `z.object`s that do NOT extend the base, so there is no inheritance carrying the field to them — and on `InterruptSchema`; aimock deliberately flattens the canonical message union into one `role`-discriminated `AGUIMessage`, which is why two edits cover all four message declarations. So a replayed message or approval request keeps the attribution that lets a client group it under the subagent that produced it instead of reading as root-raised. All four new names — the three event interfaces and `AGUISubagentFinishedOutcome` — are exported from both the package root and the `agui-stub` entrypoint, guarded by a public-surface test that fails whenever any `agui-types.ts` type is missing from either barrel. The `AGUIMessage` / `AGUIInterrupt` mirroring is NOT guarded: the existing drift suite walks `*EventSchema` declarations only and cannot see the non-event schemas, so nothing fails if that field is later dropped — closing that gap is deferred to the drift-harness rewrite rather than bolted on here. **Scope: this is a type surface only.** Unlike 1.39.0's `AGUITokenUsage`, which was reachable through `AGUIBuildOpts`, no RUNTIME code CONSUMES these types — they are exported and guarded, but `agui-handler.ts` gained no builder and no build option, so aimock can TYPE a subagent event but cannot yet EMIT one. Every added field is optional or sits on a new type, so existing fixtures and callers are byte-identical (#391)

- **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.

### Changed

- **The AG-UI drift CI lane runs every `agui-*.drift.ts` guard, not one hardcoded file.** The `agui-schema-drift` job in `.github/workflows/test-drift.yml` and `runAgUiDriftTests` in `scripts/drift-report-collector.ts` both invoked `npx vitest run src/__tests__/drift/agui-schema.drift.ts`. That positional argument is a vitest FILENAME FILTER (substring match on the test file path), not a single file, so both now pass the path prefix `src/__tests__/drift/agui-` and every AG-UI drift file runs — one today, and any future guard automatically — under a documented naming contract: an AG-UI drift guard MUST be named `src/__tests__/drift/agui-<something>.drift.ts` to land in this lane. The workflow's `paths:` trigger widens to `src/__tests__/drift/agui-*.drift.ts` to match. `vitest.config.drift.ts` already scopes `include` to `src/__tests__/drift/**/*.drift.ts`, so the prefix cannot pull in `*.test.ts` helpers from the same directory, and the lane still needs no provider API keys — these files read `src/agui-types.ts` and the cloned canonical `../ag-ui` checkout only, unlike the credit-burning `drift` leg. The collector retains the single filename as report metadata (the file a human opens when drift is reported), no longer as a run target.
Expand All @@ -17,6 +23,8 @@

- **The AG-UI drift suite's canonical-schema parser no longer drops the field declared after a trailing comment.** `extractExtendFields` stripped whole-line comments only (`/^\s*\/\/.*$/gm`). Upstream's `STATE_DELTA` declares `delta: z.array(z.any()), // JSON Patch (RFC 6902)`, and after the top-level comma split that trailing comment heads the NEXT entry, so the field-name match failed and the entry was discarded silently — which is why the drift report read canonical as declaring `subagentRunId` on 23 events rather than 24, and flagged `STATE_DELTA.subagentRunId` as aimock-only. Comments are now stripped wherever they appear; no canonical schema literal contains `//`, so the unconditional strip is safe. This is drift-harness tooling, not runtime behavior — no published surface changes (#391)

- **`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.

## [1.39.0] - 2026-08-18

### Added
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