From 0a101b95b3c1c84c8bf43cd32542b367b2303248 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 29 May 2026 00:59:29 -0300 Subject: [PATCH] =?UTF-8?q?fix(usage):=20un-invert=20GitHub=20Copilot=20Fr?= =?UTF-8?q?ee/limited=20quota=20=E2=80=94=20limited=5Fuser=5Fquotas=20is?= =?UTF-8?q?=20remaining=20(#2876)=20(#2881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.6. --- CHANGELOG.md | 3 +- open-sse/services/usage.ts | 26 ++- .../copilot-free-quota-not-inverted.test.ts | 178 ++++++++++++++++++ tests/unit/usage-service-hardening.test.ts | 32 +++- 4 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 tests/unit/copilot-free-quota-not-inverted.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1d046103..40c9612d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - **gemini:** translate signature-less Gemini thinking model tool calls to text parts to prevent `400 "missing thought_signature"` errors (#2801 โ€” thanks @herjarsa) - **translator:** strip `safety_identifier` from `/v1/responses` body before forwarding to Chat Completions upstream; fixes LobeHub-originated `400` errors (#2770) - **warning-cleanup:** relax node engine constraint to `>=22.0.0` and clean dependencies (keeping `marked-terminal` to prevent TUI REPL crash) (#2792 โ€” thanks @oyi77) +- **usage:** un-invert GitHub Copilot Free / limited plan quota โ€” `limited_user_quotas` is the *remaining* count, not used, so the dashboard now shows 100% when the quota is untouched and 0% when fully exhausted (#2876 โ€” thanks @androw) - **fix(cli):** register openclaw in the CLI tool-detector so it appears in `omniroute status` alongside its existing API and config support ([#2833](https://github.com/diegosouzapw/OmniRoute/issues/2833)) ### ๐Ÿงน Chores @@ -35,7 +36,7 @@ ### ๐Ÿ† Hall of Contributors A special thanks to everyone who contributed code, reviews, and tests for this release: -@akarray, @apoapostolov, @hartmark, @herjarsa, @jeferssonlemes, @oyi77 +@akarray, @androw, @apoapostolov, @hartmark, @herjarsa, @jeferssonlemes, @oyi77 --- diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 9b1758c079..2f36dbe365 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1261,9 +1261,17 @@ async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonR quotas, }; } else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) { - // Free/limited plan format + // Free/limited plan format. NOTE (#2876): the upstream field + // `limited_user_quotas[name]` is the *remaining* count for the month + // (it counts down toward 0 and resets on `limited_user_reset_date`), + // NOT the used count. The pre-3.8.6 implementation inverted this and + // showed "0% when not used / 100% when fully used" on the dashboard. + // Confirmed against three independent upstream parsers: + // - robinebers/openusage docs/providers/copilot.md (Free Tier table) + // - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment) + // - looplj/axonhub frontend/src/components/quota-badges.tsx const monthlyQuotas = toRecord(dataRecord.monthly_quotas); - const usedQuotas = toRecord(dataRecord.limited_user_quotas); + const remainingQuotas = toRecord(dataRecord.limited_user_quotas); const resetDate = getFieldValue( dataRecord, "limited_user_reset_date", @@ -1274,14 +1282,18 @@ async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonR const addLimitedQuota = (name: string) => { const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0); - const used = Math.max(0, toNumber(getFieldValue(usedQuotas, name, name), 0)); if (total <= 0) return null; - const clampedUsed = Math.min(used, total); + const remainingRaw = Math.max( + 0, + toNumber(getFieldValue(remainingQuotas, name, name), 0) + ); + const remaining = Math.min(remainingRaw, total); + const used = Math.max(total - remaining, 0); quotas[name] = { - used: clampedUsed, + used, total, - remaining: Math.max(total - clampedUsed, 0), - remainingPercentage: clampPercentage(((total - clampedUsed) / total) * 100), + remaining, + remainingPercentage: clampPercentage((remaining / total) * 100), unlimited: false, resetAt, }; diff --git a/tests/unit/copilot-free-quota-not-inverted.test.ts b/tests/unit/copilot-free-quota-not-inverted.test.ts new file mode 100644 index 0000000000..f7325f97cd --- /dev/null +++ b/tests/unit/copilot-free-quota-not-inverted.test.ts @@ -0,0 +1,178 @@ +/** + * Regression for #2876 โ€” GitHub Copilot Provider Quota rendered the other way around. + * + * Root cause: in the Free / limited plan path of `getGitHubUsage`, the closure + * `addLimitedQuota` treats `data.limited_user_quotas[name]` as the *used* count. + * Three independent upstream sources confirm it is the *remaining* count: + * + * 1. robinebers/openusage โ€” docs/providers/copilot.md (Free Tier example + + * "Displayed Lines" table โ€” every row labelled "remaining") + * 2. raycast/extensions โ€” agent-usage/src/copilot/fetcher.ts:77 + * ("`limited_user_quotas` behaves like the remaining amount for the month") + * 3. looplj/axonhub โ€” frontend/src/components/quota-badges.tsx:77-81 + * (destructures the value as `remaining` and computes `remaining / total`) + * + * These assertions therefore encode the upstream-correct semantics and FAIL on + * the unfixed code (the brand-new case shows 0% instead of 100%, exactly the + * symptom the reporter @androw saw). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usageService = await import("../../open-sse/services/usage.ts"); + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function stubFetch(payload: unknown) { + globalThis.fetch = async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("#2876 โ€” Copilot Free brand-new account shows 100% remaining (not 0%)", async () => { + // Reproduces the reporter's scenario: the account has never been used, so + // every entry of limited_user_quotas equals its monthly_quotas counterpart + // (full remaining = total). Before the fix this returns 0%. + stubFetch({ + copilot_plan: "free", + limited_user_reset_date: new Date(Date.now() + 30 * 24 * 60 * 60_000).toISOString(), + monthly_quotas: { + chat: 50, + completions: 2000, + }, + limited_user_quotas: { + chat: 50, + completions: 2000, + }, + }); + + const result: any = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho-brand-new", + }); + + assert.equal(result.quotas.chat.total, 50); + assert.equal(result.quotas.chat.remaining, 50); + assert.equal(result.quotas.chat.used, 0); + assert.equal( + result.quotas.chat.remainingPercentage, + 100, + "brand-new free account must show 100% remaining, not 0%" + ); + + assert.equal(result.quotas.completions.total, 2000); + assert.equal(result.quotas.completions.remaining, 2000); + assert.equal(result.quotas.completions.used, 0); + assert.equal(result.quotas.completions.remainingPercentage, 100); +}); + +test("#2876 โ€” Copilot Free realistic mid-month account computes correct remaining percentage", async () => { + // Numbers taken directly from robinebers/openusage docs/providers/copilot.md + // Free Tier example: chat 410/500 remaining, completions 4000/4000. + stubFetch({ + copilot_plan: "free", + limited_user_reset_date: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + monthly_quotas: { + chat: 500, + completions: 4000, + }, + limited_user_quotas: { + chat: 410, + completions: 4000, + }, + }); + + const result: any = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho-mid-month", + }); + + assert.equal(result.quotas.chat.total, 500); + assert.equal(result.quotas.chat.remaining, 410); + assert.equal(result.quotas.chat.used, 90); + assert.equal( + result.quotas.chat.remainingPercentage, + 82, + "410 of 500 remaining must surface as 82%, not 18%" + ); + + assert.equal(result.quotas.completions.remainingPercentage, 100); +}); + +test("#2876 โ€” Copilot Free fully-exhausted quota shows 0% remaining (not 100%)", async () => { + // The other end of the inversion: the user has burned through everything. + // limited_user_quotas counts down to 0; the dashboard must report 0%. + stubFetch({ + copilot_plan: "free", + limited_user_reset_date: new Date(Date.now() + 60_000).toISOString(), + monthly_quotas: { + chat: 50, + completions: 2000, + }, + limited_user_quotas: { + chat: 0, + completions: 0, + }, + }); + + const result: any = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho-exhausted", + }); + + assert.equal(result.quotas.chat.total, 50); + assert.equal(result.quotas.chat.remaining, 0); + assert.equal(result.quotas.chat.used, 50); + assert.equal( + result.quotas.chat.remainingPercentage, + 0, + "fully-exhausted quota must show 0% remaining, not 100%" + ); + + assert.equal(result.quotas.completions.remainingPercentage, 0); + assert.equal(result.quotas.completions.used, 2000); +}); + +test("#2876 โ€” Copilot paid plan (quota_snapshots) is unaffected by the fix", async () => { + // The paid path reads `remaining` / `percent_remaining` / `entitlement` + // directly โ€” those field names are correctly named upstream and require + // no semantic translation. Asserting the paid path still works guards + // against accidental scope creep. + stubFetch({ + copilot_plan: "pro", + quota_reset_date: new Date(Date.now() + 30 * 24 * 60 * 60_000).toISOString(), + quota_snapshots: { + premium_interactions: { + entitlement: 300, + remaining: 240, + percent_remaining: 80, + unlimited: false, + }, + chat: { + entitlement: 1000, + remaining: 950, + percent_remaining: 95, + unlimited: false, + }, + }, + }); + + const result: any = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho-pro", + }); + + assert.equal(result.quotas.premium_interactions.total, 300); + assert.equal(result.quotas.premium_interactions.remaining, 240); + assert.equal(result.quotas.premium_interactions.used, 60); + assert.equal(result.quotas.premium_interactions.remainingPercentage, 80); + + assert.equal(result.quotas.chat.remaining, 950); + assert.equal(result.quotas.chat.remainingPercentage, 95); +}); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 416d36551e..425e6c034a 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -21,6 +21,13 @@ test.afterEach(() => { }); test("usage service covers GitHub free-plan parsing, auth denial and unsupported providers", async () => { + // Free-plan fixture aligned with the upstream protocol (#2876): in + // `copilot_internal/user`, `limited_user_quotas[name]` is the REMAINING + // count for the month and counts down toward 0; `monthly_quotas[name]` + // is the total allowance. The chat numbers below (410 / 500) are the + // example values from robinebers/openusage docs/providers/copilot.md. + // We also keep an out-of-range premium_interactions remaining (70 > 50) + // to assert the defensive clamp at the upstream boundary. const calls: any[] = []; globalThis.fetch = async (_url, init = {}) => { calls.push(init); @@ -30,13 +37,13 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported limited_user_reset_date: new Date(Date.now() + 60_000).toISOString(), monthly_quotas: { premium_interactions: 50, - chat: 25, - completions: 10, + chat: 500, + completions: 4000, }, limited_user_quotas: { premium_interactions: 70, - chat: 5, - completions: 2, + chat: 410, + completions: 4000, }, }), { status: 200 } @@ -49,10 +56,21 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported }); assert.equal(freeUsage.plan, "Copilot Free"); + // premium_interactions: upstream remaining=70 clamped to total=50 โ†’ fully + // available, 0 used, 100% remaining. assert.equal(freeUsage.quotas.premium_interactions.total, 50); - assert.equal(freeUsage.quotas.premium_interactions.used, 50); - assert.equal(freeUsage.quotas.chat.remaining, 20); - assert.equal(freeUsage.quotas.completions.remainingPercentage, 80); + assert.equal(freeUsage.quotas.premium_interactions.remaining, 50); + assert.equal(freeUsage.quotas.premium_interactions.used, 0); + assert.equal(freeUsage.quotas.premium_interactions.remainingPercentage, 100); + // chat: 410 remaining of 500 โ†’ 82% remaining, 90 used. + assert.equal(freeUsage.quotas.chat.total, 500); + assert.equal(freeUsage.quotas.chat.remaining, 410); + assert.equal(freeUsage.quotas.chat.used, 90); + assert.equal(freeUsage.quotas.chat.remainingPercentage, 82); + // completions: untouched โ†’ 100% remaining. + assert.equal(freeUsage.quotas.completions.remaining, 4000); + assert.equal(freeUsage.quotas.completions.used, 0); + assert.equal(freeUsage.quotas.completions.remainingPercentage, 100); assert.equal(calls[0].headers.Authorization, "token gho-free"); assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.45.1"); assert.equal(calls[0].headers["Editor-Version"], "vscode/1.117.0");