From 17bb6f9836a56eddcb5e683e9d8f3d262f48d0cd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 21:29:20 -0500 Subject: [PATCH] feat(cliproxy): add remote routing for stats and auth endpoints - Refactor stats-fetcher.ts: 5 functions now use getProxyTarget() - Add remote-auth-fetcher.ts: fetch auth status from remote /v0/management/auth-files - Update cliproxy-auth-routes.ts: branch on isRemote for GET routes - Return 501 for account management in remote mode (unsupported) --- src/cliproxy/remote-auth-fetcher.ts | 150 ++++++++++++++++++ src/cliproxy/stats-fetcher.ts | 119 +++++++++----- src/web-server/routes/cliproxy-auth-routes.ts | 67 +++++++- 3 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 src/cliproxy/remote-auth-fetcher.ts diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts new file mode 100644 index 00000000..1c5cb01a --- /dev/null +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -0,0 +1,150 @@ +/** + * Remote Auth Fetcher + * Fetches and transforms auth data from remote CLIProxyAPI. + */ + +import { + getProxyTarget, + buildProxyUrl, + buildProxyHeaders, + ProxyTarget, +} from './proxy-target-resolver'; + +/** Remote auth file from CLIProxyAPI /v0/management/auth-files */ +interface RemoteAuthFile { + id: string; + name: string; + type: string; + provider: string; + email?: string; + status: 'active' | 'disabled' | 'unavailable'; + source: 'file' | 'memory'; +} + +/** Response from CLIProxyAPI auth-files endpoint */ +interface RemoteAuthFilesResponse { + files: RemoteAuthFile[]; +} + +/** Account info for UI display */ +export interface RemoteAccountInfo { + id: string; + email: string; + isDefault: boolean; + status: 'active' | 'disabled' | 'unavailable'; +} + +/** Auth status for a provider (UI format) */ +export interface RemoteAuthStatus { + provider: string; + displayName: string; + authenticated: boolean; + lastAuth: string | null; + tokenFiles: number; + accounts: RemoteAccountInfo[]; + defaultAccount: string | null; + source: 'remote'; +} + +/** Map CLIProxyAPI provider names to CCS internal names */ +const PROVIDER_MAP: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** Display names for providers */ +const PROVIDER_DISPLAY_NAMES: Record = { + gemini: 'Google Gemini', + agy: 'AntiGravity', + codex: 'Codex', + qwen: 'Qwen', + iflow: 'iFlow', +}; + +/** + * Fetch auth status from remote CLIProxyAPI + * @throws Error if remote is unreachable or returns error + */ +export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise { + const proxyTarget = target ?? getProxyTarget(); + + if (!proxyTarget.isRemote) { + throw new Error('fetchRemoteAuthStatus called but remote mode not enabled'); + } + + const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: buildProxyHeaders(proxyTarget), + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('Authentication failed - check auth token in settings'); + } + throw new Error(`Remote returned ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as RemoteAuthFilesResponse; + return transformRemoteAuthFiles(data.files); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Remote proxy connection timed out'); + } + throw error; + } +} + +/** Transform CLIProxyAPI auth files to CCS AuthStatus format */ +function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { + const byProvider = new Map(); + + for (const file of files) { + const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + if (!provider) continue; + + const existing = byProvider.get(provider); + if (existing) { + existing.push(file); + } else { + byProvider.set(provider, [file]); + } + } + + const result: RemoteAuthStatus[] = []; + + Array.from(byProvider.entries()).forEach(([provider, providerFiles]) => { + const activeFiles = providerFiles.filter((f) => f.status === 'active'); + const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({ + id: f.id, + email: f.email || f.name, + isDefault: idx === 0, + status: f.status, + })); + + result.push({ + provider, + displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + authenticated: activeFiles.length > 0, + lastAuth: null, + tokenFiles: providerFiles.length, + accounts, + defaultAccount: accounts.find((a) => a.isDefault)?.id || null, + source: 'remote', + }); + }); + + return result; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index e7506a74..f66e987d 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -5,7 +5,8 @@ * Requires usage-statistics-enabled: true in config.yaml. */ -import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { CCS_CONTROL_PANEL_SECRET } from './config-generator'; +import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -95,19 +96,27 @@ interface UsageApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Stats object or null if unavailable */ -export async function fetchCliproxyStats( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyStats(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout - const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/usage'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -222,20 +231,27 @@ export interface CliproxyModelsResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Categorized models or null if unavailable */ -export async function fetchCliproxyModels( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyModels(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v1/models`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v1/models'); + + // For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - // Use the internal API key for /v1 endpoints - Authorization: 'Bearer ccs-internal-managed', - }, + headers, }); clearTimeout(timeoutId); @@ -293,19 +309,27 @@ interface ErrorLogsApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Array of error log metadata or null if unavailable */ -export async function fetchCliproxyErrorLogs( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyErrorLogs(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/request-error-logs'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs( */ export async function fetchCliproxyErrorLogContent( name: string, - port: number = CLIPROXY_DEFAULT_PORT + port?: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch( - `http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`, - { - signal: controller.signal, - headers: { - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, - } + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl( + target, + `/v0/management/request-error-logs/${encodeURIComponent(name)}` ); + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + clearTimeout(timeoutId); if (!response.ok) { @@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent( * @param port CLIProxyAPI port (default: 8317) * @returns true if proxy is running */ -export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { +export async function isCliproxyRunning(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout - // Use root endpoint - CLIProxyAPI returns server info at / - const response = await fetch(`http://127.0.0.1:${port}/`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/'); + + // Health check - no auth needed for root endpoint + const response = await fetch(url, { signal: controller.signal, }); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 6d5d16b2..8302a053 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -21,6 +21,8 @@ import { removeAccount as removeAccountFn, touchAccount, } from '../../cliproxy/account-manager'; +import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -33,7 +35,24 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ router.get('/', async (_req: Request, res: Response) => { - // Initialize accounts from existing tokens on first request + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + try { + const authStatus = await fetchRemoteAuthStatus(target); + res.json({ authStatus, source: 'remote' }); + return; + } catch (error) { + res.status(503).json({ + error: (error as Error).message, + authStatus: [], + source: 'remote', + }); + return; + } + } + + // Local mode: Initialize accounts from existing tokens on first request initializeAccounts(); // Fetch CLIProxyAPI usage stats to determine active providers @@ -90,8 +109,32 @@ router.get('/', async (_req: Request, res: Response) => { /** * GET /api/cliproxy/accounts - Get all accounts across all providers */ -router.get('/accounts', (_req: Request, res: Response) => { - // Initialize accounts from existing tokens +router.get('/accounts', async (_req: Request, res: Response) => { + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + try { + const authStatus = await fetchRemoteAuthStatus(target); + // Transform RemoteAuthStatus[] to account summary format + const accounts = authStatus.flatMap((status) => + status.accounts.map((acc) => ({ + provider: status.provider, + ...acc, + })) + ); + res.json({ accounts, source: 'remote' }); + return; + } catch (error) { + res.status(503).json({ + error: (error as Error).message, + accounts: [], + source: 'remote', + }); + return; + } + } + + // Local mode: Initialize accounts from existing tokens initializeAccounts(); const accounts = getAllAccountsSummary(); @@ -118,6 +161,15 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { * POST /api/cliproxy/accounts/:provider/default - Set default account for provider */ router.post('/accounts/:provider/default', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider } = req.params; const { accountId } = req.body; @@ -145,6 +197,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account */ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider, accountId } = req.params; // Validate provider