Merge pull request #223 from kaitranntt/dev

feat(cliproxy): account quota integration with enhanced error log display
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-29 12:52:10 -08:00
committed by GitHub
21 changed files with 1560 additions and 325 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.9.0",
"version": "7.9.0-dev.1",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+4
View File
@@ -112,6 +112,10 @@ export {
export type { CliproxyStats } from './stats-fetcher';
export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher';
// Quota fetcher
export type { ModelQuota, QuotaResult } from './quota-fetcher';
export { fetchAccountQuota } from './quota-fetcher';
// OpenAI compatibility layer
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
export {
+525
View File
@@ -0,0 +1,525 @@
/**
* Quota Fetcher for Antigravity Accounts
*
* Fetches quota information from Google Cloud Code internal API.
* Used for displaying remaining quota percentages and reset times.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
/** Individual model quota info */
export interface ModelQuota {
/** Model name, e.g., "gemini-3-pro-high" */
name: string;
/** Display name from API, e.g., "Gemini 3 Pro" */
displayName?: string;
/** Remaining quota as percentage (0-100) */
percentage: number;
/** ISO timestamp when quota resets, null if unknown */
resetTime: string | null;
}
/** Quota fetch result */
export interface QuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Quota for each available model */
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** True if account lacks quota access (403) */
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;
/** True if account hasn't been activated in official Antigravity app */
isUnprovisioned?: boolean;
}
/** Google Cloud Code API endpoints */
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
const ANTIGRAVITY_API_VERSION = 'v1internal';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */
const ANTIGRAVITY_CLIENT_ID =
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const ANTIGRAVITY_CLIENT_SECRET = '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';
/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */
const LOADCODEASSIST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
'Client-Metadata':
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
};
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
const FETCHMODELS_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.104.0 darwin/arm64',
};
/** Auth file structure */
interface AntigravityAuthFile {
access_token: string;
refresh_token?: string;
email?: string;
expired?: string;
expires_in?: number;
timestamp?: number;
type?: string;
project_id?: string;
}
/** Auth data returned from file */
interface AuthData {
accessToken: string;
refreshToken: string | null;
projectId: string | null;
isExpired: boolean;
expiresAt: string | null;
}
/** Token refresh response */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
/** loadCodeAssist response */
interface LoadCodeAssistResponse {
cloudaicompanionProject?: string | { id?: string };
}
/** fetchAvailableModels response model */
interface AvailableModel {
name?: string;
displayName?: string;
quotaInfo?: {
remainingFraction?: number;
remaining_fraction?: number;
remaining?: number;
resetTime?: string;
reset_time?: string;
};
quota_info?: {
remainingFraction?: number;
remaining_fraction?: number;
remaining?: number;
resetTime?: string;
reset_time?: string;
};
}
/** fetchAvailableModels response */
interface FetchAvailableModelsResponse {
models?: Record<string, AvailableModel>;
}
/**
* Sanitize email to match CLIProxyAPI auth file naming convention
* Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName)
*/
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;
}
}
/**
* Refresh access token using refresh_token via Google OAuth
* This allows CCS to get fresh tokens independently of CLIProxyAPI
*/
async function refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string | null; error?: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
}).toString(),
});
clearTimeout(timeoutId);
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
return {
accessToken: null,
error: data.error_description || data.error || `OAuth error: ${response.status}`,
};
}
if (!data.access_token) {
return { accessToken: null, error: 'No access_token in response' };
}
return { accessToken: data.access_token };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { accessToken: null, error: 'Token refresh timeout' };
}
return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* 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
if (!fs.existsSync(authDir)) {
return null;
}
// 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,
refreshToken: data.refresh_token || null,
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')) {
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,
refreshToken: data.refresh_token || null,
projectId: data.project_id || null,
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
}
} catch {
continue;
}
}
}
return null;
}
/**
* Get project ID via loadCodeAssist endpoint
*/
async function getProjectId(
accessToken: string
): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
...LOADCODEASSIST_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
// 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;
// Extract project ID from response
let projectId: string | undefined;
if (typeof data.cloudaicompanionProject === 'string') {
projectId = data.cloudaicompanionProject;
} else if (typeof data.cloudaicompanionProject === 'object') {
projectId = data.cloudaicompanionProject?.id;
}
if (!projectId?.trim()) {
// Account authenticated but not provisioned - user needs to sign in via Antigravity app
return {
projectId: null,
error: 'Sign in to Antigravity app to activate quota.',
isUnprovisioned: true,
};
}
return { projectId: projectId.trim() };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { projectId: null, error: 'Request timeout' };
}
return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Fetch available models with quota info
* Note: projectId is kept for potential future use but not sent in body
* (CLIProxyAPI sends empty {} body for this endpoint)
*/
async function fetchAvailableModels(accessToken: string, _projectId: string): Promise<QuotaResult> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
// Match CLIProxyAPI exactly: empty body, minimal headers
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
...FETCHMODELS_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({}),
});
clearTimeout(timeoutId);
if (response.status === 403) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
isForbidden: true,
error: 'Quota access forbidden for this account',
};
}
if (response.status === 401) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Access token expired or invalid',
};
}
if (!response.ok) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: `API error: ${response.status}`,
};
}
const data = (await response.json()) as FetchAvailableModelsResponse;
const models: ModelQuota[] = [];
if (data.models && typeof data.models === 'object') {
for (const [modelId, modelData] of Object.entries(data.models)) {
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
if (!quotaInfo) continue;
// Extract remaining fraction (0-1 range)
const remaining =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
// Skip invalid values (NaN, Infinity, non-numbers)
if (typeof remaining !== 'number' || !isFinite(remaining)) continue;
// Convert to percentage (0-100) and clamp to valid range
const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
// Extract reset time
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
models.push({
name: modelId,
displayName: modelData.displayName,
percentage,
resetTime,
});
}
}
return {
success: true,
models,
lastUpdated: Date.now(),
};
} catch (err) {
clearTimeout(timeoutId);
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
};
}
}
/**
* Fetch quota for an Antigravity account
*
* @param provider - Provider name (only 'agy' supported)
* @param accountId - Account identifier (email)
* @returns Quota result with models and percentages
*/
export async function fetchAccountQuota(
provider: CLIProxyProvider,
accountId: string
): Promise<QuotaResult> {
// Only Antigravity supports quota fetching
if (provider !== 'agy') {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: `Quota not supported for provider: ${provider}`,
};
}
// Read auth data from auth file
const authData = readAuthData(provider, accountId);
if (!authData) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Auth file not found for account',
};
}
// Determine which access token to use
// File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist)
// Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior)
let accessToken = authData.accessToken;
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes
if (authData.refreshToken) {
const shouldRefresh =
authData.isExpired || // Already expired
!authData.expiresAt || // No expiry info - refresh to be safe
new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon
if (shouldRefresh) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
}
// If refresh fails, fall back to existing token (might still work)
}
}
// Get project ID - prefer stored value, fallback to API call
let projectId = authData.projectId;
if (!projectId) {
let lastProjectResult = await getProjectId(accessToken);
if (!lastProjectResult.projectId) {
// If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
lastProjectResult = await getProjectId(accessToken);
}
}
if (!lastProjectResult.projectId) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: lastProjectResult.error || 'Failed to retrieve project ID',
isUnprovisioned: lastProjectResult.isUnprovisioned,
};
}
}
projectId = lastProjectResult.projectId;
}
// Fetch models with quota
const result = await fetchAvailableModels(accessToken, projectId);
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken);
if (refreshResult.accessToken) {
return fetchAvailableModels(refreshResult.accessToken, projectId);
}
}
return result;
}
+4
View File
@@ -297,6 +297,10 @@ export interface CliproxyErrorLog {
modified: number;
/** Absolute path to the log file (injected by backend) */
absolutePath?: string;
/** HTTP status code extracted from log (injected by backend) */
statusCode?: number;
/** Model name extracted from request body (injected by backend) */
model?: string;
}
/** Response from /v0/management/request-error-logs endpoint */
+104 -6
View File
@@ -12,6 +12,8 @@ import {
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../../cliproxy/stats-fetcher';
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
import {
getCliproxyWritablePath,
getCliproxyConfigPath,
@@ -23,6 +25,48 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
const router = Router();
/**
* Extract status code and model from error log file (lightweight parsing)
* Reads first 4KB for model, last 2KB for status code
*/
async function extractErrorLogMetadata(
filePath: string
): Promise<{ statusCode?: number; model?: string }> {
try {
const fd = fs.openSync(filePath, 'r');
const stat = fs.fstatSync(fd);
const fileSize = stat.size;
// Read first 4KB for model (in request body)
const startBuffer = Buffer.alloc(Math.min(4096, fileSize));
fs.readSync(fd, startBuffer, 0, startBuffer.length, 0);
const startContent = startBuffer.toString('utf-8');
// Extract model from request body JSON: "model":"gemini-3-flash-preview"
const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/);
const model = modelMatch ? modelMatch[1] : undefined;
// Read last 2KB for status code (in response section at end)
let statusCode: number | undefined;
if (fileSize > 2048) {
const endBuffer = Buffer.alloc(2048);
fs.readSync(fd, endBuffer, 0, 2048, fileSize - 2048);
const endContent = endBuffer.toString('utf-8');
const statusMatch = endContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
} else {
// Small file - check start content for status
const statusMatch = startContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
}
fs.closeSync(fd);
return { statusCode, model };
} catch {
return {};
}
}
/**
* Shared handler for stats/usage endpoint
*/
@@ -212,14 +256,22 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise<void> =>
return;
}
// Inject absolute paths into each file entry
// Inject absolute paths and extract metadata from each file
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
const filesWithPaths = files.map((file) => ({
...file,
absolutePath: path.join(logsDir, file.name),
}));
const filesWithMetadata = await Promise.all(
files.map(async (file) => {
const absolutePath = path.join(logsDir, file.name);
const metadata = await extractErrorLogMetadata(absolutePath);
return {
...file,
absolutePath,
statusCode: metadata.statusCode,
model: metadata.model,
};
})
);
res.json({ files: filesWithPaths });
res.json({ files: filesWithMetadata });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -430,4 +482,50 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
}
});
// ==================== Account Quota ====================
/**
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account
* Returns: QuotaResult with model quotas and reset times
*/
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
const { provider, accountId } = req.params;
// Validate provider
const validProviders: CLIProxyProvider[] = [
'agy',
'gemini',
'codex',
'qwen',
'iflow',
'kiro',
'ghcp',
];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({
error: 'Invalid provider',
message: `Provider must be one of: ${validProviders.join(', ')}`,
});
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
@@ -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',
@@ -6,8 +6,9 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw, Clock } from 'lucide-react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -19,7 +20,7 @@ interface CredentialRowProps {
status: CredentialStatus;
statusMessage: string;
email?: string;
expiresAt?: string;
lastUsedAt?: string;
onRefresh?: () => void;
privacyMode?: boolean;
}
@@ -30,7 +31,7 @@ function CredentialRow({
status,
statusMessage,
email,
expiresAt,
lastUsedAt,
onRefresh,
privacyMode,
}: CredentialRowProps) {
@@ -60,16 +61,23 @@ function CredentialRow({
const config = statusConfig[status];
const Icon = config.icon;
const formatExpiry = (date?: string) => {
if (!date) return 'Never';
const expiry = new Date(date);
const now = new Date();
const diff = expiry.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
if (hours < 1) return 'Soon';
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
const formatLastUsed = (date?: string) => {
if (!date) return 'Never used';
try {
const lastUsed = new Date(date);
const now = new Date();
const diff = now.getTime() - lastUsed.getTime();
if (diff < 0) return 'Just now';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
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 'Unknown';
}
};
return (
@@ -96,8 +104,9 @@ function CredentialRow({
>
{statusMessage}
</Badge>
<div className="text-xs text-muted-foreground mt-0.5">
Expires: {formatExpiry(expiresAt)}
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5 justify-end">
<Clock className="w-3 h-3" />
{formatLastUsed(lastUsedAt)}
</div>
</div>
{status === 'warning' && onRefresh && (
@@ -129,23 +138,28 @@ function CredentialHealthSkeleton() {
export function CredentialHealthList() {
const { data: authData, isLoading } = useCliproxyAuth();
const { data: stats } = useCliproxyStats(true);
const { privacyMode } = usePrivacy();
if (isLoading) {
return <CredentialHealthSkeleton />;
}
// Flatten accounts from all providers
// Flatten accounts from all providers with runtime lastUsedAt
const credentials =
authData?.authStatus.flatMap((status) =>
(status.accounts ?? []).map((account) => ({
name: account.id,
provider: status.provider,
status: (account as { status?: CredentialStatus }).status ?? 'ready',
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
email: account.email,
expiresAt: (account as { expiresAt?: string }).expiresAt,
}))
(status.accounts ?? []).map((account) => {
const accountKey = account.email || account.id;
const runtimeLastUsed = stats?.accountStats?.[accountKey]?.lastUsedAt;
return {
name: account.id,
provider: status.provider,
status: (account as { status?: CredentialStatus }).status ?? 'ready',
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
email: account.email,
lastUsedAt: runtimeLastUsed || account.lastUsedAt,
};
})
) ?? [];
if (credentials.length === 0) {
@@ -1,88 +1,249 @@
/**
* Account Item Component
* Displays a single OAuth account with actions
* Displays a single OAuth account with actions and quota bar
*/
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { User, Star, MoreHorizontal, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
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, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import type { AccountItemProps } from './types';
/**
* Get color class based on quota percentage
*/
function getQuotaColor(percentage: number): string {
if (percentage <= 20) return 'bg-destructive';
if (percentage <= 50) return 'bg-yellow-500';
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,
onRemove,
isRemoving,
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,
account.id,
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
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
: null;
// Get earliest reset time
const nextReset =
quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null;
return (
<div
className={cn(
'flex items-center justify-between p-3 rounded-lg border transition-colors',
'flex flex-col gap-2 p-3 rounded-lg border transition-colors',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
{account.email || account.id}
</span>
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
{account.email || account.id}
</span>
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
</div>
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Quota bar - only for 'agy' provider */}
{showQuota && account.provider === 'agy' && (
<div className="pl-11">
{quotaLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Loading quota...</span>
</div>
) : avgQuota !== null ? (
<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}
</div>
)}
</div>
);
}
@@ -17,6 +17,8 @@ interface AccountsSectionProps {
onRemoveAccount: (accountId: string) => void;
isRemovingAccount?: boolean;
privacyMode?: boolean;
/** Show quota bars for accounts (only applicable for 'agy' provider) */
showQuota?: boolean;
/** Kiro-specific: show "use normal browser" toggle */
isKiro?: boolean;
kiroNoIncognito?: boolean;
@@ -31,6 +33,7 @@ export function AccountsSection({
onRemoveAccount,
isRemovingAccount,
privacyMode,
showQuota,
isKiro,
kiroNoIncognito,
onKiroNoIncognitoChange,
@@ -64,6 +67,7 @@ export function AccountsSection({
onRemove={() => onRemoveAccount(account.id)}
isRemoving={isRemovingAccount}
privacyMode={privacyMode}
showQuota={showQuota}
/>
))}
</div>
@@ -133,6 +133,7 @@ export function ModelConfigTab({
onRemoveAccount={onRemoveAccount}
isRemovingAccount={isRemovingAccount}
privacyMode={privacyMode}
showQuota={provider === 'agy'}
isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito}
@@ -39,6 +39,8 @@ export interface AccountItemProps {
onRemove: () => void;
isRemoving?: boolean;
privacyMode?: boolean;
/** Show quota bar (only for 'agy' provider) */
showQuota?: boolean;
}
export interface ModelMappingValues {
@@ -1,71 +1,86 @@
/**
* Error Log Item Component
* Individual log entry in the list view
* Individual log entry in the list view - shows status code, model, endpoint, time
*/
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Clock, FileText } from 'lucide-react';
import { Clock } from 'lucide-react';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser';
import { parseFilename, formatRelativeTime } from '@/lib/error-log-parser';
import type { ErrorLogItemProps } from './types';
export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
/** Get status badge color based on HTTP status code */
function getStatusColor(code?: number): string {
if (!code) return 'bg-gray-100 text-gray-600';
if (code === 429) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
if (code >= 500) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
if (code >= 400)
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
return 'bg-gray-100 text-gray-600';
}
/** Format model name for display (full name) */
function formatModel(model?: string): string {
if (!model) return '';
return model;
}
export function ErrorLogItem({
name,
size: _size,
modified,
isSelected,
onClick,
statusCode,
model,
}: ErrorLogItemProps) {
const parsed = useMemo(() => parseFilename(name), [name]);
const displayModel = useMemo(() => formatModel(model), [model]);
return (
<button
onClick={onClick}
className={cn(
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
'w-full px-2.5 py-2 flex items-center gap-2 text-left transition-colors',
'hover:bg-muted/40 border-l-[3px]',
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
isSelected ? 'bg-muted/50 border-l-red-500' : 'border-l-transparent'
)}
>
{/* Status Badge - prominent */}
<span
className={cn(
'shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded min-w-[32px] text-center',
getStatusColor(statusCode)
)}
>
{statusCode || '???'}
</span>
{/* Provider Icon */}
<ProviderIcon
provider={parsed.provider}
size={24}
size={18}
withBackground={true}
className="shrink-0 mt-0.5"
className="shrink-0"
/>
<div className="flex-1 min-w-0 space-y-1">
{/* Provider / Endpoint */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-foreground truncate">
{parsed.provider}
</span>
<span
className={cn(
'text-[9px] px-1 rounded border',
isSelected
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
: 'bg-muted border-border text-muted-foreground'
)}
>
LOG
</span>
</div>
<span
className="text-[11px] text-muted-foreground truncate font-medium"
title={parsed.endpoint}
>
{parsed.endpoint}
</span>
</div>
{/* Meta row: time + size */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatRelativeTime(modified)}
</span>
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{formatBytes(size)}
{/* Content */}
<div className="flex-1 min-w-0 flex flex-col">
{/* Row 1: Endpoint */}
<span className="text-[11px] font-medium text-foreground truncate">
{parsed.endpoint || 'unknown'}
</span>
{/* Row 2: Model (full name) */}
{displayModel && (
<span className="text-[10px] text-muted-foreground truncate" title={displayModel}>
{displayModel}
</span>
)}
{/* Row 3: Time */}
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/60">
<Clock className="w-2.5 h-2.5" />
<span>{formatRelativeTime(modified)}</span>
</div>
</div>
</button>
@@ -168,6 +168,8 @@ export function ErrorLogsMonitor() {
modified={log.modified}
isSelected={effectiveSelection === log.name}
onClick={() => setSelectedLog(log.name)}
statusCode={log.statusCode}
model={log.model}
/>
))}
</div>
@@ -4,79 +4,151 @@
*/
import { ScrollArea } from '@/components/ui/scroll-area';
import { Info } from 'lucide-react';
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser';
import {
getErrorTypeLabel,
formatQuotaResetDelay,
formatQuotaResetTimestamp,
type ParsedErrorLog,
} from '@/lib/error-log-parser';
import { StatusBadge } from './ui-primitives';
/** Overview tab content */
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
const quotaResetDisplay =
formatQuotaResetDelay(parsed.quotaResetDelay) ||
formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
return (
<div className="p-4 space-y-4">
{/* Status row */}
<div className="flex items-center gap-3">
<StatusBadge code={parsed.statusCode} />
<span className="text-sm font-medium">{parsed.statusText}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
{getErrorTypeLabel(parsed.errorType)}
</span>
</div>
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Status row */}
<div className="flex items-center gap-3">
<StatusBadge code={parsed.statusCode} />
<span className="text-sm font-medium">{parsed.statusText}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
{getErrorTypeLabel(parsed.errorType)}
</span>
</div>
{/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div>
<div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'}
{/* Model info - prominent display */}
{parsed.model && (
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20">
<Cpu className="w-4 h-4 text-violet-500 shrink-0" />
<div className="text-sm">
<span className="text-muted-foreground">Model: </span>
<span className="font-semibold text-violet-600 dark:text-violet-400">
{parsed.model}
</span>
</div>
</div>
)}
{/* Quota reset info for 429 errors */}
{parsed.errorType === 'rate_limit' && quotaResetDisplay && (
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<Clock className="w-4 h-4 text-amber-500 shrink-0" />
<div className="text-sm">
<span className="text-muted-foreground">Quota resets in </span>
<span className="font-semibold text-amber-600 dark:text-amber-400">
{quotaResetDisplay}
</span>
</div>
</div>
)}
{/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div>
<div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'}
</div>
</div>
</div>
</div>
{/* URL */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'}
</div>
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
</div>
{/* Suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
<div className="text-blue-500/90 leading-relaxed">
{parsed.errorType === 'rate_limit' &&
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
{parsed.errorType === 'auth' &&
'Authentication failed. Check credentials or re-authenticate with the provider.'}
{parsed.errorType === 'not_found' &&
'Endpoint not found. This endpoint may not exist on this provider.'}
{parsed.errorType === 'server' &&
'Server error from upstream. Retry or check provider status.'}
{parsed.errorType === 'timeout' &&
'Request timed out. Check network or increase timeout settings.'}
{/* URL */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'}
</div>
</div>
)}
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
</div>
{/* Actionable suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div
className={cn(
'flex items-start gap-3 p-3 rounded text-xs',
parsed.errorType === 'rate_limit'
? 'bg-amber-500/10 border border-amber-500/20'
: parsed.errorType === 'auth'
? 'bg-red-500/10 border border-red-500/20'
: 'bg-blue-500/10 border border-blue-500/20'
)}
>
{parsed.errorType === 'rate_limit' ? (
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
) : parsed.errorType === 'auth' ? (
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-500 shrink-0" />
) : (
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
)}
<div
className={cn(
'leading-relaxed',
parsed.errorType === 'rate_limit'
? 'text-amber-600 dark:text-amber-400'
: parsed.errorType === 'auth'
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400'
)}
>
{parsed.errorType === 'rate_limit' && (
<>
<strong>Rate Limited.</strong> Switch to a different account or wait for quota
reset.
{parsed.model && (
<>
{' '}
Model <code className="font-mono text-[11px]">{parsed.model}</code> has
exhausted quota.
</>
)}
</>
)}
{parsed.errorType === 'auth' &&
'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'}
{parsed.errorType === 'not_found' &&
'Endpoint not found. This endpoint may not exist on this provider.'}
{parsed.errorType === 'server' &&
'Server error from upstream. Retry later or check provider status page.'}
{parsed.errorType === 'timeout' &&
'Request timed out. Check network connection or increase timeout settings.'}
</div>
</div>
)}
</div>
</ScrollArea>
);
}
@@ -10,6 +10,10 @@ export interface ErrorLogItemProps {
modified: number;
isSelected: boolean;
onClick: () => void;
/** HTTP status code from pre-parsed metadata */
statusCode?: number;
/** Model name from pre-parsed metadata */
model?: string;
}
export interface LogContentPanelProps {
+40
View File
@@ -0,0 +1,40 @@
/**
* Progress Component
* Simple progress bar with customizable indicator color
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
max?: number;
indicatorClassName?: string;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, indicatorClassName, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<div
className={cn('h-full transition-all', indicatorClassName || 'bg-primary')}
style={{ width: `${percentage}%` }}
/>
</div>
);
}
);
Progress.displayName = 'Progress';
export { Progress };
+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(
() => ({
+41
View File
@@ -3,6 +3,7 @@
*/
import { useQuery } from '@tanstack/react-query';
import type { ModelQuota, QuotaResult } from '@/lib/api-client';
/** Per-account usage statistics */
export interface AccountUsageStats {
@@ -138,6 +139,10 @@ export interface CliproxyErrorLog {
modified: number;
/** Absolute path to the log file (injected by backend) */
absolutePath?: string;
/** HTTP status code extracted from log (injected by backend) */
statusCode?: number;
/** Model name extracted from request body (injected by backend) */
model?: string;
}
/**
@@ -189,3 +194,39 @@ export function useCliproxyErrorLogContent(name: string | null) {
staleTime: 60000, // Cache log content for 1 minute
});
}
// Re-export for consumers
export type { ModelQuota, QuotaResult };
/**
* Fetch account quota from API
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch quota';
try {
const error = await response.json();
message = error.message || message;
} catch {
// Use default message if response isn't JSON
}
throw new Error(message);
}
return response.json();
}
/**
* Hook to get account quota
* Only enabled for 'agy' provider (Antigravity) as it's the only one supporting quota
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({
queryKey: ['account-quota', provider, accountId],
queryFn: () => fetchAccountQuota(provider, accountId),
enabled: enabled && provider === 'agy' && !!accountId,
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
refetchInterval: 60000, // Refresh every 1 minute
retry: 1,
});
}
+32
View File
@@ -109,6 +109,32 @@ export interface CliproxyModelsResponse {
totalCount: number;
}
/** Individual model quota info from Google Cloud Code API */
export interface ModelQuota {
/** Model name, e.g., "gemini-3-pro-high" */
name: string;
/** Display name from API, e.g., "Gemini 3 Pro" */
displayName?: string;
/** Remaining quota as percentage (0-100) */
percentage: number;
/** ISO timestamp when quota resets, null if unknown */
resetTime: string | null;
}
/** Quota fetch result */
export interface QuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Quota for each available model */
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
}
/** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
@@ -414,4 +440,10 @@ export const api = {
body: JSON.stringify(params),
}),
},
/** Account quota API */
quota: {
/** Fetch quota for a specific account */
get: (provider: string, accountId: string) =>
request<QuotaResult>(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`),
},
};
+167 -5
View File
@@ -29,6 +29,11 @@ export interface ParsedErrorLog {
isClientError: boolean;
isServerError: boolean;
errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown';
// Extracted from request/response bodies
model: string | null;
quotaResetDelay: number | null; // seconds until reset
quotaResetTimestamp: string | null; // ISO timestamp when quota resets
}
/** Parsed filename metadata */
@@ -57,9 +62,10 @@ export function parseFilename(name: string): ParsedFilename {
result.provider = providerMatch[1];
}
// Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp}
// Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-...
const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i);
// Extract endpoint: after provider, before timestamp
// Format: error-api-provider-{provider}-{endpoint}-{timestamp}-{id}.log
// Example: error-api-provider-agy-v1-messages-2025-12-29T105823-a12b73f8.log
const endpointMatch = name.match(/error-api-provider-[^-]+-(.+?)-\d{4}-\d{2}-\d{2}T/);
if (endpointMatch) {
result.endpoint = endpointMatch[1].replace(/-/g, '/');
}
@@ -96,6 +102,9 @@ export function parseErrorLog(content: string): ParsedErrorLog {
isClientError: false,
isServerError: false,
errorType: 'unknown',
model: null,
quotaResetDelay: null,
quotaResetTimestamp: null,
};
// Split into sections
@@ -114,6 +123,10 @@ export function parseErrorLog(content: string): ParsedErrorLog {
} else if (part === 'REQUEST BODY') {
currentSection = 'request_body';
continue;
} else if (part === 'API RESPONSE') {
// Skip API RESPONSE section - we parse the actual RESPONSE section instead
currentSection = '';
continue;
} else if (part === 'RESPONSE') {
currentSection = 'response';
continue;
@@ -229,8 +242,9 @@ function computeDerivedFields(result: ParsedErrorLog): void {
result.provider = providerMatch[1];
}
// Extract endpoint from URL
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/);
// Extract endpoint from URL: /api/provider/{provider}/{version}/{endpoint}
// e.g., /api/provider/agy/v1/messages?beta=true → v1/messages
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/(.+?)(?:\?|$)/);
if (endpointMatch) {
result.endpoint = endpointMatch[1];
}
@@ -251,6 +265,113 @@ function computeDerivedFields(result: ParsedErrorLog): void {
} else if (result.statusCode === 408 || result.statusCode === 504) {
result.errorType = 'timeout';
}
// Extract model from request body
extractModelFromRequestBody(result);
// Extract quota reset info from response body (for 429 errors)
if (result.statusCode === 429) {
extractQuotaResetInfo(result);
}
}
/** Extract model name from request body JSON */
function extractModelFromRequestBody(result: ParsedErrorLog): void {
if (!result.requestBody) return;
try {
const body = JSON.parse(result.requestBody);
if (typeof body.model === 'string') {
result.model = body.model;
}
} catch {
// Not valid JSON, skip
}
}
/** Extract quota reset info from 429 response body */
function extractQuotaResetInfo(result: ParsedErrorLog): void {
if (!result.responseBody) return;
try {
const body = JSON.parse(result.responseBody);
// Look for quotaResetDelay in various response formats
// Format 1: { error: { details: [{ quotaResetDelay: "123s" }] } }
// Format 2: { error: { quotaResetDelay: 123 } }
// Format 3: { quotaResetDelay: 123, quotaResetTimeStamp: "..." }
const delay = findQuotaResetDelay(body);
if (delay !== null) {
result.quotaResetDelay = delay;
}
const timestamp = findQuotaResetTimestamp(body);
if (timestamp) {
result.quotaResetTimestamp = timestamp;
}
} catch {
// Not valid JSON, skip
}
}
/** Recursively find quotaResetDelay in response object */
function findQuotaResetDelay(obj: unknown): number | null {
if (typeof obj !== 'object' || obj === null) return null;
const record = obj as Record<string, unknown>;
// Check direct properties
if ('quotaResetDelay' in record) {
const val = record.quotaResetDelay;
if (typeof val === 'number') return val;
if (typeof val === 'string') {
// Handle "123s" format
const match = val.match(/^(\d+)s?$/);
if (match) return parseInt(match[1], 10);
}
}
// Check nested error object
if ('error' in record && typeof record.error === 'object') {
const found = findQuotaResetDelay(record.error);
if (found !== null) return found;
}
// Check details array
if ('details' in record && Array.isArray(record.details)) {
for (const detail of record.details) {
const found = findQuotaResetDelay(detail);
if (found !== null) return found;
}
}
return null;
}
/** Recursively find quotaResetTimeStamp in response object */
function findQuotaResetTimestamp(obj: unknown): string | null {
if (typeof obj !== 'object' || obj === null) return null;
const record = obj as Record<string, unknown>;
// Check direct properties (various casing)
for (const key of ['quotaResetTimeStamp', 'quotaResetTimestamp', 'resetTime', 'reset_time']) {
if (key in record && typeof record[key] === 'string') {
return record[key] as string;
}
}
// Check nested error object
if ('error' in record && typeof record.error === 'object') {
const found = findQuotaResetTimestamp(record.error);
if (found) return found;
}
// Check details array
if ('details' in record && Array.isArray(record.details)) {
for (const detail of record.details) {
const found = findQuotaResetTimestamp(detail);
if (found) return found;
}
}
return null;
}
/** Get status text for common codes */
@@ -326,3 +447,44 @@ export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
};
return labels[type] || 'Error';
}
/**
* Format quota reset delay as human-readable string
*/
export function formatQuotaResetDelay(seconds: number | null): string | null {
if (seconds === null || seconds <= 0) return null;
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
/**
* Format quota reset timestamp as relative time
*/
export function formatQuotaResetTimestamp(timestamp: string | null): string | null {
if (!timestamp) return null;
try {
const resetDate = new Date(timestamp);
const now = new Date();
const diff = resetDate.getTime() - now.getTime();
if (diff <= 0) return 'now';
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} catch {
return null;
}
}
+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
);
}