Files
metona-ai-desktop/electron/harness/adapters/anthropic.adapter.ts
T
thzxx ebe45482b0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s
feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
2026-08-30 09:44:43 +08:00

647 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Anthropic Provider AdapterP3
*
* 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 sourceURL 需下载后转换)
*
* @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<string, MetonaModelInfo> = {
'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<string, string> {
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<MetonaResponse> {
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<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
}
// ===== POST /v1/messages(流式) =====
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
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<number, { id: string; name: string; argsBuffer: string }>();
// 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<string, unknown>): MetonaStreamEvent[] => {
const events: MetonaStreamEvent[] = [];
switch (name) {
case 'content_block_start': {
const block = data.content_block as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> = {};
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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
// message_start 携带 input_tokens
if (eventName === 'message_start') {
const msg = data.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, unknown> | 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<string, unknown> = {};
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<MetonaModelInfo[]> {
// 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<Record<string, unknown>> {
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<string>();
const convertedRaw: Array<{
role: 'user' | 'assistant';
content: Array<Record<string, unknown>>;
}> = [];
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
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<Record<string, unknown>> }> =
[];
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 直接 400thinking 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<string, unknown> = {
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<string, number> = {
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 下载后转 base64Anthropic 不支持 URL 引用)
*/
private async toImageBlock(url: string): Promise<Record<string, unknown> | 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<string, unknown>, requestId: string): MetonaResponse {
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
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<string, unknown> = {};
const rawInput = block.input;
if (rawInput && typeof rawInput === 'object') args = rawInput as Record<string, unknown>;
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<string, number>) ?? {};
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,
};
}
}