feat: WebSocket transport, real billing figures, consumption-first dashboard, live quota, read timeout - #31
Merged
Conversation
…ffer 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.
`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.
… 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.
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.
…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.
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.
Merged
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.
Description
Four commits. The first was the original scope of this PR — live quota, read timeout, SSE buffer,
auto_upgrade. The rest came out of trying to answer "what is this costing me" from the dashboard and finding that it could not, for several separate reasons.1.
feat: live quota from response headers, upstream read timeout, SSE buffer fixesLive quota with no extra API call
Copilot attaches per-SKU quota to every response — dumped off a live streaming request to confirm:
The same figures
summarize_usageextracts from/copilot_internal/user, except these ride along for free. Quota previously required/usage, which costs a separate API call, so/healthand/metrics— the endpoints that actually get polled — had none.Now parsed on every proxied response and surfaced on
/health(quota),/metrics(ghc_proxy_quota_*gauges labelled bysku) and/usage(live, alongside the existing authoritative response — not replacing it, since the headers carry no plan name or per-category breakdown).These headers are undocumented, so the parser fails quiet: a value that stops parsing is ignored and the previous reading left in place. Reporting a confident
0would read as "quota exhausted", which is worse than reporting nothing.Upstream read timeout
The stream-interrupted handling added in 1.3.0 only fires when the body stream yields an error. A half-open connection yields no data, no error and no end-of-stream — so the request hangs forever and none of that handling runs.
upstream_read_timeout_secondsbounds the silence between reads rather than total duration, so long streaming answers are unaffected.The default is 900s, not the 120s originally written. That figure came from reasoning about the ~60s idle window the load balancer enforces. #30 measured the real thing: the upstream buffers
input_json_deltauntil a tool call's argument JSON is complete, then flushes it in one burst — 329.5 seconds of silence for a 35,899-token answer, scaling at ~9.5ms/token. 120s would have aborted perfectly healthy large tool calls. The reasoning and the measured number are in the code comment and asserted in the test (> 330).SSE line buffer
utiltest module, including that reassembly, runs in 0.06s.auto_upgradedefaults totrueIncluding for config files written before the key existed — plain
#[serde(default)]would have left those atfalse, silently missing every existing user.config_versionbumped to 3.Also
update.code.visualstudio.comrelease API.scripts/__pycache__/replay.cpython-313.pycfrom version control.2.
fix(gemini): map the generation parameters that have counterpartsgemini_to_openaiforwarded four fields out ofgenerationConfigand dropped the rest silently. A client asking for a fixedseedgot a non-deterministic answer; one asking for JSON output got prose. Neither with any sign the request had been altered.Adds the mappings that exist one-for-one:
candidateCount→n,seed,presencePenalty,frequencyPenalty, andresponseMimeType: "application/json"→response_format, carryingresponseSchemaacross as a JSON schema.topKandsafetySettingsstay 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.3.
feat: responses over websocket, real billing figures, and a dashboard built around themResponses over WebSocket
The model catalogue advertises
ws:/responseson ten models and the proxy did not speak it. GitHub publishes no inference API documentation — confirmed by search;docs.github.comcovers Copilot seats and billing only — so the protocol was established by probing, and is now written down indocs/api.md:endpoints.apifrom the token response, path/responses./v1/responsesalso upgrades;/ws/responsesand/realtimeare 404.{"type":"response.create","model":"...", ...}.modelmust be top level — nesting the request under"response"is rejected with[ObjectParam] [input] [invalid_type] ... expected a string, but got an object, and omittingtypewithunsupported message type:.response.*events as SSE, one per frame, each carrying asequence_number.GET /v1/responseswith anUpgradeheader 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.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; a translated Gemini response reports it as a disjointreasoning_tokens. Summing them produced a reasoning share of 262% on the dashboard.openai_usagenow normalises on field position — nested undercompletion_tokens_detailsmeans already included, flat means additive.copilot_usage.token_details, and reports the total astotal_nano_aiu(verified against the rates, 5 of 5). That is now the headline figure.claude-haiku-4.5prices cache writes above its input rate,gemini-3.5-flashat zero,gpt-5.5not at all, andgpt-4o-miniprices 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.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, withfailed_requestsreported separately. Token and billing totals still count either way: a stream cut off partway consumed what it consumed.delta.tool_calls[]fragments and the entire Responsesresponse.*event family rendered as "(stream produced no content)", andreasoning_textwas 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, where it can afford the width to be useful.
Each request renders as a conversation, with raw frames and 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.
Probeswas measured on one of five code paths, so its—read as "zero probes sent" on the other four; it is now in theIdletooltip.SessionandNeware 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. The per-model table carries a Distribution bar, because one aggregate bar cannot say which model stopped matching.
Writtenis hidden entirely on a workload that never writes — only the Anthropic surface with an explicitcache_controlmarker 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: a config file that quietly starts recording request bodies after a restart is a liability.4.
perf(anthropic): measure bodies instead of re-serialising themBoth
/v1/messagespaths serialised whole JSON trees purely to obtain byte counts, inside their four-attempt retry loops:Every other endpoint in the file — including the translated
/v1beta/modelspath — already readsresp.text()once, measures it, and parses from that string. These two were the outliers.The figures were also wrong.
req_sizemeasured the proxy's version of the request, after system prompt injection, the tool-result suffix and the model rename, while every other endpoint records what the client sent — so the dashboard's "Sent" total was adding two definitions together.response_sizemeasured a re-serialisation of the parsed tree, which differs from the wire bytes in whitespace, key order and number formatting.Verified: a 103-byte request returning 865 bytes now records exactly 103 and 865.
Type of change
Checklist
cargo buildsucceedscargo testpasses — 149 testscargo clippy --all-targetsis clean (-D warnings)CHANGELOG.md,README.md,docs/api.md,docs/configuration.mdVerification against the live upstream
scripts/protocol_check.py— 72/72 across the four HTTP surfaces.scripts/ws_check.py— 85/85 across 8 models, discovering the capable ones from the catalogue and checking both that they work and that the rest are refused cleanly.token_details:gpt-5.53840 × (500000−50000) = 1728000000;gemini-3.5-flash4058 × (150000−15000) = 547830000.claude-haiku-4.5caches a 6902-token prefix but not a 4082-token one./healthquotais{}before any request and populates after one streaming call;/metricsemits 9 gauges (3 SKUs × 3 metrics);/usagekeepsplan: enterpriseand gainslive.err.is_timeout()with the timeout set, and hangs indefinitely with it at0.auto_upgrade: verified in an isolated config dir that a legacy file lacking the key runs the update check, that an explicitfalsesuppresses it, and that--configrenders the new defaults.Relationship to #30
This branch was rebased onto #30 after it merged. Three things written locally were dropped because #30's versions are better: my keepalive implementation (a
: keepaliveSSE comment, which the SSE parser discards and which never resets a client's idle watchdog),stream_openaiverbatim passthrough, and the poison wiring for the 64 MB cap. The cap itself stays, which is the part that prevents the OOM.