cc-haha/src/server/proxy/handler.ts
程序员阿江(Relakkes) 264b24a462 fix: make provider tests match chat model normalization (#620)
Provider connectivity checks and proxy validation are API boundaries, but they kept the user-facing context suffix in the model id while the chat runtime already strips it. Normalize these paths so MiMo-style models with [1m] test the same model that sessions send.

Constraint: [1m]/[2m] are client-side context markers and upstream APIs may reject them
Rejected: Strip suffixes when saving provider config | would change the user-visible model selection instead of only the API boundary
Confidence: high
Scope-risk: narrow
Directive: Keep all provider API boundaries aligned with normalizeModelStringForAPI before sending requests upstream
Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "normalizes context-window suffixes"
Tested: bun test src/server/__tests__/providers.test.ts
Tested: bun run check:server
Tested: git diff --check
Not-tested: Live MiMo API call; no provider credential was used
Related: #620
2026-06-01 20:03:53 +08:00

297 lines
9.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 { 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<typeof getProxyFetchOptions>
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<UpstreamRequestInit, 'signal'>,
timeoutMs: number,
isStream: boolean,
): Promise<Response> {
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<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 },
)
}
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<Response> {
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<Response> {
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)
}