feat(ui): add Codex/Gemini quota API client and hooks

- add CodexQuotaResult and GeminiCliQuotaResult types

- add api.quota.getCodex and api.quota.getGemini API functions

- add useCodexQuota and useGeminiQuota React hooks

- add getMinCodexQuota, getMinGeminiQuota, getCodexResetTime, getGeminiResetTime utils
This commit is contained in:
kaitranntt
2026-01-29 21:50:43 -05:00
parent 2f5a50b801
commit 19a57c395c
3 changed files with 214 additions and 6 deletions
+105 -6
View File
@@ -3,7 +3,12 @@
*/
import { useQuery } from '@tanstack/react-query';
import type { ModelQuota, QuotaResult } from '@/lib/api-client';
import type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
GeminiCliQuotaResult,
} from '@/lib/api-client';
/** Per-account usage statistics */
export interface AccountUsageStats {
@@ -196,10 +201,13 @@ export function useCliproxyErrorLogContent(name: string | null) {
}
// Re-export for consumers
export type { ModelQuota, QuotaResult };
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult };
/** Providers with quota API support */
const SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const;
/**
* Fetch account quota from API
* Fetch account quota from API (Antigravity only)
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
@@ -216,15 +224,74 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise<Q
return response.json();
}
/**
* Fetch Codex quota from API
*/
async function fetchCodexQuotaApi(accountId: string): Promise<CodexQuotaResult> {
const response = await fetch(`/api/cliproxy/quota/codex/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch Codex 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();
}
/**
* Fetch Gemini quota from API
*/
async function fetchGeminiQuotaApi(accountId: string): Promise<GeminiCliQuotaResult> {
const response = await fetch(`/api/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch Gemini 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();
}
/** Unified quota result type for all providers */
export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult;
/**
* Fetch quota by provider (dispatcher)
*/
async function fetchQuotaByProvider(
provider: string,
accountId: string
): Promise<UnifiedQuotaResult> {
switch (provider) {
case 'codex':
return fetchCodexQuotaApi(accountId);
case 'gemini':
return fetchGeminiQuotaApi(accountId);
default:
return fetchAccountQuota(provider, accountId);
}
}
/**
* Hook to get account quota
* Supports all providers that have quota API implemented
* Supports agy, codex, and gemini providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({
queryKey: ['account-quota', provider, accountId],
queryFn: () => fetchAccountQuota(provider, accountId),
enabled: enabled && provider === 'agy' && !!accountId,
queryFn: () => fetchQuotaByProvider(provider, accountId),
enabled:
enabled &&
SUPPORTED_PROVIDERS.includes(provider as (typeof SUPPORTED_PROVIDERS)[number]) &&
!!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch
@@ -232,3 +299,35 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t
retry: 1,
});
}
/**
* Hook to get Codex quota for a specific account
*/
export function useCodexQuota(accountId: string | null) {
return useQuery({
queryKey: ['codex-quota', accountId],
queryFn: async () => {
if (!accountId) throw new Error('Account ID required');
return fetchCodexQuotaApi(accountId);
},
enabled: !!accountId,
staleTime: 30000,
refetchInterval: 60000,
});
}
/**
* Hook to get Gemini CLI quota for a specific account
*/
export function useGeminiQuota(accountId: string | null) {
return useQuery({
queryKey: ['gemini-quota', accountId],
queryFn: async () => {
if (!accountId) throw new Error('Account ID required');
return fetchGeminiQuotaApi(accountId);
},
enabled: !!accountId,
staleTime: 30000,
refetchInterval: 60000,
});
}
+70
View File
@@ -146,6 +146,70 @@ export interface QuotaResult {
error?: string;
}
/** Codex rate limit window */
export interface CodexQuotaWindow {
/** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */
label: string;
/** Percentage used (0-100) */
usedPercent: number;
/** Percentage remaining (100 - usedPercent) */
remainingPercent: number;
/** Seconds until quota resets, null if unknown */
resetAfterSeconds: number | null;
/** ISO timestamp when quota resets, null if unknown */
resetAt: string | null;
}
/** Codex quota result */
export interface CodexQuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Quota windows (primary, secondary, code review) */
windows: CodexQuotaWindow[];
/** Plan type: free, plus, team, or null if unknown */
planType: 'free' | 'plus' | 'team' | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
}
/** Gemini CLI bucket (grouped by model series) */
export interface GeminiCliBucket {
/** Unique bucket identifier (e.g., "gemini-flash-series::input") */
id: string;
/** Display label (e.g., "Gemini Flash Series") */
label: string;
/** Token type: "input", "output", or null if combined */
tokenType: string | null;
/** Remaining quota as fraction (0-1) */
remainingFraction: number;
/** Remaining quota as percentage (0-100) */
remainingPercent: number;
/** ISO timestamp when quota resets, null if unknown */
resetTime: string | null;
/** Model IDs in this bucket */
modelIds: string[];
}
/** Gemini CLI quota result */
export interface GeminiCliQuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Quota buckets grouped by model series */
buckets: GeminiCliBucket[];
/** GCP project ID for this account */
projectId: string | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
}
/** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
@@ -552,5 +616,11 @@ export const api = {
/** Fetch quota for a specific account */
get: (provider: string, accountId: string) =>
request<QuotaResult>(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`),
/** Fetch Codex quota for a specific account */
getCodex: (accountId: string) =>
request<CodexQuotaResult>(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`),
/** Fetch Gemini CLI quota for a specific account */
getGemini: (accountId: string) =>
request<GeminiCliQuotaResult>(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`),
},
};
+39
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { CodexQuotaWindow, GeminiCliBucket } from './api-client';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -298,3 +299,41 @@ export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredM
}
return groups;
}
/**
* Get minimum remaining percentage across Codex rate limit windows
*/
export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null {
if (!windows || windows.length === 0) return null;
const percentages = windows.map((w) => w.remainingPercent);
return Math.min(...percentages);
}
/**
* Get earliest reset time from Codex windows
*/
export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null {
if (!windows || windows.length === 0) return null;
const resets = windows.map((w) => w.resetAt).filter((t): t is string => t !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}
/**
* Get minimum remaining percentage across Gemini CLI buckets
*/
export function getMinGeminiQuota(buckets: GeminiCliBucket[]): number | null {
if (!buckets || buckets.length === 0) return null;
const percentages = buckets.map((b) => b.remainingPercent);
return Math.min(...percentages);
}
/**
* Get earliest reset time from Gemini buckets
*/
export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null {
if (!buckets || buckets.length === 0) return null;
const resets = buckets.map((b) => b.resetTime).filter((t): t is string => t !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}