/** * CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance. * * Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI * internally detects Claude models and routes them through its Claude executor * with full emulation (CCH signing, billing header, system prompt, uTLS, * multi-account rotation, device profile learning, etc.). * * The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER * to use CLIProxyAPI as the backend, not the wire format. Response format * is always OpenAI-compatible, so chatCore's SSE parsing works unchanged. * * Activation: * 1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback) * 2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI) */ import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals, type ProviderCredentials, } from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; const HEALTH_CHECK_TIMEOUT_MS = 5000; function resolveCliproxyapiBaseUrl(): string { const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST; const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10); return `http://${host}:${port}`; } export { resolveCliproxyapiBaseUrl }; /** * Check if a connection has CLIProxyAPI deep mode enabled via UI toggle. * Used by chatCore's resolveExecutorWithProxy to decide routing. */ export function isCliproxyapiDeepModeEnabled( providerSpecificData?: Record | null ): boolean { return providerSpecificData?.cliproxyapiMode === "claude-native"; } export class CliproxyapiExecutor extends BaseExecutor { private readonly upstreamBaseUrl: string; constructor(baseUrl?: string) { const effectiveBase = baseUrl ?? resolveCliproxyapiBaseUrl(); super("cliproxyapi", { id: "cliproxyapi", baseUrl: effectiveBase + "/v1/chat/completions", headers: { "Content-Type": "application/json" }, }); this.upstreamBaseUrl = effectiveBase; } buildUrl( _model: string, _stream: boolean, _urlIndex = 0, _credentials: ProviderCredentials | null = null ): string { // Always OpenAI-compatible. CLIProxyAPI detects Claude models internally // and applies full emulation (CCH, billing header, system prompt, uTLS). return `${this.upstreamBaseUrl}/v1/chat/completions`; } buildHeaders(credentials: ProviderCredentials | null, stream = true): Record { const key = credentials?.apiKey || credentials?.accessToken; const headers: Record = { "Content-Type": "application/json", }; if (key) { headers["Authorization"] = `Bearer ${key}`; } if (stream) { headers["Accept"] = "text/event-stream"; } return headers; } transformRequest( model: string, body: unknown, _stream: boolean, _credentials: ProviderCredentials | null ): unknown { if (!body || typeof body !== "object") return body; const transformed = { ...(body as Record) }; if (transformed.model !== model) { transformed.model = model; } return transformed; } async execute(input: { model: string; body: unknown; stream: boolean; credentials: ProviderCredentials; signal?: AbortSignal | null; log?: any; upstreamExtraHeaders?: Record | null; }) { const url = this.buildUrl(input.model, input.stream, 0, input.credentials); const headers = this.buildHeaders(input.credentials, input.stream); const transformedBody = this.transformRequest( input.model, input.body, input.stream, input.credentials ); mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders); const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = input.signal ? mergeAbortSignals(input.signal, timeoutSignal) : timeoutSignal; input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model})`); const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(transformedBody), signal: combinedSignal, }); if (response.status === HTTP_STATUS.RATE_LIMITED) { input.log?.warn?.("CPA", `CLIProxyAPI rate limited: ${response.status}`); } return { response, url, headers, transformedBody }; } /** * Health check — verifies CLIProxyAPI is reachable. */ async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> { const start = Date.now(); try { const res = await fetch(`${this.upstreamBaseUrl}/health`, { signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), }); return { ok: res.ok, latencyMs: Date.now() - start, ...(!res.ok ? { error: `HTTP ${res.status}` } : {}), }; } catch (err) { return { ok: false, latencyMs: Date.now() - start, error: err instanceof Error ? err.message : String(err), }; } } } export default CliproxyapiExecutor;