fix(quota,error-logs): match CLIProxyAPI headers and enhance error log display

Quota fetcher:
- loadCodeAssist headers now match antigravity.go exactly
- fetchAvailableModels uses empty body {} and correct User-Agent
- Prevents accounts being flagged for anomalous requests

Error logs:
- Extract status code from end of log (RESPONSE section)
- Display full model names instead of abbreviated
- Show status badges (500/429/4xx) with color coding
- Add provider icons with white background
This commit is contained in:
kaitranntt
2025-12-29 15:48:37 -05:00
parent 00597b3358
commit ac6f382f6a
9 changed files with 266 additions and 171 deletions
+19 -10
View File
@@ -54,11 +54,19 @@ const ANTIGRAVITY_CLIENT_ID =
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const ANTIGRAVITY_CLIENT_SECRET = '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';
/** API client headers */
const ANTIGRAVITY_HEADERS = {
/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */
const LOADCODEASSIST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.5 linux/amd64',
'X-Goog-Api-Client': 'gl-node/20.9.0',
'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 */
@@ -270,7 +278,7 @@ async function getProjectId(
method: 'POST',
signal: controller.signal,
headers: {
...ANTIGRAVITY_HEADERS,
...LOADCODEASSIST_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
@@ -326,24 +334,25 @@ async function getProjectId(
/**
* 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> {
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: {
...ANTIGRAVITY_HEADERS,
...FETCHMODELS_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
project: projectId,
}),
body: JSON.stringify({}),
});
clearTimeout(timeoutId);
+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 */
+56 -6
View File
@@ -25,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
*/
@@ -214,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 });
}
@@ -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>
@@ -21,132 +21,134 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
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>
{/* 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>
{/* 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>
)}
{/* 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>
{/* 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>
)}
{/* 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>
{/* 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>
<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>
{/* 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>
{/* 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" />
)}
{/* Actionable suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div
className={cn(
'leading-relaxed',
'flex items-start gap-3 p-3 rounded text-xs',
parsed.errorType === 'rate_limit'
? 'text-amber-600 dark:text-amber-400'
? 'bg-amber-500/10 border border-amber-500/20'
: parsed.errorType === 'auth'
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400'
? 'bg-red-500/10 border border-red-500/20'
: 'bg-blue-500/10 border border-blue-500/20'
)}
>
{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 === '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" />
)}
{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
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>
)}
</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 {
+4
View File
@@ -139,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;
}
/**
+8 -3
View File
@@ -62,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, '/');
}
@@ -122,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;