/** * Anthropic Provider Adapter(P3) * * Anthropic Messages API(/v1/messages)原生协议,支持 Tool Calling、流式输出、 * 扩展思考(thinking + budget_tokens)、多模态图片(base64)。 * * 与 OpenAI 兼容 API 的关键差异: * - 认证头:x-api-key + anthropic-version(非 Authorization Bearer) * - 消息结构:content 为块数组(text / tool_use / tool_result / image), * 且要求 user/assistant 严格交替(连续同角色需合并) * - 工具定义:input_schema(非 parameters);工具结果以 user 角色 tool_result 块回传 * - SSE 事件:message_start / content_block_start / content_block_delta / * content_block_stop / message_delta / message_stop(非 OpenAI chunk 格式) * - 图片:仅支持 base64 source(URL 需下载后转换) * * @see https://docs.anthropic.com/en/api/messages */ import { BaseAdapter, ContentFilterError } from './base-adapter'; import { truncatedArgumentsPayload } from './shared/sse-stream'; import log from 'electron-log'; import { nanoid } from 'nanoid'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types'; import type { MetonaModelInfo } from '../types/metona-adapter'; export class AnthropicAdapter extends BaseAdapter { override readonly providerId: string = 'anthropic'; readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5']; readonly supportsToolCalling = true; readonly supportsThinking = true; private static readonly MODEL_INFO: Record = { 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextWindow: 200_000, maxOutputTokens: 64_000, supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用', }, 'claude-opus-4-1': { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', contextWindow: 200_000, maxOutputTokens: 32_000, supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 深度推理模型', }, 'claude-haiku-4-5': { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', contextWindow: 200_000, maxOutputTokens: 32_000, supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 低延迟模型', }, }; private buildHeaders(): Record { return { 'Content-Type': 'application/json', 'x-api-key': this.config.apiKey ?? '', 'anthropic-version': '2023-06-01', ...this.config.headers, }; } // ===== POST /v1/messages(非流式) ===== async send(request: MetonaRequest): Promise { const body = await this.toNativeRequest(request, false); const response = await this.fetchWithTimeout( `${this.config.baseURL}/v1/messages`, { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body), }, this.config.timeoutMs ?? 120_000, ); if (!response.ok) { await this.throwHttpError(response, 'Anthropic API error'); } const data = (await response.json()) as Record; return this.toMetonaResponse(data, request.meta.requestId); } // ===== POST /v1/messages(流式) ===== async *sendStream(request: MetonaRequest): AsyncIterable { const body = await this.toNativeRequest(request, true); const response = await this.fetchWithTimeout( `${this.config.baseURL}/v1/messages`, { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body), }, this.config.timeoutMs ?? 300_000, ); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Anthropic stream error'); } // 非空断言:上方 if 已确保 response.body 不为 null const reader = response.body!.getReader(); const decoder = new TextDecoder(); let seq = 0; let buffer = ''; let eventName = ''; let streamEndedNormally = false; // 工具调用缓冲:content block index → { id, name, argsBuffer } const toolBlocks = new Map(); // v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。 // 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的 // 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话 // 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。 let messageStartInputTokens = 0; const base = () => ({ requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), }); /** * v0.6.4 错误事件单轨化: Anthropic `error` SSE 事件不再以普通 ERROR 流事件转发 * (引擎对 ERROR 事件的旧处理是 throw 普通 Error,最终落入 UNKNOWN 且完全绕过 * chatStreamWithRetry 的重试/故障转移)。改为抛出携带归一化 status 的异常, * 与 HTTP 层 throwHttpError 同轨:overloaded/rate_limit 走重试、authentication/ * invalid_request 不重试并可触发 fallback。 */ const anthropicErrorCodeToStatus = (code: string): number => { switch (code) { case 'overloaded_error': return 529; case 'rate_limit_error': return 429; case 'api_error': return 500; case 'timeout_error': return 504; case 'authentication_error': return 401; case 'permission_error': return 403; case 'not_found_error': return 404; case 'request_too_large': case 'invalid_request_error': return 400; default: return 500; } }; const processEvent = (name: string, data: Record): MetonaStreamEvent[] => { const events: MetonaStreamEvent[] = []; switch (name) { case 'content_block_start': { const block = data.content_block as Record | undefined; const index = (data.index as number) ?? 0; if (block?.type === 'tool_use') { toolBlocks.set(index, { id: (block.id as string) ?? `tc_${nanoid(8)}`, name: (block.name as string) ?? '', argsBuffer: '', }); } break; } case 'content_block_delta': { const delta = data.delta as Record | undefined; const index = (data.index as number) ?? 0; if (delta?.type === 'text_delta' && typeof delta.text === 'string') { events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { events.push({ type: MetonaStreamEventType.REASONING_DELTA, ...base(), delta: delta.thinking, }); } else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { const block = toolBlocks.get(index); if (block) { block.argsBuffer += delta.partial_json; events.push({ type: MetonaStreamEventType.TOOL_CALL_DELTA, ...base(), toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json }, }); } } break; } case 'content_block_stop': { const index = (data.index as number) ?? 0; const block = toolBlocks.get(index); if (block) { let args: Record = {}; try { args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; } catch (err) { // v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致 // JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层 // 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断, // 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为 // _truncatedArguments 错误参数触发模型自愈(与共享层同源、同文案契约)。 const sample = block.argsBuffer.slice(-120); log.warn( `[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`, ); args = truncatedArgumentsPayload((err as Error).message, sample); } events.push({ type: MetonaStreamEventType.TOOL_CALL_COMPLETE, ...base(), toolCall: { id: block.id, name: block.name, args, iteration: request.meta.iteration, timestamp: Date.now(), }, }); toolBlocks.delete(index); } break; } case 'message_delta': { // 结束时的 usage 统计(output_tokens 增量在此事件携带) const usage = data.usage as Record | undefined; if (usage) { events.push({ type: MetonaStreamEventType.USAGE, ...base(), usage: { inputTokens: messageStartInputTokens, outputTokens: (usage.output_tokens as number) ?? 0, totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0), // v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集, // cache_read/creation_input_tokens 与 output_tokens 同在 usage 内) cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined, cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined, }, }); } break; } case 'message_stop': { streamEndedNormally = true; events.push({ type: MetonaStreamEventType.DONE, ...base() }); break; } case 'error': { const err = data.error as Record | undefined; const code = (err?.type as string) ?? 'api_error'; const message = (err?.message as string) ?? 'Anthropic stream error'; const status = anthropicErrorCodeToStatus(code); log.warn( `[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`, ); if (code === 'content_filter_error') { throw new ContentFilterError(message, 'Anthropic SSE error event'); } const throwable = new Error(`anthropic_stream_error (${code}): ${message}`); (throwable as Error & { status: number }).status = status; throw throwable; } } return events; }; // message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总) while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.startsWith('event:')) { eventName = trimmed.slice(6).trim(); continue; } if (!trimmed.startsWith('data:')) continue; const dataStr = trimmed.slice(5).trim(); if (dataStr === '[DONE]') continue; try { const data = JSON.parse(dataStr) as Record; // message_start 携带 input_tokens if (eventName === 'message_start') { const msg = data.message as Record | undefined; const usage = msg?.usage as Record | undefined; messageStartInputTokens = (usage?.input_tokens as number) ?? 0; continue; } for (const ev of processEvent(eventName, data)) { yield ev; } } catch (parseErr) { // ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传 if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr; log.warn( `[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200), ); } } } // v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。 // 原实现在 content_block_start 与 content_block_stop 之间断连时,toolBlocks 里 // 未完成的 block 既不产生 TOOL_CALL_COMPLETE、也不 flush —— 引擎看到"零工具调用 // + 零文本"→ 误判 COMPLETED 空回复 → 会话无声停止(正是 v0.6.3 宣称根治、但在 // Anthropic 流上仍然存活的场景)。现于补发 DONE 之前,将所有未完成块按截断契约 // 转为 _truncatedArguments 自愈 tool call(解析成功的则正常产出)。 if (!streamEndedNormally) { const unfinished = [...toolBlocks.entries()]; if (unfinished.length > 0) { log.warn( `[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`, ); for (const [, block] of unfinished) { let args: Record = {}; try { args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {}; } catch (err) { args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120)); } yield { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, ...base(), toolCall: { id: block.id, name: block.name, args, iteration: request.meta.iteration, timestamp: Date.now(), }, }; } } else { log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)'); } toolBlocks.clear(); yield { type: MetonaStreamEventType.DONE, ...base() }; } } // ===== 模型与上下文窗口 ===== override async listModels(): Promise { // Anthropic 无公开 /models 列表端点,返回本地元数据 return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id }); } override getContextWindow(): number { if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { return this.config.contextWindow; } const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel]; return modelInfo?.contextWindow ?? 200_000; } // ========== 私有方法 ========== /** * 构建 Anthropic 原生请求体 * * 转换要点: * 1. MetonaMessage → Anthropic 消息(content 块数组) * 2. tool 消息 → user 角色 tool_result 块 * 3. assistant 工具调用 → tool_use 块 * 4. 连续同角色消息合并(API 要求严格交替) * 5. 首条消息必须为 user(历史以 assistant 开头时补占位) */ private async toNativeRequest( request: MetonaRequest, stream: boolean, ): Promise> { const thinkingRequested = Boolean(request.params.thinkingEnabled); // System Prompt 拼接(Anthropic 使用顶层 system 字段) const system = [ request.systemPrompt.roleDefinition, request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, request.systemPrompt.dynamicReminders, ] .filter(Boolean) .join('\n\n'); // 转换消息(非 system) // v0.6.2 纵深防御: 过滤孤立 tool 消息 — Anthropic 协议要求 tool_result 块 // 必须对应前置 assistant 的 tool_use(违反直接 400)。与 openai-format 同策略。 const pendingToolUseIds = new Set(); const convertedRaw: Array<{ role: 'user' | 'assistant'; content: Array>; }> = []; for (const m of request.messages) { if (m.role === 'system') continue; if (m.role === 'tool' && m.toolResult) { if (!pendingToolUseIds.has(m.toolResult.toolCallId)) { log.warn( `[Anthropic] Dropped orphan tool_result without matching tool_use: ${m.toolResult.toolCallId}`, ); continue; } pendingToolUseIds.delete(m.toolResult.toolCallId); // 工具结果 → user 角色 tool_result 块 const contentStr = m.toolResult.error ? m.toolResult.error : typeof m.toolResult.result === 'string' ? m.toolResult.result : JSON.stringify(m.toolResult.result); convertedRaw.push({ role: 'user', content: [ { type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }, ], }); continue; } if (m.role === 'assistant') { const content: Array> = []; if (m.content) content.push({ type: 'text', text: m.content }); for (const tc of m.toolCalls ?? []) { pendingToolUseIds.add(tc.id); content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args }); } if (content.length > 0) { convertedRaw.push({ role: 'assistant', content }); } continue; } // user 消息(含多模态图片) const content: Array> = []; if (m.content) content.push({ type: 'text', text: m.content }); for (const img of m.images ?? []) { const block = await this.toImageBlock(img.url); if (block) content.push(block); } if (content.length === 0) content.push({ type: 'text', text: '' }); convertedRaw.push({ role: 'user', content }); } const converted = convertedRaw; // 合并连续同角色消息(Anthropic 要求 user/assistant 交替) const merged: Array<{ role: 'user' | 'assistant'; content: Array> }> = []; for (const msg of converted) { const last = merged[merged.length - 1]; if (last && last.role === msg.role) { last.content.push(...msg.content); } else { merged.push({ ...msg }); } } // 首条消息必须为 user if (merged.length === 0 || merged[0].role !== 'user') { merged.unshift({ role: 'user', content: [{ type: 'text', text: '[Conversation history follows]' }], }); } // v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)— // 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400;thinking budget 已在此值内二分 const anthropicMaxOutput = AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000; // v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求 // budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens // (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。 const requestedMaxTokens = request.params.maxTokens ?? 8192; const maxTokensForRequest = thinkingRequested ? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput)) : Math.min(requestedMaxTokens, anthropicMaxOutput); const body: Record = { model: this.config.defaultModel, max_tokens: maxTokensForRequest, messages: merged, stream, }; // v0.7.3 P1-1: system 稳定前缀打 prompt cache 断言。 // Anthropic 缓存按"内容块前缀"命中 —— system 以字符串传递时无法附加 // cache_control,必须转为块数组并在最后一个块上打 {type:'ephemeral'}。 // 缓存前缀覆盖 tools + system(请求组装顺序 tools 在前):system 稳定后, // 多轮对话/多轮迭代复用同一前缀,输入 token 计费按缓存读价(约 1/10)。 // 前缀稳定性由 P1-1 保证:易变内容(日期/记忆/附件提示)已迁入用户消息。 if (system) { body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]; } else { body.system = system; } // 工具定义(input_schema 命名) if (request.tools?.length) { body.tools = request.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters, })); } // Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半) if (thinkingRequested) { const budgetMap: Record = { low: 1024, medium: 4096, high: 16384, max: 32768, }; const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384; const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2)); body.thinking = { type: 'enabled', budget_tokens: budget }; } else { body.temperature = request.params.temperature; } // 停止序列 if (request.params.stopSequences?.length) { body.stop_sequences = request.params.stopSequences; } return body; } /** * 图片 URL → Anthropic image 块 * data URI 直接解析;http(s) URL 下载后转 base64(Anthropic 不支持 URL 引用) */ private async toImageBlock(url: string): Promise | null> { try { if (url.startsWith('data:')) { // data:image/png;base64,xxx → { media_type, data } const match = url.match(/^data:([^;]+);base64,(.*)$/s); if (!match) return null; return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } }; } if (url.startsWith('http://') || url.startsWith('https://')) { const res = await this.fetchWithTimeout(url, {}, 30_000); if (!res.ok) throw new Error(`HTTP ${res.status}`); const contentType = res.headers.get('content-type') ?? 'image/png'; const buf = Buffer.from(await res.arrayBuffer()); return { type: 'image', source: { type: 'base64', media_type: contentType, data: buf.toString('base64') }, }; } return null; } catch (err) { log.warn(`[Anthropic] Failed to load image: ${(err as Error).message}`); return null; } } /** 非流式响应 → MetonaResponse */ private toMetonaResponse(data: Record, requestId: string): MetonaResponse { const contentBlocks = (data.content as Array>) ?? []; let text = ''; let reasoningContent: string | undefined; const toolCalls: MetonaResponse['toolCalls'] = []; for (const block of contentBlocks) { if (block.type === 'text') text += (block.text as string) ?? ''; else if (block.type === 'thinking') { // v0.6.4 修复: 多个 thinking 块应为累加(原实现后者覆盖前者,长推理链丢内容) const thinking = (block.thinking as string) ?? ''; if (thinking) { reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : thinking; } } else if (block.type === 'tool_use') { let args: Record = {}; const rawInput = block.input; if (rawInput && typeof rawInput === 'object') args = rawInput as Record; toolCalls?.push({ id: (block.id as string) ?? `tc_${nanoid(8)}`, name: (block.name as string) ?? '', args, iteration: 0, timestamp: Date.now(), }); } } const usage = (data.usage as Record) ?? {}; const stopReason = (data.stop_reason as string) ?? 'end_turn'; // v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把 // "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路 const finishReason: MetonaFinishReason = stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS : stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH : stopReason === 'refusal' || stopReason === 'content_filter' ? MetonaFinishReason.CONTENT_FILTER : MetonaFinishReason.STOP; return { meta: { requestId, provider: this.providerId, model: (data.model as string) ?? this.config.defaultModel, latencyMs: 0, timestamp: Date.now(), }, content: text, reasoningContent, toolCalls, usage: { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0), // v0.6.4: 补采缓存字段(与非流式调用方对齐其他 provider 的口径) cacheHitTokens: usage.cache_read_input_tokens, cacheMissTokens: usage.cache_creation_input_tokens, }, finishReason, }; } }