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 docs/readme/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ Organization example (for an enterprise, use `"tier": "enterprise"`, add `"enter
<details>
<summary><strong>What Copilot reports</strong></summary>

- **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.

Expand Down
28 changes: 27 additions & 1 deletion src/lib/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ interface CopilotInternalQuotaSnapshot {
remaining?: number;
quota_remaining?: number;
percent_remaining?: number;
credits_used?: number;
unlimited?: boolean;
}

Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions tests/lib.copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions tests/providers.copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down