Skip to content

Observability: truthful token accounting, failure records, and stream stall diagnostics - #30

Merged
cvvz merged 3 commits into
mainfrom
observability/token-accounting-and-failure-records
Jul 29, 2026
Merged

Observability: truthful token accounting, failure records, and stream stall diagnostics#30
cvvz merged 3 commits into
mainfrom
observability/token-accounting-and-failure-records

Conversation

@cvvz

@cvvz cvvz commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Why

Three separate things the dashboard could not tell you, all traced back to the same root: usage and failure state were only ever read on the success path.

  1. IN showed 2 for a request carrying a 348,483-token prompt.
  2. A failed request left no trace at all — not in the dashboard, not in error.log. Diagnosing one required a packet capture.
  3. A stalled stream could not be attributed. When a client reports Response stalled mid-stream, nothing recorded whether the upstream had gone quiet or the proxy had.

Chasing #3 turned up a real bug on the way (see Non-2xx guard below) and ended in a measured explanation of the stall, included at the bottom because it is what several of these fields exist to capture.

What changed

Token accounting

Anthropic's three input buckets — input_tokens, cache_read_input_tokens, cache_creation_input_tokens — are disjoint, so the real prompt size is their sum. Reading input_tokens alone reports single digits for a fully-cached Claude Code turn, because cache_control covers nearly the entire prompt including the newest message. That was the IN = 2.

The three protocols disagree on how they slice the total, so there are three extractors rather than one shared misreading:

Protocol Semantics
Anthropic three disjoint buckets, must be summed
OpenAI prompt_tokens already contains its cached subset — summing double-counts
Responses API Anthropic's key names, OpenAI's semantics — the trap that motivated splitting these apart

Cost now reprices the cached buckets (read 0.1x, write 1.25x) instead of charging every input token at the full rate. Premium requests come from the catalog's billing.multiplier; a model the catalog never priced is left out rather than assumed to cost a full request.

All ratio-valued fields were put on one 0–1 scale (ratio_2dp), which previously mixed percentages and fractions across the API surface.

Non-2xx guard on the passthrough path

is_streamable_status() now gates all five streaming functions. Four already had the check inline; stream_anthropic_directthe one path Claude Code takes — did not. A non-2xx upstream was therefore parsed as SSE and reached the client as a 200 text/event-stream whose body was a JSON error object, yielding no events at all. To the client that is indistinguishable from a stalled stream, which is why the real status code never surfaced.

Extracting the predicate means "which statuses may be streamed" now has a single definition point, and is unit-tested.

Failure records

  • StreamRecorder + Drop. axum drops the response body when a client disconnects, which drops the async_stream generator, which meant the store.add after the loop never ran — a client that gave up left zero evidence. Drop is the only guaranteed path, and RequestStore::add uses a sync Mutex, so it is callable from there.
  • record_failure() covers the pre-flight failures (token refresh, rate gate, connect errors) that previously returned early without recording.
  • failure_kind names which step broke: precondition_failed / connect_error / upstream_status / stream_interrupted / client_disconnected.
  • Failure records keep the partial upstream body. That partial is the main evidence for why a client gave up.

Stream diagnostics

  • upstream_idle_max_ms (via util::IdleTracker) — longest silence between two upstream chunks. This is the field that assigns blame: large means the upstream went quiet, small means it kept sending and the stall was downstream.

  • keepalive_probes — how many keepalives actually went out during that silence, shared with the recorder through an Arc<AtomicU32>. Pairs with the above: long idle + probes sent means the proxy kept signalling and the client ignored it; long idle + zero probes means the keepalive itself failed.

  • Keepalive correctness. Probes are now emitted unconditionally by holding back partial events so the downstream always sits on an event boundary (last_event_boundary). Previously a probe could be skipped exactly when the stream was mid-event — which is precisely during a stall.

  • output_tokens_final. On an Anthropic stream the authoritative output_tokens arrives only in the closing message_delta. A record finalized before that carries the opening placeholder from message_start3, for a turn that went on to emit 35,899. The dashboard now renders rather than presenting that placeholder as fact. Tri-state: Some(false), Some(true) → the number, None → unchanged behaviour for paths that do not track it.

    stop_reason looks like it would serve as this predicate and does not: the OpenAI and translated paths leave it None on success too, so using it would blank out their healthy records.

  • DirectStreamState collects the five mutable generator locals into one plain value, so the whole SSE-event rulebook is testable without standing up a stream.

