From a94c3d66004ba0921835bddd8ca5c168868e72d5 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 11 Dec 2025 02:12:39 -0500 Subject: [PATCH] feat(cliproxy): add stats fetcher and OpenAI-compatible model manager - Add stats-fetcher.ts for CLIProxy analytics integration - Add openai-compat-manager.ts for model discovery and listing - Add CLIProxy stats routes to web-server for real-time metrics - Create cliproxy-stats-card component for analytics dashboard - Add use-cliproxy-stats hook for data fetching - Extend doctor.ts with CLIProxy health checks - Update analytics page with CLIProxy usage metrics --- src/cliproxy/config-generator.ts | 105 ++++++++- src/cliproxy/index.ts | 19 ++ src/cliproxy/openai-compat-manager.ts | 223 ++++++++++++++++++ src/cliproxy/stats-fetcher.ts | 113 +++++++++ src/management/doctor.ts | 34 ++- src/web-server/routes.ts | 190 +++++++++++++++ .../analytics/cliproxy-stats-card.tsx | 208 ++++++++++++++++ .../analytics/usage-trend-chart.tsx | 8 +- ui/src/hooks/use-cliproxy-stats.ts | 74 ++++++ ui/src/pages/analytics.tsx | 156 ++++++++++-- 10 files changed, 1092 insertions(+), 38 deletions(-) create mode 100644 src/cliproxy/openai-compat-manager.ts create mode 100644 src/cliproxy/stats-fetcher.ts create mode 100644 ui/src/components/analytics/cliproxy-stats-card.tsx create mode 100644 ui/src/hooks/use-cliproxy-stats.ts diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index c267bc29..22491cc4 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -25,6 +25,13 @@ export const CLIPROXY_DEFAULT_PORT = 8317; /** Internal API key for CCS-managed requests */ const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; +/** + * Config version - bump when config format changes to trigger regeneration + * v1: Initial config (port, auth-dir, api-keys only) + * v2: Enhanced config (quota-exceeded, request-retry, usage-statistics) + */ +export const CLIPROXY_CONFIG_VERSION = 2; + /** Provider display names (static metadata) */ const PROVIDER_DISPLAY_NAMES: Record = { gemini: 'Gemini', @@ -112,26 +119,34 @@ export function getBinDir(): string { function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories - // Unified config with all providers - const config = `# CLIProxyAPI unified config generated by CCS -# Supports: gemini, codex, agy, qwen (concurrent usage) + // Unified config with enhanced CLIProxyAPI features + const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} +# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) # Generated: ${new Date().toISOString()} +# DO NOT EDIT - regenerated by CCS. Use 'ccs doctor' to update. port: ${port} debug: false logging-to-file: false -usage-statistics-enabled: false + +# Usage statistics for dashboard analytics +usage-statistics-enabled: true + +# Auto-retry on transient errors (403, 408, 500, 502, 503, 504) +request-retry: 3 +max-retry-interval: 30 + +# Auto-switch accounts on quota exceeded (429) - killer feature for multi-account +quota-exceeded: + switch-project: true + switch-preview-model: true # CCS internal authentication api-keys: - "${CCS_INTERNAL_API_KEY}" # OAuth tokens stored in auth/ directory -# CLIProxyAPI auto-discovers auth files in subdirectories auth-dir: "${authDir.replace(/\\/g, '/')}" - -# All providers configured - routes by model name -# No provider-specific sections needed - OAuth auth files provide credentials `; return config; @@ -162,6 +177,80 @@ export function generateConfig( return configPath; } +/** + * Force regenerate config.yaml with latest settings + * Deletes existing config and creates fresh one with current port + * @returns Path to new config file + */ +export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { + const configPath = getConfigPath(); + + // Read existing port if config exists (preserve user's port choice) + let effectivePort = port; + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const portMatch = content.match(/^port:\s*(\d+)/m); + if (portMatch) { + effectivePort = parseInt(portMatch[1], 10); + } + } catch { + // Use default port if reading fails + } + // Delete existing config + fs.unlinkSync(configPath); + } + + // Ensure directories exist + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); + + // Generate fresh config + const configContent = generateUnifiedConfigContent(effectivePort); + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + + return configPath; +} + +/** + * Check if config needs regeneration (version mismatch or missing required fields) + * @returns true if config should be regenerated + */ +export function configNeedsRegeneration(): boolean { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return false; // Will be created on first use + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + + // Check for version marker + const versionMatch = content.match(/CCS v(\d+)/); + if (!versionMatch) { + return true; // No version marker = old config + } + + const configVersion = parseInt(versionMatch[1], 10); + if (configVersion < CLIPROXY_CONFIG_VERSION) { + return true; // Outdated version + } + + // Check for required fields (v2 features) + const hasQuotaExceeded = content.includes('quota-exceeded:'); + const hasRequestRetry = content.includes('request-retry:'); + const hasUsageStats = content.includes('usage-statistics-enabled: true'); + + if (!hasQuotaExceeded || !hasRequestRetry || !hasUsageStats) { + return true; // Missing required fields + } + + return false; + } catch { + return true; // Error reading = regenerate + } +} + /** * Check if config exists for provider */ diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index f7f855e5..1231c2b4 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -48,6 +48,8 @@ export { // Config generation export { generateConfig, + regenerateConfig, + configNeedsRegeneration, getClaudeEnvVars, getEffectiveEnvVars, getProviderSettingsPath, @@ -62,6 +64,7 @@ export { configExists, deleteConfig, CLIPROXY_DEFAULT_PORT, + CLIPROXY_CONFIG_VERSION, } from './config-generator'; // Base config loader (for reading config/base-*.settings.json) @@ -98,3 +101,19 @@ export { getProviderTokenDir, displayAuthStatus, } from './auth-handler'; + +// Stats fetcher +export type { ClipproxyStats } from './stats-fetcher'; +export { fetchClipproxyStats, isClipproxyRunning } from './stats-fetcher'; + +// OpenAI compatibility layer +export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager'; +export { + listOpenAICompatProviders, + getOpenAICompatProvider, + addOpenAICompatProvider, + updateOpenAICompatProvider, + removeOpenAICompatProvider, + OPENROUTER_TEMPLATE, + TOGETHER_TEMPLATE, +} from './openai-compat-manager'; diff --git a/src/cliproxy/openai-compat-manager.ts b/src/cliproxy/openai-compat-manager.ts new file mode 100644 index 00000000..5254a156 --- /dev/null +++ b/src/cliproxy/openai-compat-manager.ts @@ -0,0 +1,223 @@ +/** + * OpenAI Compatibility Layer Manager + * + * Manages OpenAI-compatible providers (OpenRouter, Together, etc.) + * in CLIProxyAPI's config.yaml. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { getConfigPath } from './config-generator'; + +/** Model alias configuration */ +export interface OpenAICompatModel { + name: string; // Upstream model name + alias: string; // Client-visible alias +} + +/** OpenAI-compatible provider configuration */ +export interface OpenAICompatProvider { + name: string; + baseUrl: string; + apiKey: string; + models: OpenAICompatModel[]; +} + +/** Config.yaml structure for parsing */ +interface ConfigYaml { + port?: number; + 'api-keys'?: string[]; + 'auth-dir'?: string; + 'openai-compatibility'?: Array<{ + name: string; + 'base-url': string; + headers?: Record; + 'api-key-entries': Array<{ + 'api-key': string; + 'proxy-url'?: string; + }>; + models?: Array<{ + name: string; + alias: string; + }>; + }>; + [key: string]: unknown; +} + +/** + * Load current config.yaml + */ +function loadConfig(): ConfigYaml { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + return (yaml.load(content) as ConfigYaml) || {}; + } catch { + return {}; + } +} + +/** + * Save config.yaml with proper formatting + */ +function saveConfig(config: ConfigYaml): void { + const configPath = getConfigPath(); + const content = yaml.dump(config, { + lineWidth: -1, // Disable line wrapping + quotingType: '"', + forceQuotes: false, + }); + + fs.writeFileSync(configPath, content, { mode: 0o600 }); +} + +/** + * List all configured OpenAI-compatible providers + */ +export function listOpenAICompatProviders(): OpenAICompatProvider[] { + const config = loadConfig(); + const providers = config['openai-compatibility'] || []; + + return providers.map((p) => ({ + name: p.name, + baseUrl: p['base-url'], + apiKey: p['api-key-entries']?.[0]?.['api-key'] || '', + models: (p.models || []).map((m) => ({ + name: m.name, + alias: m.alias, + })), + })); +} + +/** + * Get a specific provider by name + */ +export function getOpenAICompatProvider(name: string): OpenAICompatProvider | null { + const providers = listOpenAICompatProviders(); + return providers.find((p) => p.name === name) || null; +} + +/** + * Add a new OpenAI-compatible provider + * @throws Error if provider with same name already exists + */ +export function addOpenAICompatProvider(provider: OpenAICompatProvider): void { + const config = loadConfig(); + + // Initialize array if not exists + if (!config['openai-compatibility']) { + config['openai-compatibility'] = []; + } + + // Check for duplicate + const existing = config['openai-compatibility'].find((p) => p.name === provider.name); + if (existing) { + throw new Error(`Provider '${provider.name}' already exists`); + } + + // Add new provider + config['openai-compatibility'].push({ + name: provider.name, + 'base-url': provider.baseUrl, + 'api-key-entries': [{ 'api-key': provider.apiKey }], + models: provider.models.map((m) => ({ + name: m.name, + alias: m.alias, + })), + }); + + saveConfig(config); +} + +/** + * Update an existing provider + * @throws Error if provider doesn't exist + */ +export function updateOpenAICompatProvider( + name: string, + updates: Partial +): void { + const config = loadConfig(); + + if (!config['openai-compatibility']) { + throw new Error(`Provider '${name}' not found`); + } + + const index = config['openai-compatibility'].findIndex((p) => p.name === name); + if (index === -1) { + throw new Error(`Provider '${name}' not found`); + } + + const provider = config['openai-compatibility'][index]; + + // Apply updates + if (updates.baseUrl) { + provider['base-url'] = updates.baseUrl; + } + if (updates.apiKey) { + provider['api-key-entries'] = [{ 'api-key': updates.apiKey }]; + } + if (updates.models) { + provider.models = updates.models.map((m) => ({ + name: m.name, + alias: m.alias, + })); + } + if (updates.name && updates.name !== name) { + provider.name = updates.name; + } + + saveConfig(config); +} + +/** + * Remove a provider + * @returns true if removed, false if not found + */ +export function removeOpenAICompatProvider(name: string): boolean { + const config = loadConfig(); + + if (!config['openai-compatibility']) { + return false; + } + + const index = config['openai-compatibility'].findIndex((p) => p.name === name); + if (index === -1) { + return false; + } + + config['openai-compatibility'].splice(index, 1); + + // Remove empty array + if (config['openai-compatibility'].length === 0) { + delete config['openai-compatibility']; + } + + saveConfig(config); + return true; +} + +/** Pre-configured OpenRouter template */ +export const OPENROUTER_TEMPLATE: Omit = { + name: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + models: [ + { name: 'anthropic/claude-3.5-sonnet', alias: 'claude-sonnet' }, + { name: 'anthropic/claude-3-opus', alias: 'claude-opus' }, + { name: 'google/gemini-pro-1.5', alias: 'gemini-pro' }, + ], +}; + +/** Pre-configured Together template */ +export const TOGETHER_TEMPLATE: Omit = { + name: 'together', + baseUrl: 'https://api.together.xyz/v1', + models: [ + { name: 'meta-llama/Llama-3-70b-chat-hf', alias: 'llama-70b' }, + { name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', alias: 'mixtral' }, + ], +}; diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts new file mode 100644 index 00000000..a442ccd4 --- /dev/null +++ b/src/cliproxy/stats-fetcher.ts @@ -0,0 +1,113 @@ +/** + * CLIProxyAPI Stats Fetcher + * + * Fetches usage statistics from CLIProxyAPI's management API. + * Requires usage-statistics-enabled: true in config.yaml. + */ + +import { CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** Usage statistics from CLIProxyAPI */ +export interface ClipproxyStats { + /** Total number of requests processed */ + totalRequests: number; + /** Token counts */ + tokens: { + input: number; + output: number; + total: number; + }; + /** Requests grouped by model */ + requestsByModel: Record; + /** Requests grouped by provider */ + requestsByProvider: Record; + /** Number of quota exceeded (429) events */ + quotaExceededCount: number; + /** Number of request retries */ + retryCount: number; + /** Timestamp of stats collection */ + collectedAt: string; +} + +/** Stats API response from CLIProxyAPI */ +interface StatsApiResponse { + total_requests?: number; + tokens?: { + input?: number; + output?: number; + }; + requests_by_model?: Record; + requests_by_provider?: Record; + quota_exceeded_count?: number; + retry_count?: number; +} + +/** + * Fetch usage statistics from CLIProxyAPI management API + * @param port CLIProxyAPI port (default: 8317) + * @returns Stats object or null if unavailable + */ +export async function fetchClipproxyStats( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout + + const response = await fetch(`http://127.0.0.1:${port}/v0/management/stats`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as StatsApiResponse; + + // Normalize the response to our interface + return { + totalRequests: data.total_requests ?? 0, + tokens: { + input: data.tokens?.input ?? 0, + output: data.tokens?.output ?? 0, + total: (data.tokens?.input ?? 0) + (data.tokens?.output ?? 0), + }, + requestsByModel: data.requests_by_model ?? {}, + requestsByProvider: data.requests_by_provider ?? {}, + quotaExceededCount: data.quota_exceeded_count ?? 0, + retryCount: data.retry_count ?? 0, + collectedAt: new Date().toISOString(), + }; + } catch { + // CLIProxyAPI not running or stats endpoint not available + return null; + } +} + +/** + * Check if CLIProxyAPI is running and responsive + * @param port CLIProxyAPI port (default: 8317) + * @returns true if proxy is running + */ +export async function isClipproxyRunning( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout + + const response = await fetch(`http://127.0.0.1:${port}/health`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} diff --git a/src/management/doctor.ts b/src/management/doctor.ts index cfb101f9..e9b45805 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -17,6 +17,9 @@ import { getConfigPath, getInstalledCliproxyVersion, CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, } from '../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { getEnvironmentDiagnostics } from './environment-diagnostics'; @@ -919,17 +922,34 @@ class Doctor { ); } - // 2. Config file exists? + // 2. Config file exists and is up-to-date? const configSpinner = ora('Checking CLIProxy config').start(); const configPath = getConfigPath(); if (fs.existsSync(configPath)) { - configSpinner.succeed(); - console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: 'cliproxy/config.yaml', - }); + // Check if config needs regeneration (version mismatch or missing features) + if (configNeedsRegeneration()) { + configSpinner.warn(); + console.log( + ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` + ); + + // Regenerate config with new features + regenerateConfig(); + + console.log(` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}`); + this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, + }); + } else { + configSpinner.succeed(); + console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`); + this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, + }); + } } else { configSpinner.info(); console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index c5a16511..c96a68d3 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -12,6 +12,16 @@ import { Config, Settings } from '../types/config'; import { expandPath } from '../utils/helpers'; import { runHealthChecks, fixHealthIssue } from './health-service'; import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler'; +import { fetchClipproxyStats, isClipproxyRunning } from '../cliproxy/stats-fetcher'; +import { + listOpenAICompatProviders, + getOpenAICompatProvider, + addOpenAICompatProvider, + updateOpenAICompatProvider, + removeOpenAICompatProvider, + OPENROUTER_TEMPLATE, + TOGETHER_TEMPLATE, +} from '../cliproxy/openai-compat-manager'; import { getAllAccountsSummary, getProviderAccounts, @@ -1023,3 +1033,183 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => { res.status(500).json({ error: (error as Error).message }); } }); + +/** + * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics + * Returns: ClipproxyStats or error if proxy not running + */ +apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise => { + try { + // Check if proxy is running first + const running = await isClipproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats', + }); + return; + } + + // Fetch stats from management API + const stats = await fetchClipproxyStats(); + if (!stats) { + res.status(503).json({ + error: 'Stats unavailable', + message: 'CLIProxyAPI is running but stats endpoint not responding', + }); + return; + } + + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/status - Check CLIProxyAPI running status + * Returns: { running: boolean } + */ +apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise => { + try { + const running = await isClipproxyRunning(); + res.json({ running }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ============================================ +// OpenAI Compatibility Layer Routes +// ============================================ + +/** + * GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers + */ +apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => { + try { + const providers = listOpenAICompatProviders(); + // Mask API keys for security + const masked = providers.map((p) => ({ + ...p, + apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', + })); + res.json({ providers: masked }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates + */ +apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => { + res.json({ + templates: [ + { ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' }, + { ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' }, + ], + }); +}); + +/** + * GET /api/cliproxy/openai-compat/:name - Get a specific provider + */ +apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const provider = getOpenAICompatProvider(req.params.name); + if (!provider) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + // Mask API key + res.json({ + ...provider, + apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/openai-compat - Add a new provider + * Body: { name, baseUrl, apiKey, models: [{ name, alias }] } + */ +apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => { + try { + const { name, baseUrl, apiKey, models } = req.body; + + // Validation + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'name is required' }); + return; + } + if (!baseUrl || typeof baseUrl !== 'string') { + res.status(400).json({ error: 'baseUrl is required' }); + return; + } + if (!apiKey || typeof apiKey !== 'string') { + res.status(400).json({ error: 'apiKey is required' }); + return; + } + + addOpenAICompatProvider({ + name, + baseUrl, + apiKey, + models: models || [], + }); + + res.status(201).json({ success: true, name }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('already exists')) { + res.status(409).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * PUT /api/cliproxy/openai-compat/:name - Update a provider + * Body: { baseUrl?, apiKey?, models?, name? (for rename) } + */ +apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const { baseUrl, apiKey, models, name: newName } = req.body; + + updateOpenAICompatProvider(req.params.name, { + baseUrl, + apiKey, + models, + name: newName, + }); + + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('not found')) { + res.status(404).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * DELETE /api/cliproxy/openai-compat/:name - Remove a provider + */ +apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const removed = removeOpenAICompatProvider(req.params.name); + if (!removed) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx new file mode 100644 index 00000000..4b6e7910 --- /dev/null +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -0,0 +1,208 @@ +/** + * CLIProxy Stats Card Component + * + * Displays CLIProxyAPI usage statistics including: + * - Proxy status (running/stopped) + * - Total requests + * - Quota exceeded events + * - Request retries + * - Requests by provider + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Activity, AlertTriangle, RefreshCw, Server, Zap } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats'; + +interface ClipproxyStatsCardProps { + className?: string; +} + +export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) { + const { data: status, isLoading: statusLoading } = useClipproxyStatus(); + const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running); + + const isLoading = statusLoading || (status?.running && statsLoading); + + if (isLoading) { + return ( + + + + + CLIProxy Stats + + + +
+ + +
+
+
+ ); + } + + // Proxy not running + if (!status?.running) { + return ( + + +
+ + + CLIProxy Stats + + + Offline + +
+
+ +

+ Start a CLIProxy session (gemini, codex, agy) to collect stats. +

+
+
+ ); + } + + // Error fetching stats + if (error) { + return ( + + +
+ + + CLIProxy Stats + + + Error + +
+
+ +

{error.message}

+
+
+ ); + } + + // Stats available + const statItems = [ + { + label: 'Requests', + value: stats?.totalRequests ?? 0, + icon: Activity, + color: 'text-blue-600', + bgColor: 'bg-blue-100 dark:bg-blue-900/20', + }, + { + label: 'Quota', + value: stats?.quotaExceededCount ?? 0, + icon: AlertTriangle, + color: stats?.quotaExceededCount ? 'text-amber-600' : 'text-muted-foreground', + bgColor: + stats?.quotaExceededCount + ? 'bg-amber-100 dark:bg-amber-900/20' + : 'bg-muted', + }, + { + label: 'Retries', + value: stats?.retryCount ?? 0, + icon: RefreshCw, + color: stats?.retryCount ? 'text-orange-600' : 'text-muted-foreground', + bgColor: + stats?.retryCount ? 'bg-orange-100 dark:bg-orange-900/20' : 'bg-muted', + }, + ]; + + // Provider breakdown + const providers = Object.entries(stats?.requestsByProvider ?? {}).sort( + (a, b) => b[1] - a[1] + ); + + return ( + + +
+ + + CLIProxy Stats + + + + Running + +
+
+ + +
+ {/* Stats row */} +
+ {statItems.map((item) => { + const Icon = item.icon; + return ( +
+
+ +
+ {item.value} + + {item.label} + +
+ ); + })} +
+ + {/* Provider breakdown */} + {providers.length > 0 && ( +
+

+ By Provider +

+
+ {providers.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+ )} + + {/* Tokens */} + {stats?.tokens && stats.tokens.total > 0 && ( +
+ Tokens: {formatNumber(stats.tokens.input)} in /{' '} + {formatNumber(stats.tokens.output)} out +
+ )} +
+
+
+
+ ); +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 7bdb8343..69f73400 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -46,20 +46,20 @@ export function UsageTrendChart({ }, [data, granularity]); if (isLoading) { - return ; + return ; } if (!data || data.length === 0) { return ( -
+

No usage data available

); } return ( -
- +
+ diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts new file mode 100644 index 00000000..0e852c74 --- /dev/null +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -0,0 +1,74 @@ +/** + * React Query hook for CLIProxyAPI stats + */ + +import { useQuery } from '@tanstack/react-query'; + +/** CLIProxy usage statistics */ +export interface ClipproxyStats { + totalRequests: number; + tokens: { + input: number; + output: number; + total: number; + }; + requestsByModel: Record; + requestsByProvider: Record; + quotaExceededCount: number; + retryCount: number; + collectedAt: string; +} + +/** CLIProxy running status */ +export interface ClipproxyStatus { + running: boolean; +} + +/** + * Fetch CLIProxy stats from API + */ +async function fetchClipproxyStats(): Promise { + const response = await fetch('/api/cliproxy/stats'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch stats'); + } + return response.json(); +} + +/** + * Fetch CLIProxy running status + */ +async function fetchClipproxyStatus(): Promise { + const response = await fetch('/api/cliproxy/status'); + if (!response.ok) { + throw new Error('Failed to fetch status'); + } + return response.json(); +} + +/** + * Hook to get CLIProxy running status + */ +export function useClipproxyStatus() { + return useQuery({ + queryKey: ['cliproxy-status'], + queryFn: fetchClipproxyStatus, + refetchInterval: 10000, // Check every 10 seconds + retry: 1, + }); +} + +/** + * Hook to get CLIProxy usage stats + */ +export function useClipproxyStats(enabled = true) { + return useQuery({ + queryKey: ['cliproxy-stats'], + queryFn: fetchClipproxyStats, + enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: 1, + staleTime: 10000, // Consider data stale after 10 seconds + }); +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index dc2c313b..fd3aef0f 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -11,15 +11,16 @@ import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from '@/components/ui/popover'; import { DateRangeFilter } from '@/components/analytics/date-range-filter'; import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards'; import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart'; import { ModelDetailsContent } from '@/components/analytics/model-details-content'; import { SessionStatsCard } from '@/components/analytics/session-stats-card'; -import { UsageInsightsCard } from '@/components/analytics/usage-insights-card'; -import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react'; +import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-stats-card'; +import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb, Zap, Gauge, Database, CheckCircle2 } from 'lucide-react'; import { useUsageSummary, useUsageTrends, @@ -30,7 +31,8 @@ import { useSessions, type ModelUsage, } from '@/hooks/use-usage'; -import { getModelColor } from '@/lib/utils'; +import { getModelColor, cn } from '@/lib/utils'; +import type { AnomalyType } from '@/hooks/use-usage'; // Format token count to human-readable (K/M/B) function formatTokens(num: number): string { @@ -40,6 +42,42 @@ function formatTokens(num: number): string { return num.toString(); } +// Anomaly type configuration for icons and colors +const ANOMALY_CONFIG: Record< + AnomalyType, + { + icon: React.ComponentType<{ className?: string }>; + color: string; + bgColor: string; + label: string; + } +> = { + high_input: { + icon: Zap, + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/20', + label: 'High Input', + }, + high_io_ratio: { + icon: Gauge, + color: 'text-orange-600 dark:text-orange-400', + bgColor: 'bg-orange-100 dark:bg-orange-900/20', + label: 'High I/O Ratio', + }, + cost_spike: { + icon: DollarSign, + color: 'text-red-600 dark:text-red-400', + bgColor: 'bg-red-100 dark:bg-red-900/20', + label: 'Cost Spike', + }, + high_cache_read: { + icon: Database, + color: 'text-cyan-600 dark:text-cyan-400', + bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', + label: 'Heavy Caching', + }, +}; + export function AnalyticsPage() { // Default to last 30 days const [dateRange, setDateRange] = useState({ @@ -96,7 +134,7 @@ export function AnalyticsPage() { }, []); return ( -
+
{/* Header */}
@@ -114,6 +152,91 @@ export function AnalyticsPage() { { label: 'All Time', range: { from: undefined, to: new Date() } }, ]} /> + {/* Usage Insights Dropdown */} + + + + + + {isInsightsLoading ? ( +
+
+
+
+
+
+ ) : insights?.summary?.totalAnomalies ? ( +
+
+ {insights.anomalies?.map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + return ( +
+
+
+ +
+
+
+

{config.label}

+ + {anomaly.date} + +
+

+ {anomaly.message} +

+ {anomaly.model && ( + + {anomaly.model} + + )} +
+
+
+ ); + })} +
+
+ ) : ( +
+
+ +
+

No anomalies detected

+

Usage patterns look normal

+
+ )} + + {lastUpdatedText && ( Updated {lastUpdatedText} @@ -135,22 +258,22 @@ export function AnalyticsPage() { {/* Main Content */} -
+
{/* Usage Trend Chart - Full Width */} - - + + Usage Trends - - + + - {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} -
+ {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */} +
{/* Cost by Model - 4/10 width with breakdown */} @@ -291,13 +414,8 @@ export function AnalyticsPage() { className="lg:col-span-2" /> - {/* Usage Insights - 2/10 width */} - + {/* CLIProxy Stats - 2/10 width */} +
{/* Model Details Popover - positioned at cursor */}