Files
thzxx 9329df68af
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m37s
CI / 全量测试 (Electron ABI) (push) Failing after 6m1s
CI / 产物编译验证 (push) Successful in 10m54s
feat: v0.8.3 工作空间切换链路根治 · 思考强度扩档 — 启动TDZ连锁/继承丢数/回显滞后三修复 · xhigh+true档位单源 · 安全防线ready后注册 · 2217 用例全量回归 + 生产模式E2E
2026-09-08 21:14:59 +08:00

869 lines
36 KiB
TypeScript
Raw Permalink 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, 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<string, MetonaModelInfo> = {
'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<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> {
// 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<string, unknown>;
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<Record<string, unknown>>) ?? []),
{ role: 'assistant', content: (data.content as Array<unknown>) ?? [] },
],
};
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<MetonaStreamEvent> {
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<number, { id: string; name: string; argsBuffer: string }>();
/** 本段原始 content 块(pause_turn 续传需按协议原样回传) */
const rawBlocks: Array<Record<string, unknown> | null> = [];
/** thinking 块签名(content_block_delta.signature_delta 累积) */
const thinkingSignatures = new Map<number, string>();
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: '',
});
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<string, unknown>;
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<string, unknown> | 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<string, unknown> = {};
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<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,
},
});
}
// v0.8.0 P0-1: 采集停止原因原始值(映射移到 DONE 发射点)
const delta = data.delta as Record<string, unknown> | 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<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;
};
// ===== 单段响应的 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<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),
);
}
}
}
// ===== 段结束处理 =====
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<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(),
// 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<Record<string, unknown>>;
log.info(
`[Anthropic] pause_turn — continuing stream turn (#${continuation + 1}, ${contentForContinuation.length} block(s) carried over)`,
);
body = {
...body,
messages: [
...((body.messages as Array<Record<string, unknown>>) ?? []),
{ 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<MetonaModelInfo[]> {
// 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<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>> = [];
// 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<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.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<string, unknown> = {
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,此处钳制到一半)
// v0.8.3: 新增 xhigh / true 档 —— xhigh 介于 high 与 max 之间取 24576
// true(模型默认档)按 high 同档预算
if (thinkingRequested) {
const budgetMap: Record<string, number> = {
low: 1024,
medium: 4096,
high: 16384,
xhigh: 24576,
max: 32768,
true: 16384,
};
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 下载后转 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://')) {
// 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<string, unknown>, requestId: string): MetonaResponse {
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
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<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 走友好提示链路
// 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,
};
}
}