/** * Proxy Handler — protocol-translating reverse proxy for OpenAI-compatible APIs. * * Receives Anthropic Messages API requests from the CLI, transforms them to * OpenAI Chat Completions or Responses API format, forwards to the upstream * provider, and transforms the response back to Anthropic format. * * Derived from cc-switch (https://github.com/farion1231/cc-switch) * Original work by Jason Young, MIT License */ import { signClaudeCodeCCHInTransformedString } from '../../utils/claudeCodeCch.js' import { ProviderService } from '../services/providerService.js' import { ensureClaudeCodeAttribution } from './claudeCodeAttribution.js' import { anthropicToOpenaiChat } from './transform/anthropicToOpenaiChat.js' import { anthropicToOpenaiResponses } from './transform/anthropicToOpenaiResponses.js' import { openaiChatToAnthropic } from './transform/openaiChatToAnthropic.js' import { openaiResponsesToAnthropic } from './transform/openaiResponsesToAnthropic.js' import { openaiChatStreamToAnthropic } from './streaming/openaiChatStreamToAnthropic.js' import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStreamToAnthropic.js' import type { AnthropicRequest } from './transform/types.js' import { getProxyFetchOptions } from '../../utils/proxy.js' import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/networkSettings.js' import { normalizeModelStringForAPI } from '../../utils/model/model.js' const providerService = new ProviderService() type ProxyFetchOptions = ReturnType type UpstreamRequestInit = RequestInit & ProxyFetchOptions function createTimeoutController(timeoutMs: number): { signal: AbortSignal clear: () => void } { const controller = new AbortController() const timer = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs) return { signal: controller.signal, clear: () => clearTimeout(timer), } } async function fetchUpstreamWithTimeout( url: string, init: Omit, timeoutMs: number, isStream: boolean, ): Promise { if (!isStream) { return fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs), }) } // For streaming requests, this timeout should only cover the connection and // response headers. Keeping the signal alive aborts long generations mid-body. const timeout = createTimeoutController(timeoutMs) try { return await fetch(url, { ...init, signal: timeout.signal, }) } finally { timeout.clear() } } export async function handleProxyRequest(req: Request, url: URL): Promise { const providerMatch = url.pathname.match(/^\/proxy\/providers\/([^/]+)\/v1\/messages$/) const providerId = providerMatch ? decodeURIComponent(providerMatch[1]!) : undefined const isActiveProxyPath = url.pathname === '/proxy/v1/messages' // Only handle POST /proxy/v1/messages or POST /proxy/providers/:providerId/v1/messages if (req.method !== 'POST' || (!isActiveProxyPath && !providerMatch)) { return Response.json( { error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages and POST /proxy/providers/:providerId/v1/messages', }, { status: 404 }, ) } // Read active/default provider config or an explicitly-scoped provider config. const config = await providerService.getProviderForProxy(providerId) if (!config) { return Response.json( { type: 'error', error: { type: 'invalid_request_error', message: providerId ? `Provider "${providerId}" is not configured for proxy` : 'No active provider configured for proxy', }, }, { status: 400 }, ) } if (config.apiFormat === 'anthropic') { return Response.json( { type: 'error', error: { type: 'invalid_request_error', message: providerId ? `Provider "${providerId}" uses anthropic format — proxy not needed` : 'Active provider uses anthropic format — proxy not needed', }, }, { status: 400 }, ) } // Parse request body let body: AnthropicRequest try { body = (await req.json()) as AnthropicRequest } catch { return Response.json( { type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } }, { status: 400 }, ) } body = ensureClaudeCodeAttribution({ ...body, model: normalizeModelStringForAPI(body.model), }) const isStream = body.stream === true const baseUrl = config.baseUrl.replace(/\/+$/, '') const networkSettings = await loadNetworkSettings() const proxyUrl = getManualNetworkProxyUrl(networkSettings) try { if (config.apiFormat === 'openai_chat') { return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl) } else { return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl) } } catch (err) { console.error('[Proxy] Upstream request failed:', err) return Response.json( { type: 'error', error: { type: 'api_error', message: err instanceof Error ? err.message : String(err), }, }, { status: 502 }, ) } } async function handleOpenaiChat( body: AnthropicRequest, baseUrl: string, apiKey: string, isStream: boolean, aiRequestTimeoutMs: number, proxyUrl: string | undefined, ): Promise { const deepSeekCompatible = shouldUseDeepSeekReasoningCompat(baseUrl) const transformed = anthropicToOpenaiChat(body, { roundTripReasoningContent: deepSeekCompatible, passThinkingToggle: deepSeekCompatible, imageContentMode: shouldUseTextOnlyOpenAIChatContent(baseUrl) ? 'text_only' : 'vision', }) const url = `${baseUrl}/v1/chat/completions` const proxyOptions = getProxyFetchOptions({ proxyUrl }) const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), ...proxyOptions, }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '') return Response.json( { type: 'error', error: { type: 'api_error', message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, }, { status: upstream.status }, ) } if (isStream) { if (!upstream.body) { return Response.json( { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } }, { status: 502 }, ) } const anthropicStream = openaiChatStreamToAnthropic(upstream.body, body.model) return new Response(anthropicStream, { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } // Non-streaming const responseBody = await upstream.json() const anthropicResponse = openaiChatToAnthropic(responseBody, body.model) return Response.json(anthropicResponse) } function shouldUseDeepSeekReasoningCompat(baseUrl: string): boolean { return ( /(^|[./-])deepseek([./-]|$)/i.test(baseUrl) || /(^|[./-])opencode\.ai([:/]|$)/i.test(baseUrl) ) } function shouldUseTextOnlyOpenAIChatContent(baseUrl: string): boolean { return shouldUseDeepSeekReasoningCompat(baseUrl) } async function handleOpenaiResponses( body: AnthropicRequest, baseUrl: string, apiKey: string, isStream: boolean, aiRequestTimeoutMs: number, proxyUrl: string | undefined, ): Promise { const transformed = anthropicToOpenaiResponses(body) const url = `${baseUrl}/v1/responses` const proxyOptions = getProxyFetchOptions({ proxyUrl }) const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), ...proxyOptions, }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '') return Response.json( { type: 'error', error: { type: 'api_error', message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, }, { status: upstream.status }, ) } if (isStream) { if (!upstream.body) { return Response.json( { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } }, { status: 502 }, ) } const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, body.model) return new Response(anthropicStream, { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } // Non-streaming const responseBody = await upstream.json() const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model) return Response.json(anthropicResponse) }