mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
231 lines
7.3 KiB
TypeScript
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)
|
|
}
|