/** * 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, readStreamChunkWithIdleTimeout } from './shared/sse-stream'; import log from 'electron-log'; import { nanoid } from 'nanoid'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent, MetonaThinkingBlock, } from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types'; import type { MetonaModelInfo } from '../types/metona-adapter'; /** * v0.8.2 P1-1: pause_turn 单次响应允许的最大续传次数。 * Anthropic 长回复以 pause_turn 分段返回,每段需把 content 原样回传继续; * 预算耗尽仍 pause_turn 时按 length(输出截断)语义收尾,防止无限续传。 */ const MAX_PAUSE_CONTINUATIONS = 5; /** * v0.8.0 P0-1: Anthropic stop_reason → 归一化 OpenAI 语义(与 MetonaFinishReason * 的非流式映射语义一致)。 * * v0.8.2 P1-1: `pause_turn` 不再折叠为 stop —— 此前长回复的暂停续传标记被当作 * 自然结束,引擎不发起续传,长输出静默截断。现 pause_turn 由 sendStream/send 的 * 续传循环在协议层消费(把本段 content 原样作为 assistant 消息回传并继续请求, * 见 MAX_PAUSE_CONTINUATIONS);仅在续传预算耗尽时按截断语义(length)收尾, * 前端据此展示"输出可能截断"提示而非无声缺失。 */ function mapAnthropicStopReason(reason: string): string { switch (reason) { case 'max_tokens': return 'length'; case 'tool_use': return 'tool_calls'; case 'refusal': case 'content_filter': return 'content_filter'; case 'pause_turn': // 续传预算耗尽的兜底语义:按输出截断处理(不可静默当自然结束) return 'length'; case 'end_turn': case 'stop_sequence': return 'stop'; default: return 'stop'; } } 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; // v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除, // 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens private static readonly MODEL_INFO: Record = { 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 旗舰模型,支持扩展思考与工具调用', }, 'claude-opus-4-1': { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 深度推理模型', }, 'claude-haiku-4-5': { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', 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 { // v0.8.2 P1-1: pause_turn 续传循环(与流式路径同语义)—— 本段 content 原样 // 作为 assistant 消息追加后重发,直到自然结束或续传预算耗尽 let body = await this.toNativeRequest(request, false); for (let continuation = 0; ; continuation++) { 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; if (data.stop_reason === 'pause_turn' && continuation < MAX_PAUSE_CONTINUATIONS) { log.info(`[Anthropic] pause_turn — continuing non-stream turn (#${continuation + 1})`); body = { ...body, messages: [ ...((body.messages as Array>) ?? []), { role: 'assistant', content: (data.content as Array) ?? [] }, ], }; continue; } return this.toMetonaResponse(data, request.meta.requestId); } } // ===== POST /v1/messages(流式) ===== /** * 流式响应(v0.8.2 P1-1 重构:pause_turn 续传主循环 + thinking 块采集)。 * * 结构:外层为续传循环 —— 收到 stop_reason=pause_turn 时,把本段 content 块 * **原样**(含 pause_turn 块与已完成的 thinking/tool_use 块)作为 assistant * 消息追加到 messages 后重发,直到自然结束或续传预算耗尽(按 length 收尾)。 * 内层为单段响应的 SSE 消费(事件机处理与 v0.6.x/v0.8.0 契约一致)。 * * thinking 块采集:thinking/redacted_thinking 块在 content_block_stop 时收敛 * (签名完备的块才进入 collectedThinkingBlocks),随最终 DONE 事件携带, * 引擎透传到 assistant 消息实现协议回传(MetonaMessage.thinkingBlocks)。 */ async *sendStream(request: MetonaRequest): AsyncIterable { let body = await this.toNativeRequest(request, true); const collectedThinkingBlocks: MetonaThinkingBlock[] = []; let seq = 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; } }; // ===== pause_turn 续传主循环 ===== for (let continuation = 0; continuation <= MAX_PAUSE_CONTINUATIONS; continuation++) { 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 buffer = ''; let eventName = ''; // ===== 本段响应的局部状态(续传时全部重置;seq 跨段连续) ===== // v0.6.4 竞态修复: message_start 捕获的 input_tokens 用局部闭包变量(fallback // adapter 是跨引擎共享单例,实例字段会跨会话串号) let messageStartInputTokens = 0; // v0.8.0 P0-1: 采集 message_delta.delta.stop_reason 的**原始值** //(pause_turn 判定与最终映射都在本层完成) let rawStopReason: string | undefined; let messageStopSeen = false; // 工具调用缓冲:content block index → { id, name, argsBuffer } const toolBlocks = new Map(); /** 本段原始 content 块(pause_turn 续传需按协议原样回传) */ const rawBlocks: Array | null> = []; /** thinking 块签名(content_block_delta.signature_delta 累积) */ const thinkingSignatures = new Map(); 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: '', }); rawBlocks[index] = { type: 'tool_use', id: (block.id as string) ?? `tc_${nanoid(8)}`, name: (block.name as string) ?? '', input: {}, }; } else if (block?.type === 'text') { rawBlocks[index] = { type: 'text', text: '' }; } else if (block?.type === 'thinking') { rawBlocks[index] = { type: 'thinking', thinking: '' }; } else if (block?.type === 'redacted_thinking') { // redacted_thinking 整块到达(data 不透明载荷),原样保留并直接收集 const rb: MetonaThinkingBlock = { type: 'redacted_thinking', data: (block.data as string) ?? '', }; rawBlocks[index] = rb as unknown as Record; collectedThinkingBlocks.push(rb); } else if (block?.type) { // server_tool_use 等未知块:原样保留(pause_turn 续传保真) rawBlocks[index] = { ...block }; } 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') { const rb = rawBlocks[index]; if (rb?.type === 'text') rb.text = ((rb.text as string) ?? '') + delta.text; events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text }); } else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { const rb = rawBlocks[index]; if (rb?.type === 'thinking') rb.thinking = ((rb.thinking as string) ?? '') + delta.thinking; events.push({ type: MetonaStreamEventType.REASONING_DELTA, ...base(), delta: delta.thinking, }); } else if (delta?.type === 'signature_delta' && typeof delta.signature === 'string') { // v0.8.2 P1-1: thinking 块签名增量(回传校验必需) thinkingSignatures.set( index, (thinkingSignatures.get(index) ?? '') + delta.signature, ); } 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 半截)—— 统一转为 _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); } const rb = rawBlocks[index]; if (rb?.type === 'tool_use') rb.input = 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); } else { // thinking 块收敛:签名完备才收集(协议回传要求;缺失签名的块回传必 400) const rb = rawBlocks[index]; if (rb?.type === 'thinking') { const signature = thinkingSignatures.get(index); if (signature) { rb.signature = signature; collectedThinkingBlocks.push(rb as unknown as MetonaThinkingBlock); } else { log.warn( '[Anthropic] thinking block finished without signature — dropped from round-trip', ); } } } 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, }, }); } // v0.8.0 P0-1: 采集停止原因原始值(映射移到 DONE 发射点) const delta = data.delta as Record | undefined; if (delta && typeof delta.stop_reason === 'string') { rawStopReason = delta.stop_reason; } break; } case 'message_stop': { // v0.8.2 P1-1: DONE 不再在此处发射 —— pause_turn 判定与续传在主循环层, // 最终 DONE 由循环层统一发射(含原始停止原因映射与思考块) messageStopSeen = true; 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; }; // ===== 单段响应的 SSE 消费 ===== while (true) { // v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能 // 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道 const { done, value } = await readStreamChunkWithIdleTimeout( reader, 60_000, this.getExternalAbortSignal(), ); 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), ); } } } // ===== 段结束处理 ===== if (!messageStopSeen) { // v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。 // 在补发 DONE 之前,将所有未完成块按截断契约转为 _truncatedArguments // 自愈 tool call(解析成功的则正常产出)。 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(), // v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因 //(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束) ...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}), ...(collectedThinkingBlocks.length > 0 ? { thinkingBlocks: collectedThinkingBlocks.slice() } : {}), }; return; } if (rawStopReason === 'pause_turn') { if (continuation < MAX_PAUSE_CONTINUATIONS) { // 协议续传:本段 content 原样(含 pause_turn 块、已完成 thinking/tool_use) // 作为 assistant 消息追加后重发。无签名的 thinking 块剔除(回传必 400)。 const contentForContinuation = rawBlocks.filter((b) => { if (!b) return false; if (b.type === 'thinking' && !b.signature) return false; return true; }) as Array>; log.info( `[Anthropic] pause_turn — continuing stream turn (#${continuation + 1}, ${contentForContinuation.length} block(s) carried over)`, ); body = { ...body, messages: [ ...((body.messages as Array>) ?? []), { role: 'assistant', content: contentForContinuation }, ], }; continue; } // 续传预算耗尽:按输出截断语义收尾(前端展示"输出可能截断",不静默丢失) log.warn( `[Anthropic] pause_turn continuation budget exhausted (${MAX_PAUSE_CONTINUATIONS}) — finishing as length`, ); yield { type: MetonaStreamEventType.DONE, ...base(), finishReason: mapAnthropicStopReason('pause_turn'), ...(collectedThinkingBlocks.length > 0 ? { thinkingBlocks: collectedThinkingBlocks.slice() } : {}), }; return; } // 自然结束:发射最终 DONE(映射原始停止原因 + 思考块) yield { type: MetonaStreamEventType.DONE, ...base(), ...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}), ...(collectedThinkingBlocks.length > 0 ? { thinkingBlocks: collectedThinkingBlocks.slice() } : {}), }; return; } } // ===== 模型与上下文窗口 ===== override async listModels(): Promise { // Anthropic 无公开 /models 列表端点,返回本地元数据 return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id }); } // getContextWindow 使用基类实现 —— v0.8.1 硬性契约:唯一来源是 // 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow), // 未配置返回 0,引擎据此跳过压缩预算计算。 // ========== 私有方法 ========== /** * 构建 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> = []; // v0.8.2 P1-1: thinking 块协议回传 —— extended thinking + tool use 的多轮 // 请求要求 assistant 消息携带原始 thinking/redacted_thinking 块(含签名), // 且必须位于 content 首位。仅在本次请求开启 thinking 时回传(thinking 关闭 // 的降级重试路径携带 thinking 块会 400);签名不完备的块直接丢弃。 if (thinkingRequested) { for (const tb of m.thinkingBlocks ?? []) { if (tb.type === 'redacted_thinking') { if (tb.data) content.push({ type: 'redacted_thinking', data: tb.data }); } else if (tb.thinking && tb.signature) { content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature }); } } } 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.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens), // 删除了旧的按模型元信息钳制逻辑(MODEL_INFO.maxOutputTokens 已删除)。 // 唯一保留的协议不变量:thinking 开启时 max_tokens ≥ 2048 —— Anthropic 协议 // 要求 budget_tokens >= 1024 且 < max_tokens,用户配置低于该下限时 API 必然 // 400,此为协议正确性下限而非输出上限(不修改用户配置的持久化值,仅在 // 本次请求体上满足协议约束)。 const requestedMaxTokens = request.params.maxTokens; const maxTokensForRequest = thinkingRequested ? Math.max(2048, requestedMaxTokens ?? 2048) : requestedMaxTokens; const body: Record = { model: this.config.defaultModel, // v0.8.1: 未配置「最大输出上限」时不下发 max_tokens(服务端默认值生效) ...(maxTokensForRequest != null ? { 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; // thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底) const budget = Math.min(effortBudget, Math.floor((maxTokensForRequest ?? 2048) / 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://')) { // v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验, // 可被诱导回读内网数据;现含 DNS pinning/重定向复检/10MB 上限/类型白名单) const { base64, mediaType } = await this.fetchImageAsBase64(url, 30_000); return { type: 'image', source: { type: 'base64', media_type: mediaType, data: 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; // v0.8.2 P1-1: 原始思考块收集(非流式路径,供引擎透传实现协议回传) const thinkingBlocks: MetonaThinkingBlock[] = []; 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; } // 签名完备才收集(协议回传要求) const signature = block.signature as string | undefined; if (thinking && signature) { thinkingBlocks.push({ type: 'thinking', thinking, signature }); } } else if (block.type === 'redacted_thinking') { // redacted_thinking 原样透传(回传协议要求) const redactedData = block.data as string | undefined; if (redactedData) thinkingBlocks.push({ type: 'redacted_thinking', data: redactedData }); } 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 走友好提示链路 // v0.8.2 P1-1: pause_turn 映射为 LENGTH(续传预算耗尽的兜底语义,正常路径 // 已在 send() 内被续传循环消费,不会带 pause_turn 到达此处) const finishReason: MetonaFinishReason = stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS : stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH : stopReason === 'pause_turn' ? 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, ...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}), 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, }; } }