cc-haha/src/server/proxy/transform/anthropicToOpenaiResponses.ts
程序员阿江(Relakkes) 2e52133a64 fix(provider): pass reasoning effort for proxies (#905)
Tested:
- bun test src/services/api/claudeEffort.test.ts src/server/__tests__/proxy-transform.test.ts src/utils/__tests__/thinking.test.ts
- bun run check:server
- git diff --check

Not-tested:
- bun run verify / coverage

Confidence: high
Scope-risk: narrow
2026-07-02 21:01:42 +08:00

192 lines
6.1 KiB
TypeScript

/**
* Request transformation: Anthropic Messages → OpenAI Responses API
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import type {
AnthropicRequest,
AnthropicContentBlock,
AnthropicMessage,
OpenAIResponsesRequest,
OpenAIResponsesInputItem,
OpenAIChatContentPart,
} from './types.js'
import { stripLeadingBillingHeader } from './billingHeader.js'
import { normalizeOpenAIReasoningEffort } from './effort.js'
export type OpenAIResponsesTransformOptions = {
/** Stable cache routing key, forwarded as `prompt_cache_key`. */
cacheKey?: string
passSamplingParams?: boolean
}
/**
* Convert Anthropic Messages request to OpenAI Responses API request.
*/
export function anthropicToOpenaiResponses(
body: AnthropicRequest,
options: OpenAIResponsesTransformOptions = {},
): OpenAIResponsesRequest {
const input: OpenAIResponsesInputItem[] = []
// Convert messages to input items
for (const msg of body.messages) {
convertMessageToInputItems(msg, input)
}
const result: OpenAIResponsesRequest = {
model: body.model,
input,
stream: body.stream,
store: false,
}
// system → instructions, minus the leading billing attribution: its
// rotating cch= signature would change the prefix every turn and defeat
// upstream prompt caching.
if (body.system) {
const instructions = typeof body.system === 'string'
? stripLeadingBillingHeader(body.system)
: body.system.map((b) => stripLeadingBillingHeader(b.text)).filter(Boolean).join('\n')
if (instructions) {
result.instructions = instructions
}
}
if (options.cacheKey) {
result.prompt_cache_key = options.cacheKey
}
// max_tokens — omit to let upstream provider use its own default/max.
// Claude Code sends very large values that exceed many providers' limits.
// Claude Code sends Anthropic sampling params that some compatible
// providers reject. Keep them opt-in for providers known to accept them.
if (options.passSamplingParams) {
if (body.temperature !== undefined) result.temperature = body.temperature
if (body.top_p !== undefined) result.top_p = body.top_p
}
// tools
if (body.tools && body.tools.length > 0) {
result.tools = body.tools
.filter((t) => t.name !== 'BatchTool')
.map((t) => ({
type: 'function',
name: t.name,
description: t.description,
parameters: t.input_schema,
}))
}
// tool_choice
if (body.tool_choice !== undefined) {
result.tool_choice = convertToolChoice(body.tool_choice)
}
// thinking → reasoning
if (body.thinking) {
const budget = body.thinking.budget_tokens
if (budget !== undefined) {
if (budget <= 1024) result.reasoning = { effort: 'low' }
else if (budget <= 8192) result.reasoning = { effort: 'medium' }
else result.reasoning = { effort: 'high' }
} else if (body.thinking.type === 'enabled') {
result.reasoning = { effort: 'high' }
}
}
const outputConfigEffort = normalizeOpenAIReasoningEffort(body.output_config?.effort)
if (outputConfigEffort !== undefined) {
result.reasoning = { ...(result.reasoning ?? {}), effort: outputConfigEffort }
}
// stop_sequences not supported in Responses API, dropped
return result
}
function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIResponsesInputItem[]): void {
const content = msg.content
// Simple string content
if (typeof content === 'string') {
output.push({ type: 'message', role: msg.role, content })
return
}
if (!Array.isArray(content) || content.length === 0) {
output.push({ type: 'message', role: msg.role, content: '' })
return
}
// Collect text/image parts and handle tool blocks separately
const contentParts: (string | OpenAIChatContentPart)[] = []
for (const block of content) {
if (block.type === 'text') {
contentParts.push(block.text)
} else if (block.type === 'image') {
contentParts.push({
type: 'image_url',
image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
})
} else if (block.type === 'tool_use') {
// Flush any accumulated content first
if (contentParts.length > 0) {
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
? contentParts[0]
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
if (flatContent) {
output.push({ type: 'message', role: msg.role, content: flatContent })
}
contentParts.length = 0
}
// Lift to function_call item
output.push({
type: 'function_call',
call_id: block.id,
name: block.name,
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
})
} else if (block.type === 'tool_result') {
// Lift to function_call_output item
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
: ''
output.push({
type: 'function_call_output',
call_id: block.tool_use_id,
output: resultContent,
})
}
// Skip thinking blocks
}
// Flush remaining content
if (contentParts.length > 0) {
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
? contentParts[0]
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
if (flatContent) {
output.push({ type: 'message', role: msg.role, content: flatContent })
}
}
}
function convertToolChoice(choice: unknown): unknown {
if (typeof choice === 'string') return choice
if (typeof choice === 'object' && choice !== null) {
const c = choice as Record<string, unknown>
if (c.type === 'auto') return 'auto'
if (c.type === 'any') return 'required'
if (c.type === 'none') return 'none'
if (c.type === 'tool' && typeof c.name === 'string') {
return { type: 'function', function: { name: c.name } }
}
}
return 'auto'
}