diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts new file mode 100644 index 00000000..7fd95ec4 --- /dev/null +++ b/src/cliproxy/management-api-client.ts @@ -0,0 +1,405 @@ +/** + * Management API Client for CLIProxyAPI + * + * HTTP client for CLIProxy Management API endpoints. + * Handles authentication, error mapping, and provides typed methods for CRUD operations. + */ + +import * as https from 'https'; +import type { + ManagementClientConfig, + ManagementHealthStatus, + ManagementApiErrorCode, + ClaudeKey, + GetClaudeKeysResponse, + ClaudeKeyPatch, +} from './management-api-types'; + +/** Default timeout for management operations (longer than health check) */ +const DEFAULT_TIMEOUT_MS = 5000; + +/** Default port for HTTP protocol */ +const DEFAULT_HTTP_PORT = 8317; + +/** Default port for HTTPS protocol */ +const DEFAULT_HTTPS_PORT = 443; + +/** + * Get effective port based on config and protocol. + */ +function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number { + if (port !== undefined && port > 0 && port <= 65535) { + return port; + } + return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT; +} + +/** + * Build URL for Management API endpoint. + */ +function buildUrl(config: ManagementClientConfig, path: string): string { + const port = getEffectivePort(config.port, config.protocol); + // Only omit port if it matches standard web ports + if ( + (config.protocol === 'https' && port === 443) || + (config.protocol === 'http' && port === 80) + ) { + return `${config.protocol}://${config.host}${path}`; + } + return `${config.protocol}://${config.host}:${port}${path}`; +} + +/** + * Map error to ManagementApiErrorCode. + */ +function mapErrorToCode(error: Error, statusCode?: number): ManagementApiErrorCode { + const message = error.message.toLowerCase(); + const rawCode = (error as NodeJS.ErrnoException).code; + const code = typeof rawCode === 'string' ? rawCode.toLowerCase() : undefined; + + // DNS resolution failed + if (code === 'enotfound' || code === 'eai_again' || message.includes('dns')) { + return 'DNS_FAILED'; + } + + // Network unreachable + if (code === 'enetunreach' || code === 'ehostunreach' || message.includes('unreachable')) { + return 'NETWORK_UNREACHABLE'; + } + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if (code === 'etimedout' || message.includes('timeout') || message.includes('aborted')) { + return 'TIMEOUT'; + } + + // HTTP status codes + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + if (statusCode === 404) { + return 'NOT_FOUND'; + } + if (statusCode === 400) { + return 'BAD_REQUEST'; + } + if (statusCode && statusCode >= 500) { + return 'SERVER_ERROR'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code. + */ +function getErrorMessage(errorCode: ManagementApiErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is CLIProxy running?'; + case 'TIMEOUT': + return 'Request timed out - server may be slow or unreachable'; + case 'AUTH_FAILED': + return 'Authentication failed - check management key'; + case 'DNS_FAILED': + return 'DNS lookup failed - check hostname'; + case 'NETWORK_UNREACHABLE': + return 'Network unreachable - check if host is accessible'; + case 'NOT_FOUND': + return 'Endpoint not found - check CLIProxy version'; + case 'BAD_REQUEST': + return 'Invalid request - check payload format'; + case 'SERVER_ERROR': + return 'Server error - check CLIProxy logs'; + default: + return rawError || 'Request failed'; + } +} + +/** + * Management API Client for CLIProxyAPI. + * Provides typed methods for CRUD operations on claude-api-key configuration. + */ +export class ManagementApiClient { + private readonly config: ManagementClientConfig; + private readonly timeout: number; + + constructor(config: ManagementClientConfig) { + this.config = config; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + } + + /** + * Build base URL for display purposes. + */ + getBaseUrl(): string { + return buildUrl(this.config, ''); + } + + /** + * Check health of Management API. + * Uses GET /v0/management/claude-api-key as health check. + */ + async health(): Promise { + const startTime = Date.now(); + try { + const response = await this.request('GET', '/v0/management/claude-api-key'); + const latencyMs = Date.now() - startTime; + + return { + healthy: true, + latencyMs, + version: response.headers?.['x-cpa-version'], + commit: response.headers?.['x-cpa-commit'], + }; + } catch (error) { + const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode }; + return { + healthy: false, + error: err.message, + errorCode: err.errorCode ?? 'UNKNOWN', + }; + } + } + + /** + * Get all claude-api-key entries from remote CLIProxy. + */ + async getClaudeKeys(): Promise { + const response = await this.request( + 'GET', + '/v0/management/claude-api-key' + ); + return response.data?.['claude-api-key'] ?? []; + } + + /** + * Replace all claude-api-key entries on remote CLIProxy. + * This is an atomic operation - all entries are replaced at once. + */ + async putClaudeKeys(keys: ClaudeKey[]): Promise { + await this.request('PUT', '/v0/management/claude-api-key', keys); + } + + /** + * Update a single claude-api-key entry by index or api-key match. + */ + async patchClaudeKey(patch: ClaudeKeyPatch): Promise { + await this.request('PATCH', '/v0/management/claude-api-key', patch); + } + + /** + * Delete a claude-api-key entry by api-key value. + */ + async deleteClaudeKey(apiKey: string): Promise { + const encodedKey = encodeURIComponent(apiKey); + await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`); + } + + /** + * Make an HTTP request to the Management API. + */ + private async request( + method: string, + path: string, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + const url = buildUrl(this.config, path); + + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${this.config.managementKey}`, + }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + // Use native https for self-signed cert support + if (this.config.protocol === 'https' && this.config.allowSelfSigned) { + return this.requestWithHttps(method, url, headers, body); + } + + return this.requestWithFetch(method, url, headers, body); + } + + /** + * Make request using native fetch API. + */ + private async requestWithFetch( + method: string, + url: string, + headers: Record, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorCode = mapErrorToCode(new Error(response.statusText), response.status); + const error = new Error(getErrorMessage(errorCode)) as Error & { + statusCode: number; + errorCode: ManagementApiErrorCode; + }; + error.statusCode = response.status; + error.errorCode = errorCode; + throw error; + } + + // Extract headers we care about + const responseHeaders: Record = {}; + const version = response.headers.get('x-cpa-version'); + const commit = response.headers.get('x-cpa-commit'); + if (version) responseHeaders['x-cpa-version'] = version; + if (commit) responseHeaders['x-cpa-commit'] = commit; + + // Parse JSON response if present + const text = await response.text(); + let data: T | undefined; + if (text) { + try { + data = JSON.parse(text) as T; + } catch { + // Non-JSON response is ok for PUT/DELETE + } + } + + return { data, headers: responseHeaders }; + } catch (error) { + clearTimeout(timeoutId); + const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode }; + if (!err.errorCode) { + err.errorCode = mapErrorToCode(err, err.statusCode); + err.message = getErrorMessage(err.errorCode, err.message); + } + throw err; + } + } + + /** + * Make request using native https module for self-signed cert support. + */ + private async requestWithHttps( + method: string, + url: string, + headers: Record, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + return new Promise((resolve, reject) => { + const agent = new https.Agent({ rejectUnauthorized: false }); + const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + + if (bodyStr) { + headers['Content-Length'] = Buffer.byteLength(bodyStr).toString(); + } + + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, this.timeout); + + const req = https.request( + url, + { + method, + headers, + agent, + timeout: this.timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + const errorCode = mapErrorToCode(new Error(res.statusMessage || ''), res.statusCode); + const error = new Error(getErrorMessage(errorCode)) as Error & { + statusCode: number; + errorCode: ManagementApiErrorCode; + }; + error.statusCode = res.statusCode; + error.errorCode = errorCode; + reject(error); + return; + } + + const responseHeaders: Record = {}; + const version = res.headers['x-cpa-version']; + const commit = res.headers['x-cpa-commit']; + if (typeof version === 'string') responseHeaders['x-cpa-version'] = version; + if (typeof commit === 'string') responseHeaders['x-cpa-commit'] = commit; + + let parsed: T | undefined; + if (data) { + try { + parsed = JSON.parse(data) as T; + } catch { + // Non-JSON response is ok + } + } + + resolve({ data: parsed, headers: responseHeaders }); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + const error = err as Error & { errorCode?: ManagementApiErrorCode }; + error.errorCode = mapErrorToCode(err); + error.message = getErrorMessage(error.errorCode, err.message); + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + const error = new Error('Request timeout') as Error & { errorCode: ManagementApiErrorCode }; + error.errorCode = 'TIMEOUT'; + reject(error); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } +} + +/** + * Create a ManagementApiClient from CCS config. + * Uses cliproxy_server.remote settings. + */ +export function createManagementClient( + remoteConfig: { + host: string; + port?: number; + protocol: 'http' | 'https'; + management_key?: string; + auth_token?: string; + timeout?: number; + }, + allowSelfSigned = true +): ManagementApiClient { + return new ManagementApiClient({ + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + managementKey: remoteConfig.management_key || remoteConfig.auth_token || '', + timeout: remoteConfig.timeout, + allowSelfSigned, + }); +} diff --git a/src/cliproxy/management-api-types.ts b/src/cliproxy/management-api-types.ts new file mode 100644 index 00000000..f0a9092a --- /dev/null +++ b/src/cliproxy/management-api-types.ts @@ -0,0 +1,123 @@ +/** + * Management API Types for CLIProxyAPI + * + * Type definitions matching CLIProxyAPI Go structs for Management API CRUD operations. + * Used for syncing CCS API profiles to remote CLIProxy instance. + */ + +/** + * Model alias within a ClaudeKey entry. + * Maps Claude model names to provider-specific models. + */ +export interface ClaudeModel { + /** Claude model name to match (e.g., "claude-3-5-sonnet") */ + name: string; + /** Target model to use instead (e.g., "glm-4.7-airx-thinking") */ + alias: string; +} + +/** + * ClaudeKey configuration for CLIProxy. + * Maps to config.ClaudeKey in CLIProxyAPI Go code. + */ +export interface ClaudeKey { + /** API key for the provider */ + 'api-key': string; + /** Prefix for model name matching (e.g., "glm-" matches "glm-*" requests) */ + prefix?: string; + /** Base URL for the provider API */ + 'base-url'?: string; + /** Optional proxy URL for requests */ + 'proxy-url'?: string; + /** Additional headers to include in requests */ + headers?: Record; + /** Models to exclude from this key */ + 'excluded-models'?: string[]; + /** Model name to alias mappings */ + models?: ClaudeModel[]; +} + +/** + * Configuration for the Management API client. + */ +export interface ManagementClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) */ + port?: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Management key for authentication (sent as Bearer token) */ + managementKey: string; + /** Request timeout in milliseconds (default: 5000) */ + timeout?: number; + /** Allow self-signed certificates for HTTPS (default: false) */ + allowSelfSigned?: boolean; +} + +/** + * Health check result from Management API. + */ +export interface ManagementHealthStatus { + /** Whether the Management API is reachable */ + healthy: boolean; + /** Version of CLIProxyAPI (from X-CPA-VERSION header) */ + version?: string; + /** Commit hash (from X-CPA-COMMIT header) */ + commit?: string; + /** Latency in milliseconds */ + latencyMs?: number; + /** Error message if not healthy */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: ManagementApiErrorCode; +} + +/** + * Error codes for Management API operations. + */ +export type ManagementApiErrorCode = + | 'CONNECTION_REFUSED' + | 'TIMEOUT' + | 'AUTH_FAILED' + | 'DNS_FAILED' + | 'NETWORK_UNREACHABLE' + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +/** + * Response from GET /v0/management/claude-api-key + */ +export interface GetClaudeKeysResponse { + 'claude-api-key': ClaudeKey[]; +} + +/** + * Patch request body for updating a single ClaudeKey. + */ +export interface ClaudeKeyPatch { + /** Index of the key to update (0-based) */ + index?: number; + /** Match by api-key value */ + match?: string; + /** Fields to update */ + value: Partial; +} + +/** + * Sync status for tracking sync operations. + */ +export interface SyncStatus { + /** Last sync timestamp (ISO 8601) */ + lastSyncAt?: string; + /** Number of profiles synced */ + profileCount: number; + /** Whether sync was successful */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Remote CLIProxy URL */ + remoteUrl?: string; +}