Session tracking

session_id, parsed out of metadata.user_id (a nested JSON string). Several Claude Code instances can share one proxy; without it their records interleave with no way to tell them apart. Clickable in the dashboard to filter.

Dashboard

  • Columns: Time · Session · Endpoint · Model · Status · In · New · Out · Idle · Probes · Duration
  • In shows total prompt size plus cache hit share (905.9k (98%)); Idle highlights past 60s
  • Failure rows tinted, failure_kind shown inline, "Failures only" filter
  • Scroll position preserved across refresh (bodies used to jump back to the top mid-read), and copy buttons on request/response bodies
  • Stats cards gained premium requests, cache hit rate, cache writes

Tooling

scripts/replay.py replays any captured request — at the upstream directly or back through the proxy — and times the SSE stream event by event. It has no stall watchdog, so it can answer the two questions a client cannot answer for itself: how long an upstream silence actually lasts, and what arrives once it ends.

What this bought, concretely

A reproducible Response stalled mid-stream that had been undiagnosable now records:

in=348,483  out=—  idle=300.0s  probes=19  status=499  client_disconnected

and the retained partial body pins the exact stopping point:

content_block_start   tool_use "Write"
content_block_delta   input_json_delta  partial_json:""
                      ← 300s of silence, then the client gave up

Replaying that same request straight at the upstream, with no watchdog, finished the story:

max_tokens output_tokens longest silence ms/token tool args stop_reason
64000 35,899 329.5s 9.18 85,744 chars tool_use
8000 8,000 78.3s 9.79 0 max_tokens

The upstream was never broken — it buffers input_json_delta until the tool-argument JSON is complete, then flushes it all at once (85,744 chars in 3.3s, far above generation speed). Silence scales linearly with output size at ~9.5ms/token, putting the 300s client threshold at roughly 31,500 output tokens. Zero ping events arrive from the upstream during any of it.

None of this is a proxy defect — it reproduces identically when bypassing the proxy entirely — but every number above came from a field this PR adds.

Testing

  • cargo test126 passed, 0 failed
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo build --release — clean
  • Dashboard render paths exercised against the three output_tokens_final states

🤖 Generated with Claude Code

cvvz and others added 2 commits July 29, 2026 06:14
The dashboard reported an IN of 2 for requests carrying a six-figure
prompt, and a request that failed left no trace at all. Both came from
one place: usage and failure state were only ever read on the success
path.

Token accounting:
- Anthropic's three input buckets (input_tokens, cache_read_*,
  cache_creation_*) are disjoint, so the real prompt size is their sum.
  Reading input_tokens alone reports single digits for a fully-cached
  Claude Code turn, because cache_control covers almost the whole prompt.
- OpenAI's prompt_tokens already contains its cached subset, and the
  Responses API reuses Anthropic's key names with OpenAI's semantics —
  hence three extractors rather than one shared misreading.
- Cost reprices the cached buckets (read 0.1x, write 1.25x) instead of
  charging every input token at the full rate.
- Premium requests come from the catalog's billing.multiplier; an
  unpriced model is omitted rather than assumed to cost a full request.

Failure records:
- is_streamable_status() now gates all five streaming paths.
  stream_anthropic_direct — the one path Claude Code takes — lacked the
  check, so a non-2xx upstream was parsed as SSE and reached the client
  as a 200 that never yields an event.
- StreamRecorder + Drop: axum drops the response body when the client
  disconnects, dropping the async_stream generator with it, so the
  store.add after the loop never ran. Drop is the only guaranteed path.
- failure_kind names which step broke; upstream_idle_max_ms and
  keepalive_probes together assign blame when a stream stalls.
- output_tokens_final marks the count provisional until message_delta
  arrives, so an aborted turn no longer reports the message_start
  placeholder as fact.
- session_id separates records when several clients share one proxy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…viour

Replays any request captured by the dashboard, straight at the upstream
or back through the proxy, and times the SSE stream event by event.

It has no stall watchdog, so it can answer the two questions a client
cannot answer for itself: how long an upstream silence actually lasts,
and what the upstream eventually sends once it ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MartinForReal

