From 19a57c395c29beb83661edb28286e01211b80261 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 21:50:43 -0500 Subject: [PATCH] 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 --- ui/src/hooks/use-cliproxy-stats.ts | 111 +++++++++++++++++++++++++++-- ui/src/lib/api-client.ts | 70 ++++++++++++++++++ ui/src/lib/utils.ts | 39 ++++++++++ 3 files changed, 214 insertions(+), 6 deletions(-) diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index e84b23d7..587e2dcc 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -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 { const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`); @@ -216,15 +224,74 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise { + 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 { + 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 { + 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, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index cf190caa..753e0de5 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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; @@ -552,5 +616,11 @@ export const api = { /** Fetch quota for a specific account */ get: (provider: string, accountId: string) => request(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`), + /** Fetch Codex quota for a specific account */ + getCodex: (accountId: string) => + request(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`), + /** Fetch Gemini CLI quota for a specific account */ + getGemini: (accountId: string) => + request(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`), }, }; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index a6966d0a..54e665f1 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -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 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]; +}