feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+419 -238
View File
@@ -20,13 +20,31 @@ 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 } from '../types';
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
* 的非流式映射语义一致)。pause_turn(长回复暂停续传标记)视为自然停止。
* 的非流式映射语义一致)。
*
* 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) {
@@ -37,8 +55,10 @@ function mapAnthropicStopReason(reason: string): string {
case 'refusal':
case 'content_filter':
return 'content_filter';
case 'end_turn':
case 'pause_turn':
// 续传预算耗尽的兜底语义:按输出截断处理(不可静默当自然结束)
return 'length';
case 'end_turn':
case 'stop_sequence':
return 'stop';
default:
@@ -90,64 +110,59 @@ export class AnthropicAdapter extends BaseAdapter {
// ===== 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,
);
// 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');
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);
}
const data = (await response.json()) as Record<string, unknown>;
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> {
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,
);
let body = await this.toNativeRequest(request, true);
const collectedThinkingBlocks: MetonaThinkingBlock[] = [];
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;
// v0.8.0 P0-1: 采集 message_delta.delta.stop_reason —— Anthropic 的停止原因
// 在 message_delta(而非 message_stop)事件携带;旧实现只读 usage,
// max_tokens 截断在流式路径完全不可见(与 OpenAI 共享层 finish_reason 缺口同源)
let streamStopReason: string | undefined;
// 工具调用缓冲: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,
@@ -187,65 +202,285 @@ export class AnthropicAdapter extends BaseAdapter {
}
};
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: '',
});
// ===== 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;
}
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') {
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) {
block.argsBuffer += delta.partial_json;
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_DELTA,
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(),
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
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;
}
break;
}
case 'content_block_stop': {
const index = (data.index as number) ?? 0;
const block = toolBlocks.get(index);
if (block) {
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) {
// 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,
block.argsBuffer.slice(-120),
);
args = truncatedArgumentsPayload((err as Error).message, sample);
}
events.push({
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(),
toolCall: {
@@ -255,153 +490,71 @@ export class AnthropicAdapter extends BaseAdapter {
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,
},
});
}
// v0.8.0 P0-1: 采集停止原因(message_delta.delta.stop_reason,可能在
// 多个 message_delta 中重复出现,取任一即可;归一化为 OpenAI 语义)
const delta = data.delta as Record<string, unknown> | undefined;
if (delta && typeof delta.stop_reason === 'string') {
streamStopReason = mapAnthropicStopReason(delta.stop_reason);
}
break;
}
case 'message_stop': {
streamEndedNormally = true;
events.push({
type: MetonaStreamEventType.DONE,
...base(),
// v0.8.0 P0-1: 携带归一化停止原因
...(streamStopReason ? { finishReason: streamStopReason } : {}),
});
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;
} 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;
}
return events;
};
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
while (true) {
// v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能
// 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
const { done, value } = await readStreamChunkWithIdleTimeout(reader);
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();
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;
}
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`,
`[Anthropic] pause_turn continuation budget exhausted (${MAX_PAUSE_CONTINUATIONS}) — finishing as length`,
);
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)');
yield {
type: MetonaStreamEventType.DONE,
...base(),
finishReason: mapAnthropicStopReason('pause_turn'),
...(collectedThinkingBlocks.length > 0
? { thinkingBlocks: collectedThinkingBlocks.slice() }
: {}),
};
return;
}
toolBlocks.clear();
// 自然结束:发射最终 DONE(映射原始停止原因 + 思考块)
yield {
type: MetonaStreamEventType.DONE,
...base(),
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因
//(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束)
...(streamStopReason ? { finishReason: streamStopReason } : {}),
...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}),
...(collectedThinkingBlocks.length > 0
? { thinkingBlocks: collectedThinkingBlocks.slice() }
: {}),
};
return;
}
}
@@ -480,6 +633,19 @@ export class AnthropicAdapter extends BaseAdapter {
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);
@@ -600,13 +766,12 @@ export class AnthropicAdapter extends BaseAdapter {
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());
// 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: contentType, data: buf.toString('base64') },
source: { type: 'base64', media_type: mediaType, data: base64 },
};
}
return null;
@@ -621,6 +786,8 @@ export class AnthropicAdapter extends BaseAdapter {
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) {
@@ -631,6 +798,15 @@ export class AnthropicAdapter extends BaseAdapter {
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;
@@ -649,14 +825,18 @@ export class AnthropicAdapter extends BaseAdapter {
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 === 'refusal' || stopReason === 'content_filter'
? MetonaFinishReason.CONTENT_FILTER
: MetonaFinishReason.STOP;
: stopReason === 'pause_turn'
? MetonaFinishReason.LENGTH
: stopReason === 'refusal' || stopReason === 'content_filter'
? MetonaFinishReason.CONTENT_FILTER
: MetonaFinishReason.STOP;
return {
meta: {
@@ -668,6 +848,7 @@ export class AnthropicAdapter extends BaseAdapter {
},
content: text,
reasoningContent,
...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}),
toolCalls,
usage: {
inputTokens: usage.input_tokens ?? 0,