From 8d9d4987dc42415ebe53760e1ac06c9c024ce17f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Feb 2026 22:32:27 +0700 Subject: [PATCH] feat(quota): add GitHub Copilot quota checks for ghcp and copilot --- src/ccs.ts | 1 + src/cliproxy/quota-fetcher-ghcp.ts | 233 ++++++++++++++ src/cliproxy/quota-types.ts | 53 +++- src/commands/cliproxy/help-subcommand.ts | 2 +- src/commands/cliproxy/index.ts | 30 +- src/commands/cliproxy/quota-subcommand.ts | 81 ++++- src/commands/copilot-command.ts | 66 ++++ src/commands/help-command.ts | 1 + src/copilot/copilot-usage.ts | 127 ++++++++ src/copilot/index.ts | 7 + src/copilot/types.ts | 33 ++ .../routes/cliproxy-stats-routes.ts | 66 +++- src/web-server/routes/copilot-routes.ts | 34 ++ .../shared/quota-tooltip-content.tsx | 32 ++ ui/src/hooks/use-cliproxy-stats.ts | 27 +- ui/src/lib/api-client.ts | 47 +++ ui/src/lib/utils.ts | 52 +++- ui/tests/unit/ui/lib/quota-utils.test.ts | 291 ++++++++++++++++++ 18 files changed, 1160 insertions(+), 23 deletions(-) create mode 100644 src/cliproxy/quota-fetcher-ghcp.ts create mode 100644 src/copilot/copilot-usage.ts diff --git a/src/ccs.ts b/src/ccs.ts index 6706c057..7c68fd9f 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -597,6 +597,7 @@ async function main(): Promise { 'auth', 'status', 'models', + 'usage', 'start', 'stop', 'enable', diff --git a/src/cliproxy/quota-fetcher-ghcp.ts b/src/cliproxy/quota-fetcher-ghcp.ts new file mode 100644 index 00000000..84c59bb4 --- /dev/null +++ b/src/cliproxy/quota-fetcher-ghcp.ts @@ -0,0 +1,233 @@ +/** + * Quota Fetcher for GitHub Copilot OAuth (ghcp) Accounts + * + * Fetches quota information from GitHub `/copilot_internal/user` endpoint + * using the account token managed by CLIProxy auth flow. + */ + +import * as fs from 'node:fs'; +import { getAccountTokenPath, getProviderAccounts } from './account-manager'; +import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types'; + +const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user'; +const GHCP_USAGE_TIMEOUT_MS = 10000; +const GHCP_USER_AGENT = 'GitHubCopilotChat/0.26.7'; +const GHCP_API_VERSION = '2025-04-01'; + +interface RawGhcpQuotaSnapshot { + entitlement?: number; + overage_count?: number; + overage_permitted?: boolean; + percent_remaining?: number; + quota_id?: string; + quota_remaining?: number; + remaining?: number; + unlimited?: boolean; +} + +interface RawGhcpUsageResponse { + copilot_plan?: string; + quota_reset_date?: string; + quota_snapshots?: { + premium_interactions?: RawGhcpQuotaSnapshot; + chat?: RawGhcpQuotaSnapshot; + completions?: RawGhcpQuotaSnapshot; + }; +} + +interface TokenData { + access_token?: string; + token?: { + access_token?: string; + }; +} + +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; + const remaining = Number(remainingRaw); + const safeEntitlement = Number.isFinite(entitlement) ? Math.max(0, entitlement) : 0; + const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : 0; + const used = Math.max(0, safeEntitlement - safeRemaining); + + const percentRemainingRaw = + typeof raw?.percent_remaining === 'number' ? raw.percent_remaining : null; + const percentRemaining = + percentRemainingRaw !== null + ? clampPercent(percentRemainingRaw) + : safeEntitlement > 0 + ? clampPercent((safeRemaining / safeEntitlement) * 100) + : 0; + + return { + entitlement: safeEntitlement, + remaining: safeRemaining, + used, + percentRemaining, + percentUsed: clampPercent(100 - percentRemaining), + unlimited: Boolean(raw?.unlimited), + overageCount: + typeof raw?.overage_count === 'number' && Number.isFinite(raw.overage_count) + ? Math.max(0, raw.overage_count) + : 0, + overagePermitted: Boolean(raw?.overage_permitted), + quotaId: raw?.quota_id || null, + }; +} + +function extractAccessToken(tokenData: TokenData): string | null { + if (typeof tokenData.access_token === 'string' && tokenData.access_token.trim()) { + return tokenData.access_token.trim(); + } + + if ( + tokenData.token && + typeof tokenData.token === 'object' && + typeof tokenData.token.access_token === 'string' && + tokenData.token.access_token.trim() + ) { + return tokenData.token.access_token.trim(); + } + + return null; +} + +function readGhcpAccessToken(accountId: string): { accessToken: string | null; error?: string } { + const account = getProviderAccounts('ghcp').find((item) => item.id === accountId); + if (!account) { + return { accessToken: null, error: `Account not found: ${accountId}` }; + } + + const tokenPath = getAccountTokenPath(account); + if (!tokenPath || !fs.existsSync(tokenPath)) { + return { accessToken: null, error: 'Auth token file not found' }; + } + + try { + const raw = fs.readFileSync(tokenPath, 'utf-8'); + const data = JSON.parse(raw) as TokenData; + const accessToken = extractAccessToken(data); + if (!accessToken) { + return { accessToken: null, error: 'No access token in auth file' }; + } + return { accessToken }; + } catch (error) { + return { + accessToken: null, + error: error instanceof Error ? error.message : 'Failed to parse auth token file', + }; + } +} + +function buildEmptyQuotaResult(error: string, accountId?: string): GhcpQuotaResult { + return { + success: false, + planType: null, + quotaResetDate: null, + snapshots: { + premiumInteractions: normalizeSnapshot(), + chat: normalizeSnapshot(), + completions: normalizeSnapshot(), + }, + lastUpdated: Date.now(), + error, + accountId, + }; +} + +function normalizeUsageResponse(raw: RawGhcpUsageResponse): GhcpQuotaResult { + const snapshots = raw.quota_snapshots || {}; + return { + success: true, + planType: raw.copilot_plan ?? null, + quotaResetDate: raw.quota_reset_date ?? null, + snapshots: { + premiumInteractions: normalizeSnapshot(snapshots.premium_interactions), + chat: normalizeSnapshot(snapshots.chat), + completions: normalizeSnapshot(snapshots.completions), + }, + lastUpdated: Date.now(), + }; +} + +/** + * Fetch quota for one ghcp account. + */ +export async function fetchGhcpQuota(accountId: string, verbose = false): Promise { + const { accessToken, error } = readGhcpAccessToken(accountId); + if (!accessToken) { + if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`); + return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId); + } + + if (verbose) console.error(`[i] Fetching ghcp quota for ${accountId}...`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS); + + try { + const response = await fetch(GHCP_USAGE_URL, { + method: 'GET', + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `token ${accessToken}`, + 'User-Agent': GHCP_USER_AGENT, + 'x-github-api-version': GHCP_API_VERSION, + }, + }); + + clearTimeout(timeoutId); + + if (response.status === 401 || response.status === 403) { + return { + ...buildEmptyQuotaResult('Authentication expired or invalid', accountId), + needsReauth: true, + }; + } + + if (response.status === 429) { + return buildEmptyQuotaResult('Rate limited - try again later', accountId); + } + + if (!response.ok) { + return buildEmptyQuotaResult(`GitHub API error: ${response.status}`, accountId); + } + + const data = (await response.json()) as RawGhcpUsageResponse; + return { + ...normalizeUsageResponse(data), + accountId, + }; + } catch (error) { + clearTimeout(timeoutId); + const message = + error instanceof Error && error.name === 'AbortError' + ? 'Request timeout' + : error instanceof Error + ? error.message + : 'Unknown error'; + return buildEmptyQuotaResult(message, accountId); + } +} + +/** + * Fetch quota for all ghcp accounts. + */ +export async function fetchAllGhcpQuotas( + verbose = false +): Promise<{ account: string; quota: GhcpQuotaResult }[]> { + const accounts = getProviderAccounts('ghcp'); + const results = await Promise.all( + accounts.map(async (account) => ({ + account: account.id, + quota: await fetchGhcpQuota(account.id, verbose), + })) + ); + return results; +} diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index 17f32f72..5966212b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -2,11 +2,11 @@ * Shared Quota Type Definitions * * Unified types for multi-provider quota system. - * Supports Antigravity, Codex, and Gemini CLI providers. + * Supports Antigravity, Codex, Gemini CLI, and GitHub Copilot OAuth providers. */ /** Supported quota providers */ -export type QuotaProvider = 'agy' | 'codex' | 'gemini'; +export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; // Re-export Antigravity types for unified access export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher'; @@ -110,3 +110,52 @@ export interface GeminiCliQuotaResult { /** True if token is expired and needs re-authentication */ needsReauth?: boolean; } + +/** + * GitHub Copilot quota snapshot. + */ +export interface GhcpQuotaSnapshot { + /** Total quota allocation for this category */ + entitlement: number; + /** Remaining quota count */ + remaining: number; + /** Used quota count */ + used: number; + /** Remaining quota percentage (0-100) */ + percentRemaining: number; + /** Used quota percentage (0-100) */ + percentUsed: number; + /** Whether this quota category is unlimited */ + unlimited: boolean; + /** Overage usage count */ + overageCount: number; + /** Whether overage is permitted */ + overagePermitted: boolean; + /** Upstream quota identifier if available */ + quotaId: string | null; +} + +/** + * GitHub Copilot quota fetch result. + */ +export interface GhcpQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Copilot plan type (individual/business/enterprise/free) */ + planType: string | null; + /** Quota reset date/time (ISO string) */ + quotaResetDate: string | null; + snapshots: { + premiumInteractions: GhcpQuotaSnapshot; + chat: GhcpQuotaSnapshot; + completions: GhcpQuotaSnapshot; + }; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID this quota belongs to */ + accountId?: string; + /** True if token is expired/invalid and user needs re-authentication */ + needsReauth?: boolean; +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index a5c2ed4d..7f3219d9 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -55,7 +55,7 @@ export async function showHelp(): Promise { ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], + ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 308f9838..60a24aec 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { /** * Parse --provider flag from args for quota command * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, all + * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all */ function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'all'; + provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; remainingArgs: string[]; } { const providerIdx = args.indexOf('--provider'); @@ -97,24 +97,29 @@ function parseProviderArg(args: string[]): { // Handle empty value if (!value) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all' + 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' ); return { provider: 'all', remainingArgs }; } // Normalize gemini-cli to gemini - const normalized = value === 'gemini-cli' ? 'gemini' : value; + const normalized = + value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; if ( normalized !== 'agy' && normalized !== 'codex' && normalized !== 'gemini' && + normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all` + `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } - return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; + return { + provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + remainingArgs, + }; } return { provider: 'all', remainingArgs: args }; } @@ -122,26 +127,31 @@ function parseProviderArg(args: string[]): { // Warn if no value or value looks like another flag if (!rawValue || rawValue.startsWith('-')) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all' + 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' ); } const value = rawValue?.toLowerCase() || 'all'; const remainingArgs = [...args]; remainingArgs.splice(providerIdx, 2); // Normalize gemini-cli to gemini - const normalized = value === 'gemini-cli' ? 'gemini' : value; + const normalized = + value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; if ( normalized !== 'agy' && normalized !== 'codex' && normalized !== 'gemini' && + normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all` + `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } - return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; + return { + provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + remainingArgs, + }; } /** diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 8bdf4830..d3445377 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -19,7 +19,12 @@ import { import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; -import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types'; +import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp'; +import type { + CodexQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +} from '../../cliproxy/quota-types'; import { isOnCooldown } from '../../cliproxy/quota-manager'; import { CLIProxyProvider } from '../../cliproxy/types'; import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; @@ -417,9 +422,68 @@ function displayGeminiCliQuotaSection( } } +function formatSnapshotLabel( + snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']] +): string { + if (snapshot.unlimited) { + return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`; + } + return `${snapshot.used}/${snapshot.entitlement} used`; +} + +function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaResult }[]): void { + console.log( + subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('ghcp', account); + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(account)}${defaultMark}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + console.log(''); + continue; + } + + const rows = [ + quota.snapshots.premiumInteractions.percentRemaining, + quota.snapshots.chat.percentRemaining, + quota.snapshots.completions.percentRemaining, + ]; + const minQuota = rows.length > 0 ? Math.min(...rows) : 0; + const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; + + console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); + if (quota.quotaResetDate) { + console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`); + } + + const items: Array<[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]> = + [ + ['Premium interactions', quota.snapshots.premiumInteractions], + ['Chat', quota.snapshots.chat], + ['Completions', quota.snapshots.completions], + ]; + + for (const [label, snapshot] of items) { + const bar = formatQuotaBar(snapshot.percentRemaining); + const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`); + console.log( + ` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}` + ); + } + + console.log(''); + } +} + export async function handleQuotaStatus( verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all' + providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all' ): Promise { await initUI(); console.log(header('Quota Status')); @@ -429,14 +493,16 @@ export async function handleQuotaStatus( agy: providerFilter === 'all' || providerFilter === 'agy', codex: providerFilter === 'all' || providerFilter === 'codex', gemini: providerFilter === 'all' || providerFilter === 'gemini', + ghcp: providerFilter === 'all' || providerFilter === 'ghcp', }; console.log(dim('Fetching quotas...')); - const [agyResults, codexResults, geminiResults] = await Promise.all([ + const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([ shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, + shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null, ]); console.log(''); @@ -467,6 +533,15 @@ export async function handleQuotaStatus( console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); console.log(''); } + + if (ghcpResults && ghcpResults.length > 0) { + displayGhcpQuotaSection(ghcpResults); + } else if (shouldFetch.ghcp) { + console.log(subheader('GitHub Copilot (0 accounts)')); + console.log(info('No GitHub Copilot accounts configured')); + console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`); + console.log(''); + } } export async function handleDoctor(verbose = false): Promise { diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 306d272e..7ce38232 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -7,6 +7,7 @@ import { startAuthFlow, getCopilotStatus, + getCopilotUsage, startDaemon, stopDaemon, getAvailableModels, @@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise { return handleStatus(); case 'models': return handleModels(); + case 'usage': + return handleUsage(); case 'start': return handleStart(); case 'stop': @@ -61,6 +64,7 @@ function handleHelp(): number { console.log(' auth Start GitHub OAuth authentication'); console.log(' status Show authentication and daemon status'); console.log(' models List available models'); + console.log(' usage Show Copilot quota usage'); console.log(' start Start copilot-api daemon'); console.log(' stop Stop copilot-api daemon'); console.log(' enable Enable copilot integration'); @@ -71,6 +75,7 @@ function handleHelp(): number { console.log(' 1. ccs copilot auth # Authenticate with GitHub'); console.log(' 2. ccs copilot enable # Enable integration'); console.log(' 3. ccs copilot start # Start daemon'); + console.log(' 4. ccs copilot usage # Check quota usage'); console.log(''); console.log('Or use the web UI: ccs config → Copilot tab'); console.log(''); @@ -190,6 +195,67 @@ async function handleModels(): Promise { return 0; } +function formatQuotaLine( + label: string, + snapshot: { + entitlement: number; + used: number; + percentUsed: number; + percentRemaining: number; + unlimited: boolean; + } +): string { + const quotaText = snapshot.unlimited + ? 'Unlimited' + : `${snapshot.used}/${snapshot.entitlement} used`; + return `${label.padEnd(20)} ${quotaText} (${snapshot.percentUsed.toFixed(1)}% used, ${snapshot.percentRemaining.toFixed(1)}% remaining)`; +} + +function formatResetDate(resetDate: string | null): string { + if (!resetDate) return 'unknown'; + const date = new Date(resetDate); + if (Number.isNaN(date.getTime())) return resetDate; + return date.toLocaleString(); +} + +/** + * Handle usage subcommand. + */ +async function handleUsage(): Promise { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const status = await getCopilotStatus(copilotConfig); + + if (!status.daemon.running) { + console.error(fail('copilot-api daemon is not running.')); + console.error(''); + console.error('Start daemon first: ccs copilot start'); + return 1; + } + + const usage = await getCopilotUsage(copilotConfig.port); + if (!usage) { + console.error(fail('Failed to fetch Copilot usage.')); + console.error(''); + console.error('Try restarting daemon: ccs copilot stop && ccs copilot start'); + return 1; + } + + console.log('GitHub Copilot Usage'); + console.log('────────────────────'); + console.log(''); + console.log(`Plan: ${usage.plan || 'unknown'}`); + console.log(`Quota Reset: ${formatResetDate(usage.quotaResetDate)}`); + console.log(''); + console.log('Quotas:'); + console.log(` ${formatQuotaLine('Premium Interactions', usage.quotas.premiumInteractions)}`); + console.log(` ${formatQuotaLine('Chat', usage.quotas.chat)}`); + console.log(` ${formatQuotaLine('Completions', usage.quotas.completions)}`); + console.log(''); + + return 0; +} + /** * Handle start subcommand. */ diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index eecffc8b..e2568651 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -220,6 +220,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs copilot auth', 'Authenticate with GitHub'], ['ccs copilot status', 'Show integration status'], ['ccs copilot models', 'List available models'], + ['ccs copilot usage', 'Show Copilot quota usage'], ['ccs copilot start', 'Start copilot-api daemon'], ['ccs copilot stop', 'Stop copilot-api daemon'], ['ccs copilot enable', 'Enable integration'], diff --git a/src/copilot/copilot-usage.ts b/src/copilot/copilot-usage.ts new file mode 100644 index 00000000..0dca9bf2 --- /dev/null +++ b/src/copilot/copilot-usage.ts @@ -0,0 +1,127 @@ +/** + * Copilot Usage Fetcher + * + * Fetches usage/quota data from copilot-api `/usage` endpoint and normalizes it + * for CLI and dashboard consumers. + */ + +import * as http from 'http'; +import type { CopilotQuotaSnapshot, CopilotUsage } from './types'; + +interface RawCopilotQuotaSnapshot { + entitlement?: number; + remaining?: number; + percent_remaining?: number; + unlimited?: boolean; +} + +interface RawCopilotUsage { + copilot_plan?: string; + quota_reset_date?: string; + quota_snapshots?: { + premium_interactions?: RawCopilotQuotaSnapshot; + chat?: RawCopilotQuotaSnapshot; + completions?: RawCopilotQuotaSnapshot; + }; +} + +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); + const safeEntitlement = Number.isFinite(entitlement) && entitlement > 0 ? entitlement : 0; + const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : 0; + const used = Math.max(0, safeEntitlement - safeRemaining); + + const percentRemainingFromApi = + raw && typeof raw.percent_remaining === 'number' ? raw.percent_remaining : null; + const percentRemaining = + percentRemainingFromApi !== null + ? clampPercent(percentRemainingFromApi) + : safeEntitlement > 0 + ? clampPercent((safeRemaining / safeEntitlement) * 100) + : 0; + + return { + entitlement: safeEntitlement, + remaining: safeRemaining, + used, + percentRemaining, + percentUsed: clampPercent(100 - percentRemaining), + unlimited: Boolean(raw?.unlimited), + }; +} + +export function normalizeCopilotUsage(raw: unknown): CopilotUsage { + const usage = (raw || {}) as RawCopilotUsage; + const snapshots = usage.quota_snapshots || {}; + + return { + plan: usage.copilot_plan ?? null, + quotaResetDate: usage.quota_reset_date ?? null, + quotas: { + premiumInteractions: normalizeSnapshot(snapshots.premium_interactions), + chat: normalizeSnapshot(snapshots.chat), + completions: normalizeSnapshot(snapshots.completions), + }, + }; +} + +/** + * Fetch Copilot usage from running copilot-api daemon. + * + * @returns normalized usage on success, null on daemon/network/parsing failure + */ +export async function fetchCopilotUsageFromDaemon(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/usage', + method: 'GET', + timeout: 5000, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200 || !data) { + resolve(null); + return; + } + + try { + const parsed = JSON.parse(data) as unknown; + resolve(normalizeCopilotUsage(parsed)); + } catch { + resolve(null); + } + }); + } + ); + + req.on('error', () => { + resolve(null); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + + req.end(); + }); +} + +export async function getCopilotUsage(port: number): Promise { + return fetchCopilotUsageFromDaemon(port); +} diff --git a/src/copilot/index.ts b/src/copilot/index.ts index b1f9d184..e692e09d 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -44,5 +44,12 @@ export { getDefaultModel, } from './copilot-models'; +// Usage +export { + normalizeCopilotUsage, + fetchCopilotUsageFromDaemon, + getCopilotUsage, +} from './copilot-usage'; + // Executor export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor'; diff --git a/src/copilot/types.ts b/src/copilot/types.ts index e0429a1d..e8d8b5f7 100644 --- a/src/copilot/types.ts +++ b/src/copilot/types.ts @@ -68,3 +68,36 @@ export interface CopilotDebugInfo { authenticated?: boolean; tokenPath?: string; } + +/** + * Quota snapshot from Copilot usage endpoint. + */ +export interface CopilotQuotaSnapshot { + /** Total quota allocation for this bucket */ + entitlement: number; + /** Remaining quota count */ + remaining: number; + /** Used quota count */ + used: number; + /** Remaining quota percentage (0-100) */ + percentRemaining: number; + /** Used quota percentage (0-100) */ + percentUsed: number; + /** Whether quota is unlimited */ + unlimited: boolean; +} + +/** + * Normalized Copilot usage response used by CLI and dashboard. + */ +export interface CopilotUsage { + /** Copilot plan name (free/pro/business/enterprise) */ + plan: string | null; + /** ISO date string when quota resets */ + quotaResetDate: string | null; + quotas: { + premiumInteractions: CopilotQuotaSnapshot; + chat: CopilotQuotaSnapshot; + completions: CopilotQuotaSnapshot; + }; +} diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index e6c6e475..571fbc86 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -15,8 +15,13 @@ import { import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; +import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp'; import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache'; -import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types'; +import type { + CodexQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +} from '../../cliproxy/quota-types'; import type { QuotaResult } from '../../cliproxy/quota-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; @@ -78,6 +83,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { return false; } +function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + /** Get configured backend from config */ function getConfiguredBackend() { try { @@ -140,7 +159,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise = if (!running) { res.status(503).json({ error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats', + message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats', }); return; } @@ -632,10 +651,51 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom } }); +/** + * GET /api/cliproxy/quota/ghcp/:accountId - Get GitHub Copilot (ghcp) quota for a specific account + * Returns: GhcpQuotaResult with premium/chat/completions quota snapshots + * Caching: 2 minute TTL to reduce GitHub API calls + */ +router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + // Check cache first + const cached = getCachedQuota('ghcp', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from GitHub API + const result = await fetchGhcpQuota(accountId); + + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheGhcpQuotaResult(result)) { + setCachedQuota('ghcp', accountId, result); + } + + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) * Returns: QuotaResult with model quotas and reset times - * NOTE: This generic route MUST come after specific routes (codex, gemini) to avoid matching them + * NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp) * Caching: 2 minute TTL to reduce external API calls */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts index 71ac1817..3d4bae9e 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -7,6 +7,8 @@ import { checkAuthStatus as checkCopilotAuth, startAuthFlow as startCopilotAuth, getCopilotStatus, + getCopilotUsage, + isDaemonRunning, startDaemon as startCopilotDaemon, stopDaemon as stopCopilotDaemon, getAvailableModels as getCopilotModels, @@ -152,6 +154,38 @@ router.get('/models', async (_req: Request, res: Response): Promise => { } }); +/** + * GET /api/copilot/usage - Get Copilot quota usage from copilot-api /usage endpoint + */ +router.get('/usage', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; + const daemonRunning = await isDaemonRunning(port); + + if (!daemonRunning) { + res.status(503).json({ + error: 'copilot-api daemon is not running', + message: 'Start daemon first: ccs copilot start', + }); + return; + } + + const usage = await getCopilotUsage(port); + if (!usage) { + res.status(503).json({ + error: 'Failed to fetch Copilot usage', + message: 'copilot-api /usage endpoint is unavailable', + }); + return; + } + + res.json(usage); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * POST /api/copilot/daemon/start - Start copilot-api daemon */ diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index cdb834ad..4e3406d6 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -14,6 +14,7 @@ import { isAgyQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, + isGhcpQuotaResult, type ModelTier, type UnifiedQuotaResult, } from '@/lib/utils'; @@ -122,6 +123,37 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); } + // GitHub Copilot (ghcp) provider tooltip + if (isGhcpQuotaResult(quota)) { + const snapshotRows = [ + { label: 'Premium Interactions', snapshot: quota.snapshots.premiumInteractions }, + { label: 'Chat', snapshot: quota.snapshots.chat }, + { label: 'Completions', snapshot: quota.snapshots.completions }, + ]; + const effectiveResetTime = quota.quotaResetDate ?? resetTime; + + return ( +
+

Quota Snapshots:

+ {quota.planType &&

Plan: {quota.planType}

} + {snapshotRows.map(({ label, snapshot }) => ( +
+ + {label} + {snapshot.unlimited ? ' (Unlimited)' : ''} + + + {snapshot.unlimited + ? 'inf' + : `${snapshot.percentRemaining}% (${snapshot.remaining}/${snapshot.entitlement})`} + +
+ ))} + +
+ ); + } + return null; } diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 91dac614..0120bddb 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -8,6 +8,7 @@ import type { QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, + GhcpQuotaResult, } from '@/lib/api-client'; import type { UnifiedQuotaResult } from '@/lib/utils'; @@ -202,10 +203,10 @@ export function useCliproxyErrorLogContent(name: string | null) { } // Re-export for consumers -export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult }; +export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult }; /** Providers with quota API support */ -export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; +export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; /** @@ -262,6 +263,24 @@ async function fetchGeminiQuotaApi(accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/ghcp/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch GitHub Copilot quota'; + try { + const error = await response.json(); + message = error.message || message; + } catch { + // Use default message if response isn't JSON + } + throw new Error(message); + } + return response.json(); +} + // Re-export unified type from utils for consumers export type { UnifiedQuotaResult } from '@/lib/utils'; @@ -277,6 +296,8 @@ async function fetchQuotaByProvider( return fetchCodexQuotaApi(accountId); case 'gemini': return fetchGeminiQuotaApi(accountId); + case 'ghcp': + return fetchGhcpQuotaApi(accountId); default: return fetchAccountQuota(provider, accountId); } @@ -284,7 +305,7 @@ async function fetchQuotaByProvider( /** * Hook to get account quota - * Supports agy, codex, and gemini providers + * Supports agy, codex, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { return useQuery({ diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 405fa210..a38c2ccb 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -337,6 +337,53 @@ export interface GeminiCliQuotaResult { cached?: boolean; } +/** GitHub Copilot quota snapshot */ +export interface GhcpQuotaSnapshot { + /** Total quota allocation for this category */ + entitlement: number; + /** Remaining quota count */ + remaining: number; + /** Used quota count */ + used: number; + /** Remaining quota percentage (0-100) */ + percentRemaining: number; + /** Used quota percentage (0-100) */ + percentUsed: number; + /** Whether this quota category is unlimited */ + unlimited: boolean; + /** Overage usage count */ + overageCount: number; + /** Whether overage is permitted */ + overagePermitted: boolean; + /** Upstream quota identifier if available */ + quotaId: string | null; +} + +/** GitHub Copilot (ghcp) quota result */ +export interface GhcpQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Copilot plan type */ + planType: string | null; + /** Quota reset date/time */ + quotaResetDate: string | null; + snapshots: { + premiumInteractions: GhcpQuotaSnapshot; + chat: GhcpQuotaSnapshot; + completions: GhcpQuotaSnapshot; + }; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID this quota belongs to */ + accountId?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; + /** True if result was served from cache */ + cached?: boolean; +} + /** Provider accounts summary */ export type ProviderAccountsMap = Record; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2c7edc70..e4743f68 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -5,6 +5,7 @@ import type { CodexQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, + GhcpQuotaResult, QuotaResult, } from './api-client'; @@ -35,6 +36,7 @@ const PROVIDER_COLORS: Record = { iflow: '#f94144', // Strawberry qwen: '#f9c74f', // Tuscan kiro: '#4d908e', // Dark Cyan (AWS-inspired) + ghcp: '#43aa8b', // Seaweed (GitHub-inspired) copilot: '#43aa8b', // Seaweed (GitHub-inspired) }; @@ -529,10 +531,37 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null { return resets.sort()[0]; } +/** + * Get minimum remaining percentage across GitHub Copilot quota snapshots + */ +export function getMinGhcpQuota(snapshots: GhcpQuotaResult['snapshots']): number | null { + if (!snapshots) return null; + + const percentages = [ + snapshots.premiumInteractions.percentRemaining, + snapshots.chat.percentRemaining, + snapshots.completions.percentRemaining, + ].filter((p) => typeof p === 'number' && isFinite(p)); + + if (percentages.length === 0) return null; + return Math.min(...percentages); +} + +/** + * Get reset time from GitHub Copilot quota result + */ +export function getGhcpResetTime(quotaResetDate: string | null): string | null { + return quotaResetDate; +} + // ==================== Unified Quota Type Guards ==================== /** Unified quota result type for provider-agnostic handling */ -export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult; +export type UnifiedQuotaResult = + | QuotaResult + | CodexQuotaResult + | GeminiCliQuotaResult + | GhcpQuotaResult; /** Type guard: Check if quota result is from Antigravity (agy) provider */ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { @@ -549,6 +578,15 @@ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiC return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets); } +/** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ +export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { + return ( + 'snapshots' in quota && + typeof (quota as GhcpQuotaResult).snapshots === 'object' && + (quota as GhcpQuotaResult).snapshots !== null + ); +} + // ==================== Unified Quota Helpers ==================== /** @@ -577,6 +615,12 @@ export function getProviderMinQuota( return getMinGeminiQuota(quota.buckets); } return null; + case 'ghcp': + case 'github-copilot': + if (isGhcpQuotaResult(quota)) { + return getMinGhcpQuota(quota.snapshots); + } + return null; default: return null; } @@ -608,6 +652,12 @@ export function getProviderResetTime( return getGeminiResetTime(quota.buckets); } return null; + case 'ghcp': + case 'github-copilot': + if (isGhcpQuotaResult(quota)) { + return getGhcpResetTime(quota.quotaResetDate); + } + return null; default: return null; } diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index 8efdf579..d6c9dcd2 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -17,12 +17,14 @@ import { isAgyQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, + isGhcpQuotaResult, } from '@/lib/utils'; import type { CodexQuotaWindow, CodexQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, + GhcpQuotaResult, QuotaResult, ModelQuota, } from '@/lib/api-client'; @@ -1051,6 +1053,63 @@ describe('isGeminiQuotaResult', () => { }); }); +describe('isGhcpQuotaResult', () => { + it('returns true for valid GHCP quota result', () => { + const quota: GhcpQuotaResult = { + success: true, + planType: 'individual', + quotaResetDate: '2026-01-31T00:00:00Z', + snapshots: { + premiumInteractions: { + entitlement: 300, + remaining: 120, + used: 180, + percentRemaining: 40, + percentUsed: 60, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: 'premium_interactions', + }, + chat: { + entitlement: 999999, + remaining: 999999, + used: 0, + percentRemaining: 100, + percentUsed: 0, + unlimited: true, + overageCount: 0, + overagePermitted: true, + quotaId: 'chat', + }, + completions: { + entitlement: 5000, + remaining: 4200, + used: 800, + percentRemaining: 84, + percentUsed: 16, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: 'completions', + }, + }, + lastUpdated: Date.now(), + }; + expect(isGhcpQuotaResult(quota)).toBe(true); + }); + + it('returns false for non-GHCP quota result', () => { + const quota: GeminiCliQuotaResult = { + success: true, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + }; + expect(isGhcpQuotaResult(quota)).toBe(false); + }); +}); + // ==================== Unified Provider Helpers ==================== describe('getProviderMinQuota', () => { @@ -1204,6 +1263,144 @@ describe('getProviderMinQuota', () => { }); }); + describe('ghcp provider', () => { + it('returns minimum quota from snapshots', () => { + const quota: GhcpQuotaResult = { + success: true, + planType: 'individual', + quotaResetDate: '2026-01-31T00:00:00Z', + snapshots: { + premiumInteractions: { + entitlement: 300, + remaining: 90, + used: 210, + percentRemaining: 30, + percentUsed: 70, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: 'premium_interactions', + }, + chat: { + entitlement: 1000, + remaining: 1000, + used: 0, + percentRemaining: 100, + percentUsed: 0, + unlimited: true, + overageCount: 0, + overagePermitted: true, + quotaId: 'chat', + }, + completions: { + entitlement: 5000, + remaining: 2750, + used: 2250, + percentRemaining: 55, + percentUsed: 45, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: 'completions', + }, + }, + lastUpdated: Date.now(), + }; + expect(getProviderMinQuota('ghcp', quota)).toBe(30); + }); + + it('supports github-copilot alias', () => { + const quota: GhcpQuotaResult = { + success: true, + planType: 'individual', + quotaResetDate: null, + snapshots: { + premiumInteractions: { + entitlement: 300, + remaining: 150, + used: 150, + percentRemaining: 50, + percentUsed: 50, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + chat: { + entitlement: 1000, + remaining: 900, + used: 100, + percentRemaining: 90, + percentUsed: 10, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + completions: { + entitlement: 1000, + remaining: 800, + used: 200, + percentRemaining: 80, + percentUsed: 20, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + }, + lastUpdated: Date.now(), + }; + expect(getProviderMinQuota('github-copilot', quota)).toBe(50); + }); + + it('returns null when GHCP quota fetch failed', () => { + const quota: GhcpQuotaResult = { + success: false, + planType: null, + quotaResetDate: null, + snapshots: { + premiumInteractions: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + chat: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + completions: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + }, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderMinQuota('ghcp', quota)).toBeNull(); + }); + }); + describe('unknown provider', () => { it('returns null for unknown provider', () => { const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; @@ -1369,6 +1566,100 @@ describe('getProviderResetTime', () => { }); }); + describe('ghcp provider', () => { + it('returns quota reset date', () => { + const quota: GhcpQuotaResult = { + success: true, + planType: 'individual', + quotaResetDate: '2026-01-31T00:00:00Z', + snapshots: { + premiumInteractions: { + entitlement: 300, + remaining: 120, + used: 180, + percentRemaining: 40, + percentUsed: 60, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + chat: { + entitlement: 1000, + remaining: 900, + used: 100, + percentRemaining: 90, + percentUsed: 10, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + completions: { + entitlement: 5000, + remaining: 2500, + used: 2500, + percentRemaining: 50, + percentUsed: 50, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + }, + lastUpdated: Date.now(), + }; + expect(getProviderResetTime('ghcp', quota)).toBe('2026-01-31T00:00:00Z'); + expect(getProviderResetTime('github-copilot', quota)).toBe('2026-01-31T00:00:00Z'); + }); + + it('returns null when GHCP quota fetch failed', () => { + const quota: GhcpQuotaResult = { + success: false, + planType: null, + quotaResetDate: null, + snapshots: { + premiumInteractions: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + chat: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + completions: { + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }, + }, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderResetTime('ghcp', quota)).toBeNull(); + }); + }); + describe('unknown provider', () => { it('returns null for unknown provider', () => { const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };