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

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

No files matched your search

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