feat(ui): replace misleading token expiry with runtime-based status

- Remove "Token expired" warning (showed stale file state, not runtime)
- Add "Active/Last used" status based on CLIProxyAPI runtime stats
- Show green checkmark for recently used accounts (within 1h)
- Show "Not used yet" for accounts without usage stats
- Remove expired warning from flow-viz account cards
- Add model quota sorting (Claude > Gemini > GPT > other)
- Add quota reset time display in tooltips
- Fix re-auth button to use correct CCS endpoint
- Reduce quota cache staleness (30s stale, 1m refresh)

CLIProxyAPI intentionally doesn't persist refreshed tokens to disk
(to prevent refresh loops), so file-based expiry was misleading.
Dashboard now shows truthful operational state from runtime stats.
This commit is contained in:
kaitranntt
2025-12-29 13:03:37 -05:00
parent 3531991b5d
commit 6ccf6c5e13
6 changed files with 427 additions and 229 deletions
+120 -30
View File
@@ -34,6 +34,10 @@ export interface QuotaResult {
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** True if token is expired and needs re-auth */
isExpired?: boolean;
/** ISO timestamp when token expires/expired */
expiresAt?: string;
}
/** Google Cloud Code API endpoints */
@@ -56,6 +60,15 @@ interface AntigravityAuthFile {
expires_in?: number;
timestamp?: number;
type?: string;
project_id?: string;
}
/** Auth data returned from file */
interface AuthData {
accessToken: string;
projectId: string | null;
isExpired: boolean;
expiresAt: string | null;
}
/** loadCodeAssist response */
@@ -89,9 +102,30 @@ interface FetchAvailableModelsResponse {
}
/**
* Read access token from auth file
* Sanitize email to match CLIProxyAPI auth file naming convention
* Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName)
*/
function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null {
function sanitizeEmail(email: string): string {
return email.replace(/@/g, '_').replace(/\./g, '_');
}
/**
* Check if token is expired based on the expired timestamp
*/
function isTokenExpired(expiredStr?: string): boolean {
if (!expiredStr) return false;
try {
const expiredDate = new Date(expiredStr);
return expiredDate.getTime() < Date.now();
} catch {
return false;
}
}
/**
* Read auth data from auth file (access token, project_id, expiry status)
*/
function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
const authDir = getAuthDir();
// Check if auth directory exists
@@ -99,24 +133,48 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string
return null;
}
// Account ID format: email with @ and . replaced by _
// Try to find matching token file
const files = fs.readdirSync(authDir);
// Sanitize accountId (email) to match auth file naming: @ and . → _
const sanitizedId = sanitizeEmail(accountId);
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
const expectedFile = `${prefix}${sanitizedId}.json`;
const filePath = path.join(authDir, expectedFile);
// Direct file access (most common case)
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as AntigravityAuthFile;
if (!data.access_token) return null;
return {
accessToken: data.access_token,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
return null;
}
}
// Fallback: scan directory for matching email in file content
const files = fs.readdirSync(authDir);
for (const file of files) {
if (file.startsWith(prefix) && file.endsWith('.json')) {
// Check if this file matches the account ID
const baseName = file.replace(prefix, '').replace('.json', '');
if (baseName === accountId || file === accountId || file === `${accountId}.json`) {
const filePath = path.join(authDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as AntigravityAuthFile;
return data.access_token || null;
} catch {
return null;
const candidatePath = path.join(authDir, file);
try {
const content = fs.readFileSync(candidatePath, 'utf-8');
const data = JSON.parse(content) as AntigravityAuthFile;
// Match by email field inside the auth file
if (data.email === accountId && data.access_token) {
return {
accessToken: data.access_token,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
}
} catch {
continue;
}
}
}
@@ -127,7 +185,9 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string
/**
* Get project ID via loadCodeAssist endpoint
*/
async function getProjectId(accessToken: string): Promise<string | null> {
async function getProjectId(
accessToken: string
): Promise<{ projectId: string | null; error?: string }> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
const controller = new AbortController();
@@ -153,7 +213,14 @@ async function getProjectId(accessToken: string): Promise<string | null> {
clearTimeout(timeoutId);
if (!response.ok) {
return null;
// Return specific error based on status
if (response.status === 401) {
return { projectId: null, error: 'Token expired or invalid' };
}
if (response.status === 403) {
return { projectId: null, error: 'Access forbidden' };
}
return { projectId: null, error: `API error: ${response.status}` };
}
const data = (await response.json()) as LoadCodeAssistResponse;
@@ -166,10 +233,17 @@ async function getProjectId(accessToken: string): Promise<string | null> {
projectId = data.cloudaicompanionProject?.id;
}
return projectId?.trim() || null;
} catch {
if (!projectId?.trim()) {
return { projectId: null, error: 'No project ID in response' };
}
return { projectId: projectId.trim() };
} catch (err) {
clearTimeout(timeoutId);
return null;
if (err instanceof Error && err.name === 'AbortError') {
return { projectId: null, error: 'Request timeout' };
}
return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
@@ -275,7 +349,7 @@ async function fetchAvailableModels(accessToken: string, projectId: string): Pro
* Fetch quota for an Antigravity account
*
* @param provider - Provider name (only 'agy' supported)
* @param accountId - Account identifier (email with _ replacing @ and .)
* @param accountId - Account identifier (email)
* @returns Quota result with models and percentages
*/
export async function fetchAccountQuota(
@@ -292,28 +366,44 @@ export async function fetchAccountQuota(
};
}
// Read access token from auth file
const accessToken = readAccessToken(provider, accountId);
if (!accessToken) {
// Read auth data from auth file
const authData = readAuthData(provider, accountId);
if (!authData) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Access token not found for account',
error: 'Auth file not found for account',
};
}
// Get project ID first
const projectId = await getProjectId(accessToken);
if (!projectId) {
// Check if token is expired
if (authData.isExpired) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Failed to retrieve project ID',
isExpired: true,
expiresAt: authData.expiresAt || undefined,
error: 'Token expired',
};
}
// Get project ID - prefer stored value, fallback to API call
let projectId = authData.projectId;
if (!projectId) {
const projectResult = await getProjectId(authData.accessToken);
if (!projectResult.projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: projectResult.error || 'Failed to retrieve project ID',
};
}
projectId = projectResult.projectId;
}
// Fetch models with quota
return fetchAvailableModels(accessToken, projectId);
return fetchAvailableModels(authData.accessToken, projectId);
}
@@ -2,9 +2,11 @@
* Account Card Component for Flow Visualization
*/
import { cn } from '@/lib/utils';
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical } from 'lucide-react';
import { GripVertical, Loader2, Clock } from 'lucide-react';
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { AccountData, DragOffset } from './types';
import { cleanEmail } from './utils';
@@ -74,6 +76,18 @@ export function AccountCard({
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for AGY accounts
const isAgy = account.provider === 'agy';
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
account.provider,
account.id,
isAgy
);
const avgQuota =
quota?.success && quota.models.length > 0
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
: null;
return (
<div
data-account-index={originalIndex}
@@ -114,6 +128,82 @@ export function AccountCard({
failure={account.failureCount}
showDetails={showDetails}
/>
{/* Quota bar for AGY accounts */}
{isAgy && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span>
</div>
) : avgQuota !== null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Quota
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
avgQuota > 50
? 'text-emerald-600 dark:text-emerald-400'
: avgQuota > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{avgQuota}%
</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
avgQuota > 50
? 'bg-emerald-500'
: avgQuota > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${avgQuota}%` }}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(quota?.models || []).map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className="truncate">{m.displayName || m.name}</span>
<span className="font-mono">{m.percentage}%</span>
</div>
))}
{(() => {
const resetTime = getEarliestResetTime(quota?.models || []);
return resetTime ? (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
</span>
</div>
) : null;
})()}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
) : null}
</div>
)}
<div
className={cn(
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
@@ -13,33 +13,21 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { User, Star, MoreHorizontal, Clock, Trash2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
User,
Star,
MoreHorizontal,
Clock,
Trash2,
Loader2,
CheckCircle2,
HelpCircle,
} from 'lucide-react';
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import type { AccountItemProps } from './types';
/**
* Format reset time as relative time (e.g., "in 2 hours")
*/
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));
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 color class based on quota percentage
*/
@@ -49,6 +37,45 @@ function getQuotaColor(percentage: number): string {
return 'bg-green-500';
}
/**
* Format relative time (e.g., "5m ago", "2h ago")
*/
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 0) return 'just now';
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
} catch {
return '';
}
}
/**
* Check if account was used recently (within last hour = token likely refreshed)
*/
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
if (!lastUsedAt) return false;
try {
const lastUsed = new Date(lastUsedAt);
const now = new Date();
const diff = now.getTime() - lastUsed.getTime();
return diff < 60 * 60 * 1000; // Within last hour
} catch {
return false;
}
}
export function AccountItem({
account,
onSetDefault,
@@ -57,6 +84,9 @@ export function AccountItem({
privacyMode,
showQuota,
}: AccountItemProps) {
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
const { data: stats } = useCliproxyStats(showQuota && account.provider === 'agy');
// Fetch quota for 'agy' provider accounts
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
account.provider,
@@ -64,6 +94,10 @@ export function AccountItem({
showQuota && account.provider === 'agy'
);
// Get last used time from runtime stats (more accurate than file)
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
// Calculate average quota across all models
const avgQuota =
quota?.success && quota.models.length > 0
@@ -72,16 +106,7 @@ export function AccountItem({
// Get earliest reset time
const nextReset =
quota?.success && quota.models.length > 0
? quota.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
)
: null;
quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null;
return (
<div
@@ -155,36 +180,65 @@ export function AccountItem({
<span>Loading quota...</span>
</div>
) : avgQuota !== null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Progress
value={avgQuota}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(avgQuota)}
/>
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{quota?.models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className="truncate">{m.displayName || m.name}</span>
<span className="font-mono">{m.percentage}%</span>
</div>
))}
{nextReset && (
<p className="text-muted-foreground mt-1">
Resets {formatResetTime(nextReset)}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="space-y-1.5">
{/* Status indicator based on runtime usage, not file state */}
<div className="flex items-center gap-1.5 text-xs">
{wasRecentlyUsed ? (
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
</>
)}
</div>
{/* Quota bar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Progress
value={avgQuota}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(avgQuota)}
/>
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(quota?.models || []).map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className="truncate">{m.displayName || m.name}</span>
<span className="font-mono">{m.percentage}%</span>
</div>
))}
{nextReset && (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : quota?.error ? (
<div className="text-xs text-muted-foreground">{quota.error}</div>
) : null}
+33 -131
View File
@@ -1,6 +1,6 @@
/**
* OAuth Auth Flow Hook for CLIProxy
* Manages popup-based OAuth authentication flows
* Triggers backend-managed OAuth authentication flows
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
@@ -13,19 +13,7 @@ interface AuthFlowState {
error: string | null;
}
const AUTH_ENDPOINTS: Record<string, string> = {
claude: '/anthropic-auth-url',
gemini: '/gemini-cli-auth-url',
codex: '/codex-auth-url',
agy: '/antigravity-auth-url',
qwen: '/qwen-auth-url',
iflow: '/iflow-auth-url',
kiro: '/kiro-auth-url',
ghcp: '/ghcp-auth-url',
};
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
const POLL_INTERVAL_MS = 500;
const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
export function useCliproxyAuthFlow() {
const [state, setState] = useState<AuthFlowState>({
@@ -34,36 +22,19 @@ export function useCliproxyAuthFlow() {
error: null,
});
const popupRef = useRef<Window | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const queryClient = useQueryClient();
// Cleanup function
const cleanup = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
}, []);
// Cleanup on unmount
useEffect(() => {
return () => cleanup();
}, [cleanup]);
return () => {
abortControllerRef.current?.abort();
};
}, []);
const startAuth = useCallback(
async (provider: string) => {
const endpoint = AUTH_ENDPOINTS[provider];
if (!endpoint) {
if (!VALID_PROVIDERS.includes(provider)) {
setState({
provider: null,
isAuthenticating: false,
@@ -72,117 +43,48 @@ export function useCliproxyAuthFlow() {
return;
}
// Abort any in-progress auth
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setState({ provider, isAuthenticating: true, error: null });
try {
// Get auth URL from API
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
if (!response.ok) {
throw new Error(`Failed to get auth URL: ${response.statusText}`);
}
// POST to CCS auth endpoint - backend opens browser and waits
const response = await fetch(`/api/cliproxy/auth/${provider}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: abortControllerRef.current.signal,
});
const data = await response.json();
const { url, state: authState } = data;
if (!url) {
throw new Error('No auth URL returned from server');
if (response.ok && data.success) {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
toast.success(`${provider} authentication successful`);
setState({ provider: null, isAuthenticating: false, error: null });
} else {
throw new Error(data.error || 'Authentication failed');
}
// Open popup
const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes');
if (!popup) {
throw new Error('Popup blocked. Please allow popups for this site.');
}
popupRef.current = popup;
// Poll for completion
pollIntervalRef.current = setInterval(async () => {
// Check if popup was closed by user
if (popup.closed) {
cleanup();
// Check final status
try {
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
const statusData = await statusRes.json();
if (statusData.status === 'ok') {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success(`${provider} authentication successful`);
setState({ provider: null, isAuthenticating: false, error: null });
} else if (statusData.status === 'error') {
setState({
provider: null,
isAuthenticating: false,
error: statusData.error || 'Authentication failed',
});
} else {
// User closed popup before completing
setState({
provider: null,
isAuthenticating: false,
error: 'Authentication cancelled',
});
}
} catch {
setState({
provider: null,
isAuthenticating: false,
error: 'Failed to check auth status',
});
}
return;
}
// Poll status while popup is open
try {
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
const statusData = await statusRes.json();
if (statusData.status === 'ok') {
cleanup();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success(`${provider} authentication successful`);
setState({ provider: null, isAuthenticating: false, error: null });
} else if (statusData.status === 'error') {
cleanup();
setState({
provider: null,
isAuthenticating: false,
error: statusData.error || 'Authentication failed',
});
}
// 'wait' status means keep polling
} catch {
// Silently ignore polling errors, will retry
}
}, POLL_INTERVAL_MS);
// Timeout after 5 minutes
timeoutRef.current = setTimeout(() => {
cleanup();
toast.error('Authentication timed out');
setState({
provider: null,
isAuthenticating: false,
error: 'Authentication timed out',
});
}, AUTH_TIMEOUT_MS);
} catch (error) {
cleanup();
if (error instanceof Error && error.name === 'AbortError') {
setState({ provider: null, isAuthenticating: false, error: null });
return;
}
const message = error instanceof Error ? error.message : 'Authentication failed';
toast.error(message);
setState({ provider: null, isAuthenticating: false, error: message });
}
},
[cleanup, queryClient]
[queryClient]
);
const cancelAuth = useCallback(() => {
cleanup();
abortControllerRef.current?.abort();
setState({ provider: null, isAuthenticating: false, error: null });
}, [cleanup]);
}, []);
return useMemo(
() => ({
+2 -2
View File
@@ -221,8 +221,8 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t
queryKey: ['account-quota', provider, accountId],
queryFn: () => fetchAccountQuota(provider, accountId),
enabled: enabled && provider === 'agy' && !!accountId,
staleTime: 60000, // Cache for 1 minute
refetchInterval: 300000, // Refresh every 5 minutes
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
refetchInterval: 60000, // Refresh every 1 minute
retry: 1,
});
}
+62
View File
@@ -54,3 +54,65 @@ export function getProviderColor(provider: string): string {
const normalized = provider.toLowerCase();
return PROVIDER_COLORS[normalized] || getModelColor(provider);
}
/**
* Sort models with Claude models first, then alphabetically
* Prioritizes: Claude > Gemini > GPT > Other (alphabetically)
*/
export function sortModelsByPriority<T extends { name: string; displayName?: string }>(
models: T[]
): T[] {
const getPriority = (model: T): number => {
const name = (model.displayName || model.name).toLowerCase();
if (name.includes('claude')) return 0;
if (name.includes('gemini')) return 1;
if (name.includes('gpt')) return 2;
return 3;
};
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 as relative time (e.g., "in 2h 30m")
*/
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));
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
);
}