【根因(main.log 实证)】 19:04 / 19:05 / 19:06 三次会话终止均为同一报错: DeepSeek 400 "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'" 缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才 将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 — DeepSeek 高频行为)时: - assistant(tool_calls) 消息不进 messages - 但 tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止 "不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。 DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的 assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。 【修复】 - engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范) - agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库) - 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带 tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts) 【纵深防御 — 孤立 tool 消息过滤】 - openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时 按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁) - anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤 - 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 / includeImages 原位转换 / 非 vision 静默丢弃 【多模态索引对齐收敛】 4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引, 孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages (includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除 各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。 【终止原因可见化】 MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")— 前端 DONE 事件非 completed 终止原因显示为 system 消息。 【v0.6.1 回归缓解】 web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏 ~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。 【验证】 lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7); electron-vite build 成功
533 lines
19 KiB
TypeScript
533 lines
19 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 } from './base-adapter';
|
||
import log from 'electron-log';
|
||
import { nanoid } from 'nanoid';
|
||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
|
||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||
|
||
export class AnthropicAdapter extends BaseAdapter {
|
||
override readonly providerId: string = 'anthropic';
|
||
readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'];
|
||
readonly supportsToolCalling = true;
|
||
readonly supportsThinking = true;
|
||
|
||
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
|
||
'claude-sonnet-4-5': {
|
||
id: 'claude-sonnet-4-5',
|
||
name: 'Claude Sonnet 4.5',
|
||
contextWindow: 200_000,
|
||
maxOutputTokens: 64_000,
|
||
supportsToolCalling: true,
|
||
supportsThinking: true,
|
||
description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用',
|
||
},
|
||
'claude-opus-4-1': {
|
||
id: 'claude-opus-4-1',
|
||
name: 'Claude Opus 4.1',
|
||
contextWindow: 200_000,
|
||
maxOutputTokens: 32_000,
|
||
supportsToolCalling: true,
|
||
supportsThinking: true,
|
||
description: 'Anthropic 深度推理模型',
|
||
},
|
||
'claude-haiku-4-5': {
|
||
id: 'claude-haiku-4-5',
|
||
name: 'Claude Haiku 4.5',
|
||
contextWindow: 200_000,
|
||
maxOutputTokens: 32_000,
|
||
supportsToolCalling: true,
|
||
supportsThinking: true,
|
||
description: 'Anthropic 低延迟模型',
|
||
},
|
||
};
|
||
|
||
private buildHeaders(): Record<string, string> {
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': this.config.apiKey ?? '',
|
||
'anthropic-version': '2023-06-01',
|
||
...this.config.headers,
|
||
};
|
||
}
|
||
|
||
// ===== POST /v1/messages(非流式) =====
|
||
|
||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||
const body = await this.toNativeRequest(request, false);
|
||
const response = await this.fetchWithTimeout(
|
||
`${this.config.baseURL}/v1/messages`,
|
||
{
|
||
method: 'POST',
|
||
headers: this.buildHeaders(),
|
||
body: JSON.stringify(body),
|
||
},
|
||
this.config.timeoutMs ?? 120_000,
|
||
);
|
||
|
||
if (!response.ok) {
|
||
await this.throwHttpError(response, 'Anthropic API error');
|
||
}
|
||
|
||
const data = (await response.json()) as Record<string, unknown>;
|
||
return this.toMetonaResponse(data, request.meta.requestId);
|
||
}
|
||
|
||
// ===== POST /v1/messages(流式) =====
|
||
|
||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||
const body = await this.toNativeRequest(request, true);
|
||
const response = await this.fetchWithTimeout(
|
||
`${this.config.baseURL}/v1/messages`,
|
||
{
|
||
method: 'POST',
|
||
headers: this.buildHeaders(),
|
||
body: JSON.stringify(body),
|
||
},
|
||
this.config.timeoutMs ?? 300_000,
|
||
);
|
||
|
||
if (!response.ok || !response.body) {
|
||
await this.throwHttpError(response, 'Anthropic stream error');
|
||
}
|
||
|
||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||
const reader = response.body!.getReader();
|
||
const decoder = new TextDecoder();
|
||
let seq = 0;
|
||
let buffer = '';
|
||
let eventName = '';
|
||
let streamEndedNormally = false;
|
||
|
||
// 工具调用缓冲:content block index → { id, name, argsBuffer }
|
||
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
|
||
|
||
const base = () => ({
|
||
requestId: request.meta.requestId,
|
||
sessionId: request.meta.sessionId,
|
||
iteration: request.meta.iteration,
|
||
seq: seq++,
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
|
||
const events: MetonaStreamEvent[] = [];
|
||
switch (name) {
|
||
case 'content_block_start': {
|
||
const block = data.content_block as Record<string, unknown> | undefined;
|
||
const index = (data.index as number) ?? 0;
|
||
if (block?.type === 'tool_use') {
|
||
toolBlocks.set(index, {
|
||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||
name: (block.name as string) ?? '',
|
||
argsBuffer: '',
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'content_block_delta': {
|
||
const delta = data.delta as Record<string, unknown> | undefined;
|
||
const index = (data.index as number) ?? 0;
|
||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
|
||
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
||
events.push({
|
||
type: MetonaStreamEventType.REASONING_DELTA,
|
||
...base(),
|
||
delta: delta.thinking,
|
||
});
|
||
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
||
const block = toolBlocks.get(index);
|
||
if (block) {
|
||
block.argsBuffer += delta.partial_json;
|
||
events.push({
|
||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||
...base(),
|
||
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'content_block_stop': {
|
||
const index = (data.index as number) ?? 0;
|
||
const block = toolBlocks.get(index);
|
||
if (block) {
|
||
let args: Record<string, unknown> = {};
|
||
try {
|
||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||
} catch {
|
||
args = {};
|
||
}
|
||
events.push({
|
||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||
...base(),
|
||
toolCall: {
|
||
id: block.id,
|
||
name: block.name,
|
||
args,
|
||
iteration: request.meta.iteration,
|
||
timestamp: Date.now(),
|
||
},
|
||
});
|
||
toolBlocks.delete(index);
|
||
}
|
||
break;
|
||
}
|
||
case 'message_delta': {
|
||
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
|
||
const usage = data.usage as Record<string, unknown> | undefined;
|
||
if (usage) {
|
||
events.push({
|
||
type: MetonaStreamEventType.USAGE,
|
||
...base(),
|
||
usage: {
|
||
inputTokens: (this.lastInputTokens as number) ?? 0,
|
||
outputTokens: (usage.output_tokens as number) ?? 0,
|
||
totalTokens:
|
||
((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
|
||
},
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'message_stop': {
|
||
streamEndedNormally = true;
|
||
events.push({ type: MetonaStreamEventType.DONE, ...base() });
|
||
break;
|
||
}
|
||
case 'error': {
|
||
const err = data.error as Record<string, unknown> | undefined;
|
||
events.push({
|
||
type: MetonaStreamEventType.ERROR,
|
||
...base(),
|
||
error: {
|
||
code: 'unknown' as never,
|
||
message: (err?.message as string) ?? 'Anthropic stream error',
|
||
retryable: false,
|
||
},
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
return events;
|
||
};
|
||
|
||
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() ?? '';
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
if (trimmed.startsWith('event:')) {
|
||
eventName = trimmed.slice(6).trim();
|
||
continue;
|
||
}
|
||
if (!trimmed.startsWith('data:')) continue;
|
||
const dataStr = trimmed.slice(5).trim();
|
||
if (dataStr === '[DONE]') continue;
|
||
|
||
try {
|
||
const data = JSON.parse(dataStr) as Record<string, unknown>;
|
||
// message_start 携带 input_tokens
|
||
if (eventName === 'message_start') {
|
||
const msg = data.message as Record<string, unknown> | undefined;
|
||
const usage = msg?.usage as Record<string, unknown> | undefined;
|
||
this.lastInputTokens = (usage?.input_tokens as number) ?? 0;
|
||
continue;
|
||
}
|
||
for (const ev of processEvent(eventName, data)) {
|
||
yield ev;
|
||
}
|
||
} catch (parseErr) {
|
||
log.warn(
|
||
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
|
||
trimmed.slice(0, 200),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 流中断(连接断开等)补发 DONE,防止 Agent Loop 挂起(与 Ollama 行为一致)
|
||
if (!streamEndedNormally) {
|
||
yield { type: MetonaStreamEventType.DONE, ...base() };
|
||
}
|
||
}
|
||
|
||
/** message_start 捕获的 input_tokens(供 message_delta 汇总 usage) */
|
||
private lastInputTokens = 0;
|
||
|
||
// ===== 模型与上下文窗口 =====
|
||
|
||
override async listModels(): Promise<MetonaModelInfo[]> {
|
||
// Anthropic 无公开 /models 列表端点,返回本地元数据
|
||
return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id });
|
||
}
|
||
|
||
override getContextWindow(): number {
|
||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||
return this.config.contextWindow;
|
||
}
|
||
const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel];
|
||
return modelInfo?.contextWindow ?? 200_000;
|
||
}
|
||
|
||
// ========== 私有方法 ==========
|
||
|
||
/**
|
||
* 构建 Anthropic 原生请求体
|
||
*
|
||
* 转换要点:
|
||
* 1. MetonaMessage → Anthropic 消息(content 块数组)
|
||
* 2. tool 消息 → user 角色 tool_result 块
|
||
* 3. assistant 工具调用 → tool_use 块
|
||
* 4. 连续同角色消息合并(API 要求严格交替)
|
||
* 5. 首条消息必须为 user(历史以 assistant 开头时补占位)
|
||
*/
|
||
private async toNativeRequest(
|
||
request: MetonaRequest,
|
||
stream: boolean,
|
||
): Promise<Record<string, unknown>> {
|
||
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
|
||
const system = [
|
||
request.systemPrompt.roleDefinition,
|
||
request.systemPrompt.outputConstraints,
|
||
request.systemPrompt.safetyGuidelines,
|
||
request.systemPrompt.dynamicReminders,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n');
|
||
|
||
// 转换消息(非 system)
|
||
// 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 直接 400;thinking budget 已在此值内二分
|
||
const anthropicMaxOutput =
|
||
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
|
||
|
||
const body: Record<string, unknown> = {
|
||
model: this.config.defaultModel,
|
||
max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput),
|
||
system,
|
||
messages: merged,
|
||
stream,
|
||
};
|
||
|
||
// 工具定义(input_schema 命名)
|
||
if (request.tools?.length) {
|
||
body.tools = request.tools.map((t) => ({
|
||
name: t.name,
|
||
description: t.description,
|
||
input_schema: t.parameters,
|
||
}));
|
||
}
|
||
|
||
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
|
||
if (request.params.thinkingEnabled) {
|
||
const budgetMap: Record<string, number> = {
|
||
low: 1024,
|
||
medium: 4096,
|
||
high: 16384,
|
||
max: 32768,
|
||
};
|
||
const budget = Math.min(
|
||
budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384,
|
||
Math.floor((body.max_tokens as number) / 2),
|
||
);
|
||
body.thinking = { type: 'enabled', budget_tokens: budget };
|
||
} else {
|
||
body.temperature = request.params.temperature;
|
||
}
|
||
|
||
// 停止序列
|
||
if (request.params.stopSequences?.length) {
|
||
body.stop_sequences = request.params.stopSequences;
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* 图片 URL → Anthropic image 块
|
||
* data URI 直接解析;http(s) URL 下载后转 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://')) {
|
||
const res = await this.fetchWithTimeout(url, {}, 30_000);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const contentType = res.headers.get('content-type') ?? 'image/png';
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
return {
|
||
type: 'image',
|
||
source: { type: 'base64', media_type: contentType, data: buf.toString('base64') },
|
||
};
|
||
}
|
||
return null;
|
||
} catch (err) {
|
||
log.warn(`[Anthropic] Failed to load image: ${(err as Error).message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 非流式响应 → MetonaResponse */
|
||
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
|
||
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
|
||
let text = '';
|
||
let reasoningContent: string | undefined;
|
||
const toolCalls: MetonaResponse['toolCalls'] = [];
|
||
|
||
for (const block of contentBlocks) {
|
||
if (block.type === 'text') text += (block.text as string) ?? '';
|
||
else if (block.type === 'thinking')
|
||
reasoningContent = (block.thinking as string) ?? undefined;
|
||
else if (block.type === 'tool_use') {
|
||
let args: Record<string, unknown> = {};
|
||
const rawInput = block.input;
|
||
if (rawInput && typeof rawInput === 'object') args = rawInput as Record<string, unknown>;
|
||
toolCalls?.push({
|
||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||
name: (block.name as string) ?? '',
|
||
args,
|
||
iteration: 0,
|
||
timestamp: Date.now(),
|
||
});
|
||
}
|
||
}
|
||
|
||
const usage = (data.usage as Record<string, number>) ?? {};
|
||
const stopReason = (data.stop_reason as string) ?? 'end_turn';
|
||
const finishReason: MetonaFinishReason =
|
||
stopReason === 'tool_use'
|
||
? MetonaFinishReason.TOOL_CALLS
|
||
: stopReason === 'max_tokens'
|
||
? MetonaFinishReason.LENGTH
|
||
: MetonaFinishReason.STOP;
|
||
|
||
return {
|
||
meta: {
|
||
requestId,
|
||
provider: this.providerId,
|
||
model: (data.model as string) ?? this.config.defaultModel,
|
||
latencyMs: 0,
|
||
timestamp: Date.now(),
|
||
},
|
||
content: text,
|
||
reasoningContent,
|
||
toolCalls,
|
||
usage: {
|
||
inputTokens: usage.input_tokens ?? 0,
|
||
outputTokens: usage.output_tokens ?? 0,
|
||
totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
|
||
},
|
||
finishReason,
|
||
};
|
||
}
|
||
}
|