cc-haha/src/server/proxy/handler.ts
程序员阿江(Relakkes) 773a8a703f Keep desktop chats on the selected provider/model
The desktop model picker now stores a session-scoped provider/model selection instead of relying on the global active provider. That selection is replayed on connect, passed into the CLI startup path, and preserved across turns until the user changes it again.

To make that true end-to-end, the server now restarts the session process when runtime selection changes, injects provider-scoped env for third-party providers, and routes proxy traffic by provider id. The selector UI was also tightened so provider grouping stays visible while the actual model choice remains readable.

Constraint: Different providers can expose the same model id, so chat runtime selection cannot be derived from model id alone
Constraint: A desktop session reuses one CLI subprocess across turns, so runtime changes must restart that process to take effect
Rejected: Keep using Settings active provider as the chat selector | conflates defaults with live session state and breaks overlapping models
Rejected: UI-only runtime switching without server restart | later turns would continue using the old CLI subprocess configuration
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep provider defaults and session runtime overrides separate, and preserve provider-scoped proxy routing when extending model selection surfaces
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test src/stores/chatStore.test.ts
Tested: cd desktop && bun run test src/__tests__/generalSettings.test.tsx
Tested: bun test src/server/__tests__/conversation-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: bun -e "await import('./src/server/services/titleService.ts'); await import('./src/server/ws/handler.ts')"
Not-tested: Real third-party provider round-trip from the desktop UI against a live upstream account
2026-04-23 13:41:27 +08:00

219 lines
6.6 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'
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(/\/+$/, '')
try {
if (config.apiFormat === 'openai_chat') {
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
} else {
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
}
} 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,
): Promise<Response> {
const transformed = anthropicToOpenaiChat(body)
const url = `${baseUrl}/v1/chat/completions`
const upstream = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(transformed),
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
})
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,
): Promise<Response> {
const transformed = anthropicToOpenaiResponses(body)
const url = `${baseUrl}/v1/responses`
const upstream = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(transformed),
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
})
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)
}