diff --git a/CHANGELOG.md b/CHANGELOG.md index 2684649..57fb505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.4.5] - 2026-08-18 + +### Fixed +- **The dashboard now shows Copilot quota before the first proxy request.** It + renders the union of the response-header snapshot and `GET /usage`, so a new + or idle process no longer hides an account quota that is already available. +- **Enterprise token-billed quota now follows the GitHub account meter.** For + those seats the upstream fields are counterintuitive: `credits_used` carries + the available AI-unit balance while `remaining` and `percent_remaining` + describe the consumed side. The dashboard now reports the correct amount and + percentage left without changing the interpretation for ordinary plans. + +### Documentation +- Documented the `/usage` response fields, their units, and the enterprise + token-billed field direction. + ## [1.4.4] - 2026-08-15 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1880749..ee69e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,7 +808,7 @@ dependencies = [ [[package]] name = "ghc-proxy" -version = "1.4.4" +version = "1.4.5" dependencies = [ "async-stream", "axum", diff --git a/Cargo.toml b/Cargo.toml index 9d302d5..2b06bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghc-proxy" -version = "1.4.4" +version = "1.4.5" edition = "2021" description = "GitHub Copilot API Proxy - Provides OpenAI and Anthropic compatible endpoints via GitHub Copilot (Rust port of ghc-tunnel)" license = "MIT" diff --git a/README.md b/README.md index 91ac9cf..1544caf 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ upstream_read_timeout_seconds: 900 # max silence from upstream; 0 disables | `POST /v1beta/models/{model}:countTokens` | Gemini token counting | | `POST /v1/embeddings` | Embeddings (also `/embeddings`) | | `GET /v1/models/full/` | Raw upstream model catalog with capabilities | -| `GET /usage` | Copilot plan and quota usage (also `check-usage`) | +| `GET /usage` | Copilot plan and quota usage in account billing units (also `check-usage`; see [API Reference](docs/api.md#usage-and-quota)) | | `GET /health` | Liveness/readiness probe (`?strict=true` for 503 when not ready) | | `GET /openapi.json` | OpenAPI v3 specification | | `GET /` | Web dashboard — overview | diff --git a/docs/api.md b/docs/api.md index b230023..ceced3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -202,7 +202,7 @@ curl http://127.0.0.1:8314/health { "status": "ok", "ready": true, - "version": "1.4.4", + "version": "1.4.5", "uptime_seconds": 128, "copilot_token": { "present": true, "expires_in_seconds": 1487 }, "models_loaded": 77, @@ -218,9 +218,49 @@ 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`. +extra API call, but the health payload stays empty until the first request has +been proxied. The overview dashboard also loads `/usage`, so its quota panel +works immediately on a new or idle process. The same figures are exported on +`/metrics` as `ghc_proxy_quota_*` gauges labelled by `sku`. + +## Usage and quota + +`GET /usage` contacts Copilot's account endpoint and returns the plan, account, +reset date, and one entry per quota SKU. It does not depend on requests recorded +by this proxy. + +```json +{ + "plan": "enterprise", + "login": "example", + "token_based_billing": true, + "quota_reset_date": "2026-09-01T00:00:00Z", + "quotas": { + "premium_interactions": { + "unlimited": false, + "entitlement": 10000000, + "remaining": 9229138, + "percent_remaining": 92.2, + "credits_used": 770861 + } + } +} +``` + +For a token-billed `premium_interactions` SKU, `entitlement`, `remaining`, and +`credits_used` are measured in **AI units**. An AI unit is an account billing +unit, not a token, request, premium interaction, or currency amount; the model, +token mix, cache use, and billing rates determine how many units a call costs. +`percent_remaining` is a percentage rather than an AI-unit count. + +Enterprise token-billed seats expose counterintuitive upstream names: +`credits_used` is the available balance, while `remaining` and +`percent_remaining` describe the consumed side. The example therefore means +770,861 of 10,000,000 AI units remain (about 7.7%) and 9,229,138 have been +consumed. The dashboard corrects this direction only for +`enterprise + token_based_billing`; ordinary plans retain the upstream field +direction. `raw` preserves the complete upstream payload for callers that need +fields omitted from the compact `quotas` object. ## Retrieve a model diff --git a/public/dashboard.html b/public/dashboard.html index a9a7d17..cbf1783 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -294,12 +294,15 @@

Prompt cache

} } - // Quota arrives on every upstream response, so it is empty only until the - // first request has been proxied. Hide the panel rather than render zeros, - // which would read as "exhausted". + // Header snapshots are unavailable until the first proxied request, while + // /usage can provide the account's quota immediately. Render the union so + // an idle proxy still shows the Copilot quota instead of an empty panel. function renderQuota(quota) { lastQuota = quota; - const skus = Object.keys(quota); + const skus = [...new Set([ + ...Object.keys(quota), + ...Object.keys(quotaExact || {}) + ])]; const panel = document.getElementById('quotaPanel'); if (skus.length === 0) { panel.hidden = true; return; } panel.hidden = false; @@ -310,28 +313,38 @@

Prompt cache

// that has been spent against reads as untouched. const showPct = p => (Number.isInteger(p) ? p.toFixed(0) : p.toFixed(1)) + '%'; - let reset = null; + let reset = quotaAccount?.quota_reset_date || null; + const account = quotaAccount || {}; + // Enterprise token-billed seats report the consumed side in `remaining` + // and the available side in `credits_used`, despite those field names. + const reversed = account.plan === 'enterprise' && account.token_based_billing; const rows = skus.map(name => { - const q = quota[name]; + const x = (quotaExact || {})[name]; + const q = { ...(x || {}), ...(quota[name] || {}) }; if (q.reset_date && !reset) reset = q.reset_date; - const x = (quotaExact || {})[name]; const exact = !q.unlimited && x && x.entitlement > 0 && x.remaining != null; - const pct = exact ? clamp(x.remaining / x.entitlement * 100) - : clamp(q.percent_remaining); - const cls = pct <= 10 ? 'err' : pct <= 25 ? 'warn' : ''; // The upstream reports consumption itself; `entitlement - remaining` // is only a stand-in for a plan that does not. const used = exact - ? (x.credits_used != null ? x.credits_used : x.entitlement - x.remaining) + ? (reversed ? x.remaining + : (x.credits_used != null ? x.credits_used : x.entitlement - x.remaining)) : null; + const remaining = exact + ? (reversed + ? (x.credits_used != null ? x.credits_used : x.entitlement - x.remaining) + : x.remaining) + : null; + const pct = exact ? clamp(remaining / x.entitlement * 100) + : clamp(reversed ? 100 - q.percent_remaining : q.percent_remaining); + const cls = pct <= 10 ? 'err' : pct <= 25 ? 'warn' : ''; const right = q.unlimited ? 'unlimited' // What is left and what has gone, in the units the entitlement is // denominated in — on a token-billed plan those are the same AI // units the Consumption panel above reports, so the two read together. : (exact - ? `${compact(x.remaining)} of ${compact(x.entitlement)} left` + + ? `${compact(remaining)} of ${compact(x.entitlement)} left` + ` · ${compact(used)} used` : `${showPct(pct)} of ${compact(q.entitlement)}`) + (q.overage > 0 ? ` · ${q.overage} over` : ''); @@ -358,7 +371,7 @@

Prompt cache

// account beside it is unreadable the moment more than one token is in // play, and `token-billed` is what says the entitlement above counts AI // units rather than interactions. - const a = quotaAccount || {}; + const a = account; const d = reset ? new Date(reset) : null; const parts = []; if (a.login) parts.push(a.login);