Observability: truthful token accounting, failure records, and stream stall diagnostics - #30
Merged
Conversation
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>
Owner
|
@codex[agent] fix ci |
Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com>
Contributor
Fixed in |
MartinForReal
approved these changes
Jul 29, 2026
8 tasks
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(¤t).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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
INshowed2for a request carrying a 348,483-token prompt.error.log. Diagnosing one required a packet capture.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. Readinginput_tokensalone reports single digits for a fully-cached Claude Code turn, becausecache_controlcovers nearly the entire prompt including the newest message. That was theIN = 2.The three protocols disagree on how they slice the total, so there are three extractors rather than one shared misreading:
prompt_tokensalready contains its cached subset — summing double-countsCost now reprices the cached buckets (read
0.1x, write1.25x) instead of charging every input token at the full rate. Premium requests come from the catalog'sbilling.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–1scale (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_direct— the one path Claude Code takes — did not. A non-2xx upstream was therefore parsed as SSE and reached the client as a200 text/event-streamwhose 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 theasync_streamgenerator, which meant thestore.addafter the loop never ran — a client that gave up left zero evidence.Dropis the only guaranteed path, andRequestStore::adduses a syncMutex, 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_kindnames which step broke:precondition_failed/connect_error/upstream_status/stream_interrupted/client_disconnected.Stream diagnostics
upstream_idle_max_ms(viautil::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 anArc<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 authoritativeoutput_tokensarrives only in the closingmessage_delta. A record finalized before that carries the opening placeholder frommessage_start—3, for a turn that went on to emit35,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_reasonlooks like it would serve as this predicate and does not: the OpenAI and translated paths leave itNoneon success too, so using it would blank out their healthy records.DirectStreamStatecollects 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 ofmetadata.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
Time · Session · Endpoint · Model · Status · In · New · Out · Idle · Probes · DurationInshows total prompt size plus cache hit share (905.9k (98%));Idlehighlights past 60sfailure_kindshown inline, "Failures only" filterTooling
scripts/replay.pyreplays 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-streamthat had been undiagnosable now records:and the retained partial body pins the exact stopping point:
Replaying that same request straight at the upstream, with no watchdog, finished the story:
max_tokensoutput_tokensstop_reasontool_usemax_tokensThe upstream was never broken — it buffers
input_json_deltauntil 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. Zeropingevents 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 test— 126 passed, 0 failedcargo clippy --all-targets -- -D warnings— cleancargo build --release— cleanoutput_tokens_finalstates🤖 Generated with Claude Code