What changed, and why
The go-to-market plan sells runs — one playthrough is a bounded bundle of roughly 20–26 AI calls — and prices packs at 3–5x the p95 cost per run. That number can't be computed today: the AI gateway already logs one line per call with token counts, but nothing ties a call to a run, and nothing turns tokens into dollars.
This PR adds both, and only those. A run id is minted on the client and rides every AI request as a header; the server binds it per request and the gateway stamps it onto each log line along with the call's computed cost. Group the lines by run id, sum the cost, and you have cost-per-run. No database, no new dependency — the change is telemetry enrichment plus the thread that carries the id from the browser to the log line.
The run id, bound per request
The id has to reach the gateway's emit(), which sits several wrapper calls below each route handler. Threading it through every signature would be noisy, so it travels in request-scoped storage instead. AsyncLocalStorage gives each Nitro request its own context: bind the id once and every awaited call in that request can read it, with no leakage across requests.
Bind with enterWith (the middleware), read with currentRunId (the gateway).
server/utils/run-context.ts · 30 lines
⋯ 15 lines hidden (lines 1–15)
A middleware binds it once per request, reading the x-run-id header. The id is untrusted client input that lands in a log line, so it is stripped to id-safe characters and length-bounded before use. Requests with no header (static assets, the dashboard) simply leave the context unset.
Header in, sanitized, bound for the rest of the request.
server/middleware/run-context.ts · 17 lines
⋯ 12 lines hidden (lines 1–12)
The rate card, and per-call cost
Cost needs a price per model. The rate card is one constant — USD per million tokens, per model — and costUsd applies it. An unpriced model returns undefined rather than a guess, so a missing rate shows up as uncounted instead of silently low.
The rate card and the cost function; note the per-lane input handling.
server/utils/cost.ts · 51 lines
⋯ 25 lines hidden (lines 1–25)
⋯ 1 line hidden (lines 51–51)
Stamping the telemetry line
The gateway is the single transport for every structured model call, so one edit to its emit() reaches all of them. Enrichment happens in one place: both the success and failure call sites get the run id, and the cost rides only on calls that reported usage. The eval-harness observer sees the same enriched line the log does.
Two new optional telemetry fields; emit() fills them once, centrally.
server/utils/ai-gateway.ts · 239 lines
⋯ 75 lines hidden (lines 1–75)
⋯ 9 lines hidden (lines 83–91)
⋯ 132 lines hidden (lines 108–239)
Carrying the id from the client
The browser owns the run boundary, so it mints the id. aiHeaders lazily creates one on a run's first server call and returns it as x-run-id, preserving any base headers; generateRunId prefers crypto.randomUUID and falls back for the rare insecure context. All seven AI fetches in the store route through aiHeaders, and resetGame clears the id so each run gets a fresh one.
Mint (generateRunId), tag (aiHeaders), and reset (resetGame) — the run's lifecycle.
stores/game.ts · 1025 lines
⋯ 167 lines hidden (lines 1–167)
⋯ 284 lines hidden (lines 179–462)
⋯ 525 lines hidden (lines 471–995)
⋯ 26 lines hidden (lines 1000–1025)
How it's verified
The pricing math and the lane convention are unit-tested in cost.spec; the request binding in run-context.spec; the client lifecycle in the store spec. The claim that matters most — that a real call actually emits both fields — is proven against the gateway with a mocked SDK, so the full path (bound context → gateway → enriched line) runs without a live key.
The observer sees a priced, run-tagged line for a real completeStructured call.