mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
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:
committed by
GitHub
parent
01041967de
commit
0a101b95b3
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
178
tests/unit/copilot-free-quota-not-inverted.test.ts
Normal file
178
tests/unit/copilot-free-quota-not-inverted.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user