From 13dffcb004ca5d0ab60699f000fdbafd78116225 Mon Sep 17 00:00:00 2001 From: "Shangxiang Fan (from Dev Box)" Date: Wed, 29 Jul 2026 10:53:26 +0800 Subject: [PATCH 1/6] 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. --- .gitignore | 4 + CHANGELOG.md | 83 ++++++ Cargo.toml | 3 + README.md | 6 +- docs/api.md | 15 +- docs/configuration.md | 38 ++- scripts/__pycache__/replay.cpython-313.pyc | Bin 16344 -> 0 bytes src/config.rs | 115 ++++++- src/main.rs | 3 +- src/server.rs | 60 +++- src/state.rs | 330 ++++++++++++++++++++- src/util.rs | 103 ++++++- 12 files changed, 735 insertions(+), 25 deletions(-) delete mode 100644 scripts/__pycache__/replay.cpython-313.pyc diff --git a/.gitignore b/.gitignore index 1b59c51..9069a41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /target **/*.rs.bk Cargo.lock.orig + +# Python bytecode from scripts/ +__pycache__/ +*.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index aad37cc..1039efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,49 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- **Live quota with no extra API call.** Copilot attaches per-SKU quota to every + response (`x-quota-snapshot-chat`, `-completions`, `-premium_interactions`), + carrying entitlement, overage, overage-permitted, percent remaining and reset + date. Those are now parsed on every proxied response and surfaced on: + - `GET /health` under `quota` + - `GET /metrics` as `ghc_proxy_quota_percent_remaining`, + `ghc_proxy_quota_entitlement` and `ghc_proxy_quota_overage`, labelled by `sku` + - `GET /usage` under `live`, alongside the existing authoritative response + + Previously quota was only available from `/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. +- **Observability for streams and failures.** Records now carry + `upstream_idle_max_ms` (longest silence between upstream chunks, which is what + assigns blame for a stall), `keepalive_probes`, `failure_kind`, `session_id` + parsed from `metadata.user_id`, and `output_tokens_final` so a count that is + still the `message_start` placeholder renders as `—` instead of being + presented as fact. The dashboard gained the matching columns, a failures-only + filter, and premium-request/cache-hit stats. +- `scripts/replay.py` replays a captured request at the upstream or back through + the proxy and times the SSE stream event by event, with no stall watchdog. +- `upstream_read_timeout_seconds` (default `900`, `0` disables, env + `GHC_PROXY_UPSTREAM_READ_TIMEOUT`). Bounds the silence *between* reads from an + upstream response rather than the total duration, so long streaming answers + are unaffected. Without it a half-open connection yields no data, no error and + no end-of-stream: the request hangs forever and the stream-interrupted + handling never runs. The default clears the longest silence measured against + the real upstream (329.5s, while a tool call's argument JSON was buffered) with + room to spare. + ### Changed +- **`auto_upgrade` now defaults to `true`**, including for config files written + before the setting existed. The proxy checks GitHub releases on startup and + replaces its own binary when a newer version is published; the replacement + takes effect on the next start. Disable with `auto_upgrade: false`, + `--no-auto-upgrade`, or `GHC_PROXY_AUTO_UPGRADE=0` — worth doing when the + binary is managed by a package manager, or lives in a build output directory + that `cargo build`/`cargo clean` also writes to. +- `config_version` bumped to `3`, so existing `config.yaml` files gain + `upstream_read_timeout_seconds` and the new `auto_upgrade` default on the next + start. - **Config schema upgrades apply automatically.** When a release introduces new `config.yaml` properties (signalled by a `config_version` bump), the missing keys are now filled with their defaults and written back to `config.yaml` on @@ -13,6 +55,47 @@ All notable changes to this project will be documented in this file. Opus 4.8 alias backfill is now scoped to pre-v2 files so it cannot overwrite customized mappings in current ones +### Fixed +- **Non-2xx upstreams on `/v1/messages` reached the client as an empty `200` + stream.** `messages_direct` only intercepted `400`; a `401`, `403`, `429` or + `5xx` fell through and was wrapped in a `200 text/event-stream` whose body was + a JSON error object, so the client waited on a stream that never produced an + event and reported a stall instead of the auth or rate-limit failure that + actually happened. This was the one path Claude Code takes. All five streaming + paths now gate on a single `is_streamable_status()` predicate +- **A client that disconnected mid-stream left no record.** axum drops the + response body on disconnect, which drops the generator, so the `store.add` + after the loop never ran. Recording now happens from `Drop` +- Pre-flight failures (token refresh, rate gate, connect errors) returned early + without recording; they are captured with a `failure_kind` +- **Keepalive probes could be silenced exactly during a stall.** The boundary + flag was set from the last chunk, so a TCP split mid-event left it stuck until + a new chunk arrived — and an upstream that went quiet right then produced no + probes at all. Partial events are now held back so the downstream always sits + on an event boundary, and the Anthropic path sends `event: ping` rather than + an SSE comment, since comments are discarded by the parser and never reset a + client's idle watchdog +- **Token counts were read from one bucket.** Anthropic's `input_tokens`, + `cache_read_input_tokens` and `cache_creation_input_tokens` are disjoint, so a + fully-cached Claude Code turn reported single digits for a 348,483-token + prompt. The three protocols slice the total differently and now have separate + extractors; cost reprices the cached buckets instead of charging every input + token at the full rate +- **Reassembling one large SSE event was quadratic.** The line buffer rescanned + the whole retained buffer on every chunk, so a single event that arrived in + many pieces was re-read from the start each time. A 4 MB event delivered in + 4 KB chunks took **7.9 seconds** of pure CPU; the search now resumes where the + previous one stopped, bringing it to milliseconds. Correctness was never + affected — only the cost of getting there. +- The line buffer could grow without bound if an upstream never emitted a + newline. A single line is now capped at 64 MB. +- Stopped scraping the Arch User Repository for the latest VS Code version. The + AUR maintainers asked projects to stop; `dynamic_vscode_version` now uses + Microsoft's own `update.code.visualstudio.com` release API and ignores + non-`major.minor.patch` builds. +- Removed `scripts/__pycache__` from version control and added the matching + `.gitignore` entries. + ## [1.3.0] - 2026-07-27 ### Added diff --git a/Cargo.toml b/Cargo.toml index a3ece90..39b97b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,3 +38,6 @@ self_update = { version = "0.44", default-features = false, features = ["reqwest toml = "1.1" [dev-dependencies] +# `test-util` (not part of tokio's `full`) enables the paused clock used to +# exercise the SSE keepalive timing deterministically. +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/README.md b/README.md index 6ba6341..6655631 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ ghc-proxy [options] --fetch-version Fetch the latest VS Code version at startup --no-fetch-version Disable dynamic VS Code version fetching --auto-upgrade Auto-upgrade app when a newer release is available + (default: on) --no-auto-upgrade Disable app auto-upgrade --update-config Persist non-schema config write-backs (schema upgrades apply automatically) -v, --version Show version @@ -162,10 +163,10 @@ address: 127.0.0.1 port: 8314 debug: false account_type: individual # individual | business | enterprise -vscode_version: "1.123.0" +vscode_version: "1.130.0" api_version: "2025-05-01" copilot_version: "0.48.1" -auto_upgrade: false +auto_upgrade: true # self-update on startup; false to disable model_mappings: exact: opus: claude-opus-4.8 @@ -181,6 +182,7 @@ system_prompt_remove: [] system_prompt_add: [] tool_result_suffix_remove: [] max_connection_retries: 3 +upstream_read_timeout_seconds: 900 # max silence from upstream; 0 disables # Optional: require this key on all LLM endpoints (Bearer / x-api-key / # x-goog-api-key). Omit or leave empty to disable authentication. diff --git a/docs/api.md b/docs/api.md index b89baea..eac58a5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -75,6 +75,12 @@ has loaded. A degraded proxy still answers `200` with `ready: false` so probes can distinguish "process alive" from "able to serve traffic". Add `?strict=true` to get `503 Service Unavailable` instead when the proxy is not ready. +The `quota` object holds the most recent per-SKU allowance reported by the +upstream. Copilot attaches it to every response, so it is current without any +extra API call — but it stays empty until the first request has been proxied. +The same figures are exported on `/metrics` as `ghc_proxy_quota_*` gauges +labelled by `sku`. + ## Retrieve a model `GET /v1/models/{model}` returns a single catalog entry in the OpenAI shape, @@ -239,7 +245,14 @@ counts, estimated cost, and prompt-cache hit rate. - **SSE keepalive** — a `: keepalive` comment is emitted after 15 seconds of silence so extended thinking does not trip the ~60 second idle timeout enforced by the upstream load balancer. Comments are ignored by every - spec-compliant SSE client. + spec-compliant SSE client. The comment is only injected at an event boundary: + if the upstream goes quiet *part way through* writing an event, splicing one + in would corrupt it, so that case is left to the read timeout below. +- **Read timeout** — `upstream_read_timeout_seconds` (default 120) bounds how + long an upstream response may stay silent between reads. It does not cap the + total duration, so long answers stream normally, but a half-open connection + is turned into a reported stream error instead of hanging the request + forever. Set it to `0` to disable. - **`anthropic-beta` passthrough** — the client's beta flags are forwarded and merged with the ones the proxy derives (`context-1m-2025-08-07` for extended context models, `context-management-2025-06-27` when the request uses diff --git a/docs/configuration.md b/docs/configuration.md index b92fa38..d05ee08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,12 +32,12 @@ debug: false account_type: individual # Header version strings (mimic the VS Code Copilot Chat client) -vscode_version: "1.123.0" +vscode_version: "1.130.0" api_version: "2025-05-01" copilot_version: "0.48.1" -# Self-update behavior -auto_upgrade: false +# Self-update behavior (enabled by default) +auto_upgrade: true # Model name mappings: exact (full match) and prefix (starts-with) model_mappings: @@ -64,6 +64,11 @@ tool_result_suffix_remove: [] # Retry: max retries for upstream connection errors (0 = none) max_connection_retries: 3 +# Max seconds of silence from an upstream response before it is treated as +# dead. Bounds silence, not total duration, so long streams are fine. +# 0 disables the timeout. +upstream_read_timeout_seconds: 900 + # Optional: require this key on all LLM endpoints (Bearer / x-api-key / # x-goog-api-key). Omit or leave empty to disable authentication. # api_key: my-secret-key @@ -149,6 +154,7 @@ ghc-proxy [options] --fetch-version Fetch the latest VS Code version at startup --no-fetch-version Disable dynamic VS Code version fetching --auto-upgrade Auto-upgrade app when a newer release is available + (default: on) --no-auto-upgrade Disable app auto-upgrade --update-config Persist non-schema config write-backs (schema upgrades apply automatically) -v, --version Show version @@ -169,10 +175,11 @@ Every config field has a `GHC_PROXY_*` override: | `GHC_PROXY_API_VERSION` | `X-GitHub-Api-Version` string | | `GHC_PROXY_COPILOT_VERSION` | Copilot Chat plugin version string | | `GHC_PROXY_MAX_CONNECTION_RETRIES` | Max connection retries | +| `GHC_PROXY_UPSTREAM_READ_TIMEOUT` | Max seconds of upstream silence (`0` disables) | | `GHC_PROXY_REDIRECT_ANTHROPIC` | Always translate Anthropic via chat completions | | `GHC_PROXY_SHOW_TOKEN` | Log tokens on refresh (`true`/`1`) | | `GHC_PROXY_DYNAMIC_VSCODE_VERSION` | Fetch latest VS Code version (`true`/`1`) | -| `GHC_PROXY_AUTO_UPGRADE` | Auto-upgrade app on startup (`true`/`1`) | +| `GHC_PROXY_AUTO_UPGRADE` | Auto-upgrade app on startup (`true`/`1`); set `0` to disable | | `GHC_PROXY_RATE_LIMIT_SECONDS` | Minimum seconds between requests | | `GHC_PROXY_RATE_LIMIT_WAIT` | Wait instead of rejecting when limited (`true`/`1`) | | `GHC_PROXY_MANUAL_APPROVE` | Require manual approval per request (`true`/`1`) | @@ -215,3 +222,26 @@ stale clients: Enable `dynamic_vscode_version` (or `--fetch-version`) to refresh the VS Code version automatically at startup. + +## Self-update + +`auto_upgrade` is **enabled by default**, including for config files written +before the setting existed. On startup the proxy checks GitHub releases and, if +a newer version is published, downloads and replaces its own binary. The +replacement takes effect on the **next** start — the running process keeps +serving the old code until it restarts. + +Disable it with any of: + +```yaml +auto_upgrade: false +``` + +```bash +ghc-proxy --no-auto-upgrade +GHC_PROXY_AUTO_UPGRADE=0 ghc-proxy +``` + +Turn it off if the binary is managed by a package manager, or if it lives +somewhere the process should not rewrite — for example a build output directory +that `cargo build` or `cargo clean` also writes to. diff --git a/scripts/__pycache__/replay.cpython-313.pyc b/scripts/__pycache__/replay.cpython-313.pyc deleted file mode 100644 index 27145d02de7b6e3f986fa5e955efb6bba60288bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16344 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 F{|~bpS9JgY diff --git a/src/config.rs b/src/config.rs index dce1c04..8ed8c5b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,7 +20,10 @@ pub const API_VERSION: &str = "2025-05-01"; pub const COPILOT_VERSION: &str = "0.48.1"; /// Config schema version used to detect when defaults/options changed and a /// persisted config should be rewritten with migrated values. -pub const CONFIG_VERSION: u32 = 2; +/// +/// Bumped to 3 for `upstream_read_timeout_seconds` and the `auto_upgrade` +/// default flip, so existing files gain both on the next start. +pub const CONFIG_VERSION: u32 = 3; /// Default model name that Claude "opus"/"sonnet" requests are mapped to. pub const DEFAULT_OPUS: &str = "claude-opus-4.8"; @@ -135,6 +138,16 @@ pub struct Config { pub tool_result_suffix_remove: Vec, #[serde(default = "default_max_retries")] pub max_connection_retries: u32, + /// Maximum seconds of silence allowed between two reads from an upstream + /// response before the request is treated as dead. This bounds *silence*, + /// not total duration, so long streaming answers are unaffected. `0` + /// disables the timeout. + /// + /// Without it a half-open connection never errors and never ends: the + /// request hangs forever, the client hangs with it, and the stream- + /// interrupted handling never runs because no error is ever observed. + #[serde(default = "default_read_timeout")] + pub upstream_read_timeout_seconds: u64, /// When true, never route to the upstream `/v1/messages` endpoint; always /// translate Anthropic requests through the OpenAI chat completions API. #[serde(default)] @@ -147,9 +160,14 @@ pub struct Config { /// the `Editor-Version` header (falling back to `vscode_version`). #[serde(default)] pub dynamic_vscode_version: bool, - /// When true, check GitHub releases and auto-upgrade this binary when a - /// newer version is available. - #[serde(default)] + /// Check GitHub releases on startup and replace this binary when a newer + /// version is available. Enabled by default, including for config files + /// written before the key existed; disable with `auto_upgrade: false`, + /// `--no-auto-upgrade`, or `GHC_PROXY_AUTO_UPGRADE=0`. + /// + /// The replacement takes effect on the next start; the running process + /// keeps serving the old code until then. + #[serde(default = "default_true")] pub auto_upgrade: bool, /// Minimum number of seconds between successive proxied requests. `None` /// disables rate limiting. @@ -197,6 +215,19 @@ fn default_copilot_version() -> String { fn default_max_retries() -> u32 { 3 } +/// Deliberately far above the longest silence a healthy upstream produces. +/// +/// An earlier 120s default was wrong: it was reasoned from the ~60s idle window +/// the upstream load balancer enforces, but measurement against the real API +/// showed Copilot buffers `input_json_delta` until a tool call's argument JSON +/// is complete and then flushes it in one burst. A 35,899-token answer went +/// **329.5 seconds** without emitting a byte, and the silence scales with +/// output size at roughly 9.5ms/token — so 120s would have aborted perfectly +/// healthy large tool calls. 15 minutes clears the worst measured case with +/// room to spare while still bounding a genuinely dead connection. +fn default_read_timeout() -> u64 { + 900 +} impl Default for Config { fn default() -> Self { @@ -215,10 +246,11 @@ impl Default for Config { system_prompt_add: Vec::new(), tool_result_suffix_remove: Vec::new(), max_connection_retries: default_max_retries(), + upstream_read_timeout_seconds: default_read_timeout(), redirect_anthropic: false, show_token: false, dynamic_vscode_version: false, - auto_upgrade: false, + auto_upgrade: true, rate_limit_seconds: None, rate_limit_wait: false, manual_approve: false, @@ -385,6 +417,8 @@ pub fn render_config_yaml(cfg: &Config) -> String { let _ = writeln!(s, "vscode_version: \"{}\"", cfg.vscode_version); let _ = writeln!(s, "api_version: \"{}\"", cfg.api_version); let _ = writeln!(s, "copilot_version: \"{}\"", cfg.copilot_version); + s.push_str("# Check GitHub releases on startup and replace this binary when a newer\n"); + s.push_str("# version is available. Takes effect on the next start.\n"); let _ = writeln!(s, "auto_upgrade: {}", cfg.auto_upgrade); s.push('\n'); s.push_str("# Model Name Mappings\n"); @@ -447,6 +481,14 @@ pub fn render_config_yaml(cfg: &Config) -> String { s.push_str("# Retry Settings\n"); s.push_str("# Max retries for upstream connection errors (0 = no retries)\n"); let _ = writeln!(s, "max_connection_retries: {}", cfg.max_connection_retries); + s.push_str("# Max seconds of silence from an upstream response before it is treated as\n"); + s.push_str("# dead. Bounds silence, not total duration, so long streams are fine.\n"); + s.push_str("# 0 disables the timeout.\n"); + let _ = writeln!( + s, + "upstream_read_timeout_seconds: {}", + cfg.upstream_read_timeout_seconds + ); if cfg.redirect_anthropic { s.push('\n'); s.push_str( @@ -646,6 +688,21 @@ pub fn load_config_with_options(write_back_on_migration: bool) -> Config { ); } } + if let Ok(val) = std::env::var("GHC_PROXY_UPSTREAM_READ_TIMEOUT") { + match val.parse::() { + Ok(secs) => { + cfg.upstream_read_timeout_seconds = secs; + tracing::info!( + "✓ Overriding upstream_read_timeout_seconds from GHC_PROXY_UPSTREAM_READ_TIMEOUT: {}", + secs + ); + } + Err(_) => tracing::warn!( + "Invalid GHC_PROXY_UPSTREAM_READ_TIMEOUT value '{}': expected a number", + val + ), + } + } if let Ok(val) = std::env::var("GHC_PROXY_REDIRECT_ANTHROPIC") { cfg.redirect_anthropic = val.eq_ignore_ascii_case("true") || val == "1"; tracing::info!( @@ -792,6 +849,54 @@ fn migrate_config(cfg: &mut Config) -> bool { mod tests { use super::*; + #[test] + fn auto_upgrade_is_on_by_default_including_for_legacy_configs() { + assert!(Config::default().auto_upgrade); + + // Config files written before the key existed must also opt in, which + // is what `#[serde(default)]` would have got wrong. + let legacy = "address: 127.0.0.1\nport: 8314\nmax_connection_retries: 3\n"; + let cfg: Config = serde_norway::from_str(legacy).unwrap(); + assert!(cfg.auto_upgrade); + + // An explicit opt-out is still honoured, and survives a round trip + // through the generated config file. + let off: Config = serde_norway::from_str("auto_upgrade: false\n").unwrap(); + assert!(!off.auto_upgrade); + let rendered: Config = serde_norway::from_str(&render_config_yaml(&off)).unwrap(); + assert!(!rendered.auto_upgrade); + } + + #[test] + fn read_timeout_defaults_are_sane() { + // Silence, not total duration — a long stream must never be cut off by + // this, but a genuinely dead connection must be. + let cfg = Config::default(); + assert_eq!(cfg.upstream_read_timeout_seconds, 900); + // The longest silence measured against the real upstream was 329.5s, + // while a tool call's argument JSON was buffered. The default must + // clear that with margin or it aborts healthy requests. + assert!(cfg.upstream_read_timeout_seconds > 330); + // The rendered config round-trips the value, including the 0 (disabled) + // case, so operators can turn it off. + let off = Config { + upstream_read_timeout_seconds: 0, + ..Default::default() + }; + let parsed: Config = serde_norway::from_str(&render_config_yaml(&off)).unwrap(); + assert_eq!(parsed.upstream_read_timeout_seconds, 0); + let parsed: Config = serde_norway::from_str(&render_config_yaml(&cfg)).unwrap(); + assert_eq!(parsed.upstream_read_timeout_seconds, 900); + } + + #[test] + fn read_timeout_missing_from_legacy_config_uses_default() { + // Existing config files predate the key and must keep working. + let legacy = "address: 127.0.0.1\nport: 8314\nmax_connection_retries: 3\n"; + let cfg: Config = serde_norway::from_str(legacy).unwrap(); + assert_eq!(cfg.upstream_read_timeout_seconds, 900); + } + #[test] fn github_models_defaults_enabled() { let gm = GithubModels::default(); diff --git a/src/main.rs b/src/main.rs index 259a1e5..965d537 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,6 +124,7 @@ Options: --fetch-version Fetch the latest VS Code version at startup --no-fetch-version Disable dynamic VS Code version fetching --auto-upgrade Auto-upgrade app when a newer release is available + (default: on) --no-auto-upgrade Disable app auto-upgrade --update-config Persist non-schema config write-backs (schema upgrades apply automatically) -v, --version Show version @@ -141,7 +142,7 @@ Environment Variables: GHC_PROXY_REDIRECT_ANTHROPIC Redirect Anthropic requests (true/1) GHC_PROXY_SHOW_TOKEN Log tokens on refresh (true/1) GHC_PROXY_DYNAMIC_VSCODE_VERSION Fetch latest VS Code version (true/1) - GHC_PROXY_AUTO_UPGRADE Auto-upgrade app on startup (true/1) + GHC_PROXY_AUTO_UPGRADE Auto-upgrade app on startup (true/1); 0 disables GHC_PROXY_RATE_LIMIT_SECONDS Minimum seconds between requests GHC_PROXY_RATE_LIMIT_WAIT Wait instead of rejecting when limited (true/1) GHC_PROXY_MANUAL_APPROVE Require manual approval per request (true/1) diff --git a/src/server.rs b/src/server.rs index b1752d2..bb47773 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1943,6 +1943,7 @@ async fn stream_gemini( Ok(r) => r, Err(e) => return gemini_error(StatusCode::GATEWAY_TIMEOUT, e.to_string()), }; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); // Surface a non-2xx upstream (JSON error, not SSE) as a normal error. if !is_streamable_status(status) { @@ -2146,8 +2147,18 @@ async fn usage(State(state): State) -> Response { if let Err(e) = state.ensure_copilot_token().await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, e); } + // The live per-SKU snapshot rides along on every proxied response, so it is + // already current; the upstream call below adds the plan name and the + // detailed per-category breakdown it does not carry. + let live = state.quota_snapshot(); match state.fetch_usage().await { - Ok(v) => Json(crate::state::summarize_usage(&v)).into_response(), + Ok(v) => { + let mut summary = crate::state::summarize_usage(&v); + if !live.is_empty() { + summary["live"] = serde_json::to_value(&live).unwrap_or(Value::Null); + } + Json(summary).into_response() + } Err(e) => error_response(StatusCode::BAD_GATEWAY, e), } } @@ -2183,6 +2194,9 @@ async fn health( "models_loaded": model_count, "requests_served": stats.request_count, "auth_required": state.api_key().is_some(), + // Reported by the upstream on every response, so this costs no extra + // API call. Empty until the first request has been proxied. + "quota": state.quota_snapshot(), }); let strict = params .get("strict") @@ -2243,6 +2257,7 @@ async fn stream_openai( Ok(r) => r, Err(e) => return error_response(StatusCode::GATEWAY_TIMEOUT, e.to_string()), }; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); // A non-2xx upstream (e.g. GitHub Models returning 401/403 as JSON when // the token lacks the `models: read` permission) is not an SSE stream — @@ -2269,6 +2284,7 @@ async fn stream_openai( } break upstream; }; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); let model = translated.clone(); // Shared with the keepalive wrapper so the record can report how @@ -2419,6 +2435,7 @@ async fn stream_responses( Ok(r) => r, Err(e) => return error_response(StatusCode::GATEWAY_TIMEOUT, e.to_string()), }; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); // A non-2xx upstream returns a JSON error body, not an SSE stream. Forward // it as a normal error response instead of wrapping it in a 200 "stream", @@ -2550,6 +2567,7 @@ async fn stream_anthropic_direct( session_id, start, } = meta; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); // Without this the error body is wrapped in a 200 `text/event-stream`, so // the client waits on a stream that never produces an event and reports a @@ -2692,6 +2710,7 @@ async fn stream_anthropic_translated( Ok(r) => r, Err(e) => return anthropic_error(StatusCode::GATEWAY_TIMEOUT, e.to_string()), }; + state.record_quota_headers(upstream.headers()); let status = upstream.status().as_u16(); // Surface a non-2xx upstream (JSON error, not SSE) as a normal error. if !is_streamable_status(status) { @@ -3255,6 +3274,45 @@ async fn metrics_openmetrics(State(state): State) -> Response { state.uptime_secs() )); + // Quota comes from headers the upstream attaches to every response, so + // scraping this endpoint never costs an extra API call. + let quotas = state.quota_snapshot(); + if !quotas.is_empty() { + out.push_str( + "# HELP ghc_proxy_quota_percent_remaining Percent of the entitlement still available.\n", + ); + out.push_str("# TYPE ghc_proxy_quota_percent_remaining gauge\n"); + for (sku, q) in "as { + out.push_str(&format!( + "ghc_proxy_quota_percent_remaining{{sku=\"{}\"}} {}\n", + metrics_label_escape(sku), + q.percent_remaining + )); + } + + out.push_str( + "# HELP ghc_proxy_quota_entitlement Allowance for the period; negative means unlimited.\n", + ); + out.push_str("# TYPE ghc_proxy_quota_entitlement gauge\n"); + for (sku, q) in "as { + out.push_str(&format!( + "ghc_proxy_quota_entitlement{{sku=\"{}\"}} {}\n", + metrics_label_escape(sku), + q.entitlement + )); + } + + out.push_str("# HELP ghc_proxy_quota_overage Amount consumed beyond the entitlement.\n"); + out.push_str("# TYPE ghc_proxy_quota_overage gauge\n"); + for (sku, q) in "as { + out.push_str(&format!( + "ghc_proxy_quota_overage{{sku=\"{}\"}} {}\n", + metrics_label_escape(sku), + q.overage + )); + } + } + out.push_str( "# HELP ghc_proxy_estimated_cost_usd_total Total estimated request cost in USD.\n", ); diff --git a/src/state.rs b/src/state.rs index c5b0588..b451ef7 100644 --- a/src/state.rs +++ b/src/state.rs @@ -6,13 +6,13 @@ use crate::auth; use crate::config::{self, Config, ModelMappings}; use crate::store::RequestStore; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use std::collections::BTreeMap; use std::sync::Arc; use std::sync::RwLock as StdRwLock; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock}; -/// Mutable token state guarded by a mutex. -#[derive(Default)] +/// Mutable token state guarded by a mutex.#[derive(Default)] pub struct TokenState { pub github_token: String, pub copilot_token: Option, @@ -37,10 +37,108 @@ pub struct AppState { pub session_id: String, /// Instant the process started serving, used to report uptime on `/health`. pub started_at: Instant, + /// Latest per-SKU quota reported by the upstream, keyed by SKU name. + pub quotas: StdRwLock>, } pub type SharedState = Arc; +/// Prefix of the per-SKU quota headers Copilot attaches to every response. +const QUOTA_HEADER_PREFIX: &str = "x-quota-snapshot-"; + +/// A quota snapshot for one billing SKU, as reported by the upstream on every +/// response. +/// +/// Copilot returns these on each request (`x-quota-snapshot-chat`, +/// `-completions`, `-premium_interactions`), so live quota costs nothing — +/// unlike `/copilot_internal/user`, which is a separate API call. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct QuotaSnapshot { + /// Allowance for the period. Negative means unlimited. + pub entitlement: f64, + /// Amount consumed beyond the entitlement. + pub overage: f64, + /// Whether going past the entitlement is allowed at all. + pub overage_permitted: bool, + /// Percentage of the entitlement still available. + pub percent_remaining: f64, + /// True when the SKU has no cap. + pub unlimited: bool, + /// When the allowance resets, if the upstream reported it. + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_date: Option, +} + +/// Decodes the `%XX` escapes used in the header's date field. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Parses a quota header value such as +/// `ent=-1&ov=0.0&ovPerm=true&rem=100.0&rst=2026-08-01T00%3A00%3A00Z`. +/// +/// Returns `None` when nothing recognizable is present, so an upstream that +/// stops sending these (they are undocumented) simply leaves the cache empty +/// rather than reporting zeroed quota. +pub fn parse_quota_snapshot(value: &str) -> Option { + let mut snapshot = QuotaSnapshot::default(); + let mut saw_field = false; + for pair in value.split('&') { + let Some((key, raw)) = pair.split_once('=') else { + continue; + }; + match key.trim() { + "ent" => { + if let Ok(v) = raw.parse::() { + snapshot.entitlement = v; + snapshot.unlimited = v < 0.0; + saw_field = true; + } + } + "ov" => { + if let Ok(v) = raw.parse::() { + snapshot.overage = v; + saw_field = true; + } + } + "ovPerm" => { + snapshot.overage_permitted = raw.eq_ignore_ascii_case("true"); + saw_field = true; + } + "rem" => { + if let Ok(v) = raw.parse::() { + snapshot.percent_remaining = v; + saw_field = true; + } + } + "rst" => { + let decoded = percent_decode(raw); + if !decoded.is_empty() { + snapshot.reset_date = Some(decoded); + saw_field = true; + } + } + _ => {} + } + } + saw_field.then_some(snapshot) +} + fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -58,14 +156,20 @@ fn now_millis() -> u128 { impl AppState { pub fn new(config: Config, github_token: String) -> Self { - // No global request timeout: SSE responses are long-lived streams. Only - // the connect phase and idle pooled connections are bounded so a dead - // upstream cannot wedge a request forever. - let http = reqwest::Client::builder() + // No global request timeout: SSE responses are long-lived streams, and + // capping their total duration would cut off legitimate long answers. + // Instead the connect phase, idle pooled connections, and the gap + // *between* reads are bounded. The read timeout is what turns a + // half-open upstream into an error the streaming paths can report, + // rather than a request that hangs forever. + let mut builder = reqwest::Client::builder() .connect_timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(90)) - .build() - .expect("failed to build HTTP client"); + .pool_idle_timeout(Duration::from_secs(90)); + if config.upstream_read_timeout_seconds > 0 { + builder = + builder.read_timeout(Duration::from_secs(config.upstream_read_timeout_seconds)); + } + let http = builder.build().expect("failed to build HTTP client"); AppState { http, config: StdRwLock::new(config), @@ -81,9 +185,41 @@ impl AppState { machine_id: auth::load_or_create_machine_id(), session_id: format!("{}{}", uuid::Uuid::new_v4(), now_millis()), started_at: Instant::now(), + quotas: StdRwLock::new(BTreeMap::new()), + } + } + + /// Records the per-SKU quota headers attached to an upstream response. + /// + /// Called for every Copilot response, so quota stays current without the + /// extra `/copilot_internal/user` round trip. Headers that do not parse are + /// ignored, leaving the previous value in place. + pub fn record_quota_headers(&self, headers: &HeaderMap) { + let mut parsed: Vec<(String, QuotaSnapshot)> = Vec::new(); + for (name, value) in headers { + let Some(sku) = name.as_str().strip_prefix(QUOTA_HEADER_PREFIX) else { + continue; + }; + let Ok(value) = value.to_str() else { continue }; + if let Some(snapshot) = parse_quota_snapshot(value) { + parsed.push((sku.to_string(), snapshot)); + } + } + if parsed.is_empty() { + return; + } + if let Ok(mut quotas) = self.quotas.write() { + for (sku, snapshot) in parsed { + quotas.insert(sku, snapshot); + } } } + /// The most recent per-SKU quota reported by the upstream. + pub fn quota_snapshot(&self) -> BTreeMap { + self.quotas.read().map(|q| q.clone()).unwrap_or_default() + } + /// Seconds this process has been running. pub fn uptime_secs(&self) -> u64 { self.started_at.elapsed().as_secs() @@ -700,7 +836,183 @@ fn premium_multiplier_from_catalog(catalog: &serde_json::Value, model: &str) -> #[cfg(test)] mod tests { use super::*; + use futures_util::StreamExt; use serde_json::json; + use tokio::io::AsyncWriteExt; + + #[test] + fn parses_a_real_quota_header() { + // Captured verbatim from a live Copilot response. + let q = parse_quota_snapshot( + "ent=-1&ov=0.0&ovPerm=true&rem=100.0&rst=2026-08-01T00%3A00%3A00Z", + ) + .expect("header should parse"); + assert_eq!(q.entitlement, -1.0); + assert!(q.unlimited, "a negative entitlement means unlimited"); + assert_eq!(q.overage, 0.0); + assert!(q.overage_permitted); + assert_eq!(q.percent_remaining, 100.0); + // The date arrives percent-encoded and must be decoded. + assert_eq!(q.reset_date.as_deref(), Some("2026-08-01T00:00:00Z")); + } + + #[test] + fn parses_a_capped_sku() { + let q = parse_quota_snapshot("ent=300&ov=12.5&ovPerm=false&rem=42.5").unwrap(); + assert_eq!(q.entitlement, 300.0); + assert!(!q.unlimited); + assert_eq!(q.overage, 12.5); + assert!(!q.overage_permitted); + assert_eq!(q.percent_remaining, 42.5); + assert!(q.reset_date.is_none()); + } + + #[test] + fn unrecognized_quota_headers_are_ignored() { + // These headers are undocumented; if the shape changes we must report + // nothing rather than a confident zero. + assert!(parse_quota_snapshot("").is_none()); + assert!(parse_quota_snapshot("garbage").is_none()); + assert!(parse_quota_snapshot("unknown=1&other=2").is_none()); + // A partially recognizable value still yields what it does contain. + assert_eq!( + parse_quota_snapshot("rem=7.5&junk=x") + .unwrap() + .percent_remaining, + 7.5 + ); + } + + #[test] + fn quota_headers_are_recorded_per_sku() { + let state = AppState::new(Config::default(), "t".into()); + assert!(state.quota_snapshot().is_empty()); + + let mut h = HeaderMap::new(); + h.insert( + "x-quota-snapshot-chat", + HeaderValue::from_static("ent=-1&ov=0.0&ovPerm=false&rem=100.0"), + ); + h.insert( + "x-quota-snapshot-premium_interactions", + HeaderValue::from_static("ent=300&ov=0.0&ovPerm=true&rem=88.0"), + ); + h.insert( + "content-type", + HeaderValue::from_static("text/event-stream"), + ); + state.record_quota_headers(&h); + + let q = state.quota_snapshot(); + assert_eq!(q.len(), 2, "only the quota headers are captured: {q:?}"); + assert_eq!(q["chat"].percent_remaining, 100.0); + assert_eq!(q["premium_interactions"].percent_remaining, 88.0); + assert_eq!(q["premium_interactions"].entitlement, 300.0); + + // A later response updates in place. + let mut h2 = HeaderMap::new(); + h2.insert( + "x-quota-snapshot-premium_interactions", + HeaderValue::from_static("ent=300&ov=0.0&ovPerm=true&rem=87.0"), + ); + state.record_quota_headers(&h2); + let q = state.quota_snapshot(); + assert_eq!(q["premium_interactions"].percent_remaining, 87.0); + // Untouched SKUs keep their previous value. + assert_eq!(q["chat"].percent_remaining, 100.0); + } + + #[test] + fn responses_without_quota_headers_leave_the_cache_alone() { + let state = AppState::new(Config::default(), "t".into()); + let mut h = HeaderMap::new(); + h.insert( + "x-quota-snapshot-chat", + HeaderValue::from_static("ent=-1&rem=55.0"), + ); + state.record_quota_headers(&h); + + // An upstream that stops sending them must not wipe what we know. + state.record_quota_headers(&HeaderMap::new()); + assert_eq!(state.quota_snapshot()["chat"].percent_remaining, 55.0); + } + + /// Serves one request: SSE headers plus half an event, then stalls forever + /// without ever closing the socket. Returns the bound address. + async fn stalling_upstream() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + let mut buf = [0u8; 4096]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; + let _ = sock + .write_all( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Transfer-Encoding: chunked\r\n\r\n", + ) + .await; + // A chunk that stops in the middle of a `data:` line. + let partial: &[u8] = b"data: {\"partial\":"; + let _ = sock + .write_all(format!("{:X}\r\n", partial.len()).as_bytes()) + .await; + let _ = sock.write_all(partial).await; + let _ = sock.write_all(b"\r\n").await; + let _ = sock.flush().await; + std::future::pending::<()>().await; + } + }); + format!("http://{addr}/") + } + + /// The streaming paths only notice a dead upstream when the body stream + /// yields an error. Without a read timeout a half-open connection yields + /// neither data nor error nor end-of-stream, so the request hangs forever + /// and none of the stream-interrupted handling ever runs. + #[tokio::test] + async fn read_timeout_surfaces_a_stalled_upstream_as_an_error() { + let url = stalling_upstream().await; + let cfg = Config { + upstream_read_timeout_seconds: 1, + ..Default::default() + }; + let state = AppState::new(cfg, "token".into()); + + let resp = state.http.get(&url).send().await.expect("headers arrive"); + assert!(resp.status().is_success()); + + let mut stream = resp.bytes_stream(); + let first = stream.next().await.expect("first chunk").expect("is data"); + assert_eq!(&first[..], b"data: {\"partial\":"); + + let outcome = tokio::time::timeout(Duration::from_secs(15), stream.next()).await; + let item = outcome.expect("must not hang past the read timeout"); + let err = item.expect("stream must yield an item").unwrap_err(); + assert!( + err.is_timeout(), + "expected a timeout error, got: {err:?} ({err})" + ); + } + + /// Setting the timeout to zero disables it, for operators who would rather + /// let a stream run indefinitely. + #[tokio::test] + async fn read_timeout_can_be_disabled() { + let url = stalling_upstream().await; + let cfg = Config { + upstream_read_timeout_seconds: 0, + ..Default::default() + }; + let state = AppState::new(cfg, "token".into()); + let resp = state.http.get(&url).send().await.expect("headers arrive"); + let mut stream = resp.bytes_stream(); + let _ = stream.next().await; + let outcome = tokio::time::timeout(Duration::from_secs(3), stream.next()).await; + assert!(outcome.is_err(), "expected the stream to still be hanging"); + } + #[test] fn premium_multiplier_reads_billing_from_the_catalog() { // Shape taken from a real `GET /models` response. diff --git a/src/util.rs b/src/util.rs index dad7ae8..5cbdef5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -19,9 +19,23 @@ use std::time::Duration; /// partial character simply waits for the rest of its bytes. Anything left over /// when the stream ends is returned by [`SseLineBuffer::flush`], so a final /// event that arrives without a trailing newline is not lost either. +/// Largest single SSE line the buffer will accumulate before giving up. +/// +/// Real events are kilobytes; this is far above anything legitimate. It exists +/// so a broken or hostile upstream that never emits a newline cannot grow the +/// buffer until the process runs out of memory. +pub const MAX_SSE_LINE_BYTES: usize = 64 * 1024 * 1024; + #[derive(Default)] pub struct SseLineBuffer { + /// Bytes of the line currently being assembled. Never contains a newline: + /// `push` always drains past the last one it finds, which is what makes + /// "only the newly arrived bytes can complete a line" true. buf: Vec, + /// Set when a single line exceeded [`MAX_SSE_LINE_BYTES`]. The stream can + /// no longer be parsed correctly from this point, so callers treat it the + /// same as a dropped connection rather than emitting a truncated event. + poisoned: bool, } impl SseLineBuffer { @@ -29,20 +43,51 @@ impl SseLineBuffer { Self::default() } + /// Whether an oversized line forced the buffer to give up. Once true the + /// remaining stream is unparseable and the caller should report the + /// response as incomplete. + pub fn is_poisoned(&self) -> bool { + self.poisoned + } + /// Appends a chunk and returns every complete line it completed, with the /// trailing `\r` (CRLF streams) already stripped. pub fn push(&mut self, chunk: &[u8]) -> Vec { + if self.poisoned { + return Vec::new(); + } + // Whatever is already buffered was searched on an earlier call and, by + // the invariant above, holds no newline. So only the bytes that just + // arrived can terminate a line, and the search starts where they do — + // every byte is examined exactly once across the whole stream. + let search_from = self.buf.len(); self.buf.extend_from_slice(chunk); + let mut lines = Vec::new(); let mut start = 0usize; - while let Some(pos) = self.buf[start..].iter().position(|&b| b == b'\n') { - let end = start + pos; + let mut cursor = search_from; + while let Some(pos) = self.buf[cursor..].iter().position(|&b| b == b'\n') { + let end = cursor + pos; + // A line spans from the end of the previous one, which may reach + // back into bytes buffered by earlier calls. lines.push(decode_line(&self.buf[start..end])); start = end + 1; + cursor = start; } if start > 0 { self.buf.drain(..start); } + if self.buf.len() > MAX_SSE_LINE_BYTES { + tracing::error!( + "[sse] discarding a {} byte line with no newline (limit {} bytes); \ + treating the stream as broken", + self.buf.len(), + MAX_SSE_LINE_BYTES + ); + self.buf.clear(); + self.buf.shrink_to_fit(); + self.poisoned = true; + } lines } @@ -245,6 +290,9 @@ pub async fn post_with_retry( .await; match result { Ok(resp) => { + // Every Copilot response carries the current quota, so recording + // it here keeps it fresh for free on every request. + state.record_quota_headers(resp.headers()); let status = resp.status().as_u16(); // If response is successful or a non-retryable error, return it if status < 400 || !is_retryable_error(status) { @@ -511,6 +559,57 @@ mod tests { assert!(b.flush().is_none()); } + #[test] + fn sse_buffer_handles_a_huge_single_event_split_into_many_chunks() { + // One 4 MB `data:` line delivered in 4 KB pieces, the shape a very + // large tool-call payload or final Responses event takes. + let payload = "x".repeat(4 * 1024 * 1024); + let line = format!("data: {{\"t\":\"{payload}\"}}\n"); + let bytes = line.as_bytes(); + + let start = std::time::Instant::now(); + let mut b = SseLineBuffer::new(); + let mut lines = Vec::new(); + for chunk in bytes.chunks(4096) { + lines.extend(b.push(chunk)); + } + let elapsed = start.elapsed(); + + assert_eq!(lines.len(), 1); + let v: Value = serde_json::from_str(sse_data(&lines[0]).unwrap()).unwrap(); + assert_eq!(v["t"].as_str().unwrap().len(), payload.len()); + assert!(b.flush().is_none()); + + // Each byte must be examined a bounded number of times. Rescanning the + // whole retained buffer on every chunk makes this quadratic and takes + // orders of magnitude longer. + assert!( + elapsed < std::time::Duration::from_secs(5), + "reassembling one large event took {elapsed:?}; the buffer is rescanning" + ); + } + + #[test] + fn sse_buffer_gives_up_on_an_unbounded_line_instead_of_growing_forever() { + // An upstream that never emits a newline must not be able to grow the + // buffer until the process dies. + let mut b = SseLineBuffer::new(); + let mib = vec![b'x'; 1024 * 1024]; + let mut pushed = 0usize; + while !b.is_poisoned() { + b.push(&mib); + pushed += mib.len(); + assert!( + pushed <= MAX_SSE_LINE_BYTES + 2 * mib.len(), + "buffer grew past the cap without giving up" + ); + } + assert!(b.is_poisoned()); + // Once poisoned it stops accumulating entirely. + assert!(b.push(b"data: anything\n").is_empty()); + assert!(b.is_poisoned()); + } + #[test] fn sse_data_extracts_payload_only() { assert_eq!(sse_data("data: {}"), Some("{}")); From a902784cc104e8ad71f63fe294256598a9b060a0 Mon Sep 17 00:00:00 2001 From: "Shangxiang Fan (from Dev Box)" Date: Wed, 29 Jul 2026 19:40:10 +0800 Subject: [PATCH 2/6] 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. --- src/gemini.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/gemini.rs b/src/gemini.rs index 08e586a..2d1953c 100644 --- a/src/gemini.rs +++ b/src/gemini.rs @@ -162,6 +162,11 @@ pub fn gemini_to_openai(req: &Value, model: &str, stream: bool) -> Value { out.insert("stream".into(), Value::Bool(stream)); // generationConfig -> top-level OpenAI sampling params. + // + // Only `topK` and `safetySettings` have no counterpart in the chat + // completions schema; everything else here maps exactly, and dropping it + // silently meant a client's `seed` or `candidateCount` had no effect and no + // explanation. if let Some(gc) = req .get("generationConfig") .or_else(|| req.get("generation_config")) @@ -181,6 +186,49 @@ pub fn gemini_to_openai(req: &Value, model: &str, stream: bool) -> Value { if let Some(v) = gc.get("stopSequences").or_else(|| gc.get("stop_sequences")) { out.insert("stop".into(), v.clone()); } + if let Some(v) = gc + .get("candidateCount") + .or_else(|| gc.get("candidate_count")) + { + out.insert("n".into(), v.clone()); + } + if let Some(v) = gc.get("seed") { + out.insert("seed".into(), v.clone()); + } + if let Some(v) = gc + .get("presencePenalty") + .or_else(|| gc.get("presence_penalty")) + { + out.insert("presence_penalty".into(), v.clone()); + } + if let Some(v) = gc + .get("frequencyPenalty") + .or_else(|| gc.get("frequency_penalty")) + { + out.insert("frequency_penalty".into(), v.clone()); + } + // Gemini asks for JSON with a mime type; chat completions with a + // response_format object. A schema, when supplied, carries over as a + // json_schema format. + let mime = gc + .get("responseMimeType") + .or_else(|| gc.get("response_mime_type")) + .and_then(Value::as_str); + if mime == Some("application/json") { + let schema = gc + .get("responseSchema") + .or_else(|| gc.get("response_schema")); + out.insert( + "response_format".into(), + match schema { + Some(s) => json!({ + "type": "json_schema", + "json_schema": {"name": "response", "schema": s} + }), + None => json!({"type": "json_object"}), + }, + ); + } } // tools.functionDeclarations -> OpenAI function tools. @@ -349,6 +397,92 @@ mod tests { assert_eq!(out["top_p"], 0.9); } + /// Everything in `generationConfig` with an exact chat-completions + /// counterpart has to carry over. These used to be dropped silently, so a + /// client's `seed` had no effect and nothing said why. + #[test] + fn generation_config_maps_the_rest() { + let req = json!({ + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "generationConfig": { + "candidateCount": 2, + "seed": 42, + "presencePenalty": 0.3, + "frequencyPenalty": 0.4, + "stopSequences": ["END"] + } + }); + let out = gemini_to_openai(&req, "m", false); + assert_eq!(out["n"], 2); + assert_eq!(out["seed"], 42); + assert_eq!(out["presence_penalty"], 0.3); + assert_eq!(out["frequency_penalty"], 0.4); + assert_eq!(out["stop"], json!(["END"])); + } + + #[test] + fn response_mime_type_becomes_response_format() { + let plain = gemini_to_openai( + &json!({ + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "generationConfig": {"responseMimeType": "application/json"} + }), + "m", + false, + ); + assert_eq!(plain["response_format"], json!({"type": "json_object"})); + + let schema = json!({"type": "object", "properties": {"a": {"type": "string"}}}); + let with_schema = gemini_to_openai( + &json!({ + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "generationConfig": { + "responseMimeType": "application/json", + "responseSchema": schema + } + }), + "m", + false, + ); + assert_eq!(with_schema["response_format"]["type"], "json_schema"); + assert_eq!( + with_schema["response_format"]["json_schema"]["schema"], + schema + ); + + // A plain-text turn must not acquire a response_format it never asked for. + let text = gemini_to_openai( + &json!({ + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "generationConfig": {"responseMimeType": "text/plain"} + }), + "m", + false, + ); + assert!(text.get("response_format").is_none()); + } + + /// `topK` and `safetySettings` have no counterpart in the chat completions + /// schema. Asserting they stay out keeps the dashboard's "dropped in + /// translation" note honest. + #[test] + fn params_without_counterparts_are_not_invented() { + let out = gemini_to_openai( + &json!({ + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "generationConfig": {"topK": 32}, + "safetySettings": [{"category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_NONE"}] + }), + "m", + false, + ); + assert!(out.get("top_k").is_none()); + assert!(out.get("topK").is_none()); + assert!(out.get("safetySettings").is_none()); + assert!(out.get("safety_settings").is_none()); + } + #[test] fn function_declarations_become_tools() { let req = json!({ From 087b51cb2d2bcba96b12a82c4aba5d7f337ec107 Mon Sep 17 00:00:00 2001 From: "Shangxiang Fan (from Dev Box)" Date: Wed, 29 Jul 2026 19:41:14 +0800 Subject: [PATCH 3/6] feat: responses over websocket, real billing figures, and a dashboard built around them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 212 +++++- Cargo.lock | 90 +++ Cargo.toml | 5 +- docs/api.md | 120 +++ public/app.css | 372 ++++++++++ public/dashboard.html | 491 +++++++++++-- public/metrics.html | 95 ++- public/requests.html | 1183 ++++++++++++++++++++++++++++-- scripts/anthropic_cache_probe.py | 79 ++ scripts/cache_demo.py | 96 +++ scripts/cache_sweep.py | 168 +++++ scripts/cache_write_probe.py | 111 +++ scripts/protocol_check.py | 475 ++++++++++++ scripts/stats_inventory.py | 123 ++++ scripts/ws_check.py | 221 ++++++ scripts/ws_explore.py | 154 ++++ scripts/ws_probe.py | 127 ++++ src/server.rs | 756 ++++++++++++++++++- src/state.rs | 28 + src/store.rs | 58 +- src/util.rs | 227 +++++- 21 files changed, 5003 insertions(+), 188 deletions(-) create mode 100644 public/app.css create mode 100644 scripts/anthropic_cache_probe.py create mode 100644 scripts/cache_demo.py create mode 100644 scripts/cache_sweep.py create mode 100644 scripts/cache_write_probe.py create mode 100644 scripts/protocol_check.py create mode 100644 scripts/stats_inventory.py create mode 100644 scripts/ws_check.py create mode 100644 scripts/ws_explore.py create mode 100644 scripts/ws_probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1039efb..b52df8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,56 @@ All notable changes to this project will be documented in this file. still the `message_start` placeholder renders as `—` instead of being presented as fact. The dashboard gained the matching columns, a failures-only filter, and premium-request/cache-hit stats. +- **The Responses API over WebSocket.** Eight models advertise `ws:/responses` + in the catalog — six of them alongside `/responses` and nothing else — and the + proxy had no way to reach it. `GET /v1/responses` with an upgrade now exposes + that transport, relayed to the matching upstream socket. + + The surface is undocumented — GitHub publishes no reference for the inference + API at all, and the token response carries no websocket URL — so the protocol + was established by probing: it lives on the same host as the HTTP API, speaks + the same `response.*` events one per text frame, and takes a *flat* request + frame with `model` at the top level. Nesting the body under `response` is + rejected, as is omitting `type`. `scripts/ws_probe.py` and + `scripts/ws_explore.py` are the tools that established this, kept for when it + changes. + + A model that does not advertise the transport is refused with an error frame + naming the alternative, rather than left waiting on the socket. Refusals and + malformed frames are recorded with a `failure_kind` like every other path, so + a failed attempt leaves something to diagnose from. WebSocket turns appear in + the dashboard under `ws:/responses`, and their transcript renders through the + same reassembly as SSE — only the transport differs. +- `scripts/ws_check.py` sweeps every model the catalog says supports the + transport, discovering the list rather than hard-coding it, and asserts the + event vocabulary, monotonic sequence numbers, termination, text and usage. - `scripts/replay.py` replays a captured request at the upstream or back through the proxy and times the SSE stream event by event, with no stall watchdog. -- `upstream_read_timeout_seconds` (default `900`, `0` disables, env +- `scripts/protocol_check.py` asserts protocol conformance against a running + proxy. The property that matters for a translating gateway is not "did it + return 200" but "did it return the shape the *client* asked for", so every + check is an assertion about the client-facing response: a Gemini caller must + receive `candidates`, never the `choices` the upstream actually sent. Covers + all four surfaces in both streaming and non-streaming modes, tool calls and + tool-result round trips, stream terminators (`message_stop`, `[DONE]`, + `response.completed`), token counting, and the error paths — an unknown model + must not arrive as a `200` SSE stream that never produces an event. +- **Prompt-cache statistics** at `GET /api/cache` and on the dashboard: where + input tokens came from (served from cache / written to cache / fresh), the + disposition of recent requests, and a per-model hit rate. A single global + number cannot tell you *which* conversation stopped matching its prefix, which + is the failure that quietly multiplies the bill. Savings are reported in both + directions — the read discount, the write premium, and the net — because + caching is not free. +- **Body capture can be toggled at runtime**, from the request browser or via + `POST /api/config/debug`. It previously required restarting the proxy with + `--debug`, by which point the request you wanted to inspect was gone; the flag + is read live, so it applies from the next call. It is not written back to + `config.yaml`, since capture puts prompts and any credentials they carry into + memory and the log and should lapse on restart. `GET /health` now reports it. + The `/api/config/` routes are guarded by the API key when one is configured — + read-only dashboard endpoints stay open, but turning on body capture must not + be something an unauthenticated caller can do.- `upstream_read_timeout_seconds` (default `900`, `0` disables, env `GHC_PROXY_UPSTREAM_READ_TIMEOUT`). Bounds the silence *between* reads from an upstream response rather than the total duration, so long streaming answers are unaffected. Without it a half-open connection yields no data, no error and @@ -37,6 +84,151 @@ All notable changes to this project will be documented in this file. room to spare. ### Changed +- **The cache panel is per model, and says why a cell is empty.** The panel + opened with one aggregate stacked bar, which cannot say *which* model stopped + matching — the question a collapsing hit rate raises. The bar is now a + Distribution column, one per model, and what remains at the top is the colour + key those bars need. Below the table, a footnote separates the two reasons a + row comes back empty: a prompt too short to be eligible at all, and one long + enough but never sent before, since nothing can be read back on first sight. + + **`Written` only appears when something was written.** Only the Anthropic + surface with an explicit `cache_control` marker ever bills a cache write. + Every other surface caches implicitly: the first call reads nothing, the next + reads the whole prefix back, and no write is billed. Measured across twelve + calls with a 27k-token prefix, exactly one reported a write. The column and + its swatch are hidden outright on a workload that never writes, rather than + showing a wall of zeros for an event that cannot happen. + + Measured while investigating: `claude-haiku-4.5` cached a 6902-token prefix + but not a 4082-token one, so a prompt in the low thousands legitimately + produces an empty row. Every surface — chat, messages, responses, Gemini and + WebSocket, streamed and not — was confirmed to carry + `copilot_usage.token_details`, so an empty row is never the proxy failing to + read what upstream reported. +- **Cache savings come from the model's own rates, not a price list.** The + per-model saving was `list price × (1 − 0.1)` for reads and + `× (1.25 − 1)` for writes — one hard-coded discount applied to every model. + Copilot states its actual per-token rate for every token type it charges, in + `copilot_usage.token_details`, and the entries are not the same on every + model: `claude-haiku-4.5` prices cache writes above its input rate, + `gemini-3.5-flash` prices them at zero, `gpt-5.5` does not price them at all, + and `gpt-4o-mini` prices nothing because Copilot includes it. The old maths + claimed a saving on models that are free. + + `/api/cache` now reports `saved_nano_aiu` in the same AI units the rest of + the dashboard bills in, computed per request from the rates that request + reported, and drops `saved_usd` / `write_premium_usd` / `net_saved_usd`. + + A `null` `saved_nano_aiu` now means no response reported rates to compute one + from, distinct from `0`, which is a real figure: nothing was cached, so + nothing was saved. +- **Failed attempts no longer count as consumption.** `request_count` counted + every record, so a burst of rejected calls inflated the request total, added + an empty-named row to the per-model 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, `/api/stats` + reports `failed_requests` separately, and the overview links to them. Token + and billing totals still count either way: a stream cut off partway consumed + what it consumed, and hiding that would understate the bill. + + The predicate — non-2xx, or a `failure_kind` on an otherwise successful + status — now has one definition shared by the statistics, the failures-only + filter and the dashboard, instead of three. +- **Dashboard restructured around consumption.** The landing page opened with + eight identically-weighted stat cards followed by the full 78-row model + catalogue, so the number that tracks spend had no more prominence than + `bytes_received`, and reference data dominated a page you open to check usage. + It now leads with premium requests, input tokens (with the cache-hit share) + and output tokens; quota bars per SKU sit directly beneath, since that is the + same quantity seen from the other end. Traffic and proxy health follow, then a + recent-requests preview, with the model catalogue folded away. + - The three pages share one stylesheet at `/app.css` instead of three drifting + inline copies, and carry the same persistent nav with a live readiness and + version indicator — previously each page offered only a lone + `← Dashboard` link. + - **Wide screens are used.** The old 1180px cap left a 2560px monitor half + empty while the twelve-column request table still scrolled sideways. Width + is now per-page: the request table gets 2000px and the metric list 1600px, + since both are data a wide screen genuinely helps you read, while the + overview keeps a 1280px measure because a handful of large numbers only + drift apart when stretched. Message text keeps a 100ch measure so prose + stays readable at full width; tool payloads are code and stay unconstrained. + - `/health` data (per-SKU quota, Copilot token expiry, uptime, readiness, + models loaded) was reachable but shown nowhere; it is now on the overview. + The request list stays on its own tab — the overview only reports how many + requests failed and links across. + - **Debug bodies render as a conversation.** `--debug` stores request and + response bodies verbatim, which is the right thing to store and the wrong + thing to read: finding what the model actually said meant scrolling past + tool schemas, content-filter blocks and, for streams, every SSE frame. The + detail view now reconstructs the exchange — system/user/assistant turns, + tool calls and results as collapsible cards, token and stop-reason chips — + and reassembles streamed fragments into the message they describe. All + three wire formats are handled, and none of them agree: Anthropic numbers + content blocks and tags every delta, chat completions hides tool arguments + inside `choices[].delta.tool_calls[]`, and the Responses API uses a flat + `response.*` vocabulary where the delta is a bare string. Reasoning is + picked up under every name the families use (`reasoning_text`, + `reasoning_content`, `response.reasoning_summary_text.delta`) rather than + dropped, and a turn that produced no visible output says whether it ran out + of token budget instead of reporting an empty stream. The raw bodies remain + one click away on a `Raw` tab. + - A third tab shows what the wire actually carried, which is the question when + a stream stalls, repeats, or ends somewhere unexpected. For a stream it + lists **every SSE frame** — sequence number, event name and a one-line gist, + each expandable to the full payload — so the `[DONE]` sentinel, a missing + `message_stop` or a duplicated index is visible instead of buried in a + scroll of raw text. For a non-streaming completion it lists **every + top-level field**, including the ones the conversation view has no place for + (`content_filter_results`, `prompt_filter_results`, `system_fingerprint`, + `service_tier`, `copilot_usage`). + - The request table's full nanosecond ISO timestamp consumed a third of the + row width; it renders as local wall-clock time with the exact value in the + tooltip, and the table scrolls horizontally rather than letting the panel + clip columns off the right edge, with the expanded detail pinned to the left + so it stays readable while the table scrolls. Empty results say so instead + of showing a header row with nothing under it. + - **Each request now says what happened, not just what was said.** The + response section opened with a row of bare chips: the numbers were there, + but not their meaning. It now leads with the outcome, normalized across + surfaces and explained — `length`, `max_tokens`, `max_output_tokens` and + `MAX_TOKENS` are the same event on four different APIs, and all four mean + the answer on screen is not the whole answer. Endings are colour-coded by + whether they are normal (`end_turn`, `stop`, `completed`), truncated, or a + refusal or filter block. + + Beneath it sit the facts each surface actually reports, omitted when + absent: reasoning effort, service tier, prompt-cache retention, response + verbosity and output item types on the Responses API; the backend build + fingerprint on chat completions, which is worth noticing because a change + there can shift results for identical input; cache-write TTL split and + inference region on Anthropic; rejected speculative tokens, which are + billed. Content-filter verdicts appear only when something was actually + flagged — an all-clear on four categories on every request is noise. + - The `Probes` column is gone. Keepalive only runs on the Anthropic streaming + path, so four of the five request paths rendered a dash that read as "zero + probes sent" when it meant "not measured here". The count now rides in the + `Idle` tooltip, which is where it was useful anyway — the pair is what + assigns blame for a stall. + - **Protocol-specific parameters moved out of the list and into the detail.** + The three surfaces agree on very little, so a table column for any of them + is structurally empty for the other two: `New` (cache writes) only exists on + Anthropic, and `Session` only when the client encodes one into + `metadata.user_id` the way Claude Code does. Both columns are gone; the + detail now carries a panel naming the surface and listing what that request + actually set — `top_k`, `stop_sequences`, `cache_control` marks and + `thinking` for Anthropic; `seed`, penalties, `response_format` and + `logprobs` for chat completions; `reasoning.effort`, `max_output_tokens`, + `store` and `truncation` for the Responses API; `generationConfig` and + `safetySettings` for Gemini. Absent parameters are omitted rather than shown + as blanks. A Gemini request is labelled `Gemini → OpenAI Chat Completions + (translated)`, because the proxy rewrites it before forwarding and the + recorded body is the rewritten one — `topK` and `safetySettings` have no + counterpart and are dropped, which the panel says instead of leaving the + reader to assume they took effect. Gemini's `contents`/`parts`, + `functionCall` and `functionResponse` shapes render like every other + surface's. - **`auto_upgrade` now defaults to `true`**, including for config files written before the setting existed. The proxy checks GitHub releases on startup and replaces its own binary when a newer version is published; the replacement @@ -56,6 +248,24 @@ All notable changes to this project will be documented in this file. customized mappings in current ones ### Fixed +- **Output token totals were not comparable across surfaces.** The two + conventions for reasoning tokens disagree about whether they are already + counted: a Responses turn reports `input 11 + output 17 == total 28` with 10 + of that output being reasoning, while the translated Gemini surface reports + `prompt 5 + completion 1 + reasoning 97 == total 103`. Taken at face value the + dashboard showed a reasoning share of 262%. `output_tokens` is now normalized + to the true total on both, the way `input_tokens` already was. +- **Gemini requests lost parameters that had exact counterparts.** The + translation to chat completions carried `temperature`, `topP`, + `maxOutputTokens` and `stopSequences` and dropped the rest of + `generationConfig` in silence, so a client's `seed`, `candidateCount`, + `presencePenalty`, `frequencyPenalty` or `responseMimeType` had no effect and + nothing said why. They now map to `seed`, `n`, `presence_penalty`, + `frequency_penalty` and `response_format` (with `responseSchema` carried over + as a `json_schema` format). `topK` and `safetySettings` genuinely have no + counterpart in the chat completions schema and are still dropped — the + dashboard names them rather than describing the loss in general terms, and a + test asserts they are not invented. - **Non-2xx upstreams on `/v1/messages` reached the client as an empty `200` stream.** `messages_direct` only intercepted `400`; a `401`, `403`, `429` or `5xx` fell through and was wrapped in a `200 text/event-stream` whose body was diff --git a/Cargo.lock b/Cargo.lock index 5b59bdb..9775012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -122,8 +123,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -394,6 +397,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -759,6 +768,7 @@ dependencies = [ "serde_norway", "tiktoken-rs", "tokio", + "tokio-tungstenite 0.28.0", "toml", "tower", "tower-http 0.7.0", @@ -1905,6 +1915,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2237,6 +2258,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2428,6 +2477,41 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2522,6 +2606,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 39b97b4..530e6d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,11 @@ name = "ghc-proxy" path = "src/main.rs" [dependencies] -axum = { version = "0.8", features = ["json"] } +axum = { version = "0.8", features = ["json", "ws"] } tokio = { version = "1", features = ["full"] } +# Upstream transport for the catalog's `ws:/responses` surface. rustls with the +# platform trust store, to match reqwest and keep enterprise CAs working. +tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-native-roots"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.7", features = ["fs"] } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream", "form"] } diff --git a/docs/api.md b/docs/api.md index eac58a5..a978f35 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,11 +37,131 @@ require a key on the LLM endpoints; see [Authentication](#authentication) below. | `GET /metrics` | OpenMetrics exposition endpoint | | `GET /requests` | Request browser | | `GET /api/stats` | Dashboard statistics (JSON) | +| `GET /api/cache` | Prompt-cache statistics, overall and per model | | `GET /api/requests` | Recent requests (JSON) | | `GET /api/audit` | Filtered audit records | | `GET /api/audit/summary` | Aggregated audit summary | | `POST /api/config/reload` | Reload `config.yaml` without restart | +| `POST /api/config/debug` | Turn request/response body capture on or off | | `GET /openapi.json` | OpenAPI v3 specification of the LLM endpoints | +| `GET /v1/responses` (Upgrade) | Responses API over WebSocket | + +## Responses over WebSocket + +Several models advertise `ws:/responses` in `supported_endpoints` alongside +`/responses`. `GET /v1/responses` (or `/responses`) with a WebSocket upgrade +exposes that transport. + +The protocol is the streaming Responses API with a different carrier: the same +`response.*` event vocabulary, one event per text frame, so a client already +written against the SSE stream needs no new parsing. Send one frame to start a +turn: + +```json +{"type": "response.create", "model": "gpt-5.5", "input": "...", "stream": true} +``` + +The frame is flat — `model` sits at the top level beside the request fields, not +nested under a `response` object. Omitting `type` or nesting the body is +rejected. + +A model that does not advertise the transport is refused with an error frame +naming the alternative rather than left waiting on the socket: + +```json +{"type": "error", "error": {"code": "unsupported_api_for_model", + "message": "Model 'claude-opus-4.6' does not support ws:/responses. Use POST /v1/responses instead."}} +``` + +WebSocket turns are recorded like any other request, under the endpoint +`ws:/responses`. + +Read-only dashboard endpoints are reachable without an API key so local +monitoring keeps working. The `/api/config/` routes are not: they mutate the +running process, and one of them turns on body capture, which writes whatever +the client sent — credentials included — into the request log. + +## Statistics and failed attempts + +`GET /api/stats` counts requests that produced an answer. Attempts that did not +— a non-2xx status, or a `failure_kind` on an otherwise successful one — are +reported separately as `failed_requests` and excluded from the rest, so a burst +of rejected calls cannot dilute a rate computed over requests that consumed +nothing. Token and billing totals count either way: a stream cut off partway +consumed what it consumed. + +## Prompt cache statistics + +`GET /api/cache` reports where input tokens came from. The hit rate is the early +warning for a broken prompt prefix: on an agent workload it should sit high and +stable, and a sudden drop means the prompt stopped matching and every turn is +paying full input price again. + +```json +{ + "totals": { + "input_tokens": 9397, + "cache_read_tokens": 4682, + "cache_creation_tokens": 4682, + "fresh_tokens": 33, + "hit_rate": 0.498, + "request_count": 4 + }, + "dispositions": { "served_from_cache": 1, "wrote_to_cache": 1, "no_cache": 2 }, + "sampled_requests": 4, + "by_model": [ + { + "model": "gemini-3.5-flash", + "requests": 4, + "input_tokens": 9397, + "cache_read_tokens": 4682, + "cache_creation_tokens": 0, + "fresh_tokens": 33, + "hit_rate": 0.498, + "saved_nano_aiu": 547830000 + } + ] +} +``` + +`totals` are the all-time running counters. `by_model` is derived from the +retained ring buffer, so it describes the most recent `sampled_requests` calls +rather than every one ever served. + +### What a zero means + +Copilot states its own per-token rates on each response, in +`copilot_usage.token_details`. `saved_nano_aiu` is computed from those rates +rather than from a price list, so a model Copilot includes at no charge +contributes nothing instead of an imagined discount. Positive means the cache +paid for itself; negative is the normal shape of a turn that populated a cache +it has not read back yet. `null` means no response for this model reported the +rates to compute one from — distinct from `0`, which is a real figure: nothing +was cached, so nothing was saved. + +`cache_creation_tokens` is only ever non-zero on the Anthropic surface with an +explicit `cache_control` marker. Every other surface caches implicitly: the +first call reads nothing, the next reads the whole prefix back, and no write is +billed. Measured across twelve calls with a 27k-token prefix, exactly one +reported a write, on `/v1/messages`. The dashboard hides the column outright +when nothing wrote. + +A model with no cache activity at all is usually not a fault either: Copilot +needs a minimum cacheable prefix before any of the prompt is eligible. +`claude-haiku-4.5` was observed caching a 6902-token prefix but not a +4082-token one. + +## Body capture + +`POST /api/config/debug` with `{"debug": true}` or `{"debug": false}` turns +request/response body capture on or off for the running process. The flag is +read live on every request, so it applies from the next call — no restart, and +no need to have predicted in advance that you would want the bodies. + +It is deliberately not written back to `config.yaml`. Capture puts prompts, tool +output and any credentials they carry into memory and the log, so it lapses on +restart rather than staying on because someone forgot. `GET /health` reports the +current value as `debug`. Streaming (SSE) is supported on the chat, responses, and messages endpoints by setting `"stream": true` in the request body. The Gemini surface streams via the diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..42f2ebe --- /dev/null +++ b/public/app.css @@ -0,0 +1,372 @@ +/* ghc-proxy dashboard — shared design system. + * + * Served from /app.css so the three pages share one visual language instead of + * carrying three drifting copies of the same rules. */ + +:root { + color-scheme: dark; + + --bg: #0d1117; + --surface: #161b22; + --surface-2: #21262d; + --border: #30363d; + --border-soft: #21262d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --ok: #3fb950; + --warn: #d29922; + --danger: #f85149; + + --radius: 8px; + --radius-sm: 6px; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + /* Page width is per-page, not global: the overview is a handful of large + * numbers and short labels, which just drift apart when stretched, while the + * request table and the metric list are data that a wide screen genuinely + * helps you read. Capped rather than fluid so an ultrawide does not turn a + * table row into a journey for the eye. */ + --page-max: 1280px; +} + +body[data-page="requests"] { --page-max: 2000px; } +body[data-page="metrics"] { --page-max: 1600px; } + +* { box-sizing: border-box; } + +body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + margin: 0; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.wrap { + max-width: var(--page-max); + margin: 0 auto; + padding: 0 1.5rem 4rem; +} + +/* ---------------------------------------------------------------- nav ---- */ +/* Persistent across all three pages: the "trunk test" wants every page to + * answer what this is, where you are, and what the other sections are. */ + +.nav { + position: sticky; + top: 0; + z-index: 10; + background: rgba(13, 17, 23, 0.85); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); + margin-bottom: 1.75rem; +} + +.nav-inner { + max-width: var(--page-max); + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; + height: 52px; +} + +.brand { + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; + white-space: nowrap; +} +.brand:hover { text-decoration: none; } + +.nav-links { display: flex; gap: 0.25rem; flex: 1; } + +.nav-links a { + color: var(--muted); + padding: 0.3rem 0.7rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; +} +.nav-links a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; } + +/* Current section, set via */ +body[data-page="overview"] .nav-links a[href="/"], +body[data-page="requests"] .nav-links a[href="/requests"], +body[data-page="metrics"] .nav-links a[href="/metrics/dashboard"] { + color: var(--text); + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--border); +} + +.nav-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--muted); + white-space: nowrap; +} + +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } +.dot.ok { background: var(--ok); } +.dot.warn { background: var(--warn); } +.dot.err { background: var(--danger); } + +/* -------------------------------------------------------------- panels --- */ + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 1.25rem; + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--border); +} + +.panel-head h2 { + margin: 0; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.panel-head .aside { font-size: 0.8rem; color: var(--muted); } +.panel-body { padding: 1rem; } +.panel-body.flush { padding: 0; } + +/* -------------------------------------------------------------- metrics -- */ +/* Deliberately unequal: the hero row is what the page is *for*, so it gets + * size, and everything else is demoted rather than competing with it. */ + +.hero { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1px; + background: var(--border); +} + +.hero .metric { background: var(--surface); padding: 1.1rem 1.25rem; } + +.metric .label { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.metric .value { + font-size: 2rem; + font-weight: 600; + line-height: 1.15; + margin-top: 0.35rem; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.metric .sub { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; } +.metric .sub b { color: var(--text); font-weight: 600; } + +/* Secondary stats: same information architecture, a third of the weight. */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.9rem 1.5rem; +} + +.stat .label { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} +.stat .value { + font-size: 1.15rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + margin-top: 0.15rem; +} +.stat .value.ok { color: var(--ok); } +.stat .value.warn { color: var(--warn); } +.stat .value.err { color: var(--danger); } + +.cols-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.25rem; } +.cols-2 > .panel { margin-bottom: 0; } + +/* ---------------------------------------------------------------- quota -- */ + +.quota-row { display: grid; gap: 0.3rem; padding: 0.7rem 0; border-bottom: 1px solid var(--border-soft); } +.quota-row:last-child { border-bottom: none; padding-bottom: 0; } +.quota-row:first-child { padding-top: 0; } + +.quota-top { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; } +.quota-name { font-family: var(--mono); font-size: 0.82rem; } +.quota-val { font-size: 0.82rem; color: var(--muted); font-variant-numeric: tabular-nums; } +.quota-val b { color: var(--text); font-weight: 600; } + +.bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } +.bar > span { display: block; height: 100%; background: var(--ok); border-radius: 3px; } +.bar > span.warn { background: var(--warn); } +.bar > span.err { background: var(--danger); } + +/* --------------------------------------------------------------- tables -- */ + +table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } + +th, td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +th { + color: var(--muted); + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.05em; + font-weight: 600; +} + +tbody tr:last-child td { border-bottom: none; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +.mono { font-family: var(--mono); } +.muted { color: var(--muted); } +.status-ok { color: var(--ok); } +.status-err { color: var(--danger); } + +/* -------------------------------------------------------------- controls -- */ + +button, .btn { + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.35rem 0.75rem; + cursor: pointer; + font: inherit; + font-size: 0.85rem; +} +button:hover:not(:disabled) { border-color: var(--muted); } +button:disabled { opacity: 0.45; cursor: default; } + +input[type="text"], input:not([type]) { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.45rem 0.7rem; + font: inherit; + font-size: 0.85rem; +} +input:focus { outline: none; border-color: var(--accent); } + +.toolbar { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--muted); +} +.toolbar label { display: flex; gap: 0.35rem; align-items: center; cursor: pointer; } + +.pager { display: flex; gap: 0.5rem; align-items: center; padding: 0.75rem 1rem; color: var(--muted); font-size: 0.85rem; } + +/* Collapsible reference material — present but never competing with the + * live numbers above it. */ +details.fold > summary { + cursor: pointer; + padding: 0.7rem 1rem; + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + list-style: none; + user-select: none; +} +details.fold > summary::-webkit-details-marker { display: none; } +details.fold > summary::before { content: "▸ "; } +details.fold[open] > summary::before { content: "▾ "; } +details.fold > summary:hover { color: var(--text); } +details.fold[open] > summary { border-bottom: 1px solid var(--border); } + +/* Where the input tokens came from, at a glance. Three shares of one bar read + * faster than three numbers you have to divide in your head. */ +.split { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--surface-2); } +.split > span { display: block; height: 100%; } +.split .s-read { background: var(--ok); } +.split .s-write { background: var(--warn); } +.split .s-fresh { background: #6e7681; } +/* Inline in a table row, where 8px would crowd the text beside it. */ +.split.mini { height: 6px; min-width: 120px; } + +.legend { display: flex; flex-wrap: wrap; gap: 0.9rem; margin-top: 0.55rem; font-size: 0.78rem; color: var(--muted); } +/* Standing on its own as a key rather than captioning a bar above it. */ +.legend:first-child { margin-top: 0; } +.legend span { display: flex; align-items: center; gap: 0.35rem; } +.legend i { width: 8px; height: 8px; border-radius: 2px; flex: none; } +.legend b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } + +.dim-note { color: var(--muted); font-size: 0.85rem; } + +/* A setting you can see is a setting you should be able to change. */ +.toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.15rem; + padding: 0.2rem 0.6rem 0.2rem 0.3rem; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + color: var(--muted); +} +.toggle .knob { + width: 26px; + height: 15px; + border-radius: 999px; + background: var(--border); + position: relative; + transition: background 0.12s ease; + flex: none; +} +.toggle .knob::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--muted); + transition: transform 0.12s ease, background 0.12s ease; +} +.toggle.on { color: var(--warn); border-color: var(--warn); } +.toggle.on .knob { background: rgba(210, 153, 34, 0.35); } +.toggle.on .knob::after { transform: translateX(11px); background: var(--warn); } +.toggle:disabled { opacity: 0.6; cursor: progress; } + +.empty { color: var(--muted); padding: 1.25rem 1rem; text-align: center; font-size: 0.875rem; } + +.scroll-y { max-height: 420px; overflow-y: auto; } + +/* The request table carries more columns than fit on a laptop. Scroll it + * rather than let the panel clip columns off the right edge. */ +.scroll-x { overflow-x: auto; } diff --git a/public/dashboard.html b/public/dashboard.html index 90c033d..4d05ab9 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -3,83 +3,436 @@ - ghc-proxy · Dashboard - + ghc-proxy · Overview + - -

GitHub Copilot API Proxy

-
-

Supported models

- - - -
IDDisplay nameOwned by
-