mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
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:
1 parent
9b53c4d838
commit
a94c3d6600
10 files changed
+1092
-38
No files matched your search
@@ -25,6 +25,13 @@ export const CLIPROXY_DEFAULT_PORT = 8317;
|
|||||||
/** Internal API key for CCS-managed requests */
|
/** Internal API key for CCS-managed requests */
|
||||||
const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
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) */
|
/** Provider display names (static metadata) */
|
||||||
const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
|
const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
@@ -112,26 +119,34 @@ export function getBinDir(): string {
|
|||||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||||
|
|
||||||
// Unified config with all providers
|
// Unified config with enhanced CLIProxyAPI features
|
||||||
const config = `# CLIProxyAPI unified config generated by CCS
|
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||||
# Supports: gemini, codex, agy, qwen (concurrent usage)
|
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||||
# Generated: ${new Date().toISOString()}
|
# Generated: ${new Date().toISOString()}
|
||||||
|
# DO NOT EDIT - regenerated by CCS. Use 'ccs doctor' to update.
|
||||||
|
|
||||||
port: ${port}
|
port: ${port}
|
||||||
debug: false
|
debug: false
|
||||||
logging-to-file: 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
|
# CCS internal authentication
|
||||||
api-keys:
|
api-keys:
|
||||||
- "${CCS_INTERNAL_API_KEY}"
|
- "${CCS_INTERNAL_API_KEY}"
|
||||||
|
|
||||||
# OAuth tokens stored in auth/ directory
|
# OAuth tokens stored in auth/ directory
|
||||||
# CLIProxyAPI auto-discovers auth files in subdirectories
|
|
||||||
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
||||||
|
|
||||||
# All providers configured - routes by model name
|
|
||||||
# No provider-specific sections needed - OAuth auth files provide credentials
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
@@ -162,6 +177,80 @@ export function generateConfig(
|
|||||||
return configPath;
|
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
|
* Check if config exists for provider
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export {
|
|||||||
// Config generation
|
// Config generation
|
||||||
export {
|
export {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
|
regenerateConfig,
|
||||||
|
configNeedsRegeneration,
|
||||||
getClaudeEnvVars,
|
getClaudeEnvVars,
|
||||||
getEffectiveEnvVars,
|
getEffectiveEnvVars,
|
||||||
getProviderSettingsPath,
|
getProviderSettingsPath,
|
||||||
@@ -62,6 +64,7 @@ export {
|
|||||||
configExists,
|
configExists,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
|
CLIPROXY_CONFIG_VERSION,
|
||||||
} from './config-generator';
|
} from './config-generator';
|
||||||
|
|
||||||
// Base config loader (for reading config/base-*.settings.json)
|
// Base config loader (for reading config/base-*.settings.json)
|
||||||
@@ -98,3 +101,19 @@ export {
|
|||||||
getProviderTokenDir,
|
getProviderTokenDir,
|
||||||
displayAuthStatus,
|
displayAuthStatus,
|
||||||
} from './auth-handler';
|
} 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';
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
getConfigPath,
|
getConfigPath,
|
||||||
getInstalledCliproxyVersion,
|
getInstalledCliproxyVersion,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
|
configNeedsRegeneration,
|
||||||
|
regenerateConfig,
|
||||||
|
CLIPROXY_CONFIG_VERSION,
|
||||||
} from '../cliproxy';
|
} from '../cliproxy';
|
||||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||||
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
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 configSpinner = ora('Checking CLIProxy config').start();
|
||||||
const configPath = getConfigPath();
|
const configPath = getConfigPath();
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
configSpinner.succeed();
|
// Check if config needs regeneration (version mismatch or missing features)
|
||||||
console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`);
|
if (configNeedsRegeneration()) {
|
||||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
configSpinner.warn();
|
||||||
status: 'OK',
|
console.log(
|
||||||
info: 'cliproxy/config.yaml',
|
` ${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 {
|
} else {
|
||||||
configSpinner.info();
|
configSpinner.info();
|
||||||
console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
|
console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { Config, Settings } from '../types/config';
|
|||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
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 {
|
import {
|
||||||
getAllAccountsSummary,
|
getAllAccountsSummary,
|
||||||
getProviderAccounts,
|
getProviderAccounts,
|
||||||
@@ -1023,3 +1033,183 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
|||||||
res.status(500).json({ error: (error as Error).message });
|
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]);
|
}, [data, granularity]);
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
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>
|
<p className="text-muted-foreground">No usage data available</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full', className)}>
|
<div className={cn('w-full h-full', className)}>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
|||||||
@@ -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
@@ -11,15 +11,16 @@ import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb, Zap, Gauge, Database, CheckCircle2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
useUsageTrends,
|
useUsageTrends,
|
||||||
@@ -30,7 +31,8 @@ import {
|
|||||||
useSessions,
|
useSessions,
|
||||||
type ModelUsage,
|
type ModelUsage,
|
||||||
} from '@/hooks/use-usage';
|
} 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)
|
// Format token count to human-readable (K/M/B)
|
||||||
function formatTokens(num: number): string {
|
function formatTokens(num: number): string {
|
||||||
@@ -40,6 +42,42 @@ function formatTokens(num: number): string {
|
|||||||
return num.toString();
|
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() {
|
export function AnalyticsPage() {
|
||||||
// Default to last 30 days
|
// Default to last 30 days
|
||||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||||
@@ -96,7 +134,7 @@ export function AnalyticsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<div>
|
<div>
|
||||||
@@ -114,6 +152,91 @@ export function AnalyticsPage() {
|
|||||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
{ 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 && (
|
{lastUpdatedText && (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Updated {lastUpdatedText}
|
Updated {lastUpdatedText}
|
||||||
@@ -135,22 +258,22 @@ export function AnalyticsPage() {
|
|||||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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 */}
|
{/* Usage Trend Chart - Full Width */}
|
||||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
|
||||||
<CardHeader className="px-3 py-2">
|
<CardHeader className="px-3 py-2 shrink-0">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
Usage Trends
|
Usage Trends
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
||||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 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) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
|
<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 */}
|
{/* Cost by Model - 4/10 width with breakdown */}
|
||||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||||
<CardHeader className="px-3 py-2">
|
<CardHeader className="px-3 py-2">
|
||||||
@@ -291,13 +414,8 @@ export function AnalyticsPage() {
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Usage Insights - 2/10 width */}
|
{/* CLIProxy Stats - 2/10 width */}
|
||||||
<UsageInsightsCard
|
<ClipproxyStatsCard className="lg:col-span-2" />
|
||||||
anomalies={insights?.anomalies}
|
|
||||||
summary={insights?.summary}
|
|
||||||
isLoading={isInsightsLoading}
|
|
||||||
className="lg:col-span-2"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Details Popover - positioned at cursor */}
|
{/* Model Details Popover - positioned at cursor */}
|
||||||
|
|||||||
Reference in new issue
Block a user