mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user