/** * 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 } from './base-adapter'; 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(); const base = () => ({ requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), }); 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 { args = {}; } 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: (this.lastInputTokens as number) ?? 0, outputTokens: (usage.output_tokens as number) ?? 0, totalTokens: ((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0), }, }); } break; } case 'message_stop': { streamEndedNormally = true; events.push({ type: MetonaStreamEventType.DONE, ...base() }); break; } case 'error': { const err = data.error as Record | undefined; events.push({ type: MetonaStreamEventType.ERROR, ...base(), error: { code: 'unknown' as never, message: (err?.message as string) ?? 'Anthropic stream error', retryable: false, }, }); break; } } 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; this.lastInputTokens = (usage?.input_tokens as number) ?? 0; continue; } for (const ev of processEvent(eventName, data)) { yield ev; } } catch (parseErr) { log.warn( `[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200), ); } } } // 流中断(连接断开等)补发 DONE,防止 Agent Loop 挂起(与 Ollama 行为一致) if (!streamEndedNormally) { yield { type: MetonaStreamEventType.DONE, ...base() }; } } /** message_start 捕获的 input_tokens(供 message_delta 汇总 usage) */ private lastInputTokens = 0; // ===== 模型与上下文窗口 ===== 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> { // System Prompt 拼接(Anthropic 使用顶层 system 字段) const system = [ request.systemPrompt.roleDefinition, request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, request.systemPrompt.dynamicReminders, ] .filter(Boolean) .join('\n\n'); // 转换消息(非 system) const converted: Array<{ role: 'user' | 'assistant'; content: Array>; }> = []; for (const m of request.messages) { if (m.role === 'system') continue; if (m.role === 'tool' && m.toolResult) { // 工具结果 → user 角色 tool_result 块 const contentStr = m.toolResult.error ? m.toolResult.error : typeof m.toolResult.result === 'string' ? m.toolResult.result : JSON.stringify(m.toolResult.result); converted.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 ?? []) { content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args }); } if (content.length > 0) { converted.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: '' }); converted.push({ role: 'user', content }); } // 合并连续同角色消息(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; const body: Record = { model: this.config.defaultModel, max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput), system, messages: merged, stream, }; // 工具定义(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 (request.params.thinkingEnabled) { const budgetMap: Record = { low: 1024, medium: 4096, high: 16384, max: 32768, }; const budget = Math.min( budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384, Math.floor((body.max_tokens as number) / 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') reasoningContent = (block.thinking as string) ?? undefined; 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'; const finishReason: MetonaFinishReason = stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS : stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH : 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), }, finishReason, }; } }