diff --git a/packages/@anthropic-ai/model-provider/src/providers/grok/modelMapping.ts b/packages/@anthropic-ai/model-provider/src/providers/grok/modelMapping.ts new file mode 100644 index 000000000..2d35f8165 --- /dev/null +++ b/packages/@anthropic-ai/model-provider/src/providers/grok/modelMapping.ts @@ -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 = { + '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 = { + 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 | 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 + } + } 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 +} diff --git a/packages/@anthropic-ai/model-provider/src/providers/openai/modelMapping.ts b/packages/@anthropic-ai/model-provider/src/providers/openai/modelMapping.ts new file mode 100644 index 000000000..2c54d5ae5 --- /dev/null +++ b/packages/@anthropic-ai/model-provider/src/providers/openai/modelMapping.ts @@ -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 = { + '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 +} diff --git a/packages/@anthropic-ai/model-provider/src/shared/openaiConvertMessages.ts b/packages/@anthropic-ai/model-provider/src/shared/openaiConvertMessages.ts new file mode 100644 index 000000000..4d2553653 --- /dev/null +++ b/packages/@anthropic-ai/model-provider/src/shared/openaiConvertMessages.ts @@ -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() + 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): 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) + 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 = [] + 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).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, +): { type: 'image_url'; image_url: { url: string } } | null { + const source = block.source as Record | 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 +} diff --git a/packages/@anthropic-ai/model-provider/src/shared/openaiConvertTools.ts b/packages/@anthropic-ai/model-provider/src/shared/openaiConvertTools.ts new file mode 100644 index 000000000..bace8208b --- /dev/null +++ b/packages/@anthropic-ai/model-provider/src/shared/openaiConvertTools.ts @@ -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 + const name = (anyTool.name as string) || '' + const description = (anyTool.description as string) || '' + const inputSchema = anyTool.input_schema as Record | 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): Record { + 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 = {} + for (const [k, v] of Object.entries(nested as Record)) { + sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record) : 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) + } + } + + // 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) : 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 + 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 + } +} diff --git a/packages/@anthropic-ai/model-provider/src/shared/openaiStreamAdapter.ts b/packages/@anthropic-ai/model-provider/src/shared/openaiStreamAdapter.ts new file mode 100644 index 000000000..9776ca319 --- /dev/null +++ b/packages/@anthropic-ai/model-provider/src/shared/openaiStreamAdapter.ts @@ -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, + model: string, +): AsyncGenerator { + 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() + + // 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() + + // 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' + } +} diff --git a/src/services/api/grok/modelMapping.ts b/src/services/api/grok/modelMapping.ts index f3e40edbc..4d0266408 100644 --- a/src/services/api/grok/modelMapping.ts +++ b/src/services/api/grok/modelMapping.ts @@ -1,107 +1,2 @@ -/** - * 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): - * GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}' - */ -const DEFAULT_MODEL_MAP: Record = { - '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 = { - 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 | 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 - } - } 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 -} +// Re-export from @anthropic-ai/model-provider +export { resolveGrokModel } from '@anthropic-ai/model-provider' diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index b525874ae..2bb2c2948 100644 --- a/src/services/api/openai/convertMessages.ts +++ b/src/services/api/openai/convertMessages.ts @@ -1,305 +1,3 @@ -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 '../../../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() - 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): 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) - 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 = [] - 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).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, -): { type: 'image_url'; image_url: { url: string } } | null { - const source = block.source as Record | 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 -} +// Re-export from @anthropic-ai/model-provider +export { anthropicMessagesToOpenAI } from '@anthropic-ai/model-provider' +export type { ConvertMessagesOptions } from '@anthropic-ai/model-provider' diff --git a/src/services/api/openai/convertTools.ts b/src/services/api/openai/convertTools.ts index bace8208b..06dd678e8 100644 --- a/src/services/api/openai/convertTools.ts +++ b/src/services/api/openai/convertTools.ts @@ -1,123 +1,2 @@ -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 - const name = (anyTool.name as string) || '' - const description = (anyTool.description as string) || '' - const inputSchema = anyTool.input_schema as Record | 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): Record { - 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 = {} - for (const [k, v] of Object.entries(nested as Record)) { - sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record) : 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) - } - } - - // 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) : 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 - 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 - } -} +// Re-export from @anthropic-ai/model-provider +export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '@anthropic-ai/model-provider' diff --git a/src/services/api/openai/modelMapping.ts b/src/services/api/openai/modelMapping.ts index 7cb49c7f9..4e3037c69 100644 --- a/src/services/api/openai/modelMapping.ts +++ b/src/services/api/openai/modelMapping.ts @@ -1,63 +1,2 @@ -/** - * 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 = { - '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 -} +// Re-export from @anthropic-ai/model-provider +export { resolveOpenAIModel } from '@anthropic-ai/model-provider' diff --git a/src/services/api/openai/streamAdapter.ts b/src/services/api/openai/streamAdapter.ts index 70f7161ff..aca317093 100644 --- a/src/services/api/openai/streamAdapter.ts +++ b/src/services/api/openai/streamAdapter.ts @@ -1,375 +1,2 @@ -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, - model: string, -): AsyncGenerator { - 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() - - // 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() - - // 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' - } -} +// Re-export from @anthropic-ai/model-provider +export { adaptOpenAIStreamToAnthropic } from '@anthropic-ai/model-provider'