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

This commit is contained in:
Tam Nhu Tran
2026-02-20 22:32:27 +07:00
parent 2eddd5c773
commit 8d9d4987dc
18 changed files with 1160 additions and 23 deletions
+1
View File
@@ -597,6 +597,7 @@ async function main(): Promise<void> {
'auth',
'status',
'models',
'usage',
'start',
'stop',
'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
*
* 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;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function showHelp(): Promise<void> {
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['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
* 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,
};
}
/**
+78 -3
View File
@@ -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<void> {
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<void> {
+66
View File
@@ -7,6 +7,7 @@
import {
startAuthFlow,
getCopilotStatus,
getCopilotUsage,
startDaemon,
stopDaemon,
getAvailableModels,
@@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
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<number> {
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.
*/
+1
View File
@@ -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'],
+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,
} from './copilot-models';
// Usage
export {
normalizeCopilotUsage,
fetchCopilotUsageFromDaemon,
getCopilotUsage,
} from './copilot-usage';
// Executor
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
+33
View File
@@ -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;
};
}
+63 -3
View File
@@ -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<void> =
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<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)
* 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<void> => {
+34
View File
@@ -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<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
*/
@@ -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 (
<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;
}
+24 -3
View File
@@ -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<GeminiCliQuotaRes
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
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({
+47
View File
@@ -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<string, OAuthAccount[]>;
+51 -1
View File
@@ -5,6 +5,7 @@ import type {
CodexQuotaResult,
GeminiCliBucket,
GeminiCliQuotaResult,
GhcpQuotaResult,
QuotaResult,
} from './api-client';
@@ -35,6 +36,7 @@ const PROVIDER_COLORS: Record<string, string> = {
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;
}
+291
View File
@@ -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() };