mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
* refactor: 创建 @anthropic-ai/model-provider 包骨架与类型定义
- 新建 workspace 包 packages/@anthropic-ai/model-provider
- 定义 ModelProviderHooks 接口(依赖注入:分析、成本、日志等)
- 定义 ClientFactories 接口(Anthropic/OpenAI/Gemini/Grok 客户端工厂)
- 搬入核心类型:Message 体系、NonNullableUsage、EMPTY_USAGE、SystemPrompt、错误常量
- 主项目 src/types/message.ts 等改为 re-export,保持向后兼容
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* 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>
* refactor: 搬入 Gemini 兼容层到 model-provider 包
- 搬入 Gemini 类型定义、消息转换、工具转换、流适配、模型映射
- 主项目 gemini/ 目录下文件改为 thin re-export proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: 搬入 errorUtils 并迁移消费者导入到 model-provider
- 搬入 formatAPIError、extractConnectionErrorDetails 等 errorUtils
- 迁移 10 个消费者文件直接从 @anthropic-ai/model-provider 导入
- 更新 emptyUsage、sdkUtilityTypes、systemPromptType 为 re-export proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: 添加 agent-loop 绘图
* Revert "feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)"
This reverts commit e458d6391d.
* docs: 添加简化版 agent loop
* fix: 修复 n 快捷键导致关闭的问题
* fix: 修复 node 下 ws 没打包问题
* docs: 修复链接
* test: 添加测试支持
* fix: 修复类型问题(#267) (#271)
* fix: 修复 Bun 的 polyfill 问题
* fix: 类型修复完成
* feat: 统一所有包的类型文件
* fix: 修复构建问题
* test: 修复类型校验 (#279)
* fix: 修复 Bun 的 polyfill 问题
* fix: 类型修复完成
* feat: 统一所有包的类型文件
* fix: 修复构建问题
* fix(remote-control): harden self-hosted session flows (#278)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
* docs: update contributors
* build: 新增 vite 构建流程
* feat: 添加环境变量支持以覆盖 max_tokens 设置
* feat(langfuse): LLM generation 记录工具定义
将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: 添加对 ACP 协议的支持 (#284)
* feat: 适配 zed acp 协议
* docs: 完善 acp 文档
* chore: 1.4.0
* conflict: 解决冲突
* feat: 添加测试覆盖率上报
* style: 改名加移动文件夹位置
* refactor: 移动测试用例及实现
* test: 修复测试用例完成
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cheng Zi Feng <1154238323@qq.com>
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
Co-authored-by: claude-code-best <272536312+claude-code-best@users.noreply.github.com>
305 lines
9.5 KiB
TypeScript
305 lines
9.5 KiB
TypeScript
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
|
|
}
|