Files
ccs/src/web-server/routes/cliproxy-stats-routes.ts
T

1117 lines
34 KiB
TypeScript

/**
* CLIProxy Stats Routes - Stats, status, models, error logs for CLIProxyAPI
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
isRunningUnderSupervisord,
restartCliproxyViaSupervisord,
} from '../../docker/supervisord-lifecycle';
import {
fetchCliproxyStats,
fetchCliproxyModels,
isCliproxyRunning,
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../../cliproxy/stats-fetcher';
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
import { fetchClaudeQuota } from '../../cliproxy/quota-fetcher-claude';
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp';
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache';
import type {
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../cliproxy/quota-types';
import type { QuotaResult } from '../../cliproxy/quota-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import {
getCliproxyWritablePath,
getCliproxyConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../cliproxy/service-manager';
import {
checkCliproxyUpdate,
getInstalledCliproxyVersion,
getStoredConfiguredBackend,
} from '../../cliproxy/binary-manager';
import {
fetchAllVersions,
isNewerVersion,
isVersionFaulty,
} from '../../cliproxy/binary/version-checker';
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
} from '../../cliproxy/platform-detector';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import {
MODEL_ENV_VAR_KEYS,
canonicalizeModelIdForProvider,
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000;
const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120;
interface QuotaRateLimitEntry {
windowStart: number;
count: number;
}
const quotaRateLimits = new Map<string, QuotaRateLimitEntry>();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy management endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
function buildQuotaRateLimitKey(req: Request, provider: string): string {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
return `${clientIp}:${provider}`;
}
function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
const key = buildQuotaRateLimitKey(req, provider);
const now = Date.now();
// Evict stale entries to prevent unbounded memory growth
if (quotaRateLimits.size > 1000) {
for (const [k, v] of quotaRateLimits) {
if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) {
quotaRateLimits.delete(k);
}
}
}
const current = quotaRateLimits.get(key);
if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) {
quotaRateLimits.set(key, { windowStart: now, count: 1 });
return false;
}
current.count += 1;
quotaRateLimits.set(key, current);
return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS;
}
/**
* Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx).
* Generic across all quota result types.
*/
export function shouldCacheQuotaResult(result: {
success: boolean;
needsReauth?: boolean;
isForbidden?: boolean;
httpStatus?: number;
retryable?: boolean;
error?: string;
}): boolean {
if (result.success) return true;
if (result.needsReauth || result.isForbidden) return true;
if (result.retryable === true) return false;
if (result.retryable === false) return true;
if (typeof result.httpStatus === 'number') {
if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) {
return false;
}
if (result.httpStatus >= 400 && result.httpStatus < 500) {
return true;
}
}
const msg = (result.error || '').toLowerCase();
if (!msg) return false;
const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed'];
return !transientPatterns.some((p) => msg.includes(p));
}
function buildUpdateCheckFallback(
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION);
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: true,
checkedAt: Date.now(),
backend,
backendLabel,
isStable,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
stabilityMessage: isStable
? undefined
: `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`,
};
}
function buildVersionsFallback(
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
return {
versions: currentVersion ? [currentVersion] : [],
latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
fromCache: true,
checkedAt: Date.now(),
currentVersion,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}
interface ResolveUpdateCheckDeps {
checkCliproxyUpdateFn?: typeof checkCliproxyUpdate;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
interface ResolveVersionsDeps {
fetchAllVersionsFn?: typeof fetchAllVersions;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
export async function resolveCliproxyUpdateCheckPayload(
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveUpdateCheckDeps = {}
) {
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
return checkCliproxyUpdateFn(backend).catch(() =>
buildUpdateCheckFallback(backend, getInstalledVersionFn)
);
}
export async function resolveCliproxyVersionsPayload(
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveVersionsDeps = {}
) {
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
const result = await fetchAllVersionsFn(false, backend).catch(() => null);
if (!result) {
return buildVersionsFallback(backend, getInstalledVersionFn);
}
return {
...result,
currentVersion: getInstalledVersionFn(backend),
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}
/**
* Extract status code and model from error log file (lightweight parsing).
* Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop.
*/
async function extractErrorLogMetadata(
filePath: string
): Promise<{ statusCode?: number; model?: string }> {
let fh: fs.promises.FileHandle | null = null;
try {
fh = await fs.promises.open(filePath, 'r');
const stat = await fh.stat();
const fileSize = stat.size;
// Read first 4KB for model (in request body)
const startBuffer = Buffer.alloc(Math.min(4096, fileSize));
await fh.read(startBuffer, 0, startBuffer.length, 0);
const startContent = startBuffer.toString('utf-8');
// Extract model from request body JSON: "model":"gemini-3-flash-preview"
const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/);
const model = modelMatch ? modelMatch[1] : undefined;
// Read last 2KB for status code (in response section at end)
let statusCode: number | undefined;
if (fileSize > 2048) {
const endBuffer = Buffer.alloc(2048);
await fh.read(endBuffer, 0, 2048, fileSize - 2048);
const endContent = endBuffer.toString('utf-8');
const statusMatch = endContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
} else {
// Small file - check start content for status
const statusMatch = startContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
}
return { statusCode, model };
} catch {
return {};
} finally {
await fh?.close();
}
}
/**
* Shared handler for stats/usage endpoint
*/
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats',
});
return;
}
// Fetch stats from management API
const stats = await fetchCliproxyStats();
if (!stats) {
res.status(503).json({
error: 'Stats unavailable',
message: 'CLIProxy Plus is running but stats endpoint not responding',
});
return;
}
res.json(stats);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
* Returns: CliproxyStats or error if proxy not running
*/
router.get('/stats', handleStatsRequest);
/**
* GET /api/cliproxy/usage - Alias for /stats (frontend compatibility)
*/
router.get('/usage', handleStatsRequest);
/**
* GET /api/cliproxy/status - Check CLIProxyAPI running status
* Returns: { running: boolean }
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
res.json({ running });
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/proxy-status - Get detailed proxy process status
* Returns: { running, port?, pid?, sessionCount?, startedAt? }
* Combines session tracker data with actual port check for accuracy
*/
router.get('/proxy-status', async (_req: Request, res: Response): Promise<void> => {
try {
// First check session tracker for detailed info
const sessionStatus = getProxyProcessStatus();
// If session tracker says running, trust it
if (sessionStatus.running) {
res.json(sessionStatus);
return;
}
// Session tracker says not running, but proxy might be running without session tracking
// (e.g., started before session persistence was implemented)
const actuallyRunning = await isCliproxyRunning();
if (actuallyRunning) {
// Proxy running but no session lock - legacy/untracked instance
res.json({
running: true,
port: CLIPROXY_DEFAULT_PORT,
sessionCount: 0, // Unknown sessions
// No pid/startedAt since we don't have session lock
});
} else {
res.json(sessionStatus);
}
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/cliproxy/proxy-start - Start the CLIProxy service
* Returns: { started, alreadyRunning, port, error? }
* Starts proxy in background if not already running
*/
router.post('/proxy-start', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await ensureCliproxyService();
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/cliproxy/proxy-stop - Stop the CLIProxy service
* Returns: { stopped, pid?, sessionCount?, error? }
*/
router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await stopProxy();
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates
* Returns: { hasUpdate, currentVersion, latestVersion, fromCache }
*/
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getStoredConfiguredBackend();
const result = await resolveCliproxyUpdateCheckPayload(backend);
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
*/
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models',
});
return;
}
// Fetch models from /v1/models endpoint
const modelsResponse = await fetchCliproxyModels();
if (!modelsResponse) {
res.status(503).json({
error: 'Models unavailable',
message: 'CLIProxy Plus is running but /v1/models endpoint not responding',
});
return;
}
res.json(modelsResponse);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Error Logs ====================
/**
* GET /api/cliproxy/error-logs - Get list of error log files
* Returns: { files: CliproxyErrorLog[] } or error if proxy not running
*/
router.get('/error-logs', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session to view error logs',
});
return;
}
const files = await fetchCliproxyErrorLogs();
if (files === null) {
res.status(503).json({
error: 'Error logs unavailable',
message: 'CLIProxy Plus is running but error logs endpoint not responding',
});
return;
}
// Inject absolute paths and extract metadata from each file
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
const filesWithMetadata = await Promise.all(
files.map(async (file) => {
const absolutePath = path.join(logsDir, file.name);
const metadata = await extractErrorLogMetadata(absolutePath);
return {
...file,
absolutePath,
statusCode: metadata.statusCode,
model: metadata.model,
};
})
);
res.json({ files: filesWithMetadata });
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/error-logs/:name - Get content of a specific error log
* Returns: plain text log content
*/
router.get('/error-logs/:name', async (req: Request, res: Response): Promise<void> => {
const { name } = req.params;
// Validate filename format and prevent path traversal
if (
!name ||
!name.startsWith('error-') ||
!name.endsWith('.log') ||
name.includes('..') ||
name.includes('/') ||
name.includes('\\')
) {
res.status(400).json({ error: 'Invalid error log filename' });
return;
}
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({ error: 'CLIProxy Plus not running' });
return;
}
const content = await fetchCliproxyErrorLogContent(name);
if (content === null) {
res.status(404).json({ error: 'Error log not found' });
return;
}
res.type('text/plain').send(content);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Config File ====================
/**
* GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content
* Returns: plain text YAML content
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content
* Body: { content: string }
* Returns: { success: true, path: string }
*/
router.put('/config.yaml', async (req: Request, res: Response): Promise<void> => {
try {
const { content } = req.body;
if (typeof content !== 'string') {
res.status(400).json({ error: 'Missing required field: content' });
return;
}
const configPath = getCliproxyConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Write atomically
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, content);
fs.renameSync(tempPath, configPath);
res.json({ success: true, path: configPath });
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Auth Files ====================
/**
* GET /api/cliproxy/auth-files - List auth files in auth directory
* Returns: { files: Array<{ name, size, mtime }> }
*/
router.get('/auth-files', async (_req: Request, res: Response): Promise<void> => {
try {
const authDir = getAuthDir();
if (!fs.existsSync(authDir)) {
res.json({ files: [] });
return;
}
const entries = fs.readdirSync(authDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => {
const filePath = path.join(authDir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
size: stat.size,
mtime: stat.mtime.getTime(),
};
});
res.json({ files, directory: authDir });
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/auth-files/download - Download auth file content
* Query: ?name=filename
* Returns: file content as octet-stream
*/
router.get('/auth-files/download', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.query;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Missing required query parameter: name' });
return;
}
// Validate filename - prevent path traversal
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
const authDir = getAuthDir();
const filePath = path.join(authDir, name);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'Auth file not found' });
return;
}
const content = fs.readFileSync(filePath);
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Model Updates ====================
/**
* PUT /api/cliproxy/models/:provider - Update model for a provider
* Body: { model: string }
* Returns: { success: true, provider, model }
*/
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
try {
const { provider } = req.params;
// Validate provider name to prevent path traversal via crafted provider param
if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) {
res.status(400).json({ error: 'Invalid provider name' });
return;
}
const { model } = req.body;
if (!model || typeof model !== 'string') {
res.status(400).json({ error: 'Missing required field: model' });
return;
}
if (model.length > 256) {
res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' });
return;
}
const deniedReason = getDeniedModelIdReasonForProvider(model, provider);
if (deniedReason) {
res.status(400).json({ error: deniedReason });
return;
}
// Get the settings file for this provider
const ccsDir = getCliproxyWritablePath();
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: `Settings file not found for provider: ${provider}` });
return;
}
// Read and update settings
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env?: Record<string, unknown>;
[key: string]: unknown;
};
const canonicalModel = canonicalizeModelIdForProvider(model, provider);
const env =
settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)
? settings.env
: {};
const previousDefault =
typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel;
const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider);
for (const key of MODEL_ENV_VAR_KEYS) {
if (key === 'ANTHROPIC_MODEL') {
env[key] = canonicalModel;
continue;
}
const current = env[key];
if (typeof current !== 'string') {
env[key] = canonicalModel;
continue;
}
const canonicalCurrent = canonicalizeModelIdForProvider(current, provider);
env[key] = canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent;
}
settings.env = env;
// Write atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.json({ success: true, provider, model: canonicalModel });
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Account Quota ====================
// NOTE: Specific routes MUST be defined BEFORE generic routes for Express routing to work correctly
// NOTE: All quota endpoints use in-memory caching (2 min TTL) to reduce external API calls
/**
* GET /api/cliproxy/quota/codex/:accountId - Get Codex quota for a specific account
* Returns: CodexQuotaResult with rate limit windows
* Caching: 2 minute TTL to reduce ChatGPT API calls
*/
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'codex')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<CodexQuotaResult>('codex', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from external API
const result = await fetchCodexQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheQuotaResult(result)) {
setCachedQuota('codex', accountId, result);
}
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/quota/claude/:accountId - Get Claude quota for a specific account
* Returns: ClaudeQuotaResult with policy windows (5h + weekly)
* Caching: 2 minute TTL to reduce Anthropic API calls
*/
router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'claude')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<ClaudeQuotaResult>('claude', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from external API
const result = await fetchClaudeQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheQuotaResult(result)) {
setCachedQuota('claude', accountId, result);
}
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account
* Returns: GeminiCliQuotaResult with quota buckets
* Caching: 2 minute TTL to reduce Google Cloud API calls
*/
router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'gemini')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from external API
const result = await fetchGeminiCliQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheQuotaResult(result)) {
setCachedQuota('gemini', accountId, result);
}
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/quota/ghcp/:accountId - Get GitHub Copilot (ghcp) quota for a specific account
* Returns: GhcpQuotaResult with premium/chat/completions quota snapshots
* Caching: 2 minute TTL to reduce GitHub API calls
*/
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'ghcp')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<GhcpQuotaResult>('ghcp', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from GitHub API
const result = await fetchGhcpQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheQuotaResult(result)) {
setCachedQuota('ghcp', accountId, result);
}
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
* Returns: QuotaResult with model quotas and reset times
* NOTE: This generic route MUST come after specific routes (codex, claude, gemini, ghcp)
* Caching: 2 minute TTL to reduce external API calls
*/
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
const { provider, accountId } = req.params;
if (isQuotaRouteRateLimited(req, provider)) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate provider - use canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({
error: 'Invalid provider',
message: `Provider must be one of: ${validProviders.join(', ')}`,
});
return;
}
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<QuotaResult>(provider, accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from external API
const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId);
// Cache successful results
if (result.success) {
setCachedQuota(provider, accountId, result);
}
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Version Management ====================
/**
* GET /api/cliproxy/versions - Get all available CLIProxyAPI versions
* Returns: { versions, latestStable, latest, currentVersion, maxStableVersion }
*/
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/cliproxy/install - Install specific CLIProxyAPI version
* Body: { version: string, force?: boolean }
* Returns: { success, restarted?, port?, requiresConfirmation?, message? }
*/
router.post('/install', async (req: Request, res: Response): Promise<void> => {
try {
const { version, force } = req.body;
if (!version || typeof version !== 'string') {
res.status(400).json({ error: 'Missing required field: version' });
return;
}
// Validate version format
if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) {
res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' });
return;
}
// Check if version is faulty (v81-85) or experimental (above max stable)
const isFaulty = isVersionFaulty(version);
const isExperimental = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION);
if (isFaulty && !force) {
res.json({
success: false,
isFaulty,
isExperimental,
requiresConfirmation: true,
message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`,
});
return;
}
if (isExperimental && !force) {
res.json({
success: false,
isFaulty,
isExperimental,
requiresConfirmation: true,
message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). Set force=true to proceed.`,
});
return;
}
const backend = getStoredConfiguredBackend();
const installResult = await installDashboardCliproxyVersion(version, backend);
res.json({
version,
isFaulty,
isExperimental,
...installResult,
});
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/cliproxy/restart - Restart CLIProxy without version change
* Returns: { success, port?, error? }
*/
router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
try {
if (isRunningUnderSupervisord()) {
// Docker mode: delegate to supervisord which owns the process lifecycle
const result = restartCliproxyViaSupervisord();
res.json(result);
return;
}
// Local mode: direct process management
await stopProxy();
// Small delay to ensure port is released
await new Promise((r) => setTimeout(r, 500));
// Start proxy
const startResult = await ensureCliproxyService();
if (startResult.started || startResult.alreadyRunning) {
res.json({ success: true, port: startResult.port });
} else {
res.json({ success: false, error: startResult.error || 'Failed to start proxy' });
}
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;