fix(usage): un-invert GitHub Copilot Free/limited quota — limited_user_quotas is remaining (#2876) (#2881)

Integrated into release/v3.8.6.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-29 00:59:29 -03:00
committed by GitHub
parent 01041967de
commit 0a101b95b3
4 changed files with 224 additions and 15 deletions

View File

@@ -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
---

View File

@@ -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,
};

View File

@@ -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);
});

View File

@@ -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");