From dcf02f13a1df3d6b2f19a5d36bbcdacc3135df9f Mon Sep 17 00:00:00 2001 From: Jakub Syty Date: Fri, 14 Aug 2026 15:56:13 +0200 Subject: [PATCH 1/2] Align GitHub Copilot quota usage with AI credits --- packages/ui/src/lib/i18n/messages/de.ts | 2 +- packages/ui/src/lib/i18n/messages/en.ts | 2 +- packages/ui/src/lib/i18n/messages/es.ts | 2 +- packages/ui/src/lib/i18n/messages/fr.ts | 2 +- packages/ui/src/lib/i18n/messages/ja.ts | 2 +- packages/ui/src/lib/i18n/messages/ko.ts | 2 +- packages/ui/src/lib/i18n/messages/pl.ts | 2 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 +- packages/ui/src/lib/i18n/messages/uk.ts | 2 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 +- packages/ui/src/lib/quota/utils.test.ts | 7 +++- packages/vscode/src/quotaProviders.test.ts | 21 ++++++++++ packages/vscode/src/quotaProviders.ts | 8 ++-- .../web/server/lib/quota/DOCUMENTATION.md | 9 ++++ .../web/server/lib/quota/providers/copilot.js | 8 ++-- .../lib/quota/providers/copilot.test.js | 42 +++++++++++++++++++ 17 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 packages/web/server/lib/quota/providers/copilot.test.js diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4018c5ae..1ff00100 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2820,7 +2820,7 @@ export const dict = { 'quota.window.premium': 'Premium-Interaktionen', 'quota.window.chat': 'Chat-Anfragen', 'quota.window.completions': 'Vervollständigungen', - 'quota.window.premiumInteractions': 'Premium-Interaktionen', + 'quota.window.premiumInteractions': 'KI-Guthaben', 'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen', 'terminalView.actions.restart': 'Terminal neu starten', 'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 77546094..9de01595 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2998,7 +2998,7 @@ export const dict = { 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', - 'quota.window.premiumInteractions': 'Premium interactions', + 'quota.window.premiumInteractions': 'AI Credits', 'chat.workStatus.ariaLabel': 'Work status', 'chat.workStatus.context.label': 'Context', 'chat.workStatus.git.changedFileSingle': '{count} file changed', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 911387e2..1d034f86 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2999,7 +2999,7 @@ export const dict: Record = { "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", - "quota.window.premiumInteractions": "Premium interactions", + "quota.window.premiumInteractions": "Créditos de IA", 'chat.workStatus.ariaLabel': 'Estado del trabajo', 'chat.workStatus.context.label': 'Contexto', 'chat.workStatus.git.changedFileSingle': '{count} archivo modificado', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4a9f2ffc..570e5fef 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2691,7 +2691,7 @@ export const dict = { 'quota.window.premium': 'Interactions premium', 'quota.window.chat': 'Requêtes de chat', 'quota.window.completions': 'Complétions', - 'quota.window.premiumInteractions': 'Interactions premium', + 'quota.window.premiumInteractions': 'Crédits IA', 'layout.mainTab.diagram': 'Diagramme', 'mobile.nav.aria': 'Navigation mobile', 'mobile.connect.welcome.title': 'Se connecter à OpenChamber', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index f58a6b89..9cb86e7a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2997,7 +2997,7 @@ export const dict: Record = { 'onboarding.localSetup.actions.checkAndContinue': 'インストール完了、確認して続行', 'onboarding.localSetup.status.autoContinue': '検出され次第自動的に続行します。', 'updateDialog.changelog.title': '新機能', - 'quota.window.premiumInteractions': 'プレミアムインタラクション', + 'quota.window.premiumInteractions': 'AIクレジット', 'chat.workStatus.ariaLabel': '作業状況', 'chat.workStatus.context.label': 'コンテキスト', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 34610de1..32f865b6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2998,7 +2998,7 @@ export const dict: Record = { 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', - 'quota.window.premiumInteractions': 'Premium interactions', + 'quota.window.premiumInteractions': 'AI 크레딧', 'chat.workStatus.ariaLabel': '작업 상태', 'chat.workStatus.context.label': '컨텍스트', 'chat.workStatus.git.changedFileSingle': '파일 {count}개 변경됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8c78bd5d..3207639d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3015,7 +3015,7 @@ export const dict: Record = { 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', - 'quota.window.premiumInteractions': 'Premium interactions', + 'quota.window.premiumInteractions': 'Kredyty AI', 'chat.workStatus.ariaLabel': 'Stan pracy', 'chat.workStatus.context.label': 'Kontekst', 'chat.workStatus.git.changedFileSingle': 'Zmieniono {count} plik', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 7d940f6f..9dbc78ac 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2999,7 +2999,7 @@ export const dict: Record = { "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", - "quota.window.premiumInteractions": "Premium interactions", + "quota.window.premiumInteractions": "Créditos de IA", 'chat.workStatus.ariaLabel': 'Status do trabalho', 'chat.workStatus.context.label': 'Contexto', 'chat.workStatus.git.changedFileSingle': '{count} arquivo alterado', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 058f152f..b20e059a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2999,7 +2999,7 @@ export const dict: Record = { "quota.window.premium": "Premium Interactions", "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", - "quota.window.premiumInteractions": "Premium interactions", + "quota.window.premiumInteractions": "Кредити ШІ", 'chat.workStatus.ariaLabel': 'Стан роботи', 'chat.workStatus.context.label': 'Контекст', 'chat.workStatus.git.changedFileSingle': 'Змінено {count} файл', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f1c3322e..480ac2fe 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2999,7 +2999,7 @@ export const dict: Record = { 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', - 'quota.window.premiumInteractions': 'Premium interactions', + 'quota.window.premiumInteractions': 'AI 点数', 'chat.workStatus.ariaLabel': '工作状态', 'chat.workStatus.context.label': '上下文', 'chat.workStatus.git.changedFileSingle': '已更改 {count} 个文件', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 59f99c81..e17c74a1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2998,7 +2998,7 @@ export const dict: Record = { 'quota.window.premium': 'Premium Interactions', 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', - 'quota.window.premiumInteractions': 'Premium interactions', + 'quota.window.premiumInteractions': 'AI 點數', 'chat.workStatus.ariaLabel': '工作狀態', 'chat.workStatus.context.label': '上下文', 'chat.workStatus.git.changedFileSingle': '已變更 {count} 個檔案', diff --git a/packages/ui/src/lib/quota/utils.test.ts b/packages/ui/src/lib/quota/utils.test.ts index afca3d75..abda6e48 100644 --- a/packages/ui/src/lib/quota/utils.test.ts +++ b/packages/ui/src/lib/quota/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { clampPercent, formatPercent } from './utils'; +import { clampPercent, formatPercent, formatWindowLabel } from './utils'; describe('quota utils', () => { test('treats non-finite percentages as missing', () => { @@ -10,4 +10,9 @@ describe('quota utils', () => { expect(formatPercent(Infinity)).toBe('-'); expect(formatPercent(-Infinity)).toBe('-'); }); + + test('labels Copilot usage as AI Credits without changing generic premium usage', () => { + expect(formatWindowLabel('premium')).toBe('Premium Interactions'); + expect(formatWindowLabel('premium_interactions')).toBe('AI Credits'); + }); }); diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index a2e314c0..2525e714 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -19,6 +19,7 @@ const AUTH = JSON.stringify({ 'opencode-go': { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, + 'github-copilot': { access: 'test-token' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; @@ -173,6 +174,26 @@ describe('Codex quota provider (VS Code parity)', () => { }); }); +describe('GitHub Copilot quota provider (VS Code parity)', () => { + test('exposes only premium interactions as the primary usage window', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + chat: { entitlement: 100, remaining: 80 }, + completions: { entitlement: 1000, remaining: 900 }, + premium_interactions: { entitlement: 300, remaining: 225 }, + }, + }))); + + const result = await fetchQuotaForProvider('github-copilot'); + + assert.equal(result.ok, true); + assert.deepEqual(Object.keys(result.usage!.windows), ['premium_interactions']); + assert.equal(result.usage!.windows.premium_interactions!.usedPercent, 25); + assert.equal(result.usage!.windows.premium_interactions!.valueLabel, '225 / 300 left'); + }); +}); + describe('Z.ai quota provider (VS Code parity)', () => { test('surfaces 5-hour, weekly, and MCP quota windows', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 32c54441..b513ad0b 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1362,9 +1362,7 @@ const buildCopilotWindows = (payload: Record) => { }); }; - addWindow('chat', quota.chat as Record | undefined); - addWindow('completions', quota.completions as Record | undefined); - addWindow('premium', quota.premium_interactions as Record | undefined); + addWindow('premium_interactions', quota.premium_interactions as Record | undefined); return windows; }; @@ -1462,7 +1460,9 @@ const fetchCopilotAddonQuota = async (): Promise => { const payload = await response.json() as Record; const windows = buildCopilotWindows(payload); - const premium = windows.premium ? { premium: windows.premium } : windows; + const premium = windows.premium_interactions + ? { premium_interactions: windows.premium_interactions } + : windows; return buildResult({ providerId: 'github-copilot-addon', diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index fb876284..a8cdceb6 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -82,6 +82,15 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it. +## GitHub Copilot quota semantics + +GitHub Copilot usage exposes only the `premium_interactions` snapshot as the +`premium_interactions` window. Shared UI labels that window **AI Credits** and treats it as +the provider's primary usage marker. Legacy chat-request quota and unlimited +completion quota are intentionally omitted. Keep +`packages/web/server/lib/quota/providers/copilot.js` and +`packages/vscode/src/quotaProviders.ts` in sync. + ## 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 964f2f71..71037c93 100644 --- a/packages/web/server/lib/quota/providers/copilot.js +++ b/packages/web/server/lib/quota/providers/copilot.js @@ -31,9 +31,7 @@ const buildCopilotWindows = (payload) => { }); }; - addWindow('chat', quota.chat); - addWindow('completions', quota.completions); - addWindow('premium', quota.premium_interactions); + addWindow('premium_interactions', quota.premium_interactions); return windows; }; @@ -144,7 +142,9 @@ export const fetchQuotaAddon = async () => { const payload = await response.json(); const windows = buildCopilotWindows(payload); - const premium = windows.premium ? { premium: windows.premium } : windows; + const premium = windows.premium_interactions + ? { premium_interactions: windows.premium_interactions } + : windows; return buildResult({ providerId: providerIdAddon, diff --git a/packages/web/server/lib/quota/providers/copilot.test.js b/packages/web/server/lib/quota/providers/copilot.test.js new file mode 100644 index 00000000..15e3c963 --- /dev/null +++ b/packages/web/server/lib/quota/providers/copilot.test.js @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ 'github-copilot': { access: 'test-token' } }), +})); + +import { fetchQuota, fetchQuotaAddon } from './copilot.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const payload = { + quota_reset_date: '2026-09-01T00:00:00Z', + quota_snapshots: { + chat: { entitlement: 100, remaining: 80 }, + completions: { entitlement: 1000, remaining: 900 }, + premium_interactions: { entitlement: 300, remaining: 225 }, + }, +}; + +const mockResponse = () => ({ + ok: true, + status: 200, + json: async () => payload, +}); + +describe('GitHub Copilot quota provider', () => { + it.each([ + ['primary provider', fetchQuota], + ['add-on provider', fetchQuotaAddon], + ])('exposes only premium interactions for the %s', async (_name, fetchProviderQuota) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse())); + + const result = await fetchProviderQuota(); + + expect(result.ok).toBe(true); + expect(Object.keys(result.usage.windows)).toEqual(['premium_interactions']); + expect(result.usage.windows.premium_interactions.usedPercent).toBe(25); + expect(result.usage.windows.premium_interactions.valueLabel).toBe('225 / 300 left'); + }); +}); From 1459b4b360850880df085f1526ed751cc07299a6 Mon Sep 17 00:00:00 2001 From: Jakub Syty Date: Wed, 26 Aug 2026 13:58:31 +0200 Subject: [PATCH 2/2] 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(); + }); });