| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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' |
|
|
| const providerService = new ProviderService() |
|
|
| export async function handleProxyRequest(req: Request, url: URL): Promise<Response> { |
| const providerMatch = url.pathname.match(/^\/proxy\/providers\/([^/]+)\/v1\/messages$/) |
| const providerId = providerMatch ? decodeURIComponent(providerMatch[1]!) : undefined |
| const isActiveProxyPath = url.pathname === '/proxy/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 }, |
| ) |
| } |
|
|
| |
| 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 }, |
| ) |
| } |
|
|
| |
| 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) |
|
|
| 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<Response> { |
| const deepSeekCompatible = shouldUseDeepSeekReasoningCompat(baseUrl) |
| const transformed = anthropicToOpenaiChat(body, { |
| roundTripReasoningContent: deepSeekCompatible, |
| passThinkingToggle: deepSeekCompatible, |
| }) |
| const url = `${baseUrl}/v1/chat/completions` |
| const proxyOptions = getProxyFetchOptions({ proxyUrl }) |
|
|
| const upstream = await fetch(url, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${apiKey}`, |
| }, |
| body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), |
| signal: AbortSignal.timeout(aiRequestTimeoutMs), |
| ...proxyOptions, |
| }) |
|
|
| 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', |
| }, |
| }) |
| } |
|
|
| |
| 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) |
| ) |
| } |
|
|
| async function handleOpenaiResponses( |
| body: AnthropicRequest, |
| baseUrl: string, |
| apiKey: string, |
| isStream: boolean, |
| aiRequestTimeoutMs: number, |
| proxyUrl: string | undefined, |
| ): Promise<Response> { |
| const transformed = anthropicToOpenaiResponses(body) |
| const url = `${baseUrl}/v1/responses` |
| const proxyOptions = getProxyFetchOptions({ proxyUrl }) |
|
|
| const upstream = await fetch(url, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${apiKey}`, |
| }, |
| body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), |
| signal: AbortSignal.timeout(aiRequestTimeoutMs), |
| ...proxyOptions, |
| }) |
|
|
| 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', |
| }, |
| }) |
| } |
|
|
| |
| const responseBody = await upstream.json() |
| const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model) |
| return Response.json(anthropicResponse) |
| } |
|
|