From 7926a777932330e4bac0c67b218e4d3eab912d36 Mon Sep 17 00:00:00 2001 From: Victor M Varela Date: Wed, 30 Sep 2026 09:41:59 +0200 Subject: [PATCH 1/2] fix(copilot): show over-limit AI credits --- docs/readme/providers.md | 1 + src/lib/copilot.ts | 29 ++++++- tests/lib.copilot.test.ts | 137 ++++++++++++++++++++++++++++++++ tests/providers.copilot.test.ts | 32 ++++++++ 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/docs/readme/providers.md b/docs/readme/providers.md index 97545eb6..3634774b 100644 --- a/docs/readme/providers.md +++ b/docs/readme/providers.md @@ -280,6 +280,7 @@ Organization example (for an enterprise, use `"tier": "enterprise"`, add `"enter What Copilot reports - **OpenCode login:** GitHub's undocumented internal `premium_interactions` snapshot, labeled **Copilot Premium Interactions** (not assumed to match public AI Credit billing). GitHub supplies the entitlement, remaining amount, optional percentage, unlimited state, and reset. Used = entitlement minus remaining; a percentage is calculated only when GitHub omits one. Token-based placeholder responses show the plan only. +- **Token-based billing over limit:** once token-based billing usage passes the entitlement, GitHub reports a negative remaining amount. OpenCode then uses the provider-reported `credits_used` total (GitHub's own UI amount), so the row shows exactly what GitHub charged. If `credits_used` is missing or invalid, usage falls back to entitlement minus remaining and is marked locally derived. The remaining percentage stays provider-reported and does not exceed 100% when over limit. - **Personal token:** GitHub's public billing report for the current UTC calendar month. It is usage only, for example `Used 100 | Included 80 | Billed 20 ($0.20)`, with no allowance, remaining percentage, or reset. A missing percentage is expected. - **Organization and enterprise tokens:** the same used/included/billed fields for that payer. If GitHub returns an additional-usage budget, it shows as a separate **Copilot Additional Usage** row comparing billed spend with that budget. diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index e5c7b321..2a4ffb5c 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -192,6 +192,7 @@ interface CopilotInternalQuotaSnapshot { remaining?: number; quota_remaining?: number; percent_remaining?: number; + credits_used?: number; unlimited?: boolean; } @@ -1237,8 +1238,9 @@ function parseCopilotInternalUser( reportedPercentRemaining !== undefined && reportedPercentRemaining >= 0 && reportedPercentRemaining <= 100; + const tokenBasedBilling = response.token_based_billing === true; const isPlaceholder = - response.token_based_billing === true && + tokenBasedBilling && (!snapshot || (snapshot.unlimited !== true && entitlement === 0 && @@ -1289,6 +1291,31 @@ function parseCopilotInternalUser( resetTimeIso, }; } + // Token-based billing reports a negative remaining once usage goes over the + // entitlement, alongside a provider-reported clamp in [0, 100]. + const overLimitCreditsUsed = readFiniteNumber(snapshot?.credits_used); + if ( + tokenBasedBilling && + entitlement !== undefined && + entitlement > 0 && + remaining !== undefined && + remaining < 0 && + hasReportedPercent + ) { + const creditsUsed = overLimitCreditsUsed; + return { + success: true, + mode: "user_quota", + unit: "premium_interactions", + used: creditsUsed !== undefined && creditsUsed >= 0 ? creditsUsed : entitlement - remaining, + authority: + creditsUsed !== undefined && creditsUsed >= 0 ? "provider_reported" : "locally_derived", + total: entitlement, + percentRemaining: reportedPercentRemaining, + plan, + resetTimeIso, + }; + } if (response.token_based_billing === true) { return { success: true, diff --git a/tests/lib.copilot.test.ts b/tests/lib.copilot.test.ts index 040174df..7cbad3ab 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -401,6 +401,143 @@ describe("GitHub Copilot AI Credit accounting", () => { }); }); + it("renders token-based over-limit usage as a quota row using the reported percentage", async () => { + authMocks.readAuthFile.mockResolvedValue({ + "github-copilot": { type: "oauth", access: "oauth-token" }, + }); + // Representative of a Copilot Business token-billing response after the + // included pool is exhausted: entitlement 3000, quota_remaining -739.5, + // credits_used 3739, percent_remaining 0. + vi.stubGlobal( + "fetch", + vi.fn(async () => + json( + copilotUser({ + copilot_plan: "business", + quota_snapshots: { + premium_interactions: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 0, + credits_used: 3739, + unlimited: false, + }, + }, + }), + ), + ) as any, + ); + + const { queryCopilotQuota, formatCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + expect(result).toEqual({ + success: true, + mode: "user_quota", + unit: "premium_interactions", + used: 3739, + authority: "provider_reported", + total: 3000, + percentRemaining: 0, + plan: "business", + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + expect(result && result.success ? formatCopilotQuota(result) : null).toBe( + "Copilot Premium Interactions 3739/3000", + ); + }); + + it("falls back to derived over-limit usage without a usable credits_used", async () => { + authMocks.readAuthFile.mockResolvedValue({ + "github-copilot": { type: "oauth", access: "oauth-token" }, + }); + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + + for (const creditsUsed of [undefined, -1, "3739"]) { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json( + copilotUser({ + copilot_plan: "business", + quota_snapshots: { + premium_interactions: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 0, + credits_used: creditsUsed, + unlimited: false, + }, + }, + }), + ), + ) as any, + ); + const result = await queryCopilotQuota(); + expect(result).toMatchObject({ + success: true, + mode: "user_quota", + used: 3739.5, + authority: "locally_derived", + total: 3000, + percentRemaining: 0, + }); + } + }); + + it("does not trust a negative remaining without token billing or a valid reported percentage", async () => { + authMocks.readAuthFile.mockResolvedValue({ + "github-copilot": { type: "oauth", access: "oauth-token" }, + }); + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + + for (const { tokenBasedBilling, snapshot, expectedMode } of [ + { + tokenBasedBilling: false, + snapshot: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 0, + unlimited: false, + }, + expectedMode: "error", + }, + { + tokenBasedBilling: true, + snapshot: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 140, + unlimited: false, + }, + expectedMode: "user_plan", + }, + ] as const) { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json( + copilotUser({ + token_based_billing: tokenBasedBilling, + quota_snapshots: { premium_interactions: snapshot }, + }), + ), + ) as any, + ); + const result = await queryCopilotQuota(); + if (expectedMode === "error") { + expect(result && !result.success ? result.error : "").toContain( + "did not include a usable quota snapshot", + ); + } else { + expect(result).toMatchObject({ success: true, mode: "user_plan" }); + } + } + }); + it.each([ "http://acme.ghe.com", "https://user@acme.ghe.com", diff --git a/tests/providers.copilot.test.ts b/tests/providers.copilot.test.ts index 48806632..1b7dd1aa 100644 --- a/tests/providers.copilot.test.ts +++ b/tests/providers.copilot.test.ts @@ -120,6 +120,38 @@ describe("copilot provider", () => { }); }); + it("renders token-based over-limit usage as a quota row with a clamped percentage", async () => { + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + (queryCopilotQuota as any).mockResolvedValueOnce({ + success: true, + mode: "user_quota", + unit: "premium_interactions", + used: 3739, + total: 3000, + percentRemaining: 0, + authority: "provider_reported", + plan: "business", + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + + const out = await copilotProvider.fetch({} as any); + expectAttemptedWithNoErrors(out); + expect(visibleEntries(out.entries, "copilot")).toEqual([ + { + name: "Copilot Premium Interactions", + group: "Copilot (business)", + label: "Quota:", + right: "3,739/3,000", + percentRemaining: 0, + resetTimeIso: "2026-02-01T00:00:00.000Z", + }, + ]); + expect(out.entries[0]?.accounting).toMatchObject({ + resultType: "quota", + authority: "provider_reported", + }); + }); + it("uses the OAuth GHE Copilot plan in the credential presentation without changing billing scope", async () => { const { getCopilotQuotaAuthDiagnostics, queryCopilotQuota } = await import( "../src/lib/copilot.js" From 38049211c7719ea1f08edca7a1ab72f6caf50f51 Mon Sep 17 00:00:00 2001 From: Victor M Varela Date: Wed, 30 Sep 2026 10:59:45 +0200 Subject: [PATCH 2/2] fix(copilot): label reported AI credits --- docs/readme/providers.md | 4 +- src/lib/copilot.ts | 11 +- tests/lib.copilot.test.ts | 174 +++++++++++++++++--------------- tests/providers.copilot.test.ts | 8 +- 4 files changed, 105 insertions(+), 92 deletions(-) diff --git a/docs/readme/providers.md b/docs/readme/providers.md index 3634774b..207b04d3 100644 --- a/docs/readme/providers.md +++ b/docs/readme/providers.md @@ -279,8 +279,8 @@ Organization example (for an enterprise, use `"tier": "enterprise"`, add `"enter
What Copilot reports -- **OpenCode login:** GitHub's undocumented internal `premium_interactions` snapshot, labeled **Copilot Premium Interactions** (not assumed to match public AI Credit billing). GitHub supplies the entitlement, remaining amount, optional percentage, unlimited state, and reset. Used = entitlement minus remaining; a percentage is calculated only when GitHub omits one. Token-based placeholder responses show the plan only. -- **Token-based billing over limit:** once token-based billing usage passes the entitlement, GitHub reports a negative remaining amount. OpenCode then uses the provider-reported `credits_used` total (GitHub's own UI amount), so the row shows exactly what GitHub charged. If `credits_used` is missing or invalid, usage falls back to entitlement minus remaining and is marked locally derived. The remaining percentage stays provider-reported and does not exceed 100% when over limit. +- **OpenCode login:** GitHub's undocumented internal `premium_interactions` snapshot, normally labeled **Copilot Premium Interactions** (not assumed to match public AI Credit billing). GitHub supplies the entitlement, remaining amount, optional percentage, unlimited state, and reset. For in-range snapshots, used is derived from entitlement minus remaining; a percentage is calculated only when GitHub omits one. Token-based placeholder responses show the plan only. +- **Token-based billing over limit:** once token-based billing usage passes the entitlement, GitHub reports a negative remaining amount plus a GitHub-reported `credits_used` AI-credit usage total. OpenCode shows that reported total; if it is missing or invalid, usage falls back to entitlement minus remaining and is marked locally derived. The remaining percentage stays provider-reported and does not exceed 100% when over limit. - **Personal token:** GitHub's public billing report for the current UTC calendar month. It is usage only, for example `Used 100 | Included 80 | Billed 20 ($0.20)`, with no allowance, remaining percentage, or reset. A missing percentage is expected. - **Organization and enterprise tokens:** the same used/included/billed fields for that payer. If GitHub returns an additional-usage budget, it shows as a separate **Copilot Additional Usage** row comparing billed spend with that budget. diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index 2a4ffb5c..96c254b3 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -1293,7 +1293,8 @@ function parseCopilotInternalUser( } // Token-based billing reports a negative remaining once usage goes over the // entitlement, alongside a provider-reported clamp in [0, 100]. - const overLimitCreditsUsed = readFiniteNumber(snapshot?.credits_used); + const creditsUsed = readFiniteNumber(snapshot?.credits_used); + const hasValidCreditsUsed = creditsUsed !== undefined && creditsUsed >= 0; if ( tokenBasedBilling && entitlement !== undefined && @@ -1302,14 +1303,12 @@ function parseCopilotInternalUser( remaining < 0 && hasReportedPercent ) { - const creditsUsed = overLimitCreditsUsed; return { success: true, mode: "user_quota", - unit: "premium_interactions", - used: creditsUsed !== undefined && creditsUsed >= 0 ? creditsUsed : entitlement - remaining, - authority: - creditsUsed !== undefined && creditsUsed >= 0 ? "provider_reported" : "locally_derived", + unit: hasValidCreditsUsed ? "ai_credits" : "premium_interactions", + used: hasValidCreditsUsed ? creditsUsed : entitlement - remaining, + authority: hasValidCreditsUsed ? "provider_reported" : "locally_derived", total: entitlement, percentRemaining: reportedPercentRemaining, plan, diff --git a/tests/lib.copilot.test.ts b/tests/lib.copilot.test.ts index 7cbad3ab..ebdcec7c 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -401,7 +401,7 @@ describe("GitHub Copilot AI Credit accounting", () => { }); }); - it("renders token-based over-limit usage as a quota row using the reported percentage", async () => { + it("renders token-based over-limit AI credits as a quota row using the reported percentage", async () => { authMocks.readAuthFile.mockResolvedValue({ "github-copilot": { type: "oauth", access: "oauth-token" }, }); @@ -434,7 +434,7 @@ describe("GitHub Copilot AI Credit accounting", () => { expect(result).toEqual({ success: true, mode: "user_quota", - unit: "premium_interactions", + unit: "ai_credits", used: 3739, authority: "provider_reported", total: 3000, @@ -443,98 +443,112 @@ describe("GitHub Copilot AI Credit accounting", () => { resetTimeIso: "2026-02-01T00:00:00.000Z", }); expect(result && result.success ? formatCopilotQuota(result) : null).toBe( - "Copilot Premium Interactions 3739/3000", + "Copilot AI Credits 3739/3000", ); }); - it("falls back to derived over-limit usage without a usable credits_used", async () => { + it.each([ + { credits_used: undefined }, + { credits_used: -1 }, + { credits_used: "3739" }, + ])("falls back to derived over-limit premium interactions without a usable credits_used", async (snapshot) => { authMocks.readAuthFile.mockResolvedValue({ "github-copilot": { type: "oauth", access: "oauth-token" }, }); - const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - - for (const creditsUsed of [undefined, -1, "3739"]) { - vi.stubGlobal( - "fetch", - vi.fn(async () => - json( - copilotUser({ - copilot_plan: "business", - quota_snapshots: { - premium_interactions: { - entitlement: 3000, - remaining: -740, - quota_remaining: -739.5, - percent_remaining: 0, - credits_used: creditsUsed, - unlimited: false, - }, + vi.stubGlobal( + "fetch", + vi.fn(async () => + json( + copilotUser({ + copilot_plan: "business", + quota_snapshots: { + premium_interactions: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 0, + unlimited: false, + ...snapshot, }, - }), - ), - ) as any, - ); - const result = await queryCopilotQuota(); - expect(result).toMatchObject({ - success: true, - mode: "user_quota", - used: 3739.5, - authority: "locally_derived", - total: 3000, - percentRemaining: 0, - }); - } + }, + }), + ), + ) as any, + ); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + await expect(queryCopilotQuota()).resolves.toMatchObject({ + success: true, + mode: "user_quota", + unit: "premium_interactions", + used: 3739.5, + authority: "locally_derived", + total: 3000, + percentRemaining: 0, + }); }); - it("does not trust a negative remaining without token billing or a valid reported percentage", async () => { + it.each([ + { + tokenBasedBilling: false, + expected: "error" as const, + snapshot: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 0, + unlimited: false, + }, + }, + { + tokenBasedBilling: true, + expected: "plan_only" as const, + snapshot: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + percent_remaining: 140, + unlimited: false, + }, + }, + { + tokenBasedBilling: true, + expected: "plan_only" as const, + snapshot: { + entitlement: 3000, + remaining: -740, + quota_remaining: -739.5, + unlimited: false, + }, + }, + ])("does not trust a negative remaining without token billing or a valid reported percentage", async ({ + tokenBasedBilling, + expected, + snapshot, + }) => { authMocks.readAuthFile.mockResolvedValue({ "github-copilot": { type: "oauth", access: "oauth-token" }, }); - const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + vi.stubGlobal( + "fetch", + vi.fn(async () => + json( + copilotUser({ + token_based_billing: tokenBasedBilling, + quota_snapshots: { premium_interactions: snapshot }, + }), + ), + ) as any, + ); - for (const { tokenBasedBilling, snapshot, expectedMode } of [ - { - tokenBasedBilling: false, - snapshot: { - entitlement: 3000, - remaining: -740, - quota_remaining: -739.5, - percent_remaining: 0, - unlimited: false, - }, - expectedMode: "error", - }, - { - tokenBasedBilling: true, - snapshot: { - entitlement: 3000, - remaining: -740, - quota_remaining: -739.5, - percent_remaining: 140, - unlimited: false, - }, - expectedMode: "user_plan", - }, - ] as const) { - vi.stubGlobal( - "fetch", - vi.fn(async () => - json( - copilotUser({ - token_based_billing: tokenBasedBilling, - quota_snapshots: { premium_interactions: snapshot }, - }), - ), - ) as any, + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + if (expected === "error") { + expect(result && !result.success ? result.error : "").toContain( + "did not include a usable quota snapshot", ); - const result = await queryCopilotQuota(); - if (expectedMode === "error") { - expect(result && !result.success ? result.error : "").toContain( - "did not include a usable quota snapshot", - ); - } else { - expect(result).toMatchObject({ success: true, mode: "user_plan" }); - } + } else { + expect(result).toMatchObject({ success: true, mode: "user_plan" }); } }); diff --git a/tests/providers.copilot.test.ts b/tests/providers.copilot.test.ts index 1b7dd1aa..54242324 100644 --- a/tests/providers.copilot.test.ts +++ b/tests/providers.copilot.test.ts @@ -120,12 +120,12 @@ describe("copilot provider", () => { }); }); - it("renders token-based over-limit usage as a quota row with a clamped percentage", async () => { + it("renders token-based over-limit AI credits as a quota row with a clamped percentage", async () => { const { queryCopilotQuota } = await import("../src/lib/copilot.js"); (queryCopilotQuota as any).mockResolvedValueOnce({ success: true, mode: "user_quota", - unit: "premium_interactions", + unit: "ai_credits", used: 3739, total: 3000, percentRemaining: 0, @@ -138,9 +138,9 @@ describe("copilot provider", () => { expectAttemptedWithNoErrors(out); expect(visibleEntries(out.entries, "copilot")).toEqual([ { - name: "Copilot Premium Interactions", + name: "Copilot AI Credits", group: "Copilot (business)", - label: "Quota:", + label: "Credits:", right: "3,739/3,000", percentRemaining: 0, resetTimeIso: "2026-02-01T00:00:00.000Z",