import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { CodexQuotaWindow, CodexQuotaResult, ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, QuotaResult, } from './api-client'; import i18n from './i18n'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } /** * Format quota percentage for UI display. * Uses rounded whole numbers to keep quota labels compact and consistent. */ export function formatQuotaPercent(value: number): string { if (!Number.isFinite(value)) return '0'; const clamped = Math.max(0, Math.min(100, value)); return `${Math.round(clamped)}`; } // Vibrant Tones Palette const VIBRANT_TONES = [ '#f94144', // Strawberry Red '#f3722c', // Pumpkin Spice '#f8961e', // Carrot Orange '#f9844a', // Atomic Tangerine '#f9c74f', // Tuscan Sun '#90be6d', // Willow Green '#43aa8b', // Seaweed '#4d908e', // Dark Cyan '#577590', // Blue Slate '#277da1', // Cerulean ]; // Provider color mapping (fixed colors for consistency) const PROVIDER_COLORS: Record = { agy: '#f3722c', // Pumpkin gemini: '#277da1', // Cerulean codex: '#f8961e', // Carrot claude: '#4d908e', // Dark Cyan vertex: '#577590', // Blue Slate iflow: '#f94144', // Strawberry qwen: '#f9c74f', // Tuscan kiro: '#4d908e', // Dark Cyan (AWS-inspired) ghcp: '#43aa8b', // Seaweed (GitHub-inspired) copilot: '#43aa8b', // Seaweed (GitHub-inspired) }; // Status colors (from Analytics Cost breakdown) - darker for light theme contrast export const STATUS_COLORS = { success: '#15803d', // Green-700 (was Seaweed #43aa8b) degraded: '#b45309', // Amber-700 (was Ochre #e09f3e) failed: '#b91c1c', // Red-700 (was Merlot #9e2a2b) } as const; export function getModelColor(model: string): string { // FNV-1a hash algorithm let hash = 0x811c9dc5; for (let i = 0; i < model.length; i++) { hash ^= model.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } // Ensure positive index return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length]; } export function getProviderColor(provider: string): string { const normalized = provider.toLowerCase(); return PROVIDER_COLORS[normalized] || getModelColor(provider); } /** * Sort models by tier: Primary (Claude/GPT) > Gemini 3 Pro > Gemini 2.5 > Others * Within each tier, sorts alphabetically by display name */ export function sortModelsByPriority( models: T[] ): T[] { const getPriority = (model: T): number => { const name = (model.displayName || model.name).toLowerCase(); // Tier 0: Primary models (Claude + GPT) - weekly limits, most valuable if (name.includes('claude') || name.includes('gpt')) return 0; // Tier 1: Gemini 3 Pro models - high capability if (name.includes('gemini 3') || name.includes('gemini-3')) return 1; // Tier 2: Gemini 2.5 Pro/Flash models - mid tier if (name.includes('gemini 2.5') || name.includes('gemini-2.5')) return 2; // Tier 3: Other Gemini models if (name.includes('gemini')) return 3; // Tier 4: Everything else return 4; }; return [...models].sort((a, b) => { const priorityDiff = getPriority(a) - getPriority(b); if (priorityDiff !== 0) return priorityDiff; // Same priority: sort alphabetically by display name const nameA = (a.displayName || a.name).toLowerCase(); const nameB = (b.displayName || b.name).toLowerCase(); return nameA.localeCompare(nameB); }); } /** * Format reset time - relative for <24h, absolute date for >=24h (weekly limits) */ export function formatResetTime(resetTime: string | null): string | null { if (!resetTime) return null; try { const reset = new Date(resetTime); const now = new Date(); const diff = reset.getTime() - now.getTime(); if (diff <= 0) return 'soon'; const hours = Math.floor(diff / (1000 * 60 * 60)); // Weekly/long resets: show absolute date (e.g., "01/27, 12:07") if (hours >= 24) { return reset.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } // Daily resets: show relative time const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) return `in ${hours}h ${minutes}m`; return `in ${minutes}m`; } catch { return null; } } /** * Get earliest reset time from models array */ export function getEarliestResetTime( models: T[] ): string | null { return models.reduce( (earliest, m) => { if (!m.resetTime) return earliest; if (!earliest) return m.resetTime; return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest; }, null as string | null ); } /** * Filter to get Claude/GPT models (primary models we care about for quota) * These have weekly limits vs Gemini's daily limits */ function filterPrimaryModels(models: T[]): T[] { return models.filter((m) => { const name = (m.displayName || m.name || '').toLowerCase(); return name.includes('claude') || name.includes('gpt'); }); } /** * Calculate the minimum quota percentage from Claude/GPT models. * Returns 0 if Claude/GPT models are missing (exhausted/removed from API response). * Only returns null if no models at all. */ export function getMinClaudeQuota< T extends { name: string; displayName?: string; percentage: number }, >(models: T[]): number | null { if (models.length === 0) return null; const primaryModels = filterPrimaryModels(models); // If no Claude/GPT models in response, they're exhausted (0%) if (primaryModels.length === 0) return 0; const percentages = primaryModels .map((m) => m.percentage) .filter((p) => typeof p === 'number' && isFinite(p)); if (percentages.length === 0) return 0; return Math.min(...percentages); } /** * Get reset time for Claude/GPT models (primary models). * Returns null only if no primary models present in response. */ export function getClaudeResetTime< T extends { name: string; displayName?: string; resetTime: string | null }, >(models: T[]): string | null { if (models.length === 0) return null; const primaryModels = filterPrimaryModels(models); if (primaryModels.length === 0) return null; return primaryModels.reduce( (earliest, m) => { if (!m.resetTime) return earliest; if (!earliest) return m.resetTime; return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest; }, null as string | null ); } // Known primary models to show when exhausted (removed from API response) const AGY_DENYLIST_REGEX = /claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i; export function isDeniedAgyModelId(modelId: string): boolean { return AGY_DENYLIST_REGEX.test((modelId || '').trim()); } const KNOWN_PRIMARY_MODELS = [ { name: 'claude-opus-4-6-thinking', displayName: 'Claude Opus 4.6 (Thinking)' }, { name: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6' }, { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' }, ]; /** Model tier for visual grouping */ export type ModelTier = 'primary' | 'gemini-3' | 'gemini-2' | 'other'; /** Model with tier info for grouped display */ export interface TieredModel { name: string; displayName: string; percentage: number; tier: ModelTier; exhausted?: boolean; } /** Get tier label for display */ export function getTierLabel(tier: ModelTier): string { switch (tier) { case 'primary': return i18n.t('utils.tierPrimary'); case 'gemini-3': return i18n.t('utils.tierGemini3'); case 'gemini-2': return i18n.t('utils.tierGemini2'); case 'other': return i18n.t('utils.tierOther'); } } /** Determine tier for a model */ function getModelTier(name: string): ModelTier { const lower = name.toLowerCase(); if (lower.includes('claude') || lower.includes('gpt')) return 'primary'; if (lower.includes('gemini 3') || lower.includes('gemini-3')) return 'gemini-3'; if (lower.includes('gemini 2') || lower.includes('gemini-2')) return 'gemini-2'; return 'other'; } /** * Convert models to tiered format with exhausted primary models injected. * Groups models by tier for visual display in tooltip. */ export function getModelsWithTiers< T extends { name: string; displayName?: string; percentage: number }, >(models: T[]): TieredModel[] { if (models.length === 0) return []; const primaryModels = filterPrimaryModels(models); const result: TieredModel[] = []; // If primary models exhausted, add known ones with 0% if (primaryModels.length === 0) { for (const known of KNOWN_PRIMARY_MODELS) { result.push({ name: known.name, displayName: known.displayName, percentage: 0, tier: 'primary', exhausted: true, }); } } // Add all models with tier info for (const m of models) { const displayName = m.displayName || m.name; const tier = getModelTier(displayName); result.push({ name: m.name, displayName, percentage: m.percentage, tier, // Mark primary models at 0% as exhausted for red styling exhausted: tier === 'primary' && m.percentage === 0, }); } // Sort by tier priority, then alphabetically within tier const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return result.sort((a, b) => { const tierDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier); if (tierDiff !== 0) return tierDiff; return a.displayName.localeCompare(b.displayName); }); } /** * Group tiered models by tier for sectioned display */ export function groupModelsByTier(models: TieredModel[]): Map { const groups = new Map(); for (const m of models) { const existing = groups.get(m.tier) || []; existing.push(m); groups.set(m.tier, existing); } return groups; } export type CodexWindowKind = | 'usage-5h' | 'usage-weekly' | 'code-review-5h' | 'code-review-weekly' | 'code-review' | 'additional-5h' | 'additional-weekly' | 'unknown'; /** * Map a Codex window into a semantic bucket. Prefers explicit category metadata * (post-2026-04 windows) and falls back to label sniffing for legacy cached data. */ export function getCodexWindowKind(labelOrWindow: string | CodexWindowSummary): CodexWindowKind { if (typeof labelOrWindow === 'object' && labelOrWindow !== null) { const w = labelOrWindow; if (w.category === 'additional' && w.cadence) { return w.cadence === 'weekly' ? 'additional-weekly' : 'additional-5h'; } if (w.category === 'code-review' && w.cadence) { return w.cadence === 'weekly' ? 'code-review-weekly' : 'code-review-5h'; } if (w.category === 'usage' && w.cadence) { return w.cadence === 'weekly' ? 'usage-weekly' : 'usage-5h'; } // Missing or incomplete category metadata -> fall through to label sniffing. return getCodexWindowKindFromLabel(w.label); } return getCodexWindowKindFromLabel(labelOrWindow); } function getCodexWindowKindFromLabel(label: string): CodexWindowKind { const lower = (label || '').toLowerCase(); const isCodeReview = lower.includes('code review') || lower.includes('code_review'); const isPrimary = lower.includes('primary'); const isSecondary = lower.includes('secondary'); if (isCodeReview) { if (isPrimary) return 'code-review-5h'; if (isSecondary) return 'code-review-weekly'; return 'code-review'; } if (isPrimary) return 'usage-5h'; if (isSecondary) return 'usage-weekly'; return 'unknown'; } /** * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the * remainder into a "Codex " display name. Other labels pass through unchanged. * * Examples: * "GPT-5.3-Codex-Spark" -> "Codex Spark" * "OmniReview" -> "OmniReview" * "" -> "" */ export function prettifyCodexFeatureLabel(featureLabel: string): string { const trimmed = (featureLabel || '').trim(); if (!trimmed) return ''; const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); if (stripped !== trimmed && stripped.length > 0) { return `Codex ${stripped}`; } return trimmed; } type CodexWindowSummary = Pick< CodexQuotaWindow, 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' >; /** * Infer code-review window cadence by comparing against usage windows. * This keeps labels stable as countdown values decrease over time. */ function inferCodeReviewCadence( window: CodexWindowSummary, allWindows: CodexWindowSummary[] ): '5h' | 'weekly' | null { const kind = getCodexWindowKind(window); if (kind === 'code-review-weekly') return 'weekly'; const reset = window.resetAfterSeconds; if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null; const usage5h = allWindows.find( (w) => getCodexWindowKind(w) === 'usage-5h' && typeof w.resetAfterSeconds === 'number' && isFinite(w.resetAfterSeconds) && w.resetAfterSeconds > 0 ); const usageWeekly = allWindows.find( (w) => getCodexWindowKind(w) === 'usage-weekly' && typeof w.resetAfterSeconds === 'number' && isFinite(w.resetAfterSeconds) && w.resetAfterSeconds > 0 ); if (!usage5h || !usageWeekly) return null; const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number)); const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number)); return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; } export function getCodexWindowDisplayLabel( labelOrWindow: string | CodexWindowSummary, allWindows: CodexWindowSummary[] = [] ): string { const label = typeof labelOrWindow === 'string' ? labelOrWindow : labelOrWindow.label; const currentWindow: CodexWindowSummary = typeof labelOrWindow === 'string' ? { label, resetAfterSeconds: null } : { label, resetAfterSeconds: labelOrWindow.resetAfterSeconds, category: labelOrWindow.category, cadence: labelOrWindow.cadence, featureLabel: labelOrWindow.featureLabel, }; const context = allWindows.length > 0 ? allWindows : [currentWindow]; switch (getCodexWindowKind(currentWindow)) { case 'usage-5h': return i18n.t('quotaTooltip.fiveHourLimit'); case 'usage-weekly': return i18n.t('quotaTooltip.weeklyLimit'); case 'code-review-5h': case 'code-review-weekly': case 'code-review': { const inferred = inferCodeReviewCadence(currentWindow, context); if (inferred === '5h') return i18n.t('utils.codeReview5h'); if (inferred === 'weekly') return i18n.t('utils.codeReviewWeekly'); return i18n.t('utils.codeReview'); } case 'additional-5h': case 'additional-weekly': { const pretty = prettifyCodexFeatureLabel(currentWindow.featureLabel ?? ''); const name = pretty || label; const kind = getCodexWindowKind(currentWindow); if (kind === 'additional-5h') return i18n.t('utils.codexAdditional5h', { name }); if (kind === 'additional-weekly') return i18n.t('utils.codexAdditionalWeekly', { name }); return i18n.t('utils.codexAdditional', { name }); } case 'unknown': return label; } } export interface CodexQuotaBreakdown { fiveHourWindow: CodexQuotaWindow | null; weeklyWindow: CodexQuotaWindow | null; codeReviewWindows: CodexQuotaWindow[]; /** Additional rate-limit windows (e.g. GPT-5.3 Codex Spark). Excluded from core 5h/weekly summary. */ additionalWindows: CodexQuotaWindow[]; unknownWindows: CodexQuotaWindow[]; } /** * Break down Codex windows into core usage windows (5h + weekly), code review, * additional (e.g. Spark), and unknown buckets. * * Prefers explicit category metadata when present so 'additional' windows * (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. * Falls back to label sniffing for legacy cached data without metadata. */ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown { if (!windows || windows.length === 0) { return { fiveHourWindow: null, weeklyWindow: null, codeReviewWindows: [], additionalWindows: [], unknownWindows: [], }; } let fiveHourWindow: CodexQuotaWindow | null = null; let weeklyWindow: CodexQuotaWindow | null = null; const codeReviewWindows: CodexQuotaWindow[] = []; const additionalWindows: CodexQuotaWindow[] = []; const unknownWindows: CodexQuotaWindow[] = []; // Eligible windows for the 5h/weekly fallback (must NOT include code-review or additional). const nonCodeReviewWindows: CodexQuotaWindow[] = []; const hasCategoryMetadata = windows.some((w) => Boolean(w.category)); if (hasCategoryMetadata) { for (const window of windows) { if (window.category === 'usage') { if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; nonCodeReviewWindows.push(window); } else if (window.category === 'code-review') { codeReviewWindows.push(window); } else if (window.category === 'additional') { additionalWindows.push(window); } else { // Window has no category but the batch carries metadata for others -> treat as unknown. unknownWindows.push(window); nonCodeReviewWindows.push(window); } } } else { // Legacy path: classify via label sniffing for cached windows without metadata. for (const window of windows) { const kind = getCodexWindowKind(window.label); switch (kind) { case 'usage-5h': if (!fiveHourWindow) fiveHourWindow = window; nonCodeReviewWindows.push(window); break; case 'usage-weekly': if (!weeklyWindow) weeklyWindow = window; nonCodeReviewWindows.push(window); break; case 'code-review-5h': case 'code-review-weekly': case 'code-review': codeReviewWindows.push(window); break; case 'additional-5h': case 'additional-weekly': // Unreachable from label-only kind, but kept for exhaustiveness. additionalWindows.push(window); break; case 'unknown': unknownWindows.push(window); nonCodeReviewWindows.push(window); break; } } } // Fallback for API label changes: infer 5h/weekly from reset horizon when explicit labels are absent. // 'additional' windows are intentionally excluded from this pool to avoid leaking Spark windows // into the core usage badges on a fresh Pro account. if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { const withReset = nonCodeReviewWindows .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); if (!fiveHourWindow) { fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; } if (!weeklyWindow) { weeklyWindow = withReset.length > 1 ? withReset[withReset.length - 1] : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; } } return { fiveHourWindow, weeklyWindow, codeReviewWindows, additionalWindows, unknownWindows, }; } /** * Get minimum remaining percentage across Codex rate limit windows */ export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null { if (!windows || windows.length === 0) return null; const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows); const usageWindows = [fiveHourWindow, weeklyWindow].filter( (w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index ); // Primary account quota should be driven by core usage windows, not code-review windows. const sourceWindows = usageWindows.length > 0 ? usageWindows : windows; const percentages = sourceWindows.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 { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows); const usageWindows = [fiveHourWindow, weeklyWindow].filter( (w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index ); const sourceWindows = usageWindows.length > 0 ? usageWindows : windows; const resets = sourceWindows.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 Claude policy windows. */ export function getMinClaudePolicyQuota(quota: ClaudeQuotaResult): number | null { if (!quota.success) return null; const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( (window): window is NonNullable => !!window ); if (coreWindows.length > 0) { return Math.min(...coreWindows.map((window) => window.remainingPercent)); } const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage'); if (usageWindows.length > 0) { return Math.min(...usageWindows.map((window) => window.remainingPercent)); } return null; } /** * Get earliest reset time from Claude policy windows. */ export function getClaudePolicyResetTime(quota: ClaudeQuotaResult): string | null { if (!quota.success) return null; const coreResets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt].filter( (value): value is string => !!value ); if (coreResets.length > 0) { return coreResets.sort()[0]; } const resets = quota.windows .filter((window) => window.rateLimitType !== 'overage') .map((window) => window.resetAt) .filter((value): value is string => value !== 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]; } /** * 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, snapshots.chat, snapshots.completions] .filter((snapshot) => snapshot.reported !== false) .map((snapshot) => (snapshot.unlimited ? 100 : snapshot.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 | ClaudeQuotaResult | GeminiCliQuotaResult | GhcpQuotaResult; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } /** Type guard: Check if quota result is from Antigravity (agy) provider */ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { if (!isRecord(quota)) return false; const models = (quota as Partial).models; return typeof quota.success === 'boolean' && Array.isArray(models); } /** Type guard: Check if quota result is from Codex provider */ export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { if (!isRecord(quota)) return false; const candidate = quota as Partial; if (typeof candidate.success !== 'boolean') return false; if (!Array.isArray(candidate.windows)) return false; if (!('planType' in candidate)) return false; return candidate.windows.every( (window) => isRecord(window) && typeof window.label === 'string' && isFiniteNumber(window.usedPercent) && isFiniteNumber(window.remainingPercent) ); } /** Type guard: Check if quota result is from Claude provider */ export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { if (!isRecord(quota)) return false; const candidate = quota as Partial; if (typeof candidate.success !== 'boolean') return false; if (!Array.isArray(candidate.windows)) return false; if ('planType' in candidate) return false; return candidate.windows.every( (window) => isRecord(window) && typeof window.rateLimitType === 'string' && isFiniteNumber(window.remainingPercent) && typeof window.status === 'string' ); } /** Type guard: Check if quota result is from Gemini CLI provider */ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult { if (!isRecord(quota)) return false; const candidate = quota as Partial; if (typeof candidate.success !== 'boolean') return false; if (!Array.isArray(candidate.buckets)) return false; return candidate.buckets.every( (bucket) => isRecord(bucket) && typeof bucket.id === 'string' && isFiniteNumber(bucket.remainingFraction) && isFiniteNumber(bucket.remainingPercent) && Array.isArray(bucket.modelIds) ); } /** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { if (!isRecord(quota)) return false; const candidate = quota as Partial; const snapshots = candidate.snapshots as Record | null | undefined; if (typeof candidate.success !== 'boolean') return false; if (!isRecord(snapshots)) return false; const snapshotKeys: Array = [ 'premiumInteractions', 'chat', 'completions', ]; return snapshotKeys.every((key) => { const snapshot = snapshots[key] as Record | undefined; return ( isRecord(snapshot) && isFiniteNumber(snapshot.percentRemaining) && isFiniteNumber(snapshot.percentUsed) ); }); } // ==================== Unified Quota Helpers ==================== export interface QuotaFailureInfo { label: string; summary: string; actionHint: string | null; technicalDetail: string | null; rawDetail: string | null; tone: 'warning' | 'muted' | 'destructive'; } function buildQuotaTechnicalDetail(quota: UnifiedQuotaResult): string | null { const details: string[] = []; if (typeof quota.httpStatus === 'number') { details.push(`HTTP ${quota.httpStatus}`); } if (typeof quota.errorCode === 'string' && quota.errorCode.trim()) { details.push(quota.errorCode.trim()); } return details.length > 0 ? details.join(' | ') : null; } function buildQuotaRawDetail( quota: UnifiedQuotaResult, summary: string, technicalDetail: string | null ): string | null { const rawDetail = quota.errorDetail?.trim() || null; if (!rawDetail) return null; const normalizedRawDetail = rawDetail.toLowerCase(); if (normalizedRawDetail === summary.toLowerCase()) { return null; } if (technicalDetail && normalizedRawDetail === technicalDetail.toLowerCase()) { return null; } return rawDetail; } export function getQuotaFailureInfo( quota: UnifiedQuotaResult | null | undefined ): QuotaFailureInfo | null { if (!quota || quota.success) { return null; } const summary = quota.error?.trim() || 'Quota information unavailable'; const actionHint = quota.actionHint?.trim() || null; const errorCode = quota.errorCode?.trim().toLowerCase() || ''; const technicalDetail = buildQuotaTechnicalDetail(quota); const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail); const lowerSummary = summary.toLowerCase(); const entitlement = 'entitlement' in quota ? quota.entitlement : undefined; if ( quota.errorCode === 'capacity_exhausted' || entitlement?.capacityState === 'capacity_exhausted' || lowerSummary.includes('no capacity available') || lowerSummary.includes('capacity exhausted') ) { return { label: 'Capacity', summary, actionHint: actionHint || 'Retry later or switch to another model. This is a temporary provider capacity issue.', technicalDetail, rawDetail, tone: 'warning', }; } if ( quota.needsReauth || errorCode === 'token_expired' || errorCode === 'reauth_required' || lowerSummary.includes('token expired') || lowerSummary.includes('re-authenticate') || lowerSummary.includes('reauth') || lowerSummary.includes('expired or invalid') ) { return { label: i18n.t('accountCard.failureLabelReauth'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintReauth'), technicalDetail, rawDetail, tone: 'warning', }; } if ( errorCode === 'deactivated_workspace' || quota.httpStatus === 402 || lowerSummary.includes('workspace deactivated') || lowerSummary.includes('payment or workspace access required') ) { return { label: i18n.t('accountCard.failureLabelWorkspace'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintWorkspace'), technicalDetail, rawDetail, tone: 'warning', }; } if ( entitlement?.accessState === 'not_entitled' || quota.isForbidden || quota.httpStatus === 403 || errorCode === 'quota_api_forbidden' || lowerSummary.includes('forbidden') ) { return { label: i18n.t('accountCard.failureLabelNoAccess'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintNoAccess'), technicalDetail, rawDetail, tone: 'muted', }; } if ( quota.httpStatus === 429 || errorCode === 'rate_limited' || lowerSummary.includes('rate limited') ) { return { label: i18n.t('accountCard.failureLabelRetry'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintRetry'), technicalDetail, rawDetail, tone: 'warning', }; } if ( errorCode === 'auth_file_missing' || errorCode === 'missing_account_id' || lowerSummary.includes('auth file not found') || lowerSummary.includes('missing chatgpt-account-id') ) { return { label: i18n.t('accountCard.failureLabelReconnect'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintReconnect'), technicalDetail, rawDetail, tone: 'muted', }; } if ( quota.retryable || errorCode === 'network_timeout' || errorCode === 'network_error' || errorCode === 'provider_unavailable' || lowerSummary.includes('timeout') || lowerSummary.includes('network') || lowerSummary.includes('fetch failed') || lowerSummary.includes('service unavailable') ) { return { label: i18n.t('accountCard.failureLabelTemporary'), summary, actionHint: actionHint || i18n.t('accountCard.failureHintTemporary'), technicalDetail, rawDetail, tone: 'warning', }; } return { label: i18n.t('accountCard.failureLabelUnavailable'), summary, actionHint, technicalDetail, rawDetail, tone: 'muted', }; } /** * Get minimum quota percentage for any provider * Centralizes provider-specific logic to eliminate duplication */ export function getProviderMinQuota( provider: string, quota: UnifiedQuotaResult | null | undefined ): number | null { if (!quota?.success) return null; const normalizedProvider = provider.trim().toLowerCase(); switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getMinClaudeQuota(quota.models); } return null; case 'codex': if (isCodexQuotaResult(quota)) { return getMinCodexQuota(quota.windows); } return null; case 'claude': case 'anthropic': if (isClaudeQuotaResult(quota)) { return getMinClaudePolicyQuota(quota); } return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getMinGeminiQuota(quota.buckets); } return null; case 'ghcp': case 'github-copilot': if (isGhcpQuotaResult(quota)) { return getMinGhcpQuota(quota.snapshots); } return null; default: return null; } } /** * Get earliest reset time for any provider * Centralizes provider-specific logic to eliminate duplication */ export function getProviderResetTime( provider: string, quota: UnifiedQuotaResult | null | undefined ): string | null { if (!quota?.success) return null; const normalizedProvider = provider.trim().toLowerCase(); switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getClaudeResetTime(quota.models); } return null; case 'codex': if (isCodexQuotaResult(quota)) { return getCodexResetTime(quota.windows); } return null; case 'claude': case 'anthropic': if (isClaudeQuotaResult(quota)) { return getClaudePolicyResetTime(quota); } return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getGeminiResetTime(quota.buckets); } return null; case 'ghcp': case 'github-copilot': if (isGhcpQuotaResult(quota)) { return getGhcpResetTime(quota.quotaResetDate); } return null; default: return null; } }