Skip to content

Commit ba9e36d

Browse files
committed
Handle Copilot 'unlimited' and percent_remaining
Parse and respect GitHub's explicit premium_interactions fields for Copilot user quota. Added helpers getFirstNestedBoolean and normalizeExplicitPercentRemaining, looked up percent_remaining and unlimited paths, and use explicit percent_remaining when present (including negative values). If the response marks quota as unlimited, return a user_quota result with unlimited:true and percentRemaining defaulting to 100, update formatCopilotQuota to display "Copilot Unlimited", and map the unlimited result to a provider value row showing "Unlimited". Also added the optional unlimited flag to CopilotQuotaResult, updated tests to cover explicit percent, negative percent, and unlimited cases, and documented the behavior in README.
1 parent 838e7fc commit ba9e36d

6 files changed

Lines changed: 209 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ Run `/quota_status` and check the Anthropic section.
340340

341341
Run `/quota_status` and check `copilot_quota_auth`, `billing_mode`, `billing_scope`, and `quota_api`.
342342

343+
For personal Copilot OAuth quota, the plugin uses GitHub's reported premium-interaction quota fields when available. If that specific quota payload marks premium interactions as unlimited, quota output renders `Unlimited` instead of a synthetic used/total ratio.
344+
343345
| Symptom | Fix |
344346
| --- | --- |
345347
| Personal quota missing | Confirm OpenCode Copilot auth works. The plugin can read OpenCode's Copilot OAuth token. |

src/lib/copilot.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ function getFirstNestedString(source: unknown, paths: string[][]): string | unde
189189
return coerceNonEmptyString(getFirstNestedValue(source, paths));
190190
}
191191

192+
function getFirstNestedBoolean(source: unknown, paths: string[][]): boolean | undefined {
193+
const value = getFirstNestedValue(source, paths);
194+
return typeof value === "boolean" ? value : undefined;
195+
}
196+
197+
function normalizeExplicitPercentRemaining(value: number | undefined): number | undefined {
198+
if (value === undefined || !Number.isFinite(value)) return undefined;
199+
return Math.min(100, Math.floor(value));
200+
}
201+
192202
function normalizeCopilotTier(value: string | undefined): CopilotTier | undefined {
193203
const normalized = value?.trim().toLowerCase();
194204
if (!normalized) return undefined;
@@ -803,6 +813,22 @@ function toUserQuotaResultFromCopilotInternal(response: unknown): CopilotQuotaRe
803813
["quota_reset_date"],
804814
["quota_reset_at"],
805815
];
816+
const percentRemainingPaths = [
817+
["quota", "percent_remaining"],
818+
["monthly_quota", "percent_remaining"],
819+
["monthly_premium_requests", "percent_remaining"],
820+
["premium_requests", "percent_remaining"],
821+
["quota_snapshots", "premium_interactions", "percent_remaining"],
822+
["percent_remaining"],
823+
];
824+
const unlimitedPaths = [
825+
["quota", "unlimited"],
826+
["monthly_quota", "unlimited"],
827+
["monthly_premium_requests", "unlimited"],
828+
["premium_requests", "unlimited"],
829+
["quota_snapshots", "premium_interactions", "unlimited"],
830+
["unlimited"],
831+
];
806832
const tierPaths = [
807833
["plan", "type"],
808834
["plan", "name"],
@@ -815,6 +841,10 @@ function toUserQuotaResultFromCopilotInternal(response: unknown): CopilotQuotaRe
815841
let total = getFirstNestedNumber(response, totalPaths);
816842
let used = getFirstNestedNumber(response, usedPaths);
817843
const remaining = getFirstNestedNumber(response, remainingPaths);
844+
const unlimited = getFirstNestedBoolean(response, unlimitedPaths) === true;
845+
const explicitPercentRemaining = normalizeExplicitPercentRemaining(
846+
getFirstNestedNumber(response, percentRemainingPaths),
847+
);
818848
const resetTimeIso =
819849
normalizeResetTimeIso(getFirstNestedString(response, resetPaths)) ?? getApproxNextResetIso();
820850
const tier = normalizeCopilotTier(getFirstNestedString(response, tierPaths));
@@ -829,6 +859,18 @@ function toUserQuotaResultFromCopilotInternal(response: unknown): CopilotQuotaRe
829859
total = COPILOT_PLAN_LIMITS[tier];
830860
}
831861

862+
if (unlimited) {
863+
return {
864+
success: true,
865+
mode: "user_quota",
866+
used: Math.max(0, used ?? 0),
867+
total: Math.max(1, total ?? 1),
868+
percentRemaining: explicitPercentRemaining ?? 100,
869+
unlimited: true,
870+
resetTimeIso,
871+
};
872+
}
873+
832874
if (!Number.isFinite(total) || total === undefined || total <= 0 || used === undefined || used < 0) {
833875
throw new Error(
834876
"GitHub /copilot_internal/user response did not include usable personal quota fields.",
@@ -840,7 +882,7 @@ function toUserQuotaResultFromCopilotInternal(response: unknown): CopilotQuotaRe
840882
mode: "user_quota",
841883
used,
842884
total,
843-
percentRemaining: computePercentRemainingFromUsed({ used, total }),
885+
percentRemaining: explicitPercentRemaining ?? computePercentRemainingFromUsed({ used, total }),
844886
resetTimeIso,
845887
};
846888
}
@@ -1111,6 +1153,10 @@ export function formatCopilotQuota(result: CopilotResult): string | null {
11111153
return `Copilot Enterprise (${result.enterprise}) ${details.join(" | ")}`;
11121154
}
11131155

1156+
if (result.unlimited) {
1157+
return "Copilot Unlimited";
1158+
}
1159+
11141160
const percentUsed = 100 - result.percentRemaining;
11151161
return `Copilot ${result.used}/${result.total} (${percentUsed}%)`;
11161162
}

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ export interface CopilotQuotaResult {
451451
used: number;
452452
total: number;
453453
percentRemaining: number;
454+
unlimited?: boolean;
454455
resetTimeIso?: string;
455456
}
456457

src/providers/copilot.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ export const copilotProvider: QuotaProvider = {
9292
);
9393
}
9494

95+
if (result.unlimited) {
96+
return attemptedResult(
97+
[
98+
{
99+
kind: "value",
100+
name: "Copilot",
101+
group: getCopilotGroup(result.mode),
102+
label: "Quota:",
103+
value: "Unlimited",
104+
resetTimeIso: result.resetTimeIso,
105+
},
106+
],
107+
[],
108+
);
109+
}
110+
95111
return attemptedResult(
96112
[
97113
{

tests/lib.copilot.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,122 @@ describe("queryCopilotQuota", () => {
244244
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://api.github.com/copilot_internal/user");
245245
});
246246

247+
it("uses explicit percent_remaining from /copilot_internal/user when present", async () => {
248+
authMocks.readAuthFile.mockResolvedValueOnce({
249+
"github-copilot": { type: "oauth", access: "oauth_access_token", refresh: "refresh" },
250+
});
251+
252+
vi.stubGlobal(
253+
"fetch",
254+
vi.fn(async () =>
255+
new Response(
256+
JSON.stringify({
257+
quota_reset_date_utc: "2026-05-01T00:00:00.000Z",
258+
quota_snapshots: {
259+
premium_interactions: {
260+
entitlement: 1500,
261+
remaining: 401,
262+
percent_remaining: 26.7,
263+
unlimited: false,
264+
},
265+
},
266+
}),
267+
{ status: 200 },
268+
),
269+
) as any,
270+
);
271+
272+
const { queryCopilotQuota } = await import("../src/lib/copilot.js");
273+
const result = await queryCopilotQuota();
274+
275+
expect(result).toEqual({
276+
success: true,
277+
mode: "user_quota",
278+
used: 1099,
279+
total: 1500,
280+
percentRemaining: 26,
281+
resetTimeIso: "2026-05-01T00:00:00.000Z",
282+
});
283+
});
284+
285+
it("preserves explicit negative percent_remaining for over-quota responses", async () => {
286+
authMocks.readAuthFile.mockResolvedValueOnce({
287+
"github-copilot": { type: "oauth", access: "oauth_access_token", refresh: "refresh" },
288+
});
289+
290+
vi.stubGlobal(
291+
"fetch",
292+
vi.fn(async () =>
293+
new Response(
294+
JSON.stringify({
295+
quota_reset_date_utc: "2026-05-01T00:00:00.000Z",
296+
quota_snapshots: {
297+
premium_interactions: {
298+
entitlement: 300,
299+
remaining: -60,
300+
percent_remaining: -20,
301+
unlimited: false,
302+
},
303+
},
304+
}),
305+
{ status: 200 },
306+
),
307+
) as any,
308+
);
309+
310+
const { queryCopilotQuota } = await import("../src/lib/copilot.js");
311+
const result = await queryCopilotQuota();
312+
313+
expect(result).toEqual({
314+
success: true,
315+
mode: "user_quota",
316+
used: 360,
317+
total: 300,
318+
percentRemaining: -20,
319+
resetTimeIso: "2026-05-01T00:00:00.000Z",
320+
});
321+
});
322+
323+
it("treats explicit unlimited premium_interactions as unlimited", async () => {
324+
authMocks.readAuthFile.mockResolvedValueOnce({
325+
"github-copilot": { type: "oauth", access: "oauth_access_token", refresh: "refresh" },
326+
});
327+
328+
vi.stubGlobal(
329+
"fetch",
330+
vi.fn(async () =>
331+
new Response(
332+
JSON.stringify({
333+
quota_reset_date_utc: "2026-04-01T00:00:00.000Z",
334+
quota_snapshots: {
335+
premium_interactions: {
336+
entitlement: 1,
337+
remaining: 1,
338+
percent_remaining: 100,
339+
unlimited: true,
340+
},
341+
},
342+
}),
343+
{ status: 200 },
344+
),
345+
) as any,
346+
);
347+
348+
const { formatCopilotQuota, queryCopilotQuota } = await import("../src/lib/copilot.js");
349+
const result = await queryCopilotQuota();
350+
351+
expect(result).toEqual({
352+
success: true,
353+
mode: "user_quota",
354+
used: 0,
355+
total: 1,
356+
percentRemaining: 100,
357+
unlimited: true,
358+
resetTimeIso: "2026-04-01T00:00:00.000Z",
359+
});
360+
expect(formatCopilotQuota(result)).toBe("Copilot Unlimited");
361+
});
362+
247363
it("returns a clear error when OAuth auth exists without an access token", async () => {
248364
authMocks.readAuthFile.mockResolvedValueOnce({
249365
"github-copilot": { type: "oauth", refresh: "refresh_only" },

tests/providers.copilot.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ describe("copilot provider", () => {
4747
expect(out.presentation).toBeUndefined();
4848
});
4949

50+
it("maps explicit unlimited personal quota into a value row", async () => {
51+
const { queryCopilotQuota } = await import("../src/lib/copilot.js");
52+
(queryCopilotQuota as any).mockResolvedValueOnce({
53+
success: true,
54+
mode: "user_quota",
55+
used: 0,
56+
total: 1,
57+
percentRemaining: 100,
58+
unlimited: true,
59+
resetTimeIso: "2026-02-01T00:00:00.000Z",
60+
});
61+
62+
const out = await copilotProvider.fetch({} as any);
63+
expectAttemptedWithNoErrors(out);
64+
expect(out.entries).toEqual([
65+
{
66+
kind: "value",
67+
name: "Copilot",
68+
group: "Copilot (personal)",
69+
label: "Quota:",
70+
value: "Unlimited",
71+
resetTimeIso: "2026-02-01T00:00:00.000Z",
72+
},
73+
]);
74+
expect(out.presentation).toBeUndefined();
75+
});
76+
5077
it("maps organization usage into a grouped-capable business entry", async () => {
5178
const { queryCopilotQuota } = await import("../src/lib/copilot.js");
5279
(queryCopilotQuota as any).mockResolvedValueOnce({

0 commit comments

Comments
 (0)