mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
import { clsx, type ClassValue } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
import type {
|
|
CodexQuotaWindow,
|
|
CodexQuotaResult,
|
|
GeminiCliBucket,
|
|
GeminiCliQuotaResult,
|
|
QuotaResult,
|
|
} from './api-client';
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
// 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
|
|
vertex: '#577590', // Blue Slate
|
|
iflow: '#f94144', // Strawberry
|
|
qwen: '#f9c74f', // Tuscan
|
|
kiro: '#4d908e', // Dark Cyan (AWS-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 KNOWN_PRIMARY_MODELS = [
|
|
{ name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' },
|
|
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' },
|
|
{ name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' },
|
|
{ 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 'Claude & GPT';
|
|
case 'gemini-3':
|
|
return 'Gemini 3';
|
|
case 'gemini-2':
|
|
return 'Gemini 2.5';
|
|
case 'other':
|
|
return 'Other';
|
|
}
|
|
}
|
|
|
|
/** 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'
|
|
| 'unknown';
|
|
|
|
/**
|
|
* Map raw Codex API window labels into semantic buckets.
|
|
*/
|
|
export function getCodexWindowKind(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';
|
|
}
|
|
|
|
type CodexWindowSummary = Pick<CodexQuotaWindow, 'label' | 'resetAfterSeconds'>;
|
|
|
|
/**
|
|
* 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.label);
|
|
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.label) === 'usage-5h' &&
|
|
typeof w.resetAfterSeconds === 'number' &&
|
|
isFinite(w.resetAfterSeconds) &&
|
|
w.resetAfterSeconds > 0
|
|
);
|
|
const usageWeekly = allWindows.find(
|
|
(w) =>
|
|
getCodexWindowKind(w.label) === '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 };
|
|
const context = allWindows.length > 0 ? allWindows : [currentWindow];
|
|
|
|
switch (getCodexWindowKind(label)) {
|
|
case 'usage-5h':
|
|
return '5h usage limit';
|
|
case 'usage-weekly':
|
|
return 'Weekly usage limit';
|
|
case 'code-review-5h':
|
|
case 'code-review-weekly':
|
|
case 'code-review': {
|
|
const inferred = inferCodeReviewCadence(currentWindow, context);
|
|
if (inferred === '5h') return 'Code review (5h)';
|
|
if (inferred === 'weekly') return 'Code review (weekly)';
|
|
return 'Code review';
|
|
}
|
|
case 'unknown':
|
|
return label;
|
|
}
|
|
}
|
|
|
|
export interface CodexQuotaBreakdown {
|
|
fiveHourWindow: CodexQuotaWindow | null;
|
|
weeklyWindow: CodexQuotaWindow | null;
|
|
codeReviewWindows: CodexQuotaWindow[];
|
|
unknownWindows: CodexQuotaWindow[];
|
|
}
|
|
|
|
/**
|
|
* Break down Codex windows into core usage windows (5h + weekly) and auxiliary windows.
|
|
*/
|
|
export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown {
|
|
if (!windows || windows.length === 0) {
|
|
return {
|
|
fiveHourWindow: null,
|
|
weeklyWindow: null,
|
|
codeReviewWindows: [],
|
|
unknownWindows: [],
|
|
};
|
|
}
|
|
|
|
let fiveHourWindow: CodexQuotaWindow | null = null;
|
|
let weeklyWindow: CodexQuotaWindow | null = null;
|
|
const codeReviewWindows: CodexQuotaWindow[] = [];
|
|
const unknownWindows: CodexQuotaWindow[] = [];
|
|
const nonCodeReviewWindows: CodexQuotaWindow[] = [];
|
|
|
|
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 'unknown':
|
|
unknownWindows.push(window);
|
|
nonCodeReviewWindows.push(window);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fallback for API label changes: infer 5h/weekly from reset horizon when explicit labels are absent.
|
|
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,
|
|
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 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];
|
|
}
|
|
|
|
// ==================== Unified Quota Type Guards ====================
|
|
|
|
/** Unified quota result type for provider-agnostic handling */
|
|
export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult;
|
|
|
|
/** Type guard: Check if quota result is from Antigravity (agy) provider */
|
|
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
|
|
return 'models' in quota && Array.isArray((quota as QuotaResult).models);
|
|
}
|
|
|
|
/** Type guard: Check if quota result is from Codex provider */
|
|
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
|
|
return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows);
|
|
}
|
|
|
|
/** Type guard: Check if quota result is from Gemini CLI provider */
|
|
export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult {
|
|
return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets);
|
|
}
|
|
|
|
// ==================== Unified Quota Helpers ====================
|
|
|
|
/**
|
|
* 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;
|
|
|
|
switch (provider) {
|
|
case 'agy':
|
|
if (isAgyQuotaResult(quota)) {
|
|
return getMinClaudeQuota(quota.models);
|
|
}
|
|
return null;
|
|
case 'codex':
|
|
if (isCodexQuotaResult(quota)) {
|
|
return getMinCodexQuota(quota.windows);
|
|
}
|
|
return null;
|
|
case 'gemini':
|
|
if (isGeminiQuotaResult(quota)) {
|
|
return getMinGeminiQuota(quota.buckets);
|
|
}
|
|
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;
|
|
|
|
switch (provider) {
|
|
case 'agy':
|
|
if (isAgyQuotaResult(quota)) {
|
|
return getClaudeResetTime(quota.models);
|
|
}
|
|
return null;
|
|
case 'codex':
|
|
if (isCodexQuotaResult(quota)) {
|
|
return getCodexResetTime(quota.windows);
|
|
}
|
|
return null;
|
|
case 'gemini':
|
|
if (isGeminiQuotaResult(quota)) {
|
|
return getGeminiResetTime(quota.buckets);
|
|
}
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|