From 17d08c03957803133aa7e6e3342ffec8de7774a1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 16:24:02 +0300 Subject: [PATCH] feat(quota): add Command Code usage tracking --- packages/ui/src/lib/i18n/messages/de.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/lib/quota/utils.ts | 3 + packages/ui/src/types/quota.ts | 1 + packages/vscode/src/commandCodeQuota.ts | 66 ++++++++++++++ packages/vscode/src/quotaProviders.test.ts | 53 +++++++++++ packages/vscode/src/quotaProviders.ts | 16 ++++ .../web/server/lib/quota/DOCUMENTATION.md | 3 + .../lib/quota/providers/command-code.js | 90 +++++++++++++++++++ .../lib/quota/providers/command-code.test.js | 72 +++++++++++++++ .../web/server/lib/quota/providers/index.js | 7 ++ 21 files changed, 345 insertions(+) create mode 100644 packages/vscode/src/commandCodeQuota.ts create mode 100644 packages/web/server/lib/quota/providers/command-code.js create mode 100644 packages/web/server/lib/quota/providers/command-code.test.js diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 89c66ff4..21b72195 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2845,6 +2845,9 @@ export const dict = { 'quota.window.monthly': 'Monatliches Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Kreditguthaben', + 'quota.window.monthlyCredits': 'Monatliche Credits', + 'quota.window.purchasedCredits': 'Gekaufte Credits', + 'quota.window.freeCredits': 'Kostenlose Credits', 'quota.window.billingCycle': 'Abrechnungszyklus', 'quota.window.auto': 'Automatisch', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 022cbc56..eb82405d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -3023,6 +3023,9 @@ export const dict = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.monthlyCredits': 'Monthly Credits', + 'quota.window.purchasedCredits': 'Purchased Credits', + 'quota.window.freeCredits': 'Free Credits', 'quota.window.billingCycle': 'Billing Cycle', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 55cb006f..0aa85137 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3024,6 +3024,9 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.monthlyCredits": "Créditos mensuales", + "quota.window.purchasedCredits": "Créditos comprados", + "quota.window.freeCredits": "Créditos gratuitos", "quota.window.billingCycle": "Billing Cycle", "quota.window.auto": "Auto", "quota.window.api": "API", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4f92a491..ddb5aa56 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2716,6 +2716,9 @@ export const dict = { 'quota.window.monthly': 'Limite mensuelle', 'quota.window.credits': 'Crédits', 'quota.window.creditsBalance': 'Solde de crédits', + 'quota.window.monthlyCredits': 'Crédits mensuels', + 'quota.window.purchasedCredits': 'Crédits achetés', + 'quota.window.freeCredits': 'Crédits gratuits', 'quota.window.billingCycle': 'Cycle de facturation', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index ed648cd2..3992ba78 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3019,6 +3019,9 @@ export const dict: Record = { 'quota.window.monthly': '月間制限', 'quota.window.credits': 'クレジット', 'quota.window.creditsBalance': 'クレジット残高', + 'quota.window.monthlyCredits': '月間クレジット', + 'quota.window.purchasedCredits': '購入済みクレジット', + 'quota.window.freeCredits': '無料クレジット', 'quota.window.billingCycle': '請求サイクル', 'quota.window.auto': '自動', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 00e909ff..dde3d0f1 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3023,6 +3023,9 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.monthlyCredits': '월간 크레딧', + 'quota.window.purchasedCredits': '구매한 크레딧', + 'quota.window.freeCredits': '무료 크레딧', 'quota.window.billingCycle': 'Billing Cycle', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b2808378..d0c85d40 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3040,6 +3040,9 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.monthlyCredits': 'Kredyty miesięczne', + 'quota.window.purchasedCredits': 'Kupione kredyty', + 'quota.window.freeCredits': 'Darmowe kredyty', 'quota.window.billingCycle': 'Billing Cycle', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 9a82a55c..8275d93c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3024,6 +3024,9 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.monthlyCredits": "Créditos mensais", + "quota.window.purchasedCredits": "Créditos comprados", + "quota.window.freeCredits": "Créditos gratuitos", "quota.window.billingCycle": "Billing Cycle", "quota.window.auto": "Auto", "quota.window.api": "API", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a7fd6cd2..3173b933 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3024,6 +3024,9 @@ export const dict: Record = { "quota.window.monthly": "Monthly Limit", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", + "quota.window.monthlyCredits": "Місячні кредити", + "quota.window.purchasedCredits": "Придбані кредити", + "quota.window.freeCredits": "Безкоштовні кредити", "quota.window.billingCycle": "Billing Cycle", "quota.window.auto": "Auto", "quota.window.api": "API", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3f05ebc5..58cca12a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3024,6 +3024,9 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.monthlyCredits': '每月积分', + 'quota.window.purchasedCredits': '已购买积分', + 'quota.window.freeCredits': '免费积分', 'quota.window.billingCycle': 'Billing Cycle', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 16edad2f..98202726 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -3023,6 +3023,9 @@ export const dict: Record = { 'quota.window.monthly': 'Monthly Limit', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', + 'quota.window.monthlyCredits': '每月點數', + 'quota.window.purchasedCredits': '已購買點數', + 'quota.window.freeCredits': '免費點數', 'quota.window.billingCycle': 'Billing Cycle', 'quota.window.auto': 'Auto', 'quota.window.api': 'API', diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 96a4906c..4c6067e0 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -8,6 +8,7 @@ export interface QuotaProviderMeta { export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'claude', name: 'Claude' }, { id: 'codex', name: 'Codex' }, + { id: 'command-code', name: 'Command Code' }, { id: 'cursor', name: 'Cursor' }, { id: 'github-copilot', name: 'GitHub Copilot' }, { id: 'google', name: 'Google' }, diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index d2c7f726..69029047 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -81,6 +81,9 @@ export const formatWindowLabel = (label: string): string => { if (label === 'monthly') return t('quota.window.monthly'); if (label === 'credits') return t('quota.window.credits'); if (label === 'credits_balance') return t('quota.window.creditsBalance'); + if (label === 'monthly_credits') return t('quota.window.monthlyCredits'); + if (label === 'purchased_credits') return t('quota.window.purchasedCredits'); + if (label === 'free_credits') return t('quota.window.freeCredits'); if (label === 'billing_cycle') return t('quota.window.billingCycle'); if (label === 'plan_limit') return t('quota.window.planLimit'); if (label === 'auto') return t('quota.window.auto'); diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 059e4b98..fc00b633 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -1,6 +1,7 @@ export type QuotaProviderId = | 'openai' | 'codex' + | 'command-code' | 'cursor' | 'claude' | 'github-copilot' diff --git a/packages/vscode/src/commandCodeQuota.ts b/packages/vscode/src/commandCodeQuota.ts new file mode 100644 index 00000000..264e932d --- /dev/null +++ b/packages/vscode/src/commandCodeQuota.ts @@ -0,0 +1,66 @@ +type CommandCodeCredits = { + credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number }; + windowLimits?: { + fiveHour?: { used?: number; cap?: number; resetAt?: number }; + weekly?: { used?: number; cap?: number; resetAt?: number }; + }; +}; + +type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string }; + +const toWindow = (data: WindowData) => ({ + usedPercent: data.usedPercent, + remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent), + windowSeconds: data.windowSeconds, + resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)), + resetAt: data.resetAt, + resetAtFormatted: null, + resetAfterFormatted: null, + valueLabel: data.valueLabel, +}); + +const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100); + +const parseCredits = (value: unknown): CommandCodeCredits | null => { + if (!value || typeof value !== 'object') return null; + const payload = value as CommandCodeCredits; + return payload; +}; + +const parseOrgId = (value: unknown): string | null | undefined => { + if (!value || typeof value !== 'object') return undefined; + const org = (value as { org?: { id?: unknown } }).org; + return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null; +}; + +const parseCommandCodeCredits = (payload: CommandCodeCredits) => { + const windows: Record> = {}; + for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) { + if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) }); + } + for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) { + if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue; + const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null; + windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` }); + } + return windows; +}; + +const requestJson = async (path: string, apiKey: string): Promise => { + const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) }); + if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed'); + if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`); + return response.json().catch(() => null); +}; + +export const fetchCommandCodeUsage = async (apiKey: string) => { + const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey)); + if (orgId === undefined) throw new Error('Command Code account could not be determined'); + const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits'; + const payload = parseCredits(await requestJson(creditsPath, apiKey)); + if (!payload) throw new Error('Command Code usage data could not be parsed'); + const windows = parseCommandCodeCredits(payload); + if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index a2e314c0..66be257b 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -17,6 +17,7 @@ const AUTH = JSON.stringify({ crof: { key: 'test-token' }, neuralwatt: { key: 'test-token' }, 'opencode-go': { key: 'test-token' }, + 'command-code': { type: 'oauth', access: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, }); @@ -102,6 +103,58 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { }); }); +describe('Command Code quota provider (VS Code parity)', () => { + test('uses the OAuth access token and resolves server-backed limits', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return mockResponse(url.endsWith('/alpha/whoami') + ? { org: { id: 'org/a' } } + : { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } }); + }) as typeof fetch; + + const result = await fetchQuotaForProvider('command-code'); + + assert.equal(result.ok, true); + assert.deepEqual(requests.map(({ url }) => url), [ + 'https://api.commandcode.ai/alpha/whoami', + 'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa', + ]); + assert.equal((requests[0].init?.headers as Record).Authorization, 'Bearer test-token'); + assert.equal(result.usage!.windows['5h']!.usedPercent, 25); + assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120'); + }); + + test('omits orgId for personal accounts', async () => { + const urls: string[] = []; + globalThis.fetch = (async (url: string) => { + urls.push(url); + return mockResponse(url.endsWith('/alpha/whoami') + ? { user: { id: 'user-1' }, org: null } + : { credits: { monthlyCredits: 120 } }); + }) as typeof fetch; + + const result = await fetchQuotaForProvider('command-code'); + + assert.equal(result.ok, true); + assert.deepEqual(urls, [ + 'https://api.commandcode.ai/alpha/whoami', + 'https://api.commandcode.ai/alpha/billing/credits', + ]); + }); + + test('formats fractional credit values for display', async () => { + globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami') + ? { org: null } + : { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch; + + const result = await fetchQuotaForProvider('command-code'); + + assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79'); + assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14'); + }); +}); + describe('Crof quota provider (VS Code parity)', () => { test('reports credits balance as valueLabel with null percent', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 }))); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 32c54441..e25c5f70 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; +import { fetchCommandCodeUsage } from './commandCodeQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; @@ -748,6 +749,9 @@ export const listConfiguredQuotaProviders = () => { const configured = new Set(); const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go'])); if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go'); + const commandCodeAuth = normalizeAuthEntry(getAuthEntry(auth, ['command-code'])); + if (commandCodeAuth && (typeof commandCodeAuth.key === 'string' || typeof commandCodeAuth.access === 'string' || typeof commandCodeAuth.token === 'string')) configured.add('command-code'); + if (process.env.COMMAND_CODE_API_KEY?.trim()) configured.add('command-code'); if (readCredential('ollama-cloud')) configured.add('ollama-cloud'); if (readCredential('cursor')) configured.add('cursor'); @@ -2746,6 +2750,18 @@ export const fetchQuotaForProvider = async (providerId: string): Promise { + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const stored = entry?.key ?? entry?.access ?? entry?.token; + return (typeof stored === 'string' ? stored.trim() : '') || process.env.COMMAND_CODE_API_KEY?.trim() || null; +}; + +const requestJson = async (path, apiKey, fetchImpl) => { + const response = await fetchImpl(`${API_BASE_URL}${path}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'OpenChamber quota provider', + }, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed'); + if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`); + return response.json().catch(() => null); +}; + +const formatCredits = (value) => String(Math.round((value + Number.EPSILON) * 100) / 100); + +const toBalanceWindow = (value) => toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: formatCredits(value), +}); + +export const parseCommandCodeCredits = (payload) => { + const root = asObject(payload); + const credits = asObject(root?.credits); + const limits = asObject(root?.windowLimits); + const windows = {}; + + for (const [label, field] of [['monthly_credits', 'monthlyCredits'], ['purchased_credits', 'purchasedCredits'], ['free_credits', 'freeCredits']]) { + const value = toNumber(credits?.[field]); + if (value !== null) windows[label] = toBalanceWindow(value); + } + + for (const [label, field, windowSeconds] of [['5h', 'fiveHour', 5 * 60 * 60], ['weekly', 'weekly', 7 * 24 * 60 * 60]]) { + const limit = asObject(limits?.[field]); + const used = toNumber(limit?.used); + const cap = toNumber(limit?.cap); + if (used === null || cap === null || cap <= 0) continue; + const resetAt = toNumber(limit?.resetAt); + windows[label] = toUsageWindow({ + usedPercent: Math.min(100, Math.max(0, used / cap * 100)), + windowSeconds, + resetAt: resetAt === null ? null : resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt, + valueLabel: `${formatCredits(used)} / ${formatCredits(cap)}`, + }); + } + + return windows; +}; + +export const fetchCommandCodeUsage = async (apiKey, fetchImpl = fetch) => { + const identity = asObject(await requestJson('/alpha/whoami', apiKey, fetchImpl)); + const org = asObject(identity?.org); + const orgId = typeof org?.id === 'string' ? org.id.trim() : ''; + const creditsPath = orgId + ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` + : '/alpha/billing/credits'; + const credits = await requestJson(creditsPath, apiKey, fetchImpl); + const windows = parseCommandCodeCredits(credits); + if (Object.keys(windows).length === 0) throw new Error('Command Code usage data could not be parsed'); + return windows; +}; + +export const isConfigured = () => Boolean(getApiKey()); + +export const fetchQuota = async (auth = readAuthFile()) => { + const apiKey = getApiKey(auth); + if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); + try { + return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } }); + } catch (error) { + return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); + } +}; diff --git a/packages/web/server/lib/quota/providers/command-code.test.js b/packages/web/server/lib/quota/providers/command-code.test.js new file mode 100644 index 00000000..4a6285c8 --- /dev/null +++ b/packages/web/server/lib/quota/providers/command-code.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchCommandCodeUsage, fetchQuota, parseCommandCodeCredits } from './command-code.js'; + +const creditsPayload = { + credits: { monthlyCredits: 120, purchasedCredits: 30, freeCredits: 5 }, + windowLimits: { + fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 }, + weekly: { used: 70, cap: 200, resetAt: 1_776_604_800 }, + }, +}; + +describe('Command Code quota provider', () => { + it('parses balances and rate-limit windows', () => { + const windows = parseCommandCodeCredits(creditsPayload); + expect(windows.monthly_credits).toMatchObject({ usedPercent: null, valueLabel: '120' }); + expect(windows.purchased_credits).toMatchObject({ usedPercent: null, valueLabel: '30' }); + expect(windows.free_credits).toMatchObject({ usedPercent: null, valueLabel: '5' }); + expect(windows['5h']).toMatchObject({ usedPercent: 25, valueLabel: '25 / 100', resetAt: 1_776_000_000_000 }); + expect(windows.weekly.usedPercent).toBe(35); + }); + + it('formats fractional credit values for display', () => { + const windows = parseCommandCodeCredits({ + credits: { monthlyCredits: 69.7947070034 }, + windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } }, + }); + expect(windows.monthly_credits.valueLabel).toBe('69.79'); + expect(windows['5h'].valueLabel).toBe('0.21 / 14'); + }); + + it('resolves the organization before fetching credits', async () => { + const requests = []; + const windows = await fetchCommandCodeUsage('secret', async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { org: { id: 'org/a' } } : creditsPayload)); + }); + expect(requests.map(({ url }) => url)).toEqual([ + 'https://api.commandcode.ai/alpha/whoami', + 'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa', + ]); + expect(requests[0].options.headers.Authorization).toBe('Bearer secret'); + expect(windows['5h'].usedPercent).toBe(25); + }); + + it('fetches account-scoped credits without orgId for personal accounts', async () => { + const urls = []; + await fetchCommandCodeUsage('secret', async (url) => { + urls.push(url); + return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { user: { id: 'user-1' }, org: null } : creditsPayload)); + }); + expect(urls).toEqual([ + 'https://api.commandcode.ai/alpha/whoami', + 'https://api.commandcode.ai/alpha/billing/credits', + ]); + }); + + it('does not expose credentials in authentication errors', async () => { + await expect(fetchCommandCodeUsage('secret', async () => new Response('', { status: 401 }))).rejects.toThrow('authentication failed'); + }); + + it('reads OAuth access credentials from the OpenCode auth file', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } }))) + .mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload))); + vi.stubGlobal('fetch', fetchMock); + const result = await fetchQuota({ 'command-code': { type: 'oauth', access: 'test-token' } }); + expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true }); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token'); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index e850672a..789f3163 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -9,6 +9,7 @@ import { buildResult } from '../utils/index.js'; import * as claude from './claude/index.js'; import * as codex from './codex.js'; +import * as commandCode from './command-code.js'; import * as copilot from './copilot.js'; import * as crof from './crof.js'; import * as cursor from './cursor.js'; @@ -29,6 +30,12 @@ import * as opencodeGo from './opencode-go.js'; import * as xai from './xai.js'; const registry = { + 'command-code': { + providerId: commandCode.providerId, + providerName: commandCode.providerName, + isConfigured: commandCode.isConfigured, + fetchQuota: commandCode.fetchQuota + }, claude: { providerId: claude.providerId, providerName: claude.providerName,