Copy link
Copy Markdown
Owner

@codex[agent] fix ci

Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com>
@Codex

Codex AI commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@codex[agent] fix ci

Fixed in 40f26ae. CI was failing on cargo fmt --all --check; I applied rustfmt-only changes in src/server.rs and src/state.rs, and verified locally with fmt/clippy/build/test all passing.

@cvvz
cvvz merged commit 8970c16 into main Jul 29, 2026
1 check passed
@MartinForReal
MartinForReal deleted the observability/token-accounting-and-failure-records branch July 29, 2026 03:46
MartinForReal added a commit that referenced this pull request Jul 29, 2026
…shboard, live quota, read timeout (#31)

* feat: live quota from response headers, upstream read timeout, SSE buffer fixes

Follows the observability work in #30 with four independent changes, plus the
correction that PR's measurements force on one of them.

Live quota with no extra API call
- Copilot attaches per-SKU quota to every response (x-quota-snapshot-chat,
  -completions, -premium_interactions): entitlement, overage, overage-permitted,
  percent remaining, reset date. Parsed on every proxied response and surfaced
  on /health, /metrics (labelled by sku) and /usage. Quota previously required
  /usage, which costs a separate /copilot_internal/user request.
- The headers are undocumented, so a value that stops parsing is ignored rather
  than reported as zero, and the previous reading is left in place. Reporting a
  confident 0 would read as "quota exhausted".

Upstream read timeout
- Without one, a half-open connection yields no data, no error and no
  end-of-stream: the request hangs forever and the stream-interrupted handling
  added in 1.3.0 never runs.
- upstream_read_timeout_seconds bounds the silence *between* reads, not total
  duration, so long streaming answers are unaffected.
- The default is 900s, not the 120s originally written. That figure was reasoned
  from the ~60s idle window the load balancer enforces; #30 measured the real
  upstream buffering input_json_delta until a tool call's argument JSON is
  complete, going 329.5s without a byte and scaling at ~9.5ms/token. 120s would
  have aborted healthy large tool calls.

SSE line buffer
- Reassembling one large event was quadratic: the buffer rescanned everything
  retained on every chunk. A 4 MB event in 4 KB chunks took 7.9s of CPU; the
  search now resumes where the previous one stopped. Correctness was never
  affected, only the cost.
- A single line is capped at 64 MB so an upstream that never emits a newline
  cannot grow the buffer until the process dies.

auto_upgrade defaults to true
- Including for config files written before the key existed, which plain
  #[serde(default)] would have got wrong.
- config_version bumped to 3 so existing files gain both new properties on the
  next start, via the mechanism added in #29.

Also
- Stopped scraping the AUR for the VS Code version; the maintainers asked
  projects to stop. Uses update.code.visualstudio.com instead.
- Removed scripts/__pycache__ from version control and ignored it.
- Backfilled the CHANGELOG entry for #30.

* fix(gemini): map the generation parameters that have counterparts

`gemini_to_openai` forwarded four fields out of `generationConfig` and
dropped the rest silently. A client asking for a fixed `seed` got a
non-deterministic answer, and one asking for JSON output got prose --
neither with any sign the request had been altered on the way through.

Adds the mappings that exist one-for-one: `candidateCount` -> `n`,
`seed`, `presencePenalty`, `frequencyPenalty`, and
`responseMimeType: "application/json"` -> `response_format`, carrying
`responseSchema` across as a JSON schema when one is supplied.

`topK` and `safetySettings` stay dropped on purpose. Neither has an
OpenAI counterpart, and approximating one would be worse than not
honouring them; a test asserts they are not invented.

* feat: responses over websocket, real billing figures, and a dashboard built around them

Three strands that turned out to be one: the dashboard could not answer
"what is this costing me", and fixing that exposed both a transport the
proxy did not speak and several figures it was reporting wrongly.

## Responses over WebSocket

The model catalogue advertises `ws:/responses` on ten models, and the
proxy did not speak it. GitHub publishes no inference API documentation
-- confirmed by search; `docs.github.com` covers Copilot seats and
billing only -- so the protocol was established by probing and is
recorded in `docs/api.md`:

- Same host as `endpoints.api` from the token response, path
  `/responses`. `/v1/responses` also upgrades; `/ws/responses` and
  `/realtime` are 404.
- Auth and headers identical to the HTTP path.
- The client sends one **flat** JSON text frame,
  `{"type":"response.create","model":"...", ...}`. `model` must be top
  level: nesting the request under `"response"` is rejected with
  `[ObjectParam] [input] [invalid_type] ... expected a string, but got
  an object`, and omitting `type` with `unsupported message type:`.
- The server replies with the same `response.*` events as SSE, one per
  frame, each carrying a `sequence_number`.

`GET /v1/responses` with an `Upgrade` header now relays to it, recording
the exchange like any other request. A model that does not advertise the
transport is refused with an error frame naming the endpoint that would
work, rather than being proxied into an upstream rejection.

`scripts/ws_check.py` discovers the capable models from the catalogue
and checks both directions: 85 assertions across 8 models pass, and
`scripts/protocol_check.py` covers the four HTTP surfaces with 72 more.

Body capture on a WebSocket reads the debug flag once, when the socket
opens, and uses that reading for both halves. Re-reading it at record
time -- as the HTTP paths can safely do -- would let a long-lived socket
produce a record with a captured request and no response, or the
reverse.

## Figures that were wrong

- **Output tokens were not comparable across surfaces.** The Responses
  API counts reasoning inside `output_tokens`; a translated Gemini
  response reports it as a disjoint `reasoning_tokens`. Summing them
  blindly produced a reasoning share of **262%** on the dashboard.
  `openai_usage` now normalises on field position -- nested under
  `completion_tokens_details` means already included, flat means
  additive -- so one output total means one thing everywhere.

- **Cost was guessed from a price list.** Copilot states its own
  per-token rate for every token type it charges, in
  `copilot_usage.token_details`, and reports the total as
  `total_nano_aiu` (verified against the rates, 5 of 5). That is now the
  headline figure, replacing an estimate that could not know a model is
  included at no charge.

- **Cache savings used one hard-coded discount for every model.** The
  entries differ: `claude-haiku-4.5` prices cache writes above its input
  rate, `gemini-3.5-flash` at zero, `gpt-5.5` not at all, and
  `gpt-4o-mini` prices nothing because Copilot includes it. The old
  maths claimed a saving on models that are free. Savings are now
  computed per request from the rates that request reported.

- **Failed attempts counted as consumption.** A burst of rejected calls
  inflated `request_count`, added an empty-named row to the cache table,
  and dragged every cache disposition toward "uncached" -- a rate
  computed over requests that consumed nothing. Statistics now cover
  requests that produced an answer, with `failed_requests` reported
  separately. Token and billing totals still count either way: a stream
  cut off partway consumed what it consumed.

- **SSE reassembly covered one and a half protocols.** OpenAI
  `delta.tool_calls[]` fragments and the entire Responses `response.*`
  event family rendered as "(stream produced no content)", and
  `reasoning_text` was discarded outright.

## Dashboard

Split into Overview, Requests and Metrics over a shared stylesheet,
with the landing page ordered by what it costs rather than by what is
easy to count. The recent-requests table moved to its own tab, which is
where it can afford the width to be useful.

Each request now renders as a conversation, with the raw frames and the
protocol-specific parameters behind tabs, and a summary of what the
response actually reported -- different per protocol, because the
protocols report different things.

Two columns were removed rather than fixed. `Probes` was measured on one
of five code paths, so its `—` read as "zero probes sent" on the other
four; it is now in the `Idle` tooltip. `Session` and `New` are
protocol-specific and moved to the detail panel.

The prompt-cache panel states what it does not know. A hit rate of zero
is shown as "nothing was cacheable yet" rather than as a measurement,
and the per-model table distinguishes three states that all used to
render as `0`: a metric the model never reported, one it reported as
zero, and one the surface has no concept of. `Written` is hidden
entirely on a workload that never writes, because only the Anthropic
surface with an explicit `cache_control` marker ever bills a cache
write -- measured across twelve calls with a 27k-token prefix, exactly
one reported one.

Body capture is togglable from the dashboard (`POST /api/config/debug`),
deliberately not persisted: it is a debugging aid, and a config file
that quietly starts recording request bodies after a restart is a
liability.

## Verification

- 134 unit tests, clippy clean under `-D warnings`.
- `scripts/protocol_check.py` 72/72, `scripts/ws_check.py` 85/85.
- Billing and reasoning figures confirmed on all six recording paths
  (four protocols x streamed/not, plus WebSocket).
- Cache savings hand-checked against `token_details`: `gpt-5.5`
  3840 x (500000-50000) = 1728000000, `gemini-3.5-flash`
  4058 x (150000-15000) = 547830000.

* perf(anthropic): measure bodies instead of re-serialising them

Both `/v1/messages` paths serialised whole JSON trees purely to obtain
byte counts, and did it inside their four-attempt retry loops:

    let req_size = serde_json::to_vec(&current).map(|v| v.len())...

The bytes were already in hand. Every other endpoint in the file --
including the translated `/v1beta/models` path -- already does
`let text = resp.text().await; let resp_size = text.len();` and parses
from that string. These two were the outliers.

The figures were also wrong, in two ways:

- `req_size` measured the proxy's version of the request, after system
  prompt injection, the tool-result suffix and the model rename. Every
  other endpoint records what the client sent. The dashboard's "Sent"
  total was adding two different definitions together.
- `response_size` measured a re-serialisation of the parsed tree, which
  differs from the bytes that came off the wire in whitespace, key order
  and number formatting.

Now the client's `body.len()` is taken once in `messages` and threaded
in, and the response is read as text once, measured, logged and parsed
from that single copy -- removing one full serialisation per retry
attempt and two per successful response.

The debug-log call was the third copy: it built the string eagerly to
pass to `log_debug_response`, which discards it unless debug logging is
on.

Verified against the wire: a 103-byte request that returns 865 bytes now
records exactly 103 and 865.

* docs(changelog): record the billing figure, the size fix, and repair two stale claims

Three corrections to the unreleased section. A missing newline had merged the body-capture and read-timeout entries into one bullet. The prompt-cache entry still described savings as a read discount, a write premium and a net figure, which later work in the same section replaced with a single figure from the model's own rates. The dashboard entry still described a hero of premium requests and a recent-requests preview, neither of which shipped.

Adds the two changes that had no entry: Copilot's own billed AI units, now recorded per request and shown as the headline, and the /v1/messages size accounting that measured the proxy's rewritten request rather than the client's.

* feat(models): default to Opus 5, and document what actually ships

The catalog gained `claude-opus-5` and `claude-sonnet-5`. Both are
generally available and match `claude-opus-4.8` on every published
capability -- 1M context, 64k output, billing multiplier 1, vision -- so
`claude-opus-5` becomes the built-in target. The table gained the
`opus5` / `5[1m]` aliases and the `claude-opus-5*`, `claude-sonnet-5*`
and `claude-sonnet-4.6` prefixes.

The v4 migration only ADDS keys. It does not lift mappings that still
hold the previous built-in default, because a value equal to an old
default is indistinguishable from a version pinned on purpose -- and
pinning is common. A real config carried `claude-opus-4-7:
claude-opus-4.7` beside `haiku: claude-opus-4.7`; a lifting pass rewrote
both. `--setup` or `--default` is how a user asks for new defaults.

Docs brought up to what ships:

- README: the Responses API over WebSocket, billed AI units and the
  three-page dashboard, runtime body capture, and the Gemini parameters
  that do and do not survive translation. Endpoint table gained
  `GET /v1/responses` (Upgrade), `/api/cache`, `/api/config/debug`,
  `/api/stats` and `/app.css`, with a note that `/api/config/` is
  guarded by the API key.
- docs/index.md: the same additions to Highlights.
- docs/configuration.md: `config_version: 4`, current mapping targets, a
  schema-version table, and an explicit statement that an upgrade only
  ever adds.
- docs/claude-code.md: the mapping example uses Opus 5, and the tips
  cover the three dashboard pages, the per-model cache breakdown with
  its ~4k-token minimum prefix, and how to turn body capture on.
- docs/getting-started.md: the startup banner now matches what the proxy
  prints (it was missing the health-check line).
- docs/api.md: `/app.css`, and the model example is no longer a
  superseded model.

`scripts/alias_check.py` resolves every alias against the live upstream,
since a mapping is only correct if the name it produces is one Copilot
accepts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants