From 1459b4b360850880df085f1526ed751cc07299a6 Mon Sep 17 00:00:00 2001 From: Jakub Syty Date: Wed, 26 Aug 2026 13:58:31 +0200 Subject: [PATCH] Adjust for unlimited plan option --- packages/vscode/src/quotaProviders.test.ts | 45 +++++++++++++++++++ packages/vscode/src/quotaProviders.ts | 34 ++++++++++---- .../web/server/lib/quota/DOCUMENTATION.md | 10 +++++ .../web/server/lib/quota/providers/copilot.js | 34 ++++++++++---- .../lib/quota/providers/copilot.test.js | 40 ++++++++++++++++- 5 files changed, 143 insertions(+), 20 deletions(-) diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 9473a28b..1875c2e0 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -267,6 +267,51 @@ describe('GitHub Copilot quota provider (VS Code parity)', () => { assert.equal(result.usage!.windows.premium_interactions!.usedPercent, 25); assert.equal(result.usage!.windows.premium_interactions!.valueLabel, '225 / 300 left'); }); + + test('add-on path mirrors the primary window shaping', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 300, remaining: 225 }, + }, + }))); + + const result = await fetchQuotaForProvider('github-copilot-addon'); + + assert.equal(result.ok, true); + assert.deepEqual(Object.keys(result.usage!.windows), ['premium_interactions']); + assert.equal(result.usage!.windows.premium_interactions!.usedPercent, 25); + }); + + test('reports unlimited plans without a percent', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { unlimited: true, entitlement: -1, remaining: -1 }, + }, + }))); + + const result = await fetchQuotaForProvider('github-copilot'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.premium_interactions!.usedPercent, null); + assert.equal(result.usage!.windows.premium_interactions!.valueLabel, 'Unlimited'); + }); + + test('falls back to percent_remaining when entitlement is unusable', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 75.5 }, + }, + }))); + + const result = await fetchQuotaForProvider('github-copilot'); + + assert.equal(result.ok, true); + assert.ok(Math.abs(result.usage!.windows.premium_interactions!.usedPercent! - 24.5) < 1e-9); + assert.equal(result.usage!.windows.premium_interactions!.valueLabel, undefined); + }); }); describe('Claude quota provider (VS Code parity)', () => { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 6460bc0f..9777012a 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1469,14 +1469,35 @@ const buildCopilotWindows = (payload: Record) => { const resetAt = toTimestamp(payload.quota_reset_date); const windows: Record = {}; + // Mirrors the quota semantics of microsoft/vscode-copilot-chat + // (CopilotUserQuotaInfo): each snapshot carries entitlement, remaining, + // unlimited, and percent_remaining. Unlimited plans report no usable + // entitlement; percent_remaining is a server-computed fallback. const addWindow = (label: string, snapshot?: Record) => { if (!snapshot) return; + + if (snapshot.unlimited === true) { + windows[label] = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt, + valueLabel: 'Unlimited', + }); + return; + } + const entitlement = toNumber(snapshot.entitlement); const remaining = toNumber(snapshot.remaining); - const usedPercent = entitlement && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) + let usedPercent = entitlement !== null && entitlement > 0 && remaining !== null + ? Math.min(100, Math.max(0, 100 - (remaining / entitlement) * 100)) : null; - const valueLabel = entitlement !== null && remaining !== null + if (usedPercent === null) { + const percentRemaining = toNumber(snapshot.percent_remaining); + if (percentRemaining !== null) { + usedPercent = Math.min(100, Math.max(0, 100 - percentRemaining)); + } + } + const valueLabel = entitlement !== null && entitlement > 0 && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` : null; windows[label] = toUsageWindow({ @@ -1584,17 +1605,12 @@ const fetchCopilotAddonQuota = async (): Promise => { } const payload = await response.json() as Record; - const windows = buildCopilotWindows(payload); - const premium = windows.premium_interactions - ? { premium_interactions: windows.premium_interactions } - : windows; - return buildResult({ providerId: 'github-copilot-addon', providerName: 'GitHub Copilot Add-on', ok: true, configured: true, - usage: { windows: premium }, + usage: { windows: buildCopilotWindows(payload) }, }); } catch (error) { return buildResult({ diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index fcf28849..1897336b 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -106,6 +106,16 @@ completion quota are intentionally omitted. Keep `packages/web/server/lib/quota/providers/copilot.js` and `packages/vscode/src/quotaProviders.ts` in sync. +The `/copilot_internal/user` endpoint is undocumented; its quota semantics mirror +what `microsoft/vscode-copilot-chat` consumes (`CopilotUserQuotaInfo`). Each +snapshot carries `entitlement`, `remaining`, `unlimited`, and +`percent_remaining`. Providers must honor these rules: + +- `unlimited: true` renders a percent-less window with an "Unlimited" value label. +- Percent math requires a positive `entitlement`; entitlements of `0`, `-1`, or null are unusable. +- When entitlement/remaining are unusable, fall back to `100 - percent_remaining`. +- Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows. + ## Notes for contributors - Keep provider IDs stable; clients use them directly. - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. diff --git a/packages/web/server/lib/quota/providers/copilot.js b/packages/web/server/lib/quota/providers/copilot.js index 71037c93..538411c8 100644 --- a/packages/web/server/lib/quota/providers/copilot.js +++ b/packages/web/server/lib/quota/providers/copilot.js @@ -13,14 +13,35 @@ const buildCopilotWindows = (payload) => { const resetAt = toTimestamp(payload?.quota_reset_date); const windows = {}; + // Mirrors the quota semantics of microsoft/vscode-copilot-chat + // (CopilotUserQuotaInfo): each snapshot carries entitlement, remaining, + // unlimited, and percent_remaining. Unlimited plans report no usable + // entitlement; percent_remaining is a server-computed fallback. const addWindow = (label, snapshot) => { if (!snapshot) return; + + if (snapshot.unlimited === true) { + windows[label] = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt, + valueLabel: 'Unlimited' + }); + return; + } + const entitlement = toNumber(snapshot.entitlement); const remaining = toNumber(snapshot.remaining); - const usedPercent = entitlement && remaining !== null - ? Math.max(0, 100 - (remaining / entitlement) * 100) + let usedPercent = entitlement !== null && entitlement > 0 && remaining !== null + ? Math.min(100, Math.max(0, 100 - (remaining / entitlement) * 100)) : null; - const valueLabel = entitlement !== null && remaining !== null + if (usedPercent === null) { + const percentRemaining = toNumber(snapshot.percent_remaining); + if (percentRemaining !== null) { + usedPercent = Math.min(100, Math.max(0, 100 - percentRemaining)); + } + } + const valueLabel = entitlement !== null && entitlement > 0 && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` : null; windows[label] = toUsageWindow({ @@ -141,17 +162,12 @@ export const fetchQuotaAddon = async () => { } const payload = await response.json(); - const windows = buildCopilotWindows(payload); - const premium = windows.premium_interactions - ? { premium_interactions: windows.premium_interactions } - : windows; - return buildResult({ providerId: providerIdAddon, providerName: providerNameAddon, ok: true, configured: true, - usage: { windows: premium } + usage: { windows: buildCopilotWindows(payload) } }); } catch (error) { return buildResult({ diff --git a/packages/web/server/lib/quota/providers/copilot.test.js b/packages/web/server/lib/quota/providers/copilot.test.js index 15e3c963..5038ed49 100644 --- a/packages/web/server/lib/quota/providers/copilot.test.js +++ b/packages/web/server/lib/quota/providers/copilot.test.js @@ -19,10 +19,10 @@ const payload = { }, }; -const mockResponse = () => ({ +const mockResponse = (body = payload) => ({ ok: true, status: 200, - json: async () => payload, + json: async () => body, }); describe('GitHub Copilot quota provider', () => { @@ -39,4 +39,40 @@ describe('GitHub Copilot quota provider', () => { expect(result.usage.windows.premium_interactions.usedPercent).toBe(25); expect(result.usage.windows.premium_interactions.valueLabel).toBe('225 / 300 left'); }); + + it.each([ + ['primary provider', fetchQuota], + ['add-on provider', fetchQuotaAddon], + ])('reports unlimited plans without a percent for the %s', async (_name, fetchProviderQuota) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { unlimited: true, entitlement: -1, remaining: -1 }, + }, + }))); + + const result = await fetchProviderQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.premium_interactions.usedPercent).toBeNull(); + expect(result.usage.windows.premium_interactions.valueLabel).toBe('Unlimited'); + }); + + it.each([ + ['primary provider', fetchQuota], + ['add-on provider', fetchQuotaAddon], + ])('falls back to percent_remaining when entitlement is unusable for the %s', async (_name, fetchProviderQuota) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 75.5 }, + }, + }))); + + const result = await fetchProviderQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.premium_interactions.usedPercent).toBeCloseTo(24.5); + expect(result.usage.windows.premium_interactions.valueLabel ?? null).toBeNull(); + }); });