Files
metona-ai-desktop/electron/harness/adapters/anthropic.adapter.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
2026-08-27 17:06:58 +08:00

640 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Anthropic Provider AdapterP3
*
* Anthropic Messages API/v1/messages)原生协议,支持 Tool Calling、流式输出、
* 扩展思考(thinking + budget_tokens)、多模态图片(base64)。
*
* 与 OpenAI 兼容 API 的关键差异:
* - 认证头:x-api-key + anthropic-version(非 Authorization Bearer
* - 消息结构:content 为块数组(text / tool_use / tool_result / image),
* 且要求 user/assistant 严格交替(连续同角色需合并)
* - 工具定义:input_schema(非 parameters);工具结果以 user 角色 tool_result 块回传
* - SSE 事件:message_start / content_block_start / content_block_delta /
* content_block_stop / message_delta / message_stop(非 OpenAI chunk 格式)
* - 图片:仅支持 base64 sourceURL 需下载后转换)
*
* @see https://docs.anthropic.com/en/api/messages
*/
import { BaseAdapter, ContentFilterError } from './base-adapter';
import { truncatedArgumentsPayload } from './shared/sse-stream';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export class AnthropicAdapter extends BaseAdapter {
override readonly providerId: string = 'anthropic';
readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'claude-sonnet-4-5': {
id: 'claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
contextWindow: 200_000,
maxOutputTokens: 64_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用',
},
'claude-opus-4-1': {
id: 'claude-opus-4-1',
name: 'Claude Opus 4.1',
contextWindow: 200_000,
maxOutputTokens: 32_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'Anthropic 深度推理模型',
},
'claude-haiku-4-5': {
id: 'claude-haiku-4-5',
name: 'Claude Haiku 4.5',
contextWindow: 200_000,
maxOutputTokens: 32_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'Anthropic 低延迟模型',
},
};
private buildHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'x-api-key': this.config.apiKey ?? '',
'anthropic-version': '2023-06-01',
...this.config.headers,
};
}
// ===== POST /v1/messages(非流式) =====
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = await this.toNativeRequest(request, false);
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/v1/messages`,
{
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 120_000,
);
if (!response.ok) {
await this.throwHttpError(response, 'Anthropic API error');
}
const data = (await response.json()) as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
}
// ===== POST /v1/messages(流式) =====
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = await this.toNativeRequest(request, true);
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/v1/messages`,
{
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 300_000,
);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Anthropic stream error');
}
// 非空断言:上方 if 已确保 response.body 不为 null
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let seq = 0;
let buffer = '';
let eventName = '';
let streamEndedNormally = false;
// 工具调用缓冲:content block index → { id, name, argsBuffer }
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
// v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。
// 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的
// 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话
// 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。
let messageStartInputTokens = 0;
const base = () => ({
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
});
/**
* v0.6.4 错误事件单轨化: Anthropic `error` SSE 事件不再以普通 ERROR 流事件转发
* (引擎对 ERROR 事件的旧处理是 throw 普通 Error,最终落入 UNKNOWN 且完全绕过
* chatStreamWithRetry 的重试/故障转移)。改为抛出携带归一化 status 的异常,
* 与 HTTP 层 throwHttpError 同轨:overloaded/rate_limit 走重试、authentication/
* invalid_request 不重试并可触发 fallback。
*/
const anthropicErrorCodeToStatus = (code: string): number => {
switch (code) {
case 'overloaded_error':
return 529;
case 'rate_limit_error':
return 429;
case 'api_error':
return 500;
case 'timeout_error':
return 504;
case 'authentication_error':
return 401;
case 'permission_error':
return 403;
case 'not_found_error':
return 404;
case 'request_too_large':
case 'invalid_request_error':
return 400;
default:
return 500;
}
};
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
const events: MetonaStreamEvent[] = [];
switch (name) {
case 'content_block_start': {
const block = data.content_block as Record<string, unknown> | undefined;
const index = (data.index as number) ?? 0;
if (block?.type === 'tool_use') {
toolBlocks.set(index, {
id: (block.id as string) ?? `tc_${nanoid(8)}`,
name: (block.name as string) ?? '',
argsBuffer: '',
});
}
break;
}
case 'content_block_delta': {
const delta = data.delta as Record<string, unknown> | undefined;
const index = (data.index as number) ?? 0;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
events.push({
type: MetonaStreamEventType.REASONING_DELTA,
...base(),
delta: delta.thinking,
});
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
const block = toolBlocks.get(index);
if (block) {
block.argsBuffer += delta.partial_json;
events.push({
type: MetonaStreamEventType.TOOL_CALL_DELTA,
...base(),
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
});
}
}
break;
}
case 'content_block_stop': {
const index = (data.index as number) ?? 0;
const block = toolBlocks.get(index);
if (block) {
let args: Record<string, unknown> = {};
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) {
// v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致
// JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层
// 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断,
// 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为
// _truncatedArguments 错误参数触发模型自愈(与共享层同源、同文案契约)。
const sample = block.argsBuffer.slice(-120);
log.warn(
`[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`,
);
args = truncatedArgumentsPayload((err as Error).message, sample);
}
events.push({
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(),
toolCall: {
id: block.id,
name: block.name,
args,
iteration: request.meta.iteration,
timestamp: Date.now(),
},
});
toolBlocks.delete(index);
}
break;
}
case 'message_delta': {
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
const usage = data.usage as Record<string, unknown> | undefined;
if (usage) {
events.push({
type: MetonaStreamEventType.USAGE,
...base(),
usage: {
inputTokens: messageStartInputTokens,
outputTokens: (usage.output_tokens as number) ?? 0,
totalTokens:
messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined,
},
});
}
break;
}
case 'message_stop': {
streamEndedNormally = true;
events.push({ type: MetonaStreamEventType.DONE, ...base() });
break;
}
case 'error': {
const err = data.error as Record<string, unknown> | undefined;
const code = (err?.type as string) ?? 'api_error';
const message = (err?.message as string) ?? 'Anthropic stream error';
const status = anthropicErrorCodeToStatus(code);
log.warn(
`[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`,
);
if (code === 'content_filter_error') {
throw new ContentFilterError(message, 'Anthropic SSE error event');
}
const throwable = new Error(`anthropic_stream_error (${code}): ${message}`);
(throwable as Error & { status: number }).status = status;
throw throwable;
}
}
return events;
};
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('event:')) {
eventName = trimmed.slice(6).trim();
continue;
}
if (!trimmed.startsWith('data:')) continue;
const dataStr = trimmed.slice(5).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr) as Record<string, unknown>;
// message_start 携带 input_tokens
if (eventName === 'message_start') {
const msg = data.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, unknown> | undefined;
messageStartInputTokens = (usage?.input_tokens as number) ?? 0;
continue;
}
for (const ev of processEvent(eventName, data)) {
yield ev;
}
} catch (parseErr) {
// ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传
if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr;
log.warn(
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
trimmed.slice(0, 200),
);
}
}
}
// v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。
// 原实现在 content_block_start 与 content_block_stop 之间断连时,toolBlocks 里
// 未完成的 block 既不产生 TOOL_CALL_COMPLETE、也不 flush —— 引擎看到"零工具调用
// + 零文本"→ 误判 COMPLETED 空回复 → 会话无声停止(正是 v0.6.3 宣称根治、但在
// Anthropic 流上仍然存活的场景)。现于补发 DONE 之前,将所有未完成块按截断契约
// 转为 _truncatedArguments 自愈 tool call(解析成功的则正常产出)。
if (!streamEndedNormally) {
const unfinished = [...toolBlocks.entries()];
if (unfinished.length > 0) {
log.warn(
`[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`,
);
for (const [, block] of unfinished) {
let args: Record<string, unknown> = {};
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) {
args = truncatedArgumentsPayload(
(err as Error).message,
block.argsBuffer.slice(-120),
);
}
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(),
toolCall: {
id: block.id,
name: block.name,
args,
iteration: request.meta.iteration,
timestamp: Date.now(),
},
};
}
} else {
log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
}
toolBlocks.clear();
yield { type: MetonaStreamEventType.DONE, ...base() };
}
}
// ===== 模型与上下文窗口 =====
override async listModels(): Promise<MetonaModelInfo[]> {
// Anthropic 无公开 /models 列表端点,返回本地元数据
return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id });
}
override getContextWindow(): number {
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 200_000;
}
// ========== 私有方法 ==========
/**
* 构建 Anthropic 原生请求体
*
* 转换要点:
* 1. MetonaMessage → Anthropic 消息(content 块数组)
* 2. tool 消息 → user 角色 tool_result 块
* 3. assistant 工具调用 → tool_use 块
* 4. 连续同角色消息合并(API 要求严格交替)
* 5. 首条消息必须为 user(历史以 assistant 开头时补占位)
*/
private async toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Promise<Record<string, unknown>> {
const thinkingRequested = Boolean(request.params.thinkingEnabled);
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
const system = [
request.systemPrompt.roleDefinition,
request.systemPrompt.outputConstraints,
request.systemPrompt.safetyGuidelines,
request.systemPrompt.dynamicReminders,
]
.filter(Boolean)
.join('\n\n');
// 转换消息(非 system
// v0.6.2 纵深防御: 过滤孤立 tool 消息 — Anthropic 协议要求 tool_result 块
// 必须对应前置 assistant 的 tool_use(违反直接 400)。与 openai-format 同策略。
const pendingToolUseIds = new Set<string>();
const convertedRaw: Array<{
role: 'user' | 'assistant';
content: Array<Record<string, unknown>>;
}> = [];
for (const m of request.messages) {
if (m.role === 'system') continue;
if (m.role === 'tool' && m.toolResult) {
if (!pendingToolUseIds.has(m.toolResult.toolCallId)) {
log.warn(
`[Anthropic] Dropped orphan tool_result without matching tool_use: ${m.toolResult.toolCallId}`,
);
continue;
}
pendingToolUseIds.delete(m.toolResult.toolCallId);
// 工具结果 → user 角色 tool_result 块
const contentStr = m.toolResult.error
? m.toolResult.error
: typeof m.toolResult.result === 'string'
? m.toolResult.result
: JSON.stringify(m.toolResult.result);
convertedRaw.push({
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr },
],
});
continue;
}
if (m.role === 'assistant') {
const content: Array<Record<string, unknown>> = [];
if (m.content) content.push({ type: 'text', text: m.content });
for (const tc of m.toolCalls ?? []) {
pendingToolUseIds.add(tc.id);
content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
}
if (content.length > 0) {
convertedRaw.push({ role: 'assistant', content });
}
continue;
}
// user 消息(含多模态图片)
const content: Array<Record<string, unknown>> = [];
if (m.content) content.push({ type: 'text', text: m.content });
for (const img of m.images ?? []) {
const block = await this.toImageBlock(img.url);
if (block) content.push(block);
}
if (content.length === 0) content.push({ type: 'text', text: '' });
convertedRaw.push({ role: 'user', content });
}
const converted = convertedRaw;
// 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> =
[];
for (const msg of converted) {
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
last.content.push(...msg.content);
} else {
merged.push({ ...msg });
}
}
// 首条消息必须为 user
if (merged.length === 0 || merged[0].role !== 'user') {
merged.unshift({
role: 'user',
content: [{ type: 'text', text: '[Conversation history follows]' }],
});
}
// v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)—
// 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400thinking budget 已在此值内二分
const anthropicMaxOutput =
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
// v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求
// budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens
// (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。
const requestedMaxTokens = request.params.maxTokens ?? 8192;
const maxTokensForRequest = thinkingRequested
? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput))
: Math.min(requestedMaxTokens, anthropicMaxOutput);
const body: Record<string, unknown> = {
model: this.config.defaultModel,
max_tokens: maxTokensForRequest,
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 (thinkingRequested) {
const budgetMap: Record<string, number> = {
low: 1024,
medium: 4096,
high: 16384,
max: 32768,
};
const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384;
const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2));
body.thinking = { type: 'enabled', budget_tokens: budget };
} else {
body.temperature = request.params.temperature;
}
// 停止序列
if (request.params.stopSequences?.length) {
body.stop_sequences = request.params.stopSequences;
}
return body;
}
/**
* 图片 URL → Anthropic image 块
* data URI 直接解析;http(s) URL 下载后转 base64Anthropic 不支持 URL 引用)
*/
private async toImageBlock(url: string): Promise<Record<string, unknown> | null> {
try {
if (url.startsWith('data:')) {
// data:image/png;base64,xxx → { media_type, data }
const match = url.match(/^data:([^;]+);base64,(.*)$/s);
if (!match) return null;
return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } };
}
if (url.startsWith('http://') || url.startsWith('https://')) {
const res = await this.fetchWithTimeout(url, {}, 30_000);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const contentType = res.headers.get('content-type') ?? 'image/png';
const buf = Buffer.from(await res.arrayBuffer());
return {
type: 'image',
source: { type: 'base64', media_type: contentType, data: buf.toString('base64') },
};
}
return null;
} catch (err) {
log.warn(`[Anthropic] Failed to load image: ${(err as Error).message}`);
return null;
}
}
/** 非流式响应 → MetonaResponse */
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
let text = '';
let reasoningContent: string | undefined;
const toolCalls: MetonaResponse['toolCalls'] = [];
for (const block of contentBlocks) {
if (block.type === 'text') text += (block.text as string) ?? '';
else if (block.type === 'thinking') {
// v0.6.4 修复: 多个 thinking 块应为累加(原实现后者覆盖前者,长推理链丢内容)
const thinking = (block.thinking as string) ?? '';
if (thinking) {
reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : thinking;
}
} else if (block.type === 'tool_use') {
let args: Record<string, unknown> = {};
const rawInput = block.input;
if (rawInput && typeof rawInput === 'object') args = rawInput as Record<string, unknown>;
toolCalls?.push({
id: (block.id as string) ?? `tc_${nanoid(8)}`,
name: (block.name as string) ?? '',
args,
iteration: 0,
timestamp: Date.now(),
});
}
}
const usage = (data.usage as Record<string, number>) ?? {};
const stopReason = (data.stop_reason as string) ?? 'end_turn';
// v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把
// "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路
const finishReason: MetonaFinishReason =
stopReason === 'tool_use'
? MetonaFinishReason.TOOL_CALLS
: stopReason === 'max_tokens'
? MetonaFinishReason.LENGTH
: stopReason === 'refusal' || stopReason === 'content_filter'
? MetonaFinishReason.CONTENT_FILTER
: MetonaFinishReason.STOP;
return {
meta: {
requestId,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
},
content: text,
reasoningContent,
toolCalls,
usage: {
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
// v0.6.4: 补采缓存字段(与非流式调用方对齐其他 provider 的口径)
cacheHitTokens: usage.cache_read_input_tokens,
cacheMissTokens: usage.cache_creation_input_tokens,
},
finishReason,
};
}
}