diff --git a/docs/readme/providers.md b/docs/readme/providers.md index 97545eb6..207b04d3 100644 --- a/docs/readme/providers.md +++ b/docs/readme/providers.md @@ -279,7 +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. +- **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 e5c7b321..96c254b3 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,30 @@ 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 creditsUsed = readFiniteNumber(snapshot?.credits_used); + const hasValidCreditsUsed = creditsUsed !== undefined && creditsUsed >= 0; + if ( + tokenBasedBilling && + entitlement !== undefined && + entitlement > 0 && + remaining !== undefined && + remaining < 0 && + hasReportedPercent + ) { + return { + success: true, + mode: "user_quota", + unit: hasValidCreditsUsed ? "ai_credits" : "premium_interactions", + used: hasValidCreditsUsed ? creditsUsed : entitlement - remaining, + authority: hasValidCreditsUsed ? "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..ebdcec7c 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -401,6 +401,157 @@ describe("GitHub Copilot AI Credit accounting", () => { }); }); + 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" }, + }); + // 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: "ai_credits", + 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 AI Credits 3739/3000", + ); + }); + + 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" }, + }); + 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 { 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.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" }, + }); + 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", + ); + } 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..54242324 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 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: "ai_credits", + 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 AI Credits", + group: "Copilot (business)", + label: "Credits:", + 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"