mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
feat(quota): add GitHub Copilot quota checks for ghcp and copilot
This commit is contained in:
1 parent
2eddd5c773
commit
8d9d4987dc
18 files changed
+1160
-23
No files matched your search
@@ -597,6 +597,7 @@ async function main(): Promise<void> {
|
|||||||
'auth',
|
'auth',
|
||||||
'status',
|
'status',
|
||||||
'models',
|
'models',
|
||||||
|
'usage',
|
||||||
'start',
|
'start',
|
||||||
'stop',
|
'stop',
|
||||||
'enable',
|
'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
|
* Shared Quota Type Definitions
|
||||||
*
|
*
|
||||||
* Unified types for multi-provider quota system.
|
* Unified types for multi-provider quota system.
|
||||||
* Supports Antigravity, Codex, and Gemini CLI providers.
|
* Supports Antigravity, Codex, Gemini CLI, and GitHub Copilot OAuth providers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Supported quota providers */
|
/** Supported quota providers */
|
||||||
export type QuotaProvider = 'agy' | 'codex' | 'gemini';
|
export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp';
|
||||||
|
|
||||||
// Re-export Antigravity types for unified access
|
// Re-export Antigravity types for unified access
|
||||||
export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher';
|
export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher';
|
||||||
@@ -110,3 +110,52 @@ export interface GeminiCliQuotaResult {
|
|||||||
/** True if token is expired and needs re-authentication */
|
/** True if token is expired and needs re-authentication */
|
||||||
needsReauth?: boolean;
|
needsReauth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Copilot quota snapshot.
|
||||||
|
*/
|
||||||
|
export interface GhcpQuotaSnapshot {
|
||||||
|
/** Total quota allocation for this category */
|
||||||
|
entitlement: number;
|
||||||
|
/** Remaining quota count */
|
||||||
|
remaining: number;
|
||||||
|
/** Used quota count */
|
||||||
|
used: number;
|
||||||
|
/** Remaining quota percentage (0-100) */
|
||||||
|
percentRemaining: number;
|
||||||
|
/** Used quota percentage (0-100) */
|
||||||
|
percentUsed: number;
|
||||||
|
/** Whether this quota category is unlimited */
|
||||||
|
unlimited: boolean;
|
||||||
|
/** Overage usage count */
|
||||||
|
overageCount: number;
|
||||||
|
/** Whether overage is permitted */
|
||||||
|
overagePermitted: boolean;
|
||||||
|
/** Upstream quota identifier if available */
|
||||||
|
quotaId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Copilot quota fetch result.
|
||||||
|
*/
|
||||||
|
export interface GhcpQuotaResult {
|
||||||
|
/** Whether fetch succeeded */
|
||||||
|
success: boolean;
|
||||||
|
/** Copilot plan type (individual/business/enterprise/free) */
|
||||||
|
planType: string | null;
|
||||||
|
/** Quota reset date/time (ISO string) */
|
||||||
|
quotaResetDate: string | null;
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: GhcpQuotaSnapshot;
|
||||||
|
chat: GhcpQuotaSnapshot;
|
||||||
|
completions: GhcpQuotaSnapshot;
|
||||||
|
};
|
||||||
|
/** Timestamp of fetch */
|
||||||
|
lastUpdated: number;
|
||||||
|
/** Error message if fetch failed */
|
||||||
|
error?: string;
|
||||||
|
/** Account ID this quota belongs to */
|
||||||
|
accountId?: string;
|
||||||
|
/** True if token is expired/invalid and user needs re-authentication */
|
||||||
|
needsReauth?: boolean;
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ export async function showHelp(): Promise<void> {
|
|||||||
['pause <account>', 'Pause account (skip in rotation)'],
|
['pause <account>', 'Pause account (skip in rotation)'],
|
||||||
['resume <account>', 'Resume paused account'],
|
['resume <account>', 'Resume paused account'],
|
||||||
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
|
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
|
||||||
['quota --provider <name>', 'Filter by provider (agy|codex|gemini)'],
|
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
|
|||||||
/**
|
/**
|
||||||
* Parse --provider flag from args for quota command
|
* Parse --provider flag from args for quota command
|
||||||
* Returns the provider filter value and remaining args
|
* Returns the provider filter value and remaining args
|
||||||
* Accepts: agy, codex, gemini, gemini-cli, all
|
* Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all
|
||||||
*/
|
*/
|
||||||
function parseProviderArg(args: string[]): {
|
function parseProviderArg(args: string[]): {
|
||||||
provider: 'agy' | 'codex' | 'gemini' | 'all';
|
provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
|
||||||
remainingArgs: string[];
|
remainingArgs: string[];
|
||||||
} {
|
} {
|
||||||
const providerIdx = args.indexOf('--provider');
|
const providerIdx = args.indexOf('--provider');
|
||||||
@@ -97,24 +97,29 @@ function parseProviderArg(args: string[]): {
|
|||||||
// Handle empty value
|
// Handle empty value
|
||||||
if (!value) {
|
if (!value) {
|
||||||
console.error(
|
console.error(
|
||||||
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all'
|
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
|
||||||
);
|
);
|
||||||
return { provider: 'all', remainingArgs };
|
return { provider: 'all', remainingArgs };
|
||||||
}
|
}
|
||||||
// Normalize gemini-cli to gemini
|
// Normalize gemini-cli to gemini
|
||||||
const normalized = value === 'gemini-cli' ? 'gemini' : value;
|
const normalized =
|
||||||
|
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
|
||||||
if (
|
if (
|
||||||
normalized !== 'agy' &&
|
normalized !== 'agy' &&
|
||||||
normalized !== 'codex' &&
|
normalized !== 'codex' &&
|
||||||
normalized !== 'gemini' &&
|
normalized !== 'gemini' &&
|
||||||
|
normalized !== 'ghcp' &&
|
||||||
normalized !== 'all'
|
normalized !== 'all'
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`
|
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
|
||||||
);
|
);
|
||||||
return { provider: 'all', remainingArgs };
|
return { provider: 'all', remainingArgs };
|
||||||
}
|
}
|
||||||
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
|
return {
|
||||||
|
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
|
||||||
|
remainingArgs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { provider: 'all', remainingArgs: args };
|
return { provider: 'all', remainingArgs: args };
|
||||||
}
|
}
|
||||||
@@ -122,26 +127,31 @@ function parseProviderArg(args: string[]): {
|
|||||||
// Warn if no value or value looks like another flag
|
// Warn if no value or value looks like another flag
|
||||||
if (!rawValue || rawValue.startsWith('-')) {
|
if (!rawValue || rawValue.startsWith('-')) {
|
||||||
console.error(
|
console.error(
|
||||||
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all'
|
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const value = rawValue?.toLowerCase() || 'all';
|
const value = rawValue?.toLowerCase() || 'all';
|
||||||
const remainingArgs = [...args];
|
const remainingArgs = [...args];
|
||||||
remainingArgs.splice(providerIdx, 2);
|
remainingArgs.splice(providerIdx, 2);
|
||||||
// Normalize gemini-cli to gemini
|
// Normalize gemini-cli to gemini
|
||||||
const normalized = value === 'gemini-cli' ? 'gemini' : value;
|
const normalized =
|
||||||
|
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
|
||||||
if (
|
if (
|
||||||
normalized !== 'agy' &&
|
normalized !== 'agy' &&
|
||||||
normalized !== 'codex' &&
|
normalized !== 'codex' &&
|
||||||
normalized !== 'gemini' &&
|
normalized !== 'gemini' &&
|
||||||
|
normalized !== 'ghcp' &&
|
||||||
normalized !== 'all'
|
normalized !== 'all'
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`
|
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
|
||||||
);
|
);
|
||||||
return { provider: 'all', remainingArgs };
|
return { provider: 'all', remainingArgs };
|
||||||
}
|
}
|
||||||
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
|
return {
|
||||||
|
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
|
||||||
|
remainingArgs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
|
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
|
||||||
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
|
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
|
||||||
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
|
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
|
||||||
import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types';
|
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
|
||||||
|
import type {
|
||||||
|
CodexQuotaResult,
|
||||||
|
GeminiCliQuotaResult,
|
||||||
|
GhcpQuotaResult,
|
||||||
|
} from '../../cliproxy/quota-types';
|
||||||
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
||||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
import { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||||
@@ -417,9 +422,68 @@ function displayGeminiCliQuotaSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSnapshotLabel(
|
||||||
|
snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]
|
||||||
|
): string {
|
||||||
|
if (snapshot.unlimited) {
|
||||||
|
return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`;
|
||||||
|
}
|
||||||
|
return `${snapshot.used}/${snapshot.entitlement} used`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaResult }[]): void {
|
||||||
|
console.log(
|
||||||
|
subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
for (const { account, quota } of results) {
|
||||||
|
const accountInfo = findAccountByQuery('ghcp', account);
|
||||||
|
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
|
if (!quota.success) {
|
||||||
|
console.log(` ${fail(account)}${defaultMark}`);
|
||||||
|
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
||||||
|
console.log('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
quota.snapshots.premiumInteractions.percentRemaining,
|
||||||
|
quota.snapshots.chat.percentRemaining,
|
||||||
|
quota.snapshots.completions.percentRemaining,
|
||||||
|
];
|
||||||
|
const minQuota = rows.length > 0 ? Math.min(...rows) : 0;
|
||||||
|
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||||
|
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||||
|
|
||||||
|
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||||
|
if (quota.quotaResetDate) {
|
||||||
|
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Array<[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]> =
|
||||||
|
[
|
||||||
|
['Premium interactions', quota.snapshots.premiumInteractions],
|
||||||
|
['Chat', quota.snapshots.chat],
|
||||||
|
['Completions', quota.snapshots.completions],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, snapshot] of items) {
|
||||||
|
const bar = formatQuotaBar(snapshot.percentRemaining);
|
||||||
|
const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`);
|
||||||
|
console.log(
|
||||||
|
` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleQuotaStatus(
|
export async function handleQuotaStatus(
|
||||||
verbose = false,
|
verbose = false,
|
||||||
providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all'
|
providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
console.log(header('Quota Status'));
|
console.log(header('Quota Status'));
|
||||||
@@ -429,14 +493,16 @@ export async function handleQuotaStatus(
|
|||||||
agy: providerFilter === 'all' || providerFilter === 'agy',
|
agy: providerFilter === 'all' || providerFilter === 'agy',
|
||||||
codex: providerFilter === 'all' || providerFilter === 'codex',
|
codex: providerFilter === 'all' || providerFilter === 'codex',
|
||||||
gemini: providerFilter === 'all' || providerFilter === 'gemini',
|
gemini: providerFilter === 'all' || providerFilter === 'gemini',
|
||||||
|
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(dim('Fetching quotas...'));
|
console.log(dim('Fetching quotas...'));
|
||||||
|
|
||||||
const [agyResults, codexResults, geminiResults] = await Promise.all([
|
const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([
|
||||||
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
|
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
|
||||||
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
|
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
|
||||||
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
|
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
|
||||||
|
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -467,6 +533,15 @@ export async function handleQuotaStatus(
|
|||||||
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
|
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ghcpResults && ghcpResults.length > 0) {
|
||||||
|
displayGhcpQuotaSection(ghcpResults);
|
||||||
|
} else if (shouldFetch.ghcp) {
|
||||||
|
console.log(subheader('GitHub Copilot (0 accounts)'));
|
||||||
|
console.log(info('No GitHub Copilot accounts configured'));
|
||||||
|
console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDoctor(verbose = false): Promise<void> {
|
export async function handleDoctor(verbose = false): Promise<void> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import {
|
import {
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
getCopilotStatus,
|
getCopilotStatus,
|
||||||
|
getCopilotUsage,
|
||||||
startDaemon,
|
startDaemon,
|
||||||
stopDaemon,
|
stopDaemon,
|
||||||
getAvailableModels,
|
getAvailableModels,
|
||||||
@@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
|
|||||||
return handleStatus();
|
return handleStatus();
|
||||||
case 'models':
|
case 'models':
|
||||||
return handleModels();
|
return handleModels();
|
||||||
|
case 'usage':
|
||||||
|
return handleUsage();
|
||||||
case 'start':
|
case 'start':
|
||||||
return handleStart();
|
return handleStart();
|
||||||
case 'stop':
|
case 'stop':
|
||||||
@@ -61,6 +64,7 @@ function handleHelp(): number {
|
|||||||
console.log(' auth Start GitHub OAuth authentication');
|
console.log(' auth Start GitHub OAuth authentication');
|
||||||
console.log(' status Show authentication and daemon status');
|
console.log(' status Show authentication and daemon status');
|
||||||
console.log(' models List available models');
|
console.log(' models List available models');
|
||||||
|
console.log(' usage Show Copilot quota usage');
|
||||||
console.log(' start Start copilot-api daemon');
|
console.log(' start Start copilot-api daemon');
|
||||||
console.log(' stop Stop copilot-api daemon');
|
console.log(' stop Stop copilot-api daemon');
|
||||||
console.log(' enable Enable copilot integration');
|
console.log(' enable Enable copilot integration');
|
||||||
@@ -71,6 +75,7 @@ function handleHelp(): number {
|
|||||||
console.log(' 1. ccs copilot auth # Authenticate with GitHub');
|
console.log(' 1. ccs copilot auth # Authenticate with GitHub');
|
||||||
console.log(' 2. ccs copilot enable # Enable integration');
|
console.log(' 2. ccs copilot enable # Enable integration');
|
||||||
console.log(' 3. ccs copilot start # Start daemon');
|
console.log(' 3. ccs copilot start # Start daemon');
|
||||||
|
console.log(' 4. ccs copilot usage # Check quota usage');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Or use the web UI: ccs config → Copilot tab');
|
console.log('Or use the web UI: ccs config → Copilot tab');
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -190,6 +195,67 @@ async function handleModels(): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatQuotaLine(
|
||||||
|
label: string,
|
||||||
|
snapshot: {
|
||||||
|
entitlement: number;
|
||||||
|
used: number;
|
||||||
|
percentUsed: number;
|
||||||
|
percentRemaining: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const quotaText = snapshot.unlimited
|
||||||
|
? 'Unlimited'
|
||||||
|
: `${snapshot.used}/${snapshot.entitlement} used`;
|
||||||
|
return `${label.padEnd(20)} ${quotaText} (${snapshot.percentUsed.toFixed(1)}% used, ${snapshot.percentRemaining.toFixed(1)}% remaining)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetDate(resetDate: string | null): string {
|
||||||
|
if (!resetDate) return 'unknown';
|
||||||
|
const date = new Date(resetDate);
|
||||||
|
if (Number.isNaN(date.getTime())) return resetDate;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle usage subcommand.
|
||||||
|
*/
|
||||||
|
async function handleUsage(): Promise<number> {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
|
||||||
|
const status = await getCopilotStatus(copilotConfig);
|
||||||
|
|
||||||
|
if (!status.daemon.running) {
|
||||||
|
console.error(fail('copilot-api daemon is not running.'));
|
||||||
|
console.error('');
|
||||||
|
console.error('Start daemon first: ccs copilot start');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await getCopilotUsage(copilotConfig.port);
|
||||||
|
if (!usage) {
|
||||||
|
console.error(fail('Failed to fetch Copilot usage.'));
|
||||||
|
console.error('');
|
||||||
|
console.error('Try restarting daemon: ccs copilot stop && ccs copilot start');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GitHub Copilot Usage');
|
||||||
|
console.log('────────────────────');
|
||||||
|
console.log('');
|
||||||
|
console.log(`Plan: ${usage.plan || 'unknown'}`);
|
||||||
|
console.log(`Quota Reset: ${formatResetDate(usage.quotaResetDate)}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Quotas:');
|
||||||
|
console.log(` ${formatQuotaLine('Premium Interactions', usage.quotas.premiumInteractions)}`);
|
||||||
|
console.log(` ${formatQuotaLine('Chat', usage.quotas.chat)}`);
|
||||||
|
console.log(` ${formatQuotaLine('Completions', usage.quotas.completions)}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle start subcommand.
|
* Handle start subcommand.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
['ccs copilot auth', 'Authenticate with GitHub'],
|
['ccs copilot auth', 'Authenticate with GitHub'],
|
||||||
['ccs copilot status', 'Show integration status'],
|
['ccs copilot status', 'Show integration status'],
|
||||||
['ccs copilot models', 'List available models'],
|
['ccs copilot models', 'List available models'],
|
||||||
|
['ccs copilot usage', 'Show Copilot quota usage'],
|
||||||
['ccs copilot start', 'Start copilot-api daemon'],
|
['ccs copilot start', 'Start copilot-api daemon'],
|
||||||
['ccs copilot stop', 'Stop copilot-api daemon'],
|
['ccs copilot stop', 'Stop copilot-api daemon'],
|
||||||
['ccs copilot enable', 'Enable integration'],
|
['ccs copilot enable', 'Enable integration'],
|
||||||
|
|||||||
@@ -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,
|
getDefaultModel,
|
||||||
} from './copilot-models';
|
} from './copilot-models';
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
export {
|
||||||
|
normalizeCopilotUsage,
|
||||||
|
fetchCopilotUsageFromDaemon,
|
||||||
|
getCopilotUsage,
|
||||||
|
} from './copilot-usage';
|
||||||
|
|
||||||
// Executor
|
// Executor
|
||||||
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
|
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
|
||||||
@@ -68,3 +68,36 @@ export interface CopilotDebugInfo {
|
|||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
tokenPath?: string;
|
tokenPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quota snapshot from Copilot usage endpoint.
|
||||||
|
*/
|
||||||
|
export interface CopilotQuotaSnapshot {
|
||||||
|
/** Total quota allocation for this bucket */
|
||||||
|
entitlement: number;
|
||||||
|
/** Remaining quota count */
|
||||||
|
remaining: number;
|
||||||
|
/** Used quota count */
|
||||||
|
used: number;
|
||||||
|
/** Remaining quota percentage (0-100) */
|
||||||
|
percentRemaining: number;
|
||||||
|
/** Used quota percentage (0-100) */
|
||||||
|
percentUsed: number;
|
||||||
|
/** Whether quota is unlimited */
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized Copilot usage response used by CLI and dashboard.
|
||||||
|
*/
|
||||||
|
export interface CopilotUsage {
|
||||||
|
/** Copilot plan name (free/pro/business/enterprise) */
|
||||||
|
plan: string | null;
|
||||||
|
/** ISO date string when quota resets */
|
||||||
|
quotaResetDate: string | null;
|
||||||
|
quotas: {
|
||||||
|
premiumInteractions: CopilotQuotaSnapshot;
|
||||||
|
chat: CopilotQuotaSnapshot;
|
||||||
|
completions: CopilotQuotaSnapshot;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,8 +15,13 @@ import {
|
|||||||
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
|
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
|
||||||
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
|
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
|
||||||
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
|
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
|
||||||
|
import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp';
|
||||||
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache';
|
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache';
|
||||||
import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types';
|
import type {
|
||||||
|
CodexQuotaResult,
|
||||||
|
GeminiCliQuotaResult,
|
||||||
|
GhcpQuotaResult,
|
||||||
|
} from '../../cliproxy/quota-types';
|
||||||
import type { QuotaResult } from '../../cliproxy/quota-fetcher';
|
import type { QuotaResult } from '../../cliproxy/quota-fetcher';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
@@ -78,6 +83,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean {
|
||||||
|
if (result.success) return true;
|
||||||
|
if (result.needsReauth) return true;
|
||||||
|
|
||||||
|
const msg = (result.error || '').toLowerCase();
|
||||||
|
if (!msg) return false;
|
||||||
|
if (msg.includes('timeout')) return false;
|
||||||
|
if (msg.includes('rate limited')) return false;
|
||||||
|
if (msg.includes('api error: 5')) return false;
|
||||||
|
if (msg.includes('fetch failed')) return false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get configured backend from config */
|
/** Get configured backend from config */
|
||||||
function getConfiguredBackend() {
|
function getConfiguredBackend() {
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +159,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
|
|||||||
if (!running) {
|
if (!running) {
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
error: 'CLIProxy Plus not running',
|
error: 'CLIProxy Plus not running',
|
||||||
message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats',
|
message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -632,10 +651,51 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/quota/ghcp/:accountId - Get GitHub Copilot (ghcp) quota for a specific account
|
||||||
|
* Returns: GhcpQuotaResult with premium/chat/completions quota snapshots
|
||||||
|
* Caching: 2 minute TTL to reduce GitHub API calls
|
||||||
|
*/
|
||||||
|
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
// Validate accountId - prevent path traversal
|
||||||
|
if (
|
||||||
|
!accountId ||
|
||||||
|
accountId.includes('..') ||
|
||||||
|
accountId.includes('/') ||
|
||||||
|
accountId.includes('\\')
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: 'Invalid account ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedQuota<GhcpQuotaResult>('ghcp', accountId);
|
||||||
|
if (cached) {
|
||||||
|
res.json({ ...cached, cached: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from GitHub API
|
||||||
|
const result = await fetchGhcpQuota(accountId);
|
||||||
|
|
||||||
|
// Cache successful and stable failure states; skip transient network failures.
|
||||||
|
if (shouldCacheGhcpQuotaResult(result)) {
|
||||||
|
setCachedQuota('ghcp', accountId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
|
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
|
||||||
* Returns: QuotaResult with model quotas and reset times
|
* Returns: QuotaResult with model quotas and reset times
|
||||||
* NOTE: This generic route MUST come after specific routes (codex, gemini) to avoid matching them
|
* NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp)
|
||||||
* Caching: 2 minute TTL to reduce external API calls
|
* Caching: 2 minute TTL to reduce external API calls
|
||||||
*/
|
*/
|
||||||
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
|
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
checkAuthStatus as checkCopilotAuth,
|
checkAuthStatus as checkCopilotAuth,
|
||||||
startAuthFlow as startCopilotAuth,
|
startAuthFlow as startCopilotAuth,
|
||||||
getCopilotStatus,
|
getCopilotStatus,
|
||||||
|
getCopilotUsage,
|
||||||
|
isDaemonRunning,
|
||||||
startDaemon as startCopilotDaemon,
|
startDaemon as startCopilotDaemon,
|
||||||
stopDaemon as stopCopilotDaemon,
|
stopDaemon as stopCopilotDaemon,
|
||||||
getAvailableModels as getCopilotModels,
|
getAvailableModels as getCopilotModels,
|
||||||
@@ -152,6 +154,38 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/copilot/usage - Get Copilot quota usage from copilot-api /usage endpoint
|
||||||
|
*/
|
||||||
|
router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
|
||||||
|
const daemonRunning = await isDaemonRunning(port);
|
||||||
|
|
||||||
|
if (!daemonRunning) {
|
||||||
|
res.status(503).json({
|
||||||
|
error: 'copilot-api daemon is not running',
|
||||||
|
message: 'Start daemon first: ccs copilot start',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await getCopilotUsage(port);
|
||||||
|
if (!usage) {
|
||||||
|
res.status(503).json({
|
||||||
|
error: 'Failed to fetch Copilot usage',
|
||||||
|
message: 'copilot-api /usage endpoint is unavailable',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(usage);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/copilot/daemon/start - Start copilot-api daemon
|
* POST /api/copilot/daemon/start - Start copilot-api daemon
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
isAgyQuotaResult,
|
isAgyQuotaResult,
|
||||||
isCodexQuotaResult,
|
isCodexQuotaResult,
|
||||||
isGeminiQuotaResult,
|
isGeminiQuotaResult,
|
||||||
|
isGhcpQuotaResult,
|
||||||
type ModelTier,
|
type ModelTier,
|
||||||
type UnifiedQuotaResult,
|
type UnifiedQuotaResult,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
@@ -122,6 +123,37 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub Copilot (ghcp) provider tooltip
|
||||||
|
if (isGhcpQuotaResult(quota)) {
|
||||||
|
const snapshotRows = [
|
||||||
|
{ label: 'Premium Interactions', snapshot: quota.snapshots.premiumInteractions },
|
||||||
|
{ label: 'Chat', snapshot: quota.snapshots.chat },
|
||||||
|
{ label: 'Completions', snapshot: quota.snapshots.completions },
|
||||||
|
];
|
||||||
|
const effectiveResetTime = quota.quotaResetDate ?? resetTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">Quota Snapshots:</p>
|
||||||
|
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
||||||
|
{snapshotRows.map(({ label, snapshot }) => (
|
||||||
|
<div key={label} className="flex justify-between gap-4">
|
||||||
|
<span className={cn(snapshot.percentRemaining < 20 && 'text-red-500')}>
|
||||||
|
{label}
|
||||||
|
{snapshot.unlimited ? ' (Unlimited)' : ''}
|
||||||
|
</span>
|
||||||
|
<span className={cn('font-mono', snapshot.percentRemaining < 20 && 'text-red-500')}>
|
||||||
|
{snapshot.unlimited
|
||||||
|
? 'inf'
|
||||||
|
: `${snapshot.percentRemaining}% (${snapshot.remaining}/${snapshot.entitlement})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ResetTimeIndicator resetTime={effectiveResetTime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
QuotaResult,
|
QuotaResult,
|
||||||
CodexQuotaResult,
|
CodexQuotaResult,
|
||||||
GeminiCliQuotaResult,
|
GeminiCliQuotaResult,
|
||||||
|
GhcpQuotaResult,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import type { UnifiedQuotaResult } from '@/lib/utils';
|
import type { UnifiedQuotaResult } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -202,10 +203,10 @@ export function useCliproxyErrorLogContent(name: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for consumers
|
// Re-export for consumers
|
||||||
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult };
|
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult };
|
||||||
|
|
||||||
/** Providers with quota API support */
|
/** Providers with quota API support */
|
||||||
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const;
|
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const;
|
||||||
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
|
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,6 +263,24 @@ async function fetchGeminiQuotaApi(accountId: string): Promise<GeminiCliQuotaRes
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch GitHub Copilot (ghcp) quota from API
|
||||||
|
*/
|
||||||
|
async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
|
||||||
|
const response = await fetch(`/api/cliproxy/quota/ghcp/${encodeURIComponent(accountId)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to fetch GitHub Copilot quota';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
message = error.message || message;
|
||||||
|
} catch {
|
||||||
|
// Use default message if response isn't JSON
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export unified type from utils for consumers
|
// Re-export unified type from utils for consumers
|
||||||
export type { UnifiedQuotaResult } from '@/lib/utils';
|
export type { UnifiedQuotaResult } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -277,6 +296,8 @@ async function fetchQuotaByProvider(
|
|||||||
return fetchCodexQuotaApi(accountId);
|
return fetchCodexQuotaApi(accountId);
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return fetchGeminiQuotaApi(accountId);
|
return fetchGeminiQuotaApi(accountId);
|
||||||
|
case 'ghcp':
|
||||||
|
return fetchGhcpQuotaApi(accountId);
|
||||||
default:
|
default:
|
||||||
return fetchAccountQuota(provider, accountId);
|
return fetchAccountQuota(provider, accountId);
|
||||||
}
|
}
|
||||||
@@ -284,7 +305,7 @@ async function fetchQuotaByProvider(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get account quota
|
* Hook to get account quota
|
||||||
* Supports agy, codex, and gemini providers
|
* Supports agy, codex, gemini, and ghcp providers
|
||||||
*/
|
*/
|
||||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -337,6 +337,53 @@ export interface GeminiCliQuotaResult {
|
|||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GitHub Copilot quota snapshot */
|
||||||
|
export interface GhcpQuotaSnapshot {
|
||||||
|
/** Total quota allocation for this category */
|
||||||
|
entitlement: number;
|
||||||
|
/** Remaining quota count */
|
||||||
|
remaining: number;
|
||||||
|
/** Used quota count */
|
||||||
|
used: number;
|
||||||
|
/** Remaining quota percentage (0-100) */
|
||||||
|
percentRemaining: number;
|
||||||
|
/** Used quota percentage (0-100) */
|
||||||
|
percentUsed: number;
|
||||||
|
/** Whether this quota category is unlimited */
|
||||||
|
unlimited: boolean;
|
||||||
|
/** Overage usage count */
|
||||||
|
overageCount: number;
|
||||||
|
/** Whether overage is permitted */
|
||||||
|
overagePermitted: boolean;
|
||||||
|
/** Upstream quota identifier if available */
|
||||||
|
quotaId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GitHub Copilot (ghcp) quota result */
|
||||||
|
export interface GhcpQuotaResult {
|
||||||
|
/** Whether fetch succeeded */
|
||||||
|
success: boolean;
|
||||||
|
/** Copilot plan type */
|
||||||
|
planType: string | null;
|
||||||
|
/** Quota reset date/time */
|
||||||
|
quotaResetDate: string | null;
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: GhcpQuotaSnapshot;
|
||||||
|
chat: GhcpQuotaSnapshot;
|
||||||
|
completions: GhcpQuotaSnapshot;
|
||||||
|
};
|
||||||
|
/** Timestamp of fetch */
|
||||||
|
lastUpdated: number;
|
||||||
|
/** Error message if fetch failed */
|
||||||
|
error?: string;
|
||||||
|
/** Account ID this quota belongs to */
|
||||||
|
accountId?: string;
|
||||||
|
/** True if token is expired and needs re-authentication */
|
||||||
|
needsReauth?: boolean;
|
||||||
|
/** True if result was served from cache */
|
||||||
|
cached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Provider accounts summary */
|
/** Provider accounts summary */
|
||||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||||
|
|
||||||
|
|||||||
+51
-1
@@ -5,6 +5,7 @@ import type {
|
|||||||
CodexQuotaResult,
|
CodexQuotaResult,
|
||||||
GeminiCliBucket,
|
GeminiCliBucket,
|
||||||
GeminiCliQuotaResult,
|
GeminiCliQuotaResult,
|
||||||
|
GhcpQuotaResult,
|
||||||
QuotaResult,
|
QuotaResult,
|
||||||
} from './api-client';
|
} from './api-client';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||||||
iflow: '#f94144', // Strawberry
|
iflow: '#f94144', // Strawberry
|
||||||
qwen: '#f9c74f', // Tuscan
|
qwen: '#f9c74f', // Tuscan
|
||||||
kiro: '#4d908e', // Dark Cyan (AWS-inspired)
|
kiro: '#4d908e', // Dark Cyan (AWS-inspired)
|
||||||
|
ghcp: '#43aa8b', // Seaweed (GitHub-inspired)
|
||||||
copilot: '#43aa8b', // Seaweed (GitHub-inspired)
|
copilot: '#43aa8b', // Seaweed (GitHub-inspired)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -529,10 +531,37 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null {
|
|||||||
return resets.sort()[0];
|
return resets.sort()[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minimum remaining percentage across GitHub Copilot quota snapshots
|
||||||
|
*/
|
||||||
|
export function getMinGhcpQuota(snapshots: GhcpQuotaResult['snapshots']): number | null {
|
||||||
|
if (!snapshots) return null;
|
||||||
|
|
||||||
|
const percentages = [
|
||||||
|
snapshots.premiumInteractions.percentRemaining,
|
||||||
|
snapshots.chat.percentRemaining,
|
||||||
|
snapshots.completions.percentRemaining,
|
||||||
|
].filter((p) => typeof p === 'number' && isFinite(p));
|
||||||
|
|
||||||
|
if (percentages.length === 0) return null;
|
||||||
|
return Math.min(...percentages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reset time from GitHub Copilot quota result
|
||||||
|
*/
|
||||||
|
export function getGhcpResetTime(quotaResetDate: string | null): string | null {
|
||||||
|
return quotaResetDate;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Unified Quota Type Guards ====================
|
// ==================== Unified Quota Type Guards ====================
|
||||||
|
|
||||||
/** Unified quota result type for provider-agnostic handling */
|
/** Unified quota result type for provider-agnostic handling */
|
||||||
export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult;
|
export type UnifiedQuotaResult =
|
||||||
|
| QuotaResult
|
||||||
|
| CodexQuotaResult
|
||||||
|
| GeminiCliQuotaResult
|
||||||
|
| GhcpQuotaResult;
|
||||||
|
|
||||||
/** Type guard: Check if quota result is from Antigravity (agy) provider */
|
/** Type guard: Check if quota result is from Antigravity (agy) provider */
|
||||||
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
|
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
|
||||||
@@ -549,6 +578,15 @@ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiC
|
|||||||
return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets);
|
return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */
|
||||||
|
export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult {
|
||||||
|
return (
|
||||||
|
'snapshots' in quota &&
|
||||||
|
typeof (quota as GhcpQuotaResult).snapshots === 'object' &&
|
||||||
|
(quota as GhcpQuotaResult).snapshots !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Unified Quota Helpers ====================
|
// ==================== Unified Quota Helpers ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -577,6 +615,12 @@ export function getProviderMinQuota(
|
|||||||
return getMinGeminiQuota(quota.buckets);
|
return getMinGeminiQuota(quota.buckets);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
case 'ghcp':
|
||||||
|
case 'github-copilot':
|
||||||
|
if (isGhcpQuotaResult(quota)) {
|
||||||
|
return getMinGhcpQuota(quota.snapshots);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -608,6 +652,12 @@ export function getProviderResetTime(
|
|||||||
return getGeminiResetTime(quota.buckets);
|
return getGeminiResetTime(quota.buckets);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
case 'ghcp':
|
||||||
|
case 'github-copilot':
|
||||||
|
if (isGhcpQuotaResult(quota)) {
|
||||||
|
return getGhcpResetTime(quota.quotaResetDate);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
isAgyQuotaResult,
|
isAgyQuotaResult,
|
||||||
isCodexQuotaResult,
|
isCodexQuotaResult,
|
||||||
isGeminiQuotaResult,
|
isGeminiQuotaResult,
|
||||||
|
isGhcpQuotaResult,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
CodexQuotaWindow,
|
CodexQuotaWindow,
|
||||||
CodexQuotaResult,
|
CodexQuotaResult,
|
||||||
GeminiCliBucket,
|
GeminiCliBucket,
|
||||||
GeminiCliQuotaResult,
|
GeminiCliQuotaResult,
|
||||||
|
GhcpQuotaResult,
|
||||||
QuotaResult,
|
QuotaResult,
|
||||||
ModelQuota,
|
ModelQuota,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
@@ -1051,6 +1053,63 @@ describe('isGeminiQuotaResult', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isGhcpQuotaResult', () => {
|
||||||
|
it('returns true for valid GHCP quota result', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: true,
|
||||||
|
planType: 'individual',
|
||||||
|
quotaResetDate: '2026-01-31T00:00:00Z',
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 300,
|
||||||
|
remaining: 120,
|
||||||
|
used: 180,
|
||||||
|
percentRemaining: 40,
|
||||||
|
percentUsed: 60,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: 'premium_interactions',
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 999999,
|
||||||
|
remaining: 999999,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 100,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: true,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: true,
|
||||||
|
quotaId: 'chat',
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 5000,
|
||||||
|
remaining: 4200,
|
||||||
|
used: 800,
|
||||||
|
percentRemaining: 84,
|
||||||
|
percentUsed: 16,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: 'completions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
expect(isGhcpQuotaResult(quota)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-GHCP quota result', () => {
|
||||||
|
const quota: GeminiCliQuotaResult = {
|
||||||
|
success: true,
|
||||||
|
buckets: [],
|
||||||
|
projectId: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
expect(isGhcpQuotaResult(quota)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== Unified Provider Helpers ====================
|
// ==================== Unified Provider Helpers ====================
|
||||||
|
|
||||||
describe('getProviderMinQuota', () => {
|
describe('getProviderMinQuota', () => {
|
||||||
@@ -1204,6 +1263,144 @@ describe('getProviderMinQuota', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ghcp provider', () => {
|
||||||
|
it('returns minimum quota from snapshots', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: true,
|
||||||
|
planType: 'individual',
|
||||||
|
quotaResetDate: '2026-01-31T00:00:00Z',
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 300,
|
||||||
|
remaining: 90,
|
||||||
|
used: 210,
|
||||||
|
percentRemaining: 30,
|
||||||
|
percentUsed: 70,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: 'premium_interactions',
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 1000,
|
||||||
|
remaining: 1000,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 100,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: true,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: true,
|
||||||
|
quotaId: 'chat',
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 5000,
|
||||||
|
remaining: 2750,
|
||||||
|
used: 2250,
|
||||||
|
percentRemaining: 55,
|
||||||
|
percentUsed: 45,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: 'completions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
expect(getProviderMinQuota('ghcp', quota)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports github-copilot alias', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: true,
|
||||||
|
planType: 'individual',
|
||||||
|
quotaResetDate: null,
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 300,
|
||||||
|
remaining: 150,
|
||||||
|
used: 150,
|
||||||
|
percentRemaining: 50,
|
||||||
|
percentUsed: 50,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 1000,
|
||||||
|
remaining: 900,
|
||||||
|
used: 100,
|
||||||
|
percentRemaining: 90,
|
||||||
|
percentUsed: 10,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 1000,
|
||||||
|
remaining: 800,
|
||||||
|
used: 200,
|
||||||
|
percentRemaining: 80,
|
||||||
|
percentUsed: 20,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
expect(getProviderMinQuota('github-copilot', quota)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when GHCP quota fetch failed', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: false,
|
||||||
|
planType: null,
|
||||||
|
quotaResetDate: null,
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
error: 'Failed',
|
||||||
|
};
|
||||||
|
expect(getProviderMinQuota('ghcp', quota)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('unknown provider', () => {
|
describe('unknown provider', () => {
|
||||||
it('returns null for unknown provider', () => {
|
it('returns null for unknown provider', () => {
|
||||||
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };
|
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };
|
||||||
@@ -1369,6 +1566,100 @@ describe('getProviderResetTime', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ghcp provider', () => {
|
||||||
|
it('returns quota reset date', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: true,
|
||||||
|
planType: 'individual',
|
||||||
|
quotaResetDate: '2026-01-31T00:00:00Z',
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 300,
|
||||||
|
remaining: 120,
|
||||||
|
used: 180,
|
||||||
|
percentRemaining: 40,
|
||||||
|
percentUsed: 60,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 1000,
|
||||||
|
remaining: 900,
|
||||||
|
used: 100,
|
||||||
|
percentRemaining: 90,
|
||||||
|
percentUsed: 10,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 5000,
|
||||||
|
remaining: 2500,
|
||||||
|
used: 2500,
|
||||||
|
percentRemaining: 50,
|
||||||
|
percentUsed: 50,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
expect(getProviderResetTime('ghcp', quota)).toBe('2026-01-31T00:00:00Z');
|
||||||
|
expect(getProviderResetTime('github-copilot', quota)).toBe('2026-01-31T00:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when GHCP quota fetch failed', () => {
|
||||||
|
const quota: GhcpQuotaResult = {
|
||||||
|
success: false,
|
||||||
|
planType: null,
|
||||||
|
quotaResetDate: null,
|
||||||
|
snapshots: {
|
||||||
|
premiumInteractions: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
entitlement: 0,
|
||||||
|
remaining: 0,
|
||||||
|
used: 0,
|
||||||
|
percentRemaining: 0,
|
||||||
|
percentUsed: 0,
|
||||||
|
unlimited: false,
|
||||||
|
overageCount: 0,
|
||||||
|
overagePermitted: false,
|
||||||
|
quotaId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
error: 'Failed',
|
||||||
|
};
|
||||||
|
expect(getProviderResetTime('ghcp', quota)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('unknown provider', () => {
|
describe('unknown provider', () => {
|
||||||
it('returns null for unknown provider', () => {
|
it('returns null for unknown provider', () => {
|
||||||
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };
|
const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() };
|
||||||
|
|||||||
Reference in new issue
Block a user