feat(quota): add GitHub Copilot quota checks for ghcp and copilot

This commit is contained in:
Tam Nhu Tran committed 2026-02-20 22:32:27 +07:00
1 parent 2eddd5c773
commit 8d9d4987dc
18 files changed
+1160 -23

No files matched your search

+1
View File
@@ -597,6 +597,7 @@ async function main(): Promise<void> {
'auth', 'auth',
'status', 'status',
'models', 'models',
'usage',
'start', 'start',
'stop', 'stop',
'enable', 'enable',
+233
View File
@@ -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<GhcpQuotaResult> {
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;
}
+51 -2
View File
@@ -2,11 +2,11 @@
* Shared Quota Type Definitions * Shared Quota Type Definitions
* *
* Unified types for multi-provider quota system. * 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 */ /** Supported quota providers */
export type QuotaProvider = 'agy' | 'codex' | 'gemini'; export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp';
// Re-export Antigravity types for unified access // Re-export Antigravity types for unified access
export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher'; export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher';
@@ -110,3 +110,52 @@ export interface GeminiCliQuotaResult {
/** True if token is expired and needs re-authentication */ /** True if token is expired and needs re-authentication */
needsReauth?: boolean; 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;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function showHelp(): Promise<void> {
['pause <account>', 'Pause account (skip in rotation)'], ['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'], ['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini)'], ['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
], ],
], ],
[ [
+20 -10
View File
@@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
/** /**
* Parse --provider flag from args for quota command * Parse --provider flag from args for quota command
* Returns the provider filter value and remaining args * 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[]): { function parseProviderArg(args: string[]): {
provider: 'agy' | 'codex' | 'gemini' | 'all'; provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
remainingArgs: string[]; remainingArgs: string[];
} { } {
const providerIdx = args.indexOf('--provider'); const providerIdx = args.indexOf('--provider');
@@ -97,24 +97,29 @@ function parseProviderArg(args: string[]): {
// Handle empty value // Handle empty value
if (!value) { if (!value) {
console.error( 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 }; return { provider: 'all', remainingArgs };
} }
// Normalize gemini-cli to gemini // Normalize gemini-cli to gemini
const normalized = value === 'gemini-cli' ? 'gemini' : value; const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if ( if (
normalized !== 'agy' && normalized !== 'agy' &&
normalized !== 'codex' && normalized !== 'codex' &&
normalized !== 'gemini' && normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all' normalized !== 'all'
) { ) {
console.error( 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: '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 }; return { provider: 'all', remainingArgs: args };
} }
@@ -122,26 +127,31 @@ function parseProviderArg(args: string[]): {
// Warn if no value or value looks like another flag // Warn if no value or value looks like another flag
if (!rawValue || rawValue.startsWith('-')) { if (!rawValue || rawValue.startsWith('-')) {
console.error( 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 value = rawValue?.toLowerCase() || 'all';
const remainingArgs = [...args]; const remainingArgs = [...args];
remainingArgs.splice(providerIdx, 2); remainingArgs.splice(providerIdx, 2);
// Normalize gemini-cli to gemini // Normalize gemini-cli to gemini
const normalized = value === 'gemini-cli' ? 'gemini' : value; const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if ( if (
normalized !== 'agy' && normalized !== 'agy' &&
normalized !== 'codex' && normalized !== 'codex' &&
normalized !== 'gemini' && normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all' normalized !== 'all'
) { ) {
console.error( 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: 'all', remainingArgs };
} }
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
} }
/** /**
+78 -3
View File
@@ -19,7 +19,12 @@ import {
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; 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 { isOnCooldown } from '../../cliproxy/quota-manager';
import { CLIProxyProvider } from '../../cliproxy/types'; import { CLIProxyProvider } from '../../cliproxy/types';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; 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( export async function handleQuotaStatus(
verbose = false, verbose = false,
providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all' providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
): Promise<void> { ): Promise<void> {
await initUI(); await initUI();
console.log(header('Quota Status')); console.log(header('Quota Status'));
@@ -429,14 +493,16 @@ export async function handleQuotaStatus(
agy: providerFilter === 'all' || providerFilter === 'agy', agy: providerFilter === 'all' || providerFilter === 'agy',
codex: providerFilter === 'all' || providerFilter === 'codex', codex: providerFilter === 'all' || providerFilter === 'codex',
gemini: providerFilter === 'all' || providerFilter === 'gemini', gemini: providerFilter === 'all' || providerFilter === 'gemini',
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
}; };
console.log(dim('Fetching quotas...')); 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.agy ? fetchAllProviderQuotas('agy', verbose) : null,
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
]); ]);
console.log(''); console.log('');
@@ -467,6 +533,15 @@ export async function handleQuotaStatus(
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
console.log(''); 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<void> { export async function handleDoctor(verbose = false): Promise<void> {
+66
View File
@@ -7,6 +7,7 @@
import { import {
startAuthFlow, startAuthFlow,
getCopilotStatus, getCopilotStatus,
getCopilotUsage,
startDaemon, startDaemon,
stopDaemon, stopDaemon,
getAvailableModels, getAvailableModels,
@@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
return handleStatus(); return handleStatus();
case 'models': case 'models':
return handleModels(); return handleModels();
case 'usage':
return handleUsage();
case 'start': case 'start':
return handleStart(); return handleStart();
case 'stop': case 'stop':
@@ -61,6 +64,7 @@ function handleHelp(): number {
console.log(' auth Start GitHub OAuth authentication'); console.log(' auth Start GitHub OAuth authentication');
console.log(' status Show authentication and daemon status'); console.log(' status Show authentication and daemon status');
console.log(' models List available models'); console.log(' models List available models');
console.log(' usage Show Copilot quota usage');
console.log(' start Start copilot-api daemon'); console.log(' start Start copilot-api daemon');
console.log(' stop Stop copilot-api daemon'); console.log(' stop Stop copilot-api daemon');
console.log(' enable Enable copilot integration'); console.log(' enable Enable copilot integration');
@@ -71,6 +75,7 @@ function handleHelp(): number {
console.log(' 1. ccs copilot auth # Authenticate with GitHub'); console.log(' 1. ccs copilot auth # Authenticate with GitHub');
console.log(' 2. ccs copilot enable # Enable integration'); console.log(' 2. ccs copilot enable # Enable integration');
console.log(' 3. ccs copilot start # Start daemon'); console.log(' 3. ccs copilot start # Start daemon');
console.log(' 4. ccs copilot usage # Check quota usage');
console.log(''); console.log('');
console.log('Or use the web UI: ccs config → Copilot tab'); console.log('Or use the web UI: ccs config → Copilot tab');
console.log(''); console.log('');
@@ -190,6 +195,67 @@ async function handleModels(): Promise<number> {
return 0; 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<number> {
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. * Handle start subcommand.
*/ */
+1
View File
@@ -220,6 +220,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs copilot auth', 'Authenticate with GitHub'], ['ccs copilot auth', 'Authenticate with GitHub'],
['ccs copilot status', 'Show integration status'], ['ccs copilot status', 'Show integration status'],
['ccs copilot models', 'List available models'], ['ccs copilot models', 'List available models'],
['ccs copilot usage', 'Show Copilot quota usage'],
['ccs copilot start', 'Start copilot-api daemon'], ['ccs copilot start', 'Start copilot-api daemon'],
['ccs copilot stop', 'Stop copilot-api daemon'], ['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'], ['ccs copilot enable', 'Enable integration'],
+127
View File
@@ -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<CopilotUsage | null> {
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<CopilotUsage | null> {
return fetchCopilotUsageFromDaemon(port);
}
+7
View File
@@ -44,5 +44,12 @@ export {
getDefaultModel, getDefaultModel,
} from './copilot-models'; } from './copilot-models';
// Usage
export {
normalizeCopilotUsage,
fetchCopilotUsageFromDaemon,
getCopilotUsage,
} from './copilot-usage';
// Executor // Executor
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor'; export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
+33
View File
@@ -68,3 +68,36 @@ export interface CopilotDebugInfo {
authenticated?: boolean; authenticated?: boolean;
tokenPath?: string; 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;
};
}
+63 -3
View File
@@ -15,8 +15,13 @@ import {
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp';
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache'; 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 { QuotaResult } from '../../cliproxy/quota-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types'; import type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
@@ -78,6 +83,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean {
return false; 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 */ /** Get configured backend from config */
function getConfiguredBackend() { function getConfiguredBackend() {
try { try {
@@ -140,7 +159,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
if (!running) { if (!running) {
res.status(503).json({ res.status(503).json({
error: 'CLIProxy Plus not running', 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; 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<void> => {
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<GhcpQuotaResult>('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) * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
* Returns: QuotaResult with model quotas and reset times * 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 * Caching: 2 minute TTL to reduce external API calls
*/ */
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => { router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
+34
View File
@@ -7,6 +7,8 @@ import {
checkAuthStatus as checkCopilotAuth, checkAuthStatus as checkCopilotAuth,
startAuthFlow as startCopilotAuth, startAuthFlow as startCopilotAuth,
getCopilotStatus, getCopilotStatus,
getCopilotUsage,
isDaemonRunning,
startDaemon as startCopilotDaemon, startDaemon as startCopilotDaemon,
stopDaemon as stopCopilotDaemon, stopDaemon as stopCopilotDaemon,
getAvailableModels as getCopilotModels, getAvailableModels as getCopilotModels,
@@ -152,6 +154,38 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
} }
}); });
/**
* GET /api/copilot/usage - Get Copilot quota usage from copilot-api /usage endpoint
*/
router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
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 * POST /api/copilot/daemon/start - Start copilot-api daemon
*/ */
@@ -14,6 +14,7 @@ import {
isAgyQuotaResult, isAgyQuotaResult,
isCodexQuotaResult, isCodexQuotaResult,
isGeminiQuotaResult, isGeminiQuotaResult,
isGhcpQuotaResult,
type ModelTier, type ModelTier,
type UnifiedQuotaResult, type UnifiedQuotaResult,
} from '@/lib/utils'; } 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 (
<div className="text-xs space-y-1">
<p className="font-medium">Quota Snapshots:</p>
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
{snapshotRows.map(({ label, snapshot }) => (
<div key={label} className="flex justify-between gap-4">
<span className={cn(snapshot.percentRemaining < 20 && 'text-red-500')}>
{label}
{snapshot.unlimited ? ' (Unlimited)' : ''}
</span>
<span className={cn('font-mono', snapshot.percentRemaining < 20 && 'text-red-500')}>
{snapshot.unlimited
? 'inf'
: `${snapshot.percentRemaining}% (${snapshot.remaining}/${snapshot.entitlement})`}
</span>
</div>
))}
<ResetTimeIndicator resetTime={effectiveResetTime} />
</div>
);
}
return null; return null;
} }
+24 -3
View File
@@ -8,6 +8,7 @@ import type {
QuotaResult, QuotaResult,
CodexQuotaResult, CodexQuotaResult,
GeminiCliQuotaResult, GeminiCliQuotaResult,
GhcpQuotaResult,
} from '@/lib/api-client'; } from '@/lib/api-client';
import type { UnifiedQuotaResult } from '@/lib/utils'; import type { UnifiedQuotaResult } from '@/lib/utils';
@@ -202,10 +203,10 @@ export function useCliproxyErrorLogContent(name: string | null) {
} }
// Re-export for consumers // Re-export for consumers
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult }; export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult };
/** Providers with quota API support */ /** 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]; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
/** /**
@@ -262,6 +263,24 @@ async function fetchGeminiQuotaApi(accountId: string): Promise<GeminiCliQuotaRes
return response.json(); return response.json();
} }
/**
* Fetch GitHub Copilot (ghcp) quota from API
*/
async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
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 // Re-export unified type from utils for consumers
export type { UnifiedQuotaResult } from '@/lib/utils'; export type { UnifiedQuotaResult } from '@/lib/utils';
@@ -277,6 +296,8 @@ async function fetchQuotaByProvider(
return fetchCodexQuotaApi(accountId); return fetchCodexQuotaApi(accountId);
case 'gemini': case 'gemini':
return fetchGeminiQuotaApi(accountId); return fetchGeminiQuotaApi(accountId);
case 'ghcp':
return fetchGhcpQuotaApi(accountId);
default: default:
return fetchAccountQuota(provider, accountId); return fetchAccountQuota(provider, accountId);
} }
@@ -284,7 +305,7 @@ async function fetchQuotaByProvider(
/** /**
* Hook to get account quota * 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) { export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({ return useQuery({
+47
View File
@@ -337,6 +337,53 @@ export interface GeminiCliQuotaResult {
cached?: boolean; 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 */ /** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>; export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
+51 -1
View File
@@ -5,6 +5,7 @@ import type {
CodexQuotaResult, CodexQuotaResult,
GeminiCliBucket, GeminiCliBucket,
GeminiCliQuotaResult, GeminiCliQuotaResult,
GhcpQuotaResult,
QuotaResult, QuotaResult,
} from './api-client'; } from './api-client';
@@ -35,6 +36,7 @@ const PROVIDER_COLORS: Record<string, string> = {
iflow: '#f94144', // Strawberry iflow: '#f94144', // Strawberry
qwen: '#f9c74f', // Tuscan qwen: '#f9c74f', // Tuscan
kiro: '#4d908e', // Dark Cyan (AWS-inspired) kiro: '#4d908e', // Dark Cyan (AWS-inspired)
ghcp: '#43aa8b', // Seaweed (GitHub-inspired)
copilot: '#43aa8b', // Seaweed (GitHub-inspired) copilot: '#43aa8b', // Seaweed (GitHub-inspired)
}; };
@@ -529,10 +531,37 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null {
return resets.sort()[0]; 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 Type Guards ====================
/** Unified quota result type for provider-agnostic handling */ /** 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 */ /** Type guard: Check if quota result is from Antigravity (agy) provider */
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { 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); 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 ==================== // ==================== Unified Quota Helpers ====================
/** /**
@@ -577,6 +615,12 @@ export function getProviderMinQuota(
return getMinGeminiQuota(quota.buckets); return getMinGeminiQuota(quota.buckets);
} }
return null; return null;
case 'ghcp':
case 'github-copilot':
if (isGhcpQuotaResult(quota)) {
return getMinGhcpQuota(quota.snapshots);
}
return null;
default: default:
return null; return null;
} }
@@ -608,6 +652,12 @@ export function getProviderResetTime(
return getGeminiResetTime(quota.buckets); return getGeminiResetTime(quota.buckets);
} }
return null; return null;
case 'ghcp':
case 'github-copilot':
if (isGhcpQuotaResult(quota)) {
return getGhcpResetTime(quota.quotaResetDate);
}
return null;
default: default:
return null; return null;
} }
+291
View File
@@ -17,12 +17,14 @@ import {
isAgyQuotaResult, isAgyQuotaResult,
isCodexQuotaResult, isCodexQuotaResult,
isGeminiQuotaResult, isGeminiQuotaResult,
isGhcpQuotaResult,
} from '@/lib/utils'; } from '@/lib/utils';
import type { import type {
CodexQuotaWindow, CodexQuotaWindow,
CodexQuotaResult, CodexQuotaResult,
GeminiCliBucket, GeminiCliBucket,
GeminiCliQuotaResult, GeminiCliQuotaResult,
GhcpQuotaResult,
QuotaResult, QuotaResult,
ModelQuota, ModelQuota,
} from '@/lib/api-client'; } 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 ==================== // ==================== Unified Provider Helpers ====================
describe('getProviderMinQuota', () => { 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', () => { describe('unknown provider', () => {
it('returns null for unknown provider', () => { it('returns null for unknown provider', () => {
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; 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', () => { describe('unknown provider', () => {
it('returns null for unknown provider', () => { it('returns null for unknown provider', () => {
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };