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
This commit is contained in:
kaitranntt
2025-12-11 02:12:39 -05:00
parent 9b53c4d838
commit a94c3d6600
10 changed files with 1092 additions and 38 deletions
+97 -8
View File
@@ -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<CLIProxyProvider, string> = {
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
*/
+19
View File
@@ -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';
+223
View File
@@ -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<string, string>;
'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<OpenAICompatProvider>
): 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<OpenAICompatProvider, 'apiKey'> = {
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<OpenAICompatProvider, 'apiKey'> = {
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' },
],
};
+113
View File
@@ -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<string, number>;
/** Requests grouped by provider */
requestsByProvider: Record<string, number>;
/** 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<string, number>;
requests_by_provider?: Record<string, number>;
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<ClipproxyStats | null> {
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<boolean> {
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;
}
}
+27 -7
View File
@@ -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)`);
+190
View File
@@ -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<void> => {
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<void> => {
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 });
}
});
@@ -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 (
<Card className={cn('flex flex-col h-full', className)}>
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1">
<div className="space-y-3">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
</Card>
);
}
// Proxy not running
if (!status?.running) {
return (
<Card className={cn('flex flex-col h-full border-dashed', className)}>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
CLIProxy Stats
</CardTitle>
<Badge variant="secondary" className="text-[10px] h-5">
Offline
</Badge>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
<p className="text-xs text-muted-foreground text-center">
Start a CLIProxy session (gemini, codex, agy) to collect stats.
</p>
</CardContent>
</Card>
);
}
// Error fetching stats
if (error) {
return (
<Card className={cn('flex flex-col h-full border-destructive/50', className)}>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
<Badge variant="destructive" className="text-[10px] h-5">
Error
</Badge>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1">
<p className="text-xs text-destructive">{error.message}</p>
</CardContent>
</Card>
);
}
// 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 (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
<CardHeader className="px-3 py-2 border-b bg-muted/5">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
<Badge
variant="outline"
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3 w-3 mr-0.5" />
Running
</Badge>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0">
<ScrollArea className="h-full">
<div className="p-3 space-y-3">
{/* Stats row */}
<div className="grid grid-cols-3 gap-2">
{statItems.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="flex flex-col items-center p-2 rounded-lg bg-muted/50"
>
<div className={cn('p-1 rounded-md mb-1', item.bgColor)}>
<Icon className={cn('h-3 w-3', item.color)} />
</div>
<span className="text-sm font-bold">{item.value}</span>
<span className="text-[9px] text-muted-foreground">
{item.label}
</span>
</div>
);
})}
</div>
{/* Provider breakdown */}
{providers.length > 0 && (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">
By Provider
</p>
<div className="flex flex-wrap gap-1">
{providers.map(([provider, count]) => (
<Badge key={provider} variant="outline" className="text-[10px] h-5 px-1.5">
{provider}: {count}
</Badge>
))}
</div>
</div>
)}
{/* Tokens */}
{stats?.tokens && stats.tokens.total > 0 && (
<div className="text-[10px] text-muted-foreground">
Tokens: {formatNumber(stats.tokens.input)} in /{' '}
{formatNumber(stats.tokens.output)} out
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
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();
}
@@ -46,20 +46,20 @@ export function UsageTrendChart({
}, [data, granularity]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
return <Skeleton className={cn('h-full w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<div className={cn('h-full flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<div className={cn('w-full h-full', className)}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
+74
View File
@@ -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<string, number>;
requestsByProvider: Record<string, number>;
quotaExceededCount: number;
retryCount: number;
collectedAt: string;
}
/** CLIProxy running status */
export interface ClipproxyStatus {
running: boolean;
}
/**
* Fetch CLIProxy stats from API
*/
async function fetchClipproxyStats(): Promise<ClipproxyStats> {
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<ClipproxyStatus> {
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
});
}
+137 -19
View File
@@ -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<DateRange | undefined>({
@@ -96,7 +134,7 @@ export function AnalyticsPage() {
}, []);
return (
<div className="flex flex-col h-[calc(100vh-5.5rem)] overflow-hidden p-4 gap-3">
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
@@ -114,6 +152,91 @@ export function AnalyticsPage() {
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{/* Usage Insights Dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-8"
title="Usage Insights"
>
<Lightbulb
className={`w-3.5 h-3.5 ${
insights?.summary?.totalAnomalies ? 'text-amber-500' : 'text-green-500'
}`}
/>
<span className="text-xs">Insights</span>
{insights?.summary?.totalAnomalies ? (
<Badge
variant="destructive"
className="h-4 px-1 text-[10px] font-bold ml-0.5"
>
{insights.summary.totalAnomalies}
</Badge>
) : (
<Badge
variant="outline"
className="h-4 px-1 text-[10px] font-bold ml-0.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
OK
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
{isInsightsLoading ? (
<div className="p-4 flex items-center justify-center">
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
<div className="h-8 w-8 bg-muted rounded-full" />
<div className="h-4 w-32 bg-muted rounded" />
</div>
</div>
) : insights?.summary?.totalAnomalies ? (
<div className="max-h-[300px] overflow-y-auto">
<div className="divide-y">
{insights.anomalies?.map((anomaly, index) => {
const config = ANOMALY_CONFIG[anomaly.type];
const Icon = config.icon;
return (
<div key={index} className="p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-start gap-3">
<div className={cn('p-2 rounded-lg shrink-0', config.bgColor)}>
<Icon className={cn('h-4 w-4', config.color)} />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm">{config.label}</p>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{anomaly.date}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{anomaly.message}
</p>
{anomaly.model && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-mono">
{anomaly.model}
</Badge>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="p-6 text-center text-muted-foreground">
<div className="w-10 h-10 mx-auto rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-2">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-foreground text-sm">No anomalies detected</p>
<p className="text-xs mt-1">Usage patterns look normal</p>
</div>
)}
</PopoverContent>
</Popover>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
@@ -135,22 +258,22 @@ export function AnalyticsPage() {
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0 gap-3">
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
<CardHeader className="px-3 py-2">
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model - 4/10 width with breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
@@ -291,13 +414,8 @@ export function AnalyticsPage() {
className="lg:col-span-2"
/>
{/* Usage Insights - 2/10 width */}
<UsageInsightsCard
anomalies={insights?.anomalies}
summary={insights?.summary}
isLoading={isInsightsLoading}
className="lg:col-span-2"
/>
{/* CLIProxy Stats - 2/10 width */}
<ClipproxyStatsCard className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}