mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +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.
|
* 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 */
|
/** Per-account usage statistics */
|
||||||
export interface AccountUsageStats {
|
export interface AccountUsageStats {
|
||||||
@@ -95,19 +96,27 @@ interface UsageApiResponse {
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns Stats object or null if unavailable
|
* @returns Stats object or null if unavailable
|
||||||
*/
|
*/
|
||||||
export async function fetchCliproxyStats(
|
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
|
||||||
port: number = CLIPROXY_DEFAULT_PORT
|
|
||||||
): Promise<CliproxyStats | null> {
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
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,
|
signal: controller.signal,
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -222,20 +231,27 @@ export interface CliproxyModelsResponse {
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns Categorized models or null if unavailable
|
* @returns Categorized models or null if unavailable
|
||||||
*/
|
*/
|
||||||
export async function fetchCliproxyModels(
|
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
|
||||||
port: number = CLIPROXY_DEFAULT_PORT
|
|
||||||
): Promise<CliproxyModelsResponse | null> {
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
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,
|
signal: controller.signal,
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'application/json',
|
|
||||||
// Use the internal API key for /v1 endpoints
|
|
||||||
Authorization: 'Bearer ccs-internal-managed',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -293,19 +309,27 @@ interface ErrorLogsApiResponse {
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns Array of error log metadata or null if unavailable
|
* @returns Array of error log metadata or null if unavailable
|
||||||
*/
|
*/
|
||||||
export async function fetchCliproxyErrorLogs(
|
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
|
||||||
port: number = CLIPROXY_DEFAULT_PORT
|
|
||||||
): Promise<CliproxyErrorLog[] | null> {
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
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,
|
signal: controller.signal,
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs(
|
|||||||
*/
|
*/
|
||||||
export async function fetchCliproxyErrorLogContent(
|
export async function fetchCliproxyErrorLogContent(
|
||||||
name: string,
|
name: string,
|
||||||
port: number = CLIPROXY_DEFAULT_PORT
|
port?: number
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
const response = await fetch(
|
// Dynamic target resolution
|
||||||
`http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`,
|
const target = getProxyTarget();
|
||||||
{
|
// Allow port override for local testing only
|
||||||
signal: controller.signal,
|
if (port !== undefined && !target.isRemote) {
|
||||||
headers: {
|
target.port = port;
|
||||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
}
|
||||||
},
|
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);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent(
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns true if proxy is running
|
* @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 {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||||
|
|
||||||
// Use root endpoint - CLIProxyAPI returns server info at /
|
// Dynamic target resolution
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
removeAccount as removeAccountFn,
|
removeAccount as removeAccountFn,
|
||||||
touchAccount,
|
touchAccount,
|
||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
|
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||||
|
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
|
||||||
const router = Router();
|
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
|
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||||
*/
|
*/
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
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();
|
initializeAccounts();
|
||||||
|
|
||||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
// 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
|
* GET /api/cliproxy/accounts - Get all accounts across all providers
|
||||||
*/
|
*/
|
||||||
router.get('/accounts', (_req: Request, res: Response) => {
|
router.get('/accounts', async (_req: Request, res: Response) => {
|
||||||
// Initialize accounts from existing tokens
|
// 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();
|
initializeAccounts();
|
||||||
|
|
||||||
const accounts = getAllAccountsSummary();
|
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
|
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
|
||||||
*/
|
*/
|
||||||
router.post('/accounts/:provider/default', (req: Request, res: Response): void => {
|
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 { provider } = req.params;
|
||||||
const { accountId } = req.body;
|
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
|
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
|
||||||
*/
|
*/
|
||||||
router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => {
|
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;
|
const { provider, accountId } = req.params;
|
||||||
|
|
||||||
// Validate provider
|
// Validate provider
|
||||||
|
|||||||
Reference in New Issue
Block a user