Files
ccs/ui/src/lib/utils.ts
T

1090 lines
34 KiB
TypeScript

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<string, string> = {
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<T extends { name: string; displayName?: string }>(
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<T extends { resetTime: string | null }>(
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<T extends { name: string; displayName?: string }>(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<ModelTier, TieredModel[]> {
const groups = new Map<ModelTier, TieredModel[]>();
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 <Feature>" 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<typeof window> => !!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<string, unknown> {
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<QuotaResult>).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<CodexQuotaResult>;
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<ClaudeQuotaResult>;
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<GeminiCliQuotaResult>;
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<GhcpQuotaResult>;
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
if (typeof candidate.success !== 'boolean') return false;
if (!isRecord(snapshots)) return false;
const snapshotKeys: Array<keyof GhcpQuotaResult['snapshots']> = [
'premiumInteractions',
'chat',
'completions',
];
return snapshotKeys.every((key) => {
const snapshot = snapshots[key] as Record<string, unknown> | 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;
}
}