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