mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +00:00
refactor: 提升 OpenAI 转换器和模型映射到 model-provider 包
- 搬入 OpenAI 消息转换(convertMessages)、工具转换(convertTools)、流适配(streamAdapter) - 搬入 OpenAI 和 Grok 模型映射(resolveOpenAIModel、resolveGrokModel) - 主项目文件改为 thin re-export proxy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Default mapping from Anthropic model names to Grok model names.
|
||||||
|
*
|
||||||
|
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||||
|
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||||
|
*/
|
||||||
|
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||||
|
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||||
|
'claude-sonnet-4-5-20250929': 'grok-3-mini-fast',
|
||||||
|
'claude-sonnet-4-6': 'grok-3-mini-fast',
|
||||||
|
'claude-opus-4-20250514': 'grok-4.20-reasoning',
|
||||||
|
'claude-opus-4-1-20250805': 'grok-4.20-reasoning',
|
||||||
|
'claude-opus-4-5-20251101': 'grok-4.20-reasoning',
|
||||||
|
'claude-opus-4-6': 'grok-4.20-reasoning',
|
||||||
|
'claude-haiku-4-5-20251001': 'grok-3-mini-fast',
|
||||||
|
'claude-3-5-haiku-20241022': 'grok-3-mini-fast',
|
||||||
|
'claude-3-7-sonnet-20250219': 'grok-3-mini-fast',
|
||||||
|
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||||
|
opus: 'grok-4.20-reasoning',
|
||||||
|
sonnet: 'grok-3-mini-fast',
|
||||||
|
haiku: 'grok-3-mini-fast',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
|
if (/opus/i.test(model)) return 'opus'
|
||||||
|
if (/sonnet/i.test(model)) return 'sonnet'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserModelMap(): Record<string, string> | null {
|
||||||
|
const raw = process.env.GROK_MODEL_MAP
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, string>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Grok model name for a given Anthropic model.
|
||||||
|
*/
|
||||||
|
export function resolveGrokModel(anthropicModel: string): string {
|
||||||
|
if (process.env.GROK_MODEL) {
|
||||||
|
return process.env.GROK_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
|
const family = getModelFamily(cleanModel)
|
||||||
|
|
||||||
|
const userMap = getUserModelMap()
|
||||||
|
if (userMap && family && userMap[family]) {
|
||||||
|
return userMap[family]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family) {
|
||||||
|
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
|
const grokOverride = process.env[grokEnvVar]
|
||||||
|
if (grokOverride) return grokOverride
|
||||||
|
|
||||||
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
|
if (anthropicOverride) return anthropicOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||||
|
return DEFAULT_MODEL_MAP[cleanModel]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||||
|
return DEFAULT_FAMILY_MAP[family]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanModel
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Default mapping from Anthropic model names to OpenAI model names.
|
||||||
|
* Used only when ANTHROPIC_DEFAULT_*_MODEL env vars are not set.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||||
|
'claude-sonnet-4-20250514': 'gpt-4o',
|
||||||
|
'claude-sonnet-4-5-20250929': 'gpt-4o',
|
||||||
|
'claude-sonnet-4-6': 'gpt-4o',
|
||||||
|
'claude-opus-4-20250514': 'o3',
|
||||||
|
'claude-opus-4-1-20250805': 'o3',
|
||||||
|
'claude-opus-4-5-20251101': 'o3',
|
||||||
|
'claude-opus-4-6': 'o3',
|
||||||
|
'claude-haiku-4-5-20251001': 'gpt-4o-mini',
|
||||||
|
'claude-3-5-haiku-20241022': 'gpt-4o-mini',
|
||||||
|
'claude-3-7-sonnet-20250219': 'gpt-4o',
|
||||||
|
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
|
if (/opus/i.test(model)) return 'opus'
|
||||||
|
if (/sonnet/i.test(model)) return 'sonnet'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenAI model name for a given Anthropic model.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. OPENAI_MODEL env var (override all)
|
||||||
|
* 2. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL)
|
||||||
|
* 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility)
|
||||||
|
* 4. DEFAULT_MODEL_MAP lookup
|
||||||
|
* 5. Pass through original model name
|
||||||
|
*/
|
||||||
|
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||||
|
if (process.env.OPENAI_MODEL) {
|
||||||
|
return process.env.OPENAI_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
|
|
||||||
|
const family = getModelFamily(cleanModel)
|
||||||
|
if (family) {
|
||||||
|
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
|
const openaiOverride = process.env[openaiEnvVar]
|
||||||
|
if (openaiOverride) return openaiOverride
|
||||||
|
|
||||||
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
|
if (anthropicOverride) return anthropicOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import type {
|
||||||
|
BetaContentBlockParam,
|
||||||
|
BetaToolResultBlockParam,
|
||||||
|
BetaToolUseBlock,
|
||||||
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type {
|
||||||
|
ChatCompletionAssistantMessageParam,
|
||||||
|
ChatCompletionMessageParam,
|
||||||
|
ChatCompletionSystemMessageParam,
|
||||||
|
ChatCompletionToolMessageParam,
|
||||||
|
ChatCompletionUserMessageParam,
|
||||||
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
|
import type { AssistantMessage, UserMessage } from '../types/message.js'
|
||||||
|
import type { SystemPrompt } from '../types/systemPrompt.js'
|
||||||
|
|
||||||
|
export interface ConvertMessagesOptions {
|
||||||
|
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
||||||
|
* (required for DeepSeek thinking mode with tool calls). */
|
||||||
|
enableThinking?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert internal (UserMessage | AssistantMessage)[] to OpenAI-format messages.
|
||||||
|
*
|
||||||
|
* Key conversions:
|
||||||
|
* - system prompt → role: "system" message prepended
|
||||||
|
* - tool_use blocks → tool_calls[] on assistant message
|
||||||
|
* - tool_result blocks → role: "tool" messages
|
||||||
|
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
|
||||||
|
* - cache_control → stripped
|
||||||
|
*/
|
||||||
|
export function anthropicMessagesToOpenAI(
|
||||||
|
messages: (UserMessage | AssistantMessage)[],
|
||||||
|
systemPrompt: SystemPrompt,
|
||||||
|
options?: ConvertMessagesOptions,
|
||||||
|
): ChatCompletionMessageParam[] {
|
||||||
|
const result: ChatCompletionMessageParam[] = []
|
||||||
|
const enableThinking = options?.enableThinking ?? false
|
||||||
|
|
||||||
|
// Prepend system prompt as system message
|
||||||
|
const systemText = systemPromptToText(systemPrompt)
|
||||||
|
if (systemText) {
|
||||||
|
result.push({
|
||||||
|
role: 'system',
|
||||||
|
content: systemText,
|
||||||
|
} satisfies ChatCompletionSystemMessageParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When thinking mode is on, detect turn boundaries so that reasoning_content
|
||||||
|
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
|
||||||
|
// A "new turn" starts when a user text message appears after at least one assistant response.
|
||||||
|
const turnBoundaries = new Set<number>()
|
||||||
|
if (enableThinking) {
|
||||||
|
let hasSeenAssistant = false
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i]
|
||||||
|
if (msg.type === 'assistant') {
|
||||||
|
hasSeenAssistant = true
|
||||||
|
}
|
||||||
|
if (msg.type === 'user' && hasSeenAssistant) {
|
||||||
|
const content = msg.message.content
|
||||||
|
// A user message starts a new turn if it contains any non-tool_result content
|
||||||
|
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||||
|
// because they are continuations of the previous assistant tool call.
|
||||||
|
const startsNewUserTurn = typeof content === 'string'
|
||||||
|
? content.length > 0
|
||||||
|
: Array.isArray(content) && content.some(
|
||||||
|
(b: any) =>
|
||||||
|
typeof b === 'string' ||
|
||||||
|
(b &&
|
||||||
|
typeof b === 'object' &&
|
||||||
|
'type' in b &&
|
||||||
|
b.type !== 'tool_result'),
|
||||||
|
)
|
||||||
|
if (startsNewUserTurn) {
|
||||||
|
turnBoundaries.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i]
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'user':
|
||||||
|
result.push(...convertInternalUserMessage(msg))
|
||||||
|
break
|
||||||
|
case 'assistant':
|
||||||
|
// Preserve reasoning_content unless we're before a turn boundary
|
||||||
|
// (i.e., from a previous user Q&A round)
|
||||||
|
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||||
|
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||||
|
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||||
|
return systemPrompt
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
|
||||||
|
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
|
||||||
|
*/
|
||||||
|
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
|
||||||
|
for (const b of boundaries) {
|
||||||
|
if (i < b) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertInternalUserMessage(
|
||||||
|
msg: UserMessage,
|
||||||
|
): ChatCompletionMessageParam[] {
|
||||||
|
const result: ChatCompletionMessageParam[] = []
|
||||||
|
const content = msg.message.content
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
result.push({
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
} satisfies ChatCompletionUserMessageParam)
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
const textParts: string[] = []
|
||||||
|
const toolResults: BetaToolResultBlockParam[] = []
|
||||||
|
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
textParts.push(block)
|
||||||
|
} else if (block.type === 'text') {
|
||||||
|
textParts.push(block.text)
|
||||||
|
} else if (block.type === 'tool_result') {
|
||||||
|
toolResults.push(block as BetaToolResultBlockParam)
|
||||||
|
} else if (block.type === 'image') {
|
||||||
|
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
||||||
|
if (imagePart) {
|
||||||
|
imageParts.push(imagePart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: tool messages must come BEFORE any user message in the result.
|
||||||
|
// OpenAI API requires that a tool message immediately follows the assistant
|
||||||
|
// message with tool_calls. If we emit a user message first, the API will
|
||||||
|
// reject the request with "insufficient tool messages following tool_calls".
|
||||||
|
for (const tr of toolResults) {
|
||||||
|
result.push(convertToolResult(tr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有图片,构建多模态 content 数组
|
||||||
|
if (imageParts.length > 0) {
|
||||||
|
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||||
|
}
|
||||||
|
multiContent.push(...imageParts)
|
||||||
|
result.push({
|
||||||
|
role: 'user',
|
||||||
|
content: multiContent,
|
||||||
|
} satisfies ChatCompletionUserMessageParam)
|
||||||
|
} else if (textParts.length > 0) {
|
||||||
|
result.push({
|
||||||
|
role: 'user',
|
||||||
|
content: textParts.join('\n'),
|
||||||
|
} satisfies ChatCompletionUserMessageParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToolResult(
|
||||||
|
block: BetaToolResultBlockParam,
|
||||||
|
): ChatCompletionToolMessageParam {
|
||||||
|
let content: string
|
||||||
|
if (typeof block.content === 'string') {
|
||||||
|
content = block.content
|
||||||
|
} else if (Array.isArray(block.content)) {
|
||||||
|
content = block.content
|
||||||
|
.map(c => {
|
||||||
|
if (typeof c === 'string') return c
|
||||||
|
if ('text' in c) return c.text
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
} else {
|
||||||
|
content = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: block.tool_use_id,
|
||||||
|
content,
|
||||||
|
} satisfies ChatCompletionToolMessageParam
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertInternalAssistantMessage(
|
||||||
|
msg: AssistantMessage,
|
||||||
|
preserveReasoning = false,
|
||||||
|
): ChatCompletionMessageParam[] {
|
||||||
|
const content = msg.message.content
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
} satisfies ChatCompletionAssistantMessageParam,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
} satisfies ChatCompletionAssistantMessageParam,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const textParts: string[] = []
|
||||||
|
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||||
|
const reasoningParts: string[] = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
textParts.push(block)
|
||||||
|
} else if (block.type === 'text') {
|
||||||
|
textParts.push(block.text)
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
const tu = block as BetaToolUseBlock
|
||||||
|
toolCalls.push({
|
||||||
|
id: tu.id,
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tu.name,
|
||||||
|
arguments:
|
||||||
|
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||||
|
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||||
|
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
||||||
|
if (typeof thinkingText === 'string' && thinkingText) {
|
||||||
|
reasoningParts.push(thinkingText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip redacted_thinking, server_tool_use, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ChatCompletionAssistantMessageParam = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||||
|
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||||
|
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
||||||
|
}
|
||||||
|
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Anthropic image 块转换为 OpenAI image_url 格式。
|
||||||
|
*
|
||||||
|
* Anthropic 格式: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
||||||
|
* OpenAI 格式: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
|
||||||
|
*/
|
||||||
|
function convertImageBlockToOpenAI(
|
||||||
|
block: Record<string, unknown>,
|
||||||
|
): { type: 'image_url'; image_url: { url: string } } | null {
|
||||||
|
const source = block.source as Record<string, unknown> | undefined
|
||||||
|
if (!source) return null
|
||||||
|
|
||||||
|
if (source.type === 'base64' && typeof source.data === 'string') {
|
||||||
|
const mediaType = (source.media_type as string) || 'image/png'
|
||||||
|
return {
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: `data:${mediaType};base64,${source.data}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// url 类型的图片直接传递
|
||||||
|
if (source.type === 'url' && typeof source.url === 'string') {
|
||||||
|
return {
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: source.url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions.mjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Anthropic tool schemas to OpenAI function calling format.
|
||||||
|
*
|
||||||
|
* Anthropic: { name, description, input_schema }
|
||||||
|
* OpenAI: { type: "function", function: { name, description, parameters } }
|
||||||
|
*
|
||||||
|
* Anthropic-specific fields (cache_control, defer_loading, etc.) are stripped.
|
||||||
|
*/
|
||||||
|
export function anthropicToolsToOpenAI(
|
||||||
|
tools: BetaToolUnion[],
|
||||||
|
): ChatCompletionTool[] {
|
||||||
|
return tools
|
||||||
|
.filter(tool => {
|
||||||
|
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||||
|
const toolType = (tool as unknown as { type?: string }).type
|
||||||
|
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||||
|
})
|
||||||
|
.map(tool => {
|
||||||
|
// Handle the various tool shapes from Anthropic SDK
|
||||||
|
const anyTool = tool as unknown as Record<string, unknown>
|
||||||
|
const name = (anyTool.name as string) || ''
|
||||||
|
const description = (anyTool.description as string) || ''
|
||||||
|
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||||
|
},
|
||||||
|
} satisfies ChatCompletionTool
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sanitize a JSON Schema for OpenAI-compatible providers.
|
||||||
|
*
|
||||||
|
* Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) do not
|
||||||
|
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||||
|
* single-element array, which is semantically equivalent.
|
||||||
|
*/
|
||||||
|
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!schema || typeof schema !== 'object') return schema
|
||||||
|
|
||||||
|
const result = { ...schema }
|
||||||
|
|
||||||
|
// Convert `const` → `enum: [value]`
|
||||||
|
if ('const' in result) {
|
||||||
|
result.enum = [result.const]
|
||||||
|
delete result.const
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process nested schemas
|
||||||
|
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
||||||
|
for (const key of objectKeys) {
|
||||||
|
const nested = result[key]
|
||||||
|
if (nested && typeof nested === 'object') {
|
||||||
|
const sanitized: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||||
|
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
||||||
|
}
|
||||||
|
result[key] = sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process single-schema keys
|
||||||
|
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
||||||
|
for (const key of singleKeys) {
|
||||||
|
const nested = result[key]
|
||||||
|
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||||
|
result[key] = sanitizeJsonSchema(nested as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process array-of-schemas keys
|
||||||
|
const arrayKeys = ['anyOf', 'oneOf', 'allOf'] as const
|
||||||
|
for (const key of arrayKeys) {
|
||||||
|
const nested = result[key]
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
result[key] = nested.map(item =>
|
||||||
|
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Anthropic tool_choice to OpenAI tool_choice format.
|
||||||
|
*
|
||||||
|
* Anthropic → OpenAI:
|
||||||
|
* - { type: "auto" } → "auto"
|
||||||
|
* - { type: "any" } → "required"
|
||||||
|
* - { type: "tool", name } → { type: "function", function: { name } }
|
||||||
|
* - undefined → undefined (use provider default)
|
||||||
|
*/
|
||||||
|
export function anthropicToolChoiceToOpenAI(
|
||||||
|
toolChoice: unknown,
|
||||||
|
): string | { type: 'function'; function: { name: string } } | undefined {
|
||||||
|
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
||||||
|
|
||||||
|
const tc = toolChoice as Record<string, unknown>
|
||||||
|
const type = tc.type as string
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'auto':
|
||||||
|
return 'auto'
|
||||||
|
case 'any':
|
||||||
|
return 'required'
|
||||||
|
case 'tool':
|
||||||
|
return {
|
||||||
|
type: 'function',
|
||||||
|
function: { name: tc.name as string },
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
|
||||||
|
*
|
||||||
|
* Mapping:
|
||||||
|
* First chunk → message_start
|
||||||
|
* delta.reasoning_content → content_block_start(thinking) + thinking_delta + content_block_stop
|
||||||
|
* delta.content → content_block_start(text) + text_delta + content_block_stop
|
||||||
|
* delta.tool_calls → content_block_start(tool_use) + input_json_delta + content_block_stop
|
||||||
|
* finish_reason → message_delta(stop_reason) + message_stop
|
||||||
|
*
|
||||||
|
* Usage field mapping (OpenAI → Anthropic):
|
||||||
|
* prompt_tokens → input_tokens
|
||||||
|
* completion_tokens → output_tokens
|
||||||
|
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||||
|
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||||
|
*
|
||||||
|
* All four fields are emitted in the post-loop message_delta (not message_start)
|
||||||
|
* so that trailing usage chunks (sent after finish_reason by some
|
||||||
|
* OpenAI-compatible endpoints) are fully captured before the final counts are reported.
|
||||||
|
*
|
||||||
|
* Thinking support:
|
||||||
|
* DeepSeek and compatible providers send `delta.reasoning_content` for chain-of-thought.
|
||||||
|
* This is mapped to Anthropic's `thinking` content blocks:
|
||||||
|
* content_block_start: { type: 'thinking', thinking: '', signature: '' }
|
||||||
|
* content_block_delta: { type: 'thinking_delta', thinking: '...' }
|
||||||
|
*
|
||||||
|
* Prompt caching:
|
||||||
|
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
||||||
|
* This is mapped to Anthropic's cache_read_input_tokens.
|
||||||
|
*/
|
||||||
|
export async function* adaptOpenAIStreamToAnthropic(
|
||||||
|
stream: AsyncIterable<ChatCompletionChunk>,
|
||||||
|
model: string,
|
||||||
|
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||||
|
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||||
|
|
||||||
|
let started = false
|
||||||
|
let currentContentIndex = -1
|
||||||
|
|
||||||
|
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||||
|
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||||
|
|
||||||
|
// Track thinking block state
|
||||||
|
let thinkingBlockOpen = false
|
||||||
|
|
||||||
|
// Track text block state
|
||||||
|
let textBlockOpen = false
|
||||||
|
|
||||||
|
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||||
|
let inputTokens = 0
|
||||||
|
let outputTokens = 0
|
||||||
|
let cachedReadTokens = 0
|
||||||
|
|
||||||
|
// Track all open content block indices (for cleanup)
|
||||||
|
const openBlockIndices = new Set<number>()
|
||||||
|
|
||||||
|
// Deferred finish state
|
||||||
|
let pendingFinishReason: string | null = null
|
||||||
|
let pendingHasToolCalls = false
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const choice = chunk.choices?.[0]
|
||||||
|
const delta = choice?.delta
|
||||||
|
|
||||||
|
// Extract usage from any chunk that carries it.
|
||||||
|
if (chunk.usage) {
|
||||||
|
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||||
|
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||||
|
const details = (chunk.usage as any).prompt_tokens_details
|
||||||
|
if (details?.cached_tokens != null) {
|
||||||
|
cachedReadTokens = details.cached_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit message_start on first chunk
|
||||||
|
if (!started) {
|
||||||
|
started = true
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: messageId,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
model,
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: cachedReadTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chunks that carry only usage data (no delta content)
|
||||||
|
if (!delta) continue
|
||||||
|
|
||||||
|
// Handle reasoning_content → Anthropic thinking block
|
||||||
|
const reasoningContent = (delta as any).reasoning_content
|
||||||
|
if (reasoningContent != null && reasoningContent !== '') {
|
||||||
|
if (!thinkingBlockOpen) {
|
||||||
|
currentContentIndex++
|
||||||
|
thinkingBlockOpen = true
|
||||||
|
openBlockIndices.add(currentContentIndex)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: currentContentIndex,
|
||||||
|
content_block: {
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
signature: '',
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: currentContentIndex,
|
||||||
|
delta: {
|
||||||
|
type: 'thinking_delta',
|
||||||
|
thinking: reasoningContent,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text content
|
||||||
|
if (delta.content != null && delta.content !== '') {
|
||||||
|
if (!textBlockOpen) {
|
||||||
|
// Close thinking block if still open
|
||||||
|
if (thinkingBlockOpen) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: currentContentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(currentContentIndex)
|
||||||
|
thinkingBlockOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContentIndex++
|
||||||
|
textBlockOpen = true
|
||||||
|
openBlockIndices.add(currentContentIndex)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: currentContentIndex,
|
||||||
|
content_block: {
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: currentContentIndex,
|
||||||
|
delta: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: delta.content,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
if (delta.tool_calls) {
|
||||||
|
for (const tc of delta.tool_calls) {
|
||||||
|
const tcIndex = tc.index
|
||||||
|
|
||||||
|
if (!toolBlocks.has(tcIndex)) {
|
||||||
|
// Close thinking block if open
|
||||||
|
if (thinkingBlockOpen) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: currentContentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(currentContentIndex)
|
||||||
|
thinkingBlockOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close text block if open
|
||||||
|
if (textBlockOpen) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: currentContentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(currentContentIndex)
|
||||||
|
textBlockOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new tool_use block
|
||||||
|
currentContentIndex++
|
||||||
|
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||||
|
const toolName = tc.function?.name || ''
|
||||||
|
|
||||||
|
toolBlocks.set(tcIndex, {
|
||||||
|
contentIndex: currentContentIndex,
|
||||||
|
id: toolId,
|
||||||
|
name: toolName,
|
||||||
|
arguments: '',
|
||||||
|
})
|
||||||
|
openBlockIndices.add(currentContentIndex)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: currentContentIndex,
|
||||||
|
content_block: {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: toolId,
|
||||||
|
name: toolName,
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream argument fragments
|
||||||
|
const argFragment = tc.function?.arguments
|
||||||
|
if (argFragment) {
|
||||||
|
toolBlocks.get(tcIndex)!.arguments += argFragment
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: toolBlocks.get(tcIndex)!.contentIndex,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: argFragment,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle finish
|
||||||
|
if (choice?.finish_reason) {
|
||||||
|
if (thinkingBlockOpen) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: currentContentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(currentContentIndex)
|
||||||
|
thinkingBlockOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textBlockOpen) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: currentContentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(currentContentIndex)
|
||||||
|
textBlockOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, block] of toolBlocks) {
|
||||||
|
if (openBlockIndices.has(block.contentIndex)) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: block.contentIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openBlockIndices.delete(block.contentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFinishReason = choice.finish_reason
|
||||||
|
pendingHasToolCalls = toolBlocks.size > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: close any remaining open blocks
|
||||||
|
for (const idx of openBlockIndices) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: idx,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit message_delta + message_stop
|
||||||
|
if (pendingFinishReason !== null) {
|
||||||
|
const stopReason =
|
||||||
|
pendingFinishReason === 'length'
|
||||||
|
? 'max_tokens'
|
||||||
|
: pendingHasToolCalls
|
||||||
|
? 'tool_use'
|
||||||
|
: mapFinishReason(pendingFinishReason)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: {
|
||||||
|
stop_reason: stopReason,
|
||||||
|
stop_sequence: null,
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_read_input_tokens: cachedReadTokens,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_stop',
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||||
|
*/
|
||||||
|
function mapFinishReason(reason: string): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'stop':
|
||||||
|
return 'end_turn'
|
||||||
|
case 'tool_calls':
|
||||||
|
return 'tool_use'
|
||||||
|
case 'length':
|
||||||
|
return 'max_tokens'
|
||||||
|
case 'content_filter':
|
||||||
|
return 'end_turn'
|
||||||
|
default:
|
||||||
|
return 'end_turn'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +1,2 @@
|
|||||||
/**
|
// Re-export from @anthropic-ai/model-provider
|
||||||
* Default mapping from Anthropic model names to Grok model names.
|
export { resolveGrokModel } from '@anthropic-ai/model-provider'
|
||||||
*
|
|
||||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
|
||||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string):
|
|
||||||
* GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}'
|
|
||||||
*/
|
|
||||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|
||||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
|
||||||
'claude-sonnet-4-5-20250929': 'grok-3-mini-fast',
|
|
||||||
'claude-sonnet-4-6': 'grok-3-mini-fast',
|
|
||||||
'claude-opus-4-20250514': 'grok-4.20-reasoning',
|
|
||||||
'claude-opus-4-1-20250805': 'grok-4.20-reasoning',
|
|
||||||
'claude-opus-4-5-20251101': 'grok-4.20-reasoning',
|
|
||||||
'claude-opus-4-6': 'grok-4.20-reasoning',
|
|
||||||
'claude-haiku-4-5-20251001': 'grok-3-mini-fast',
|
|
||||||
'claude-3-5-haiku-20241022': 'grok-3-mini-fast',
|
|
||||||
'claude-3-7-sonnet-20250219': 'grok-3-mini-fast',
|
|
||||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Family-level mapping defaults (used by GROK_MODEL_MAP).
|
|
||||||
*/
|
|
||||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
|
||||||
opus: 'grok-4.20-reasoning',
|
|
||||||
sonnet: 'grok-3-mini-fast',
|
|
||||||
haiku: 'grok-3-mini-fast',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|
||||||
if (/haiku/i.test(model)) return 'haiku'
|
|
||||||
if (/opus/i.test(model)) return 'opus'
|
|
||||||
if (/sonnet/i.test(model)) return 'sonnet'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse user-provided model map from GROK_MODEL_MAP env var.
|
|
||||||
* Accepts JSON like: {"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}
|
|
||||||
*/
|
|
||||||
function getUserModelMap(): Record<string, string> | null {
|
|
||||||
const raw = process.env.GROK_MODEL_MAP
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
return parsed as Record<string, string>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore invalid JSON
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the Grok model name for a given Anthropic model.
|
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. GROK_MODEL env var (override all)
|
|
||||||
* 2. GROK_MODEL_MAP env var — JSON family map (e.g. {"opus":"grok-4"})
|
|
||||||
* 3. GROK_DEFAULT_{FAMILY}_MODEL env var (e.g. GROK_DEFAULT_OPUS_MODEL)
|
|
||||||
* 4. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compat)
|
|
||||||
* 5. DEFAULT_MODEL_MAP lookup
|
|
||||||
* 6. Family-level default
|
|
||||||
* 7. Pass through original model name
|
|
||||||
*/
|
|
||||||
export function resolveGrokModel(anthropicModel: string): string {
|
|
||||||
// 1. Global override
|
|
||||||
if (process.env.GROK_MODEL) {
|
|
||||||
return process.env.GROK_MODEL
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
|
||||||
const family = getModelFamily(cleanModel)
|
|
||||||
|
|
||||||
// 2. User-provided model map
|
|
||||||
const userMap = getUserModelMap()
|
|
||||||
if (userMap && family && userMap[family]) {
|
|
||||||
return userMap[family]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family) {
|
|
||||||
// 3. Grok-specific family override
|
|
||||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
|
||||||
const grokOverride = process.env[grokEnvVar]
|
|
||||||
if (grokOverride) return grokOverride
|
|
||||||
|
|
||||||
// 4. Anthropic env var (backward compat)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
|
||||||
if (anthropicOverride) return anthropicOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Exact model name lookup
|
|
||||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
|
||||||
return DEFAULT_MODEL_MAP[cleanModel]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Family-level default
|
|
||||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
|
||||||
return DEFAULT_FAMILY_MAP[family]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Pass through
|
|
||||||
return cleanModel
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,305 +1,3 @@
|
|||||||
import type {
|
// Re-export from @anthropic-ai/model-provider
|
||||||
BetaContentBlockParam,
|
export { anthropicMessagesToOpenAI } from '@anthropic-ai/model-provider'
|
||||||
BetaToolResultBlockParam,
|
export type { ConvertMessagesOptions } from '@anthropic-ai/model-provider'
|
||||||
BetaToolUseBlock,
|
|
||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
||||||
import type {
|
|
||||||
ChatCompletionAssistantMessageParam,
|
|
||||||
ChatCompletionMessageParam,
|
|
||||||
ChatCompletionSystemMessageParam,
|
|
||||||
ChatCompletionToolMessageParam,
|
|
||||||
ChatCompletionUserMessageParam,
|
|
||||||
} from 'openai/resources/chat/completions/completions.mjs'
|
|
||||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
|
||||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
|
||||||
|
|
||||||
export interface ConvertMessagesOptions {
|
|
||||||
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
|
||||||
* (required for DeepSeek thinking mode with tool calls). */
|
|
||||||
enableThinking?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert internal (UserMessage | AssistantMessage)[] to OpenAI-format messages.
|
|
||||||
*
|
|
||||||
* Key conversions:
|
|
||||||
* - system prompt → role: "system" message prepended
|
|
||||||
* - tool_use blocks → tool_calls[] on assistant message
|
|
||||||
* - tool_result blocks → role: "tool" messages
|
|
||||||
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
|
|
||||||
* - cache_control → stripped
|
|
||||||
*/
|
|
||||||
export function anthropicMessagesToOpenAI(
|
|
||||||
messages: (UserMessage | AssistantMessage)[],
|
|
||||||
systemPrompt: SystemPrompt,
|
|
||||||
options?: ConvertMessagesOptions,
|
|
||||||
): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
const enableThinking = options?.enableThinking ?? false
|
|
||||||
|
|
||||||
// Prepend system prompt as system message
|
|
||||||
const systemText = systemPromptToText(systemPrompt)
|
|
||||||
if (systemText) {
|
|
||||||
result.push({
|
|
||||||
role: 'system',
|
|
||||||
content: systemText,
|
|
||||||
} satisfies ChatCompletionSystemMessageParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When thinking mode is on, detect turn boundaries so that reasoning_content
|
|
||||||
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
|
|
||||||
// A "new turn" starts when a user text message appears after at least one assistant response.
|
|
||||||
const turnBoundaries = new Set<number>()
|
|
||||||
if (enableThinking) {
|
|
||||||
let hasSeenAssistant = false
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const msg = messages[i]
|
|
||||||
if (msg.type === 'assistant') {
|
|
||||||
hasSeenAssistant = true
|
|
||||||
}
|
|
||||||
if (msg.type === 'user' && hasSeenAssistant) {
|
|
||||||
const content = msg.message.content
|
|
||||||
// A user message starts a new turn if it contains any non-tool_result content
|
|
||||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
|
||||||
// because they are continuations of the previous assistant tool call.
|
|
||||||
const startsNewUserTurn = typeof content === 'string'
|
|
||||||
? content.length > 0
|
|
||||||
: Array.isArray(content) && content.some(
|
|
||||||
(b: any) =>
|
|
||||||
typeof b === 'string' ||
|
|
||||||
(b &&
|
|
||||||
typeof b === 'object' &&
|
|
||||||
'type' in b &&
|
|
||||||
b.type !== 'tool_result'),
|
|
||||||
)
|
|
||||||
if (startsNewUserTurn) {
|
|
||||||
turnBoundaries.add(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const msg = messages[i]
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'user':
|
|
||||||
result.push(...convertInternalUserMessage(msg))
|
|
||||||
break
|
|
||||||
case 'assistant':
|
|
||||||
// Preserve reasoning_content unless we're before a turn boundary
|
|
||||||
// (i.e., from a previous user Q&A round)
|
|
||||||
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
|
||||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
|
||||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
|
||||||
return systemPrompt
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
|
|
||||||
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
|
|
||||||
*/
|
|
||||||
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
|
|
||||||
for (const b of boundaries) {
|
|
||||||
if (i < b) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertInternalUserMessage(
|
|
||||||
msg: UserMessage,
|
|
||||||
): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
const content = msg.message.content
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
result.push({
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
} satisfies ChatCompletionUserMessageParam)
|
|
||||||
} else if (Array.isArray(content)) {
|
|
||||||
const textParts: string[] = []
|
|
||||||
const toolResults: BetaToolResultBlockParam[] = []
|
|
||||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
|
||||||
|
|
||||||
for (const block of content) {
|
|
||||||
if (typeof block === 'string') {
|
|
||||||
textParts.push(block)
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textParts.push(block.text)
|
|
||||||
} else if (block.type === 'tool_result') {
|
|
||||||
toolResults.push(block as BetaToolResultBlockParam)
|
|
||||||
} else if (block.type === 'image') {
|
|
||||||
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
|
||||||
if (imagePart) {
|
|
||||||
imageParts.push(imagePart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: tool messages must come BEFORE any user message in the result.
|
|
||||||
// OpenAI API requires that a tool message immediately follows the assistant
|
|
||||||
// message with tool_calls. If we emit a user message first, the API will
|
|
||||||
// reject the request with "insufficient tool messages following tool_calls".
|
|
||||||
// See: https://github.com/anthropics/claude-code/issues/xxx
|
|
||||||
for (const tr of toolResults) {
|
|
||||||
result.push(convertToolResult(tr))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有图片,构建多模态 content 数组
|
|
||||||
if (imageParts.length > 0) {
|
|
||||||
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
|
||||||
if (textParts.length > 0) {
|
|
||||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
|
||||||
}
|
|
||||||
multiContent.push(...imageParts)
|
|
||||||
result.push({
|
|
||||||
role: 'user',
|
|
||||||
content: multiContent,
|
|
||||||
} satisfies ChatCompletionUserMessageParam)
|
|
||||||
} else if (textParts.length > 0) {
|
|
||||||
result.push({
|
|
||||||
role: 'user',
|
|
||||||
content: textParts.join('\n'),
|
|
||||||
} satisfies ChatCompletionUserMessageParam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToolResult(
|
|
||||||
block: BetaToolResultBlockParam,
|
|
||||||
): ChatCompletionToolMessageParam {
|
|
||||||
let content: string
|
|
||||||
if (typeof block.content === 'string') {
|
|
||||||
content = block.content
|
|
||||||
} else if (Array.isArray(block.content)) {
|
|
||||||
content = block.content
|
|
||||||
.map(c => {
|
|
||||||
if (typeof c === 'string') return c
|
|
||||||
if ('text' in c) return c.text
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
} else {
|
|
||||||
content = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: 'tool',
|
|
||||||
tool_call_id: block.tool_use_id,
|
|
||||||
content,
|
|
||||||
} satisfies ChatCompletionToolMessageParam
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertInternalAssistantMessage(
|
|
||||||
msg: AssistantMessage,
|
|
||||||
preserveReasoning = false,
|
|
||||||
): ChatCompletionMessageParam[] {
|
|
||||||
const content = msg.message.content
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content,
|
|
||||||
} satisfies ChatCompletionAssistantMessageParam,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(content)) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
} satisfies ChatCompletionAssistantMessageParam,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const textParts: string[] = []
|
|
||||||
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
|
||||||
const reasoningParts: string[] = []
|
|
||||||
|
|
||||||
for (const block of content) {
|
|
||||||
if (typeof block === 'string') {
|
|
||||||
textParts.push(block)
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textParts.push(block.text)
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
const tu = block as BetaToolUseBlock
|
|
||||||
toolCalls.push({
|
|
||||||
id: tu.id,
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: tu.name,
|
|
||||||
arguments:
|
|
||||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
|
||||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
|
||||||
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
|
||||||
if (typeof thinkingText === 'string' && thinkingText) {
|
|
||||||
reasoningParts.push(thinkingText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip redacted_thinking, server_tool_use, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ChatCompletionAssistantMessageParam = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
|
||||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
|
||||||
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
|
||||||
}
|
|
||||||
|
|
||||||
return [result]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 Anthropic image 块转换为 OpenAI image_url 格式。
|
|
||||||
*
|
|
||||||
* Anthropic 格式: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
|
||||||
* OpenAI 格式: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
|
|
||||||
*/
|
|
||||||
function convertImageBlockToOpenAI(
|
|
||||||
block: Record<string, unknown>,
|
|
||||||
): { type: 'image_url'; image_url: { url: string } } | null {
|
|
||||||
const source = block.source as Record<string, unknown> | undefined
|
|
||||||
if (!source) return null
|
|
||||||
|
|
||||||
if (source.type === 'base64' && typeof source.data === 'string') {
|
|
||||||
const mediaType = (source.media_type as string) || 'image/png'
|
|
||||||
return {
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${mediaType};base64,${source.data}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// url 类型的图片直接传递
|
|
||||||
if (source.type === 'url' && typeof source.url === 'string') {
|
|
||||||
return {
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: source.url,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,123 +1,2 @@
|
|||||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
// Re-export from @anthropic-ai/model-provider
|
||||||
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions.mjs'
|
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '@anthropic-ai/model-provider'
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Anthropic tool schemas to OpenAI function calling format.
|
|
||||||
*
|
|
||||||
* Anthropic: { name, description, input_schema }
|
|
||||||
* OpenAI: { type: "function", function: { name, description, parameters } }
|
|
||||||
*
|
|
||||||
* Anthropic-specific fields (cache_control, defer_loading, etc.) are stripped.
|
|
||||||
*/
|
|
||||||
export function anthropicToolsToOpenAI(
|
|
||||||
tools: BetaToolUnion[],
|
|
||||||
): ChatCompletionTool[] {
|
|
||||||
return tools
|
|
||||||
.filter(tool => {
|
|
||||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
|
||||||
const toolType = (tool as unknown as { type?: string }).type
|
|
||||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
|
||||||
})
|
|
||||||
.map(tool => {
|
|
||||||
// Handle the various tool shapes from Anthropic SDK
|
|
||||||
const anyTool = tool as unknown as Record<string, unknown>
|
|
||||||
const name = (anyTool.name as string) || ''
|
|
||||||
const description = (anyTool.description as string) || ''
|
|
||||||
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'function' as const,
|
|
||||||
function: {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
|
||||||
},
|
|
||||||
} satisfies ChatCompletionTool
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively sanitize a JSON Schema for OpenAI-compatible providers.
|
|
||||||
*
|
|
||||||
* Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) do not
|
|
||||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
|
||||||
* single-element array, which is semantically equivalent.
|
|
||||||
*/
|
|
||||||
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
if (!schema || typeof schema !== 'object') return schema
|
|
||||||
|
|
||||||
const result = { ...schema }
|
|
||||||
|
|
||||||
// Convert `const` → `enum: [value]`
|
|
||||||
if ('const' in result) {
|
|
||||||
result.enum = [result.const]
|
|
||||||
delete result.const
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively process nested schemas
|
|
||||||
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
|
||||||
for (const key of objectKeys) {
|
|
||||||
const nested = result[key]
|
|
||||||
if (nested && typeof nested === 'object') {
|
|
||||||
const sanitized: Record<string, unknown> = {}
|
|
||||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
|
||||||
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
|
||||||
}
|
|
||||||
result[key] = sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively process single-schema keys
|
|
||||||
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
|
||||||
for (const key of singleKeys) {
|
|
||||||
const nested = result[key]
|
|
||||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
||||||
result[key] = sanitizeJsonSchema(nested as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively process array-of-schemas keys
|
|
||||||
const arrayKeys = ['anyOf', 'oneOf', 'allOf'] as const
|
|
||||||
for (const key of arrayKeys) {
|
|
||||||
const nested = result[key]
|
|
||||||
if (Array.isArray(nested)) {
|
|
||||||
result[key] = nested.map(item =>
|
|
||||||
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Anthropic tool_choice to OpenAI tool_choice format.
|
|
||||||
*
|
|
||||||
* Anthropic → OpenAI:
|
|
||||||
* - { type: "auto" } → "auto"
|
|
||||||
* - { type: "any" } → "required"
|
|
||||||
* - { type: "tool", name } → { type: "function", function: { name } }
|
|
||||||
* - undefined → undefined (use provider default)
|
|
||||||
*/
|
|
||||||
export function anthropicToolChoiceToOpenAI(
|
|
||||||
toolChoice: unknown,
|
|
||||||
): string | { type: 'function'; function: { name: string } } | undefined {
|
|
||||||
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
|
||||||
|
|
||||||
const tc = toolChoice as Record<string, unknown>
|
|
||||||
const type = tc.type as string
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'auto':
|
|
||||||
return 'auto'
|
|
||||||
case 'any':
|
|
||||||
return 'required'
|
|
||||||
case 'tool':
|
|
||||||
return {
|
|
||||||
type: 'function',
|
|
||||||
function: { name: tc.name as string },
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,63 +1,2 @@
|
|||||||
/**
|
// Re-export from @anthropic-ai/model-provider
|
||||||
* Default mapping from Anthropic model names to OpenAI model names.
|
export { resolveOpenAIModel } from '@anthropic-ai/model-provider'
|
||||||
* Used only when ANTHROPIC_DEFAULT_*_MODEL env vars are not set.
|
|
||||||
*/
|
|
||||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|
||||||
'claude-sonnet-4-20250514': 'gpt-4o',
|
|
||||||
'claude-sonnet-4-5-20250929': 'gpt-4o',
|
|
||||||
'claude-sonnet-4-6': 'gpt-4o',
|
|
||||||
'claude-opus-4-20250514': 'o3',
|
|
||||||
'claude-opus-4-1-20250805': 'o3',
|
|
||||||
'claude-opus-4-5-20251101': 'o3',
|
|
||||||
'claude-opus-4-6': 'o3',
|
|
||||||
'claude-haiku-4-5-20251001': 'gpt-4o-mini',
|
|
||||||
'claude-3-5-haiku-20241022': 'gpt-4o-mini',
|
|
||||||
'claude-3-7-sonnet-20250219': 'gpt-4o',
|
|
||||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
|
|
||||||
*/
|
|
||||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|
||||||
if (/haiku/i.test(model)) return 'haiku'
|
|
||||||
if (/opus/i.test(model)) return 'opus'
|
|
||||||
if (/sonnet/i.test(model)) return 'sonnet'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the OpenAI model name for a given Anthropic model.
|
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. OPENAI_MODEL env var (override all)
|
|
||||||
* 2. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL)
|
|
||||||
* 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility)
|
|
||||||
* 4. DEFAULT_MODEL_MAP lookup
|
|
||||||
* 5. Pass through original model name
|
|
||||||
*/
|
|
||||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
|
||||||
// Highest priority: explicit override
|
|
||||||
if (process.env.OPENAI_MODEL) {
|
|
||||||
return process.env.OPENAI_MODEL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip [1m] suffix if present (Claude-specific modifier)
|
|
||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
|
||||||
|
|
||||||
// Check family-specific overrides
|
|
||||||
const family = getModelFamily(cleanModel)
|
|
||||||
if (family) {
|
|
||||||
// OpenAI-specific family override (preferred for openai provider)
|
|
||||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
|
||||||
const openaiOverride = process.env[openaiEnvVar]
|
|
||||||
if (openaiOverride) return openaiOverride
|
|
||||||
|
|
||||||
// Anthropic env var (backward compatibility)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
|
||||||
if (anthropicOverride) return anthropicOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,375 +1,2 @@
|
|||||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
// Re-export from @anthropic-ai/model-provider
|
||||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
export { adaptOpenAIStreamToAnthropic } from '@anthropic-ai/model-provider'
|
||||||
import { randomUUID } from 'crypto'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
|
|
||||||
*
|
|
||||||
* Mapping:
|
|
||||||
* First chunk → message_start
|
|
||||||
* delta.reasoning_content → content_block_start(thinking) + thinking_delta + content_block_stop
|
|
||||||
* delta.content → content_block_start(text) + text_delta + content_block_stop
|
|
||||||
* delta.tool_calls → content_block_start(tool_use) + input_json_delta + content_block_stop
|
|
||||||
* finish_reason → message_delta(stop_reason) + message_stop
|
|
||||||
*
|
|
||||||
* Usage field mapping (OpenAI → Anthropic):
|
|
||||||
* prompt_tokens → input_tokens
|
|
||||||
* completion_tokens → output_tokens
|
|
||||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
|
||||||
*
|
|
||||||
* All four fields are emitted in the post-loop message_delta (not message_start)
|
|
||||||
* so that trailing usage chunks (sent after finish_reason by some
|
|
||||||
* OpenAI-compatible endpoints) are fully captured before the final counts are reported.
|
|
||||||
*
|
|
||||||
* Thinking support:
|
|
||||||
* DeepSeek and compatible providers send `delta.reasoning_content` for chain-of-thought.
|
|
||||||
* This is mapped to Anthropic's `thinking` content blocks:
|
|
||||||
* content_block_start: { type: 'thinking', thinking: '', signature: '' }
|
|
||||||
* content_block_delta: { type: 'thinking_delta', thinking: '...' }
|
|
||||||
*
|
|
||||||
* Prompt caching:
|
|
||||||
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
|
||||||
* This is mapped to Anthropic's cache_read_input_tokens.
|
|
||||||
*/
|
|
||||||
export async function* adaptOpenAIStreamToAnthropic(
|
|
||||||
stream: AsyncIterable<ChatCompletionChunk>,
|
|
||||||
model: string,
|
|
||||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
|
||||||
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
|
||||||
|
|
||||||
let started = false
|
|
||||||
let currentContentIndex = -1
|
|
||||||
|
|
||||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
|
||||||
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
|
||||||
|
|
||||||
// Track thinking block state
|
|
||||||
let thinkingBlockOpen = false
|
|
||||||
|
|
||||||
// Track text block state
|
|
||||||
let textBlockOpen = false
|
|
||||||
|
|
||||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no standard OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
|
||||||
let inputTokens = 0
|
|
||||||
let outputTokens = 0
|
|
||||||
let cachedReadTokens = 0
|
|
||||||
|
|
||||||
// Track all open content block indices (for cleanup)
|
|
||||||
const openBlockIndices = new Set<number>()
|
|
||||||
|
|
||||||
// Deferred finish state: populated when finish_reason is encountered so that
|
|
||||||
// message_delta / message_stop are emitted AFTER the stream loop ends.
|
|
||||||
// This ensures usage chunks that arrive after the finish_reason chunk are
|
|
||||||
// captured before we emit the final token counts.
|
|
||||||
let pendingFinishReason: string | null = null
|
|
||||||
let pendingHasToolCalls = false
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const choice = chunk.choices?.[0]
|
|
||||||
const delta = choice?.delta
|
|
||||||
|
|
||||||
// Extract usage from any chunk that carries it.
|
|
||||||
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
|
|
||||||
// final chunk that arrives AFTER the finish_reason chunk. Reading it here
|
|
||||||
// (before emitting message_delta) ensures the token counts are available
|
|
||||||
// when we later emit message_delta.
|
|
||||||
if (chunk.usage) {
|
|
||||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
|
||||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
|
||||||
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
|
|
||||||
// → Anthropic cache_read_input_tokens
|
|
||||||
// Note: OpenAI has no equivalent for cache_creation_input_tokens.
|
|
||||||
const details = (chunk.usage as any).prompt_tokens_details
|
|
||||||
if (details?.cached_tokens != null) {
|
|
||||||
cachedReadTokens = details.cached_tokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit message_start on first chunk
|
|
||||||
if (!started) {
|
|
||||||
started = true
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: messageId,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
content: [],
|
|
||||||
model,
|
|
||||||
stop_reason: null,
|
|
||||||
stop_sequence: null,
|
|
||||||
usage: {
|
|
||||||
input_tokens: inputTokens,
|
|
||||||
output_tokens: 0,
|
|
||||||
cache_creation_input_tokens: 0,
|
|
||||||
cache_read_input_tokens: cachedReadTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip chunks that carry only usage data (no delta content)
|
|
||||||
if (!delta) continue
|
|
||||||
|
|
||||||
// Handle reasoning_content → Anthropic thinking block
|
|
||||||
// DeepSeek and compatible providers send delta.reasoning_content
|
|
||||||
const reasoningContent = (delta as any).reasoning_content
|
|
||||||
if (reasoningContent != null && reasoningContent !== '') {
|
|
||||||
if (!thinkingBlockOpen) {
|
|
||||||
currentContentIndex++
|
|
||||||
thinkingBlockOpen = true
|
|
||||||
openBlockIndices.add(currentContentIndex)
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_start',
|
|
||||||
index: currentContentIndex,
|
|
||||||
content_block: {
|
|
||||||
type: 'thinking',
|
|
||||||
thinking: '',
|
|
||||||
signature: '',
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index: currentContentIndex,
|
|
||||||
delta: {
|
|
||||||
type: 'thinking_delta',
|
|
||||||
thinking: reasoningContent,
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle text content
|
|
||||||
if (delta.content != null && delta.content !== '') {
|
|
||||||
if (!textBlockOpen) {
|
|
||||||
// Close thinking block if still open (reasoning done, now generating answer)
|
|
||||||
if (thinkingBlockOpen) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: currentContentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(currentContentIndex)
|
|
||||||
thinkingBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
currentContentIndex++
|
|
||||||
textBlockOpen = true
|
|
||||||
openBlockIndices.add(currentContentIndex)
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_start',
|
|
||||||
index: currentContentIndex,
|
|
||||||
content_block: {
|
|
||||||
type: 'text',
|
|
||||||
text: '',
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index: currentContentIndex,
|
|
||||||
delta: {
|
|
||||||
type: 'text_delta',
|
|
||||||
text: delta.content,
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tool calls
|
|
||||||
if (delta.tool_calls) {
|
|
||||||
for (const tc of delta.tool_calls) {
|
|
||||||
const tcIndex = tc.index
|
|
||||||
|
|
||||||
if (!toolBlocks.has(tcIndex)) {
|
|
||||||
// Close thinking block if open
|
|
||||||
if (thinkingBlockOpen) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: currentContentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(currentContentIndex)
|
|
||||||
thinkingBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close text block if open
|
|
||||||
if (textBlockOpen) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: currentContentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(currentContentIndex)
|
|
||||||
textBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new tool_use block
|
|
||||||
currentContentIndex++
|
|
||||||
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
|
||||||
const toolName = tc.function?.name || ''
|
|
||||||
|
|
||||||
toolBlocks.set(tcIndex, {
|
|
||||||
contentIndex: currentContentIndex,
|
|
||||||
id: toolId,
|
|
||||||
name: toolName,
|
|
||||||
arguments: '',
|
|
||||||
})
|
|
||||||
openBlockIndices.add(currentContentIndex)
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_start',
|
|
||||||
index: currentContentIndex,
|
|
||||||
content_block: {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: toolId,
|
|
||||||
name: toolName,
|
|
||||||
input: {},
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream argument fragments
|
|
||||||
const argFragment = tc.function?.arguments
|
|
||||||
if (argFragment) {
|
|
||||||
toolBlocks.get(tcIndex)!.arguments += argFragment
|
|
||||||
yield {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index: toolBlocks.get(tcIndex)!.contentIndex,
|
|
||||||
delta: {
|
|
||||||
type: 'input_json_delta',
|
|
||||||
partial_json: argFragment,
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle finish: close all open content blocks and record the finish_reason.
|
|
||||||
// message_delta + message_stop are emitted AFTER the stream loop so that any
|
|
||||||
// trailing usage chunk (sent after the finish chunk by some endpoints)
|
|
||||||
// is captured first — ensuring token counts are non-zero.
|
|
||||||
if (choice?.finish_reason) {
|
|
||||||
// Close thinking block if still open
|
|
||||||
if (thinkingBlockOpen) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: currentContentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(currentContentIndex)
|
|
||||||
thinkingBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close text block if still open
|
|
||||||
if (textBlockOpen) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: currentContentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(currentContentIndex)
|
|
||||||
textBlockOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close all tool blocks that haven't been closed yet
|
|
||||||
for (const [, block] of toolBlocks) {
|
|
||||||
if (openBlockIndices.has(block.contentIndex)) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: block.contentIndex,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
openBlockIndices.delete(block.contentIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defer message_delta / message_stop until after the loop so that any
|
|
||||||
// trailing usage chunk is processed before we emit the final token counts.
|
|
||||||
pendingFinishReason = choice.finish_reason
|
|
||||||
pendingHasToolCalls = toolBlocks.size > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety: close any remaining open blocks if stream ended without finish_reason
|
|
||||||
for (const idx of openBlockIndices) {
|
|
||||||
yield {
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index: idx,
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit message_delta + message_stop now that the stream is fully consumed.
|
|
||||||
// Usage values (inputTokens / outputTokens) reflect all chunks including any
|
|
||||||
// trailing usage-only chunk sent after the finish_reason chunk.
|
|
||||||
if (pendingFinishReason !== null) {
|
|
||||||
// Map finish_reason to Anthropic stop_reason.
|
|
||||||
// CRITICAL: When finish_reason is 'length' (token budget exhausted), always
|
|
||||||
// report 'max_tokens' regardless of whether partial tool calls were received.
|
|
||||||
// Otherwise the query loop would try to execute tool calls with incomplete
|
|
||||||
// JSON arguments instead of triggering the max_tokens retry/recovery path.
|
|
||||||
const stopReason =
|
|
||||||
pendingFinishReason === 'length'
|
|
||||||
? 'max_tokens'
|
|
||||||
: pendingHasToolCalls
|
|
||||||
? 'tool_use'
|
|
||||||
: mapFinishReason(pendingFinishReason)
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: {
|
|
||||||
stop_reason: stopReason,
|
|
||||||
stop_sequence: null,
|
|
||||||
},
|
|
||||||
// Carry all four Anthropic usage fields so queryModelOpenAI's message_delta
|
|
||||||
// handler (which spreads this into the accumulated usage object) can override
|
|
||||||
// every field that message_start emitted as 0. For endpoints that send usage
|
|
||||||
// in a trailing chunk (e.g. DeepSeek), message_start is emitted on the first
|
|
||||||
// content chunk before the trailing usage chunk arrives, so all four fields
|
|
||||||
// start at 0. By the time we reach here (post-loop) the trailing chunk has
|
|
||||||
// been processed and all values reflect the real counts.
|
|
||||||
//
|
|
||||||
// OpenAI → Anthropic field mapping:
|
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no OpenAI equivalent) → cache_creation_input_tokens (stays 0)
|
|
||||||
usage: {
|
|
||||||
input_tokens: inputTokens,
|
|
||||||
output_tokens: outputTokens,
|
|
||||||
cache_read_input_tokens: cachedReadTokens,
|
|
||||||
cache_creation_input_tokens: 0,
|
|
||||||
},
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'message_stop',
|
|
||||||
} as BetaRawMessageStreamEvent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
|
||||||
*
|
|
||||||
* stop → end_turn
|
|
||||||
* tool_calls → tool_use
|
|
||||||
* length → max_tokens
|
|
||||||
* content_filter → end_turn
|
|
||||||
*/
|
|
||||||
function mapFinishReason(reason: string): string {
|
|
||||||
switch (reason) {
|
|
||||||
case 'stop':
|
|
||||||
return 'end_turn'
|
|
||||||
case 'tool_calls':
|
|
||||||
return 'tool_use'
|
|
||||||
case 'length':
|
|
||||||
return 'max_tokens'
|
|
||||||
case 'content_filter':
|
|
||||||
return 'end_turn'
|
|
||||||
default:
|
|
||||||
return 'end_turn'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user