Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

---

Expand Down
26 changes: 19 additions & 7 deletions open-sse/services/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
};
Expand Down
178 changes: 178 additions & 0 deletions tests/unit/copilot-free-quota-not-inverted.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 25 additions & 7 deletions tests/unit/usage-service-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 }
Expand All @@ -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");
Expand Down