869 lines
36 KiB
TypeScript
869 lines
36 KiB
TypeScript
/**
|
||
* 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<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 下载后转 base64(Anthropic 不支持 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,
|
||
};
|
||
}
|
||
}
|