cc-haha/src/server/proxy/handler.ts
程序员阿江(Relakkes) 8b7abb41b7 feat: Add unified AI network settings
Settings General now owns AI request timeout and proxy policy so Anthropic-native and OpenAI-compatible provider paths share one user-visible control. The desktop UI persists the network settings, the server proxy and provider checks read them directly, and CLI sessions receive the same timeout/proxy environment.

Constraint: Provider request behavior must be consistent across Anthropic, OpenAI Chat, and OpenAI Responses formats
Rejected: Keep preset API_TIMEOUT_MS precedence | it would make the General timeout control unreliable for some providers
Confidence: high
Scope-risk: moderate
Directive: Do not add protocol-specific AI request timeout controls without preserving General as the final override
Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/network-settings.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/proxy-network-settings.test.ts
Tested: cd desktop && bun run test -- --run src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx
Tested: bun run check:desktop
Tested: bun run check:server
Not-tested: socks5 manual proxy support; manual proxy validation currently accepts HTTP/HTTPS URLs only
2026-05-19 17:48:05 +08:00

231 lines
7.3 KiB
TypeScript

/**
* 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 { ProviderService } from '../services/providerService.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'
// 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 },
)
}
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 transformed = anthropicToOpenaiChat(body)
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: JSON.stringify(transformed),
signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)),
...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',
},
})
}
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiChatToAnthropic(responseBody, body.model)
return Response.json(anthropicResponse)
}
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: JSON.stringify(transformed),
signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)),
...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',
},
})
}
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model)
return Response.json(anthropicResponse)
}