Files
metona-ai-desktop/electron/harness/adapters/anthropic.adapter.ts
T
thzxx 3a30e8f5b4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s
fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/
压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。

P1 MCP 工具运行中增删不同步引擎:
- 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开
  server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool)
- 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失
  (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖)
- 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 /
  disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。
  懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/
  运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。

P1 maxTokens 超模型上限直接 400:
- 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、
  Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用
- 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按
  MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义;
  Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随

测试(224 → 236 用例):
- 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch
  记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic
  opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/
  推理模型字段名 / 未配置默认值安全性
- 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK
  + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连
  server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 /
  回调异常不阻断 MCP 主流程

能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent
工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并
路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ /
记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不
回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。

验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
2026-08-21 22:36:52 +08:00

521 lines
18 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 } 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<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 }>();
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<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 {
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<string, unknown> | 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<string, unknown> | 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<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;
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<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>> {
// 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<Record<string, unknown>>;
}> = [];
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<Record<string, unknown>> = [];
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<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: '' });
converted.push({ role: 'user', content });
}
// 合并连续同角色消息(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;
const body: Record<string, unknown> = {
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<string, number> = {
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 下载后转 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')
reasoningContent = (block.thinking as string) ?? undefined;
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';
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,
};
}
}