mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 14:17:11 +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
@@ -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