diff --git a/src/cliproxy/quota-fetcher-ghcp.ts b/src/cliproxy/quota-fetcher-ghcp.ts index 84c59bb4..e2c4f8f2 100644 --- a/src/cliproxy/quota-fetcher-ghcp.ts +++ b/src/cliproxy/quota-fetcher-ghcp.ts @@ -8,9 +8,14 @@ import * as fs from 'node:fs'; import { getAccountTokenPath, getProviderAccounts } from './account-manager'; import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types'; +import { clampPercent } from '../utils/percentage'; const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user'; const GHCP_USAGE_TIMEOUT_MS = 10000; +/** + * Mirrors headers currently accepted by GitHub Copilot internal usage endpoint. + * Keep aligned with upstream Copilot client/API changes when quota calls break. + */ const GHCP_USER_AGENT = 'GitHubCopilotChat/0.26.7'; const GHCP_API_VERSION = '2025-04-01'; @@ -42,11 +47,6 @@ interface TokenData { }; } -function clampPercent(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(100, value)); -} - function normalizeSnapshot(raw?: RawGhcpQuotaSnapshot): GhcpQuotaSnapshot { const entitlement = Number(raw?.entitlement ?? 0); const remainingRaw = raw?.remaining ?? raw?.quota_remaining ?? 0; @@ -161,6 +161,7 @@ function normalizeUsageResponse(raw: RawGhcpUsageResponse): GhcpQuotaResult { export async function fetchGhcpQuota(accountId: string, verbose = false): Promise { const { accessToken, error } = readGhcpAccessToken(accountId); if (!accessToken) { + // Safe diagnostic: accountId + generic error only (never log token values/file contents). if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`); return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId); } @@ -231,3 +232,6 @@ export async function fetchAllGhcpQuotas( ); return results; } + +// Export for testing +export { normalizeSnapshot as normalizeGhcpSnapshot, extractAccessToken as extractGhcpAccessToken }; diff --git a/src/copilot/copilot-usage.ts b/src/copilot/copilot-usage.ts index 0dca9bf2..e498cda4 100644 --- a/src/copilot/copilot-usage.ts +++ b/src/copilot/copilot-usage.ts @@ -7,6 +7,7 @@ import * as http from 'http'; import type { CopilotQuotaSnapshot, CopilotUsage } from './types'; +import { clampPercent } from '../utils/percentage'; interface RawCopilotQuotaSnapshot { entitlement?: number; @@ -25,11 +26,6 @@ interface RawCopilotUsage { }; } -function clampPercent(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(100, value)); -} - function normalizeSnapshot(raw?: RawCopilotQuotaSnapshot): CopilotQuotaSnapshot { const entitlement = Number(raw?.entitlement ?? 0); const remaining = Number(raw?.remaining ?? 0); diff --git a/src/utils/percentage.ts b/src/utils/percentage.ts new file mode 100644 index 00000000..5808b6de --- /dev/null +++ b/src/utils/percentage.ts @@ -0,0 +1,7 @@ +/** + * Clamp percentage-like values to a safe 0-100 range. + */ +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} diff --git a/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts new file mode 100644 index 00000000..963c6f11 --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts @@ -0,0 +1,232 @@ +/** + * GitHub Copilot (GHCP) Quota Fetcher Unit Tests + * + * Covers normalization and token extraction edge cases. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + normalizeGhcpSnapshot, + extractGhcpAccessToken, + fetchGhcpQuota, +} from '../../../src/cliproxy/quota-fetcher-ghcp'; + +let tmpDir: string; +let originalCcsHome: string | undefined; +let originalFetch: typeof fetch; + +function createGhcpAccount( + accountId: string, + tokenPayload: Record, + tokenFile = `${accountId}.json` +): void { + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload)); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + ghcp: { + default: accountId, + accounts: { + [accountId]: { + nickname: accountId, + tokenFile, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ghcp-quota-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('GHCP Quota Fetcher', () => { + describe('normalizeGhcpSnapshot', () => { + it('handles missing/undefined raw data', () => { + const snapshot = normalizeGhcpSnapshot(); + + expect(snapshot).toEqual({ + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 100, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }); + }); + + it('clamps percent_remaining to 0-100 range', () => { + const above = normalizeGhcpSnapshot({ + entitlement: 100, + remaining: 80, + percent_remaining: 140, + }); + const below = normalizeGhcpSnapshot({ + entitlement: 100, + remaining: 80, + percent_remaining: -15, + }); + + expect(above.percentRemaining).toBe(100); + expect(above.percentUsed).toBe(0); + expect(below.percentRemaining).toBe(0); + expect(below.percentUsed).toBe(100); + }); + + it('calculates percentRemaining when API does not provide it', () => { + const snapshot = normalizeGhcpSnapshot({ + entitlement: 80, + remaining: 20, + }); + + expect(snapshot.entitlement).toBe(80); + expect(snapshot.remaining).toBe(20); + expect(snapshot.used).toBe(60); + expect(snapshot.percentRemaining).toBe(25); + expect(snapshot.percentUsed).toBe(75); + }); + + it('handles non-finite entitlement values safely', () => { + const snapshot = normalizeGhcpSnapshot({ + entitlement: Number.POSITIVE_INFINITY, + remaining: 25, + }); + + expect(snapshot.entitlement).toBe(0); + expect(snapshot.remaining).toBe(25); + expect(snapshot.used).toBe(0); + expect(snapshot.percentRemaining).toBe(0); + expect(snapshot.percentUsed).toBe(100); + }); + }); + + describe('extractGhcpAccessToken', () => { + it('extracts from top-level access_token', () => { + const token = extractGhcpAccessToken({ + access_token: ' top-level-token ', + }); + expect(token).toBe('top-level-token'); + }); + + it('extracts from nested token.access_token', () => { + const token = extractGhcpAccessToken({ + token: { + access_token: 'nested-token', + }, + }); + expect(token).toBe('nested-token'); + }); + + it('returns null for empty/whitespace tokens', () => { + const emptyTopLevel = extractGhcpAccessToken({ access_token: ' ' }); + const emptyNested = extractGhcpAccessToken({ + token: { access_token: ' ' }, + }); + + expect(emptyTopLevel).toBeNull(); + expect(emptyNested).toBeNull(); + }); + }); + + describe('fetchGhcpQuota', () => { + it('fetches and normalizes quota for a valid account token', async () => { + createGhcpAccount('ghcp-main', { access_token: 'top-level-token' }); + + global.fetch = mock((url: string, options?: RequestInit) => { + expect(url).toBe('https://api.github.com/copilot_internal/user'); + expect(options?.method).toBe('GET'); + expect(options?.headers).toEqual({ + Accept: 'application/json', + Authorization: 'token top-level-token', + 'User-Agent': 'GitHubCopilotChat/0.26.7', + 'x-github-api-version': '2025-04-01', + }); + + return Promise.resolve( + new Response( + JSON.stringify({ + copilot_plan: 'business', + quota_reset_date: '2026-02-28T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 1000, remaining: 900 }, + chat: { entitlement: 500, remaining: 100, percent_remaining: 20 }, + completions: { entitlement: 250, remaining: 125 }, + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + }) as typeof fetch; + + const result = await fetchGhcpQuota('ghcp-main'); + + expect(result.success).toBe(true); + expect(result.accountId).toBe('ghcp-main'); + expect(result.planType).toBe('business'); + expect(result.quotaResetDate).toBe('2026-02-28T00:00:00Z'); + expect(result.snapshots.premiumInteractions.percentRemaining).toBe(90); + expect(result.snapshots.chat.percentRemaining).toBe(20); + expect(result.snapshots.completions.percentRemaining).toBe(50); + }); + + it('returns needsReauth on 401/403 responses', async () => { + createGhcpAccount('ghcp-auth', { access_token: 'token-auth' }); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch; + + const result = await fetchGhcpQuota('ghcp-auth'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toBe('Authentication expired or invalid'); + }); + + it('fails fast when token file has no valid access token', async () => { + createGhcpAccount('ghcp-missing-token', { access_token: ' ' }); + const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 }))); + global.fetch = fetchMock as typeof fetch; + + const result = await fetchGhcpQuota('ghcp-missing-token'); + + expect(result.success).toBe(false); + expect(result.error).toBe('No access token in auth file'); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index faca5f72..8f04bc45 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -590,10 +590,16 @@ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiC /** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { + const candidate = quota as GhcpQuotaResult; + const snapshots = candidate.snapshots as Record | null | undefined; + return ( 'snapshots' in quota && - typeof (quota as GhcpQuotaResult).snapshots === 'object' && - (quota as GhcpQuotaResult).snapshots !== null + typeof snapshots === 'object' && + snapshots !== null && + 'premiumInteractions' in snapshots && + 'chat' in snapshots && + 'completions' in snapshots ); }