diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index d4c24849..6f0e19d3 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2910,7 +2910,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 cedf598c..47104589 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -3111,7 +3111,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 9c836683..f0f9f48d 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3112,7 +3112,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 2bbd63d7..eae42bb2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2803,7 +2803,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 f47ed145..cddf221f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3110,7 +3110,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 3f3edd34..4002cd41 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3111,7 +3111,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 3c0556f5..996d2af8 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3128,7 +3128,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 a4b53b0c..6c9605f9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3112,7 +3112,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 9ef4ae3a..a79881fe 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3112,7 +3112,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 1a39b4b9..7b9d57b7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3112,7 +3112,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 97421782..bdbe84bc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -3111,7 +3111,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 0ab8938c..783583a5 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' }, anthropic: { access: 'test-token', refresh: 'test-refresh' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; @@ -196,6 +197,71 @@ 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'); + }); + + 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)', () => { test('parses current limits, model-scoped limits, and extra usage', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index c70b43b7..a9ce3204 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1465,14 +1465,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({ @@ -1483,9 +1504,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; }; @@ -1582,15 +1601,12 @@ const fetchCopilotAddonQuota = async (): Promise => { } const payload = await response.json() as Record; - const windows = buildCopilotWindows(payload); - const premium = windows.premium ? { premium: windows.premium } : 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 83e905a4..1897336b 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -97,6 +97,25 @@ 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. + +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 964f2f71..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({ @@ -31,9 +52,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; }; @@ -143,15 +162,12 @@ export const fetchQuotaAddon = async () => { } const payload = await response.json(); - const windows = buildCopilotWindows(payload); - const premium = windows.premium ? { premium: windows.premium } : 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 new file mode 100644 index 00000000..5038ed49 --- /dev/null +++ b/packages/web/server/lib/quota/providers/copilot.test.js @@ -0,0 +1,78 @@ +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 = (body = payload) => ({ + ok: true, + status: 200, + json: async () => body, +}); + +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'); + }); + + 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(); + }); +});