mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(quota): add GitHub Copilot quota checks for ghcp and copilot
This commit is contained in:
@@ -597,6 +597,7 @@ async function main(): Promise<void> {
|
||||
'auth',
|
||||
'status',
|
||||
'models',
|
||||
'usage',
|
||||
'start',
|
||||
'stop',
|
||||
'enable',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
Reference in New Issue
Block a user