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)
This commit is contained in:
kaitranntt
2025-12-21 00:42:54 -05:00
parent 9e2fd096e4
commit 17bb6f9836
3 changed files with 295 additions and 41 deletions
+150
View File
@@ -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<string, string> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
/** Display names for providers */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
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<RemoteAuthStatus[]> {
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<string, RemoteAuthFile[]>();
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;
}
+81 -38
View File
@@ -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<CliproxyStats | null> {
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | 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/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<CliproxyModelsResponse | null> {
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
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<CliproxyErrorLog[] | null> {
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
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<string | null> {
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<boolean> {
export async function isCliproxyRunning(port?: number): Promise<boolean> {
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,
});
+64 -3
View File
@@ -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