feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -16,11 +16,7 @@
|
||||
* 外部类型穿透铁律不变:OpenAI 原生类型止步于本文件,向上只产出 Metona IR。
|
||||
*/
|
||||
|
||||
import type {
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
} from '../../types';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../../types';
|
||||
import { MetonaFinishReason } from '../../types';
|
||||
import type { MetonaModelInfo } from '../../types/metona-adapter';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from './sse-stream';
|
||||
@@ -49,8 +45,10 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||||
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
|
||||
|
||||
/** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */
|
||||
// v0.7.4 P4-5: 1M → 128K(与 base-adapter 兜底对齐)——未知模型按最保守主流窗口预算,
|
||||
// 防止压缩阈值按 1M 计算导致实际小窗口模型先 413 再压缩
|
||||
protected defaultContextWindowFallback(): number {
|
||||
return 1_000_000;
|
||||
return 128_000;
|
||||
}
|
||||
|
||||
// ===== 认证头 =====
|
||||
|
||||
@@ -42,6 +42,48 @@ export class SseUpstreamError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.7.4 P1-2: 带空闲超时的流读取辅助 — 全协议读循环统一入口。
|
||||
*
|
||||
* 背景:fetch 流式响应在"服务器保活但不再推送数据"时,reader.read() 会无限挂起。
|
||||
* 引擎 totalTimeoutMs 只在迭代之间检查,无法兜底流内挂死;用户只能手动 abort。
|
||||
*
|
||||
* 本函数在每次 read 前启动 IDLE_TIMEOUT_MS 计时器,数据到达即重置;
|
||||
* 连续超时未收到数据则抛 SseUpstreamError(504) —— 沿 async generator 传播进
|
||||
* 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。
|
||||
* SSE / Ollama NDJSON / Anthropic 事件机三处读循环共用,杜绝三份重复实现漂移。
|
||||
*
|
||||
* @param reader 流的 reader
|
||||
* @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒)
|
||||
* @returns { done, value },done=true 表示流正常结束
|
||||
*/
|
||||
export async function readStreamChunkWithIdleTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
idleTimeoutMs = 60_000,
|
||||
): Promise<{ done: boolean; value: Uint8Array | undefined }> {
|
||||
let idleExpired = false;
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
const idleController = new Promise<{ done: true; value: undefined }>((resolve) => {
|
||||
idleTimer = setTimeout(() => {
|
||||
idleExpired = true;
|
||||
resolve({ done: true, value: undefined });
|
||||
}, idleTimeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([reader.read(), idleController]);
|
||||
if (idleExpired) {
|
||||
throw new SseUpstreamError(
|
||||
`Stream idle timeout after ${idleTimeoutMs}ms (no data received)`,
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.6.4: OpenAI 兼容流帧的最小结构化类型(仅承载本解析器实际消费的字段) */
|
||||
interface SseStreamFrame {
|
||||
choices?: Array<{
|
||||
@@ -80,11 +122,7 @@ function extractUpstreamErrorFrame(
|
||||
if (!chunk || typeof chunk !== 'object') return null;
|
||||
const c = chunk as Record<string, unknown>;
|
||||
let errObj: unknown = c.error;
|
||||
if (
|
||||
(!errObj || typeof errObj !== 'object') &&
|
||||
Array.isArray(c.choices) &&
|
||||
c.choices.length > 0
|
||||
) {
|
||||
if ((!errObj || typeof errObj !== 'object') && Array.isArray(c.choices) && c.choices.length > 0) {
|
||||
errObj = (c.choices[0] as Record<string, unknown> | undefined)?.error;
|
||||
}
|
||||
|
||||
@@ -110,7 +148,11 @@ function extractUpstreamErrorFrame(
|
||||
|
||||
// 无消息且无状态码的无害空对象不算错误(防御性)
|
||||
if (!message && rawStatus === undefined && !rawCode) return null;
|
||||
return { message: message || `upstream error (${rawCode || rawStatus})`, status: rawStatus, code: rawCode || undefined };
|
||||
return {
|
||||
message: message || `upstream error (${rawCode || rawStatus})`,
|
||||
status: rawStatus,
|
||||
code: rawCode || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 上游字符串错误码 → 归一化 HTTP status(用于帧内缺失数值 status 时仍能驱动重试判定) */
|
||||
@@ -136,10 +178,13 @@ function makeUpstreamThrowable(info: { message: string; status?: number; code?:
|
||||
return new ContentFilterError(info.message, 'SSE 流中收到上游安全审核错误');
|
||||
}
|
||||
const status = info.status ?? (info.code ? providerCodeToStatus(info.code) : undefined);
|
||||
return new SseUpstreamError(`upstream_error${info.code ? ` (${info.code})` : ''}: ${info.message}`, {
|
||||
status,
|
||||
providerCode: info.code,
|
||||
});
|
||||
return new SseUpstreamError(
|
||||
`upstream_error${info.code ? ` (${info.code})` : ''}: ${info.message}`,
|
||||
{
|
||||
status,
|
||||
providerCode: info.code,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,173 +327,189 @@ export async function* parseSSEStream(
|
||||
// v0.6.3: 是否收到过 [DONE](流断开兜底用)
|
||||
let sawDone = false;
|
||||
|
||||
// v0.7.4 P1-2: 流空闲超时 — 服务器保活但不再推送数据(连接挂死)时,
|
||||
// reader.read() 会无限挂起,totalTimeoutMs 只在迭代之间检查,无法兜底。
|
||||
// 连续 IDLE_TIMEOUT_MS 无任何数据则抛出 SseUpstreamError(504),
|
||||
// 异常沿 chatStreamWithRetry 的 catch 进入既有重试/故障转移通道。
|
||||
// 选择 60s 而非 30s:慢速模型(思考模式)正常 chunk 间隔可达数十秒,
|
||||
// 过短会误杀仍在思考的合法请求。实现收敛到共享 readStreamChunkWithIdleTimeout。
|
||||
const IDLE_TIMEOUT_MS = 60_000;
|
||||
|
||||
// 工具调用缓冲区:index → { name, argsBuffer }
|
||||
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
try {
|
||||
while (true) {
|
||||
// 数据到达即重置空闲窗口(辅助函数内部实现)
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader, IDLE_TIMEOUT_MS);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// v0.6.4: 兼容 `data:{...}`(无空格)变体 — 部分代理网关不带空格,
|
||||
// 原实现的 startsWith('data: ') 会将其整帧跳过
|
||||
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data) continue;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// v0.6.4: 兼容 `data:{...}`(无空格)变体 — 部分代理网关不带空格,
|
||||
// 原实现的 startsWith('data: ') 会将其整帧跳过
|
||||
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data) continue;
|
||||
|
||||
// 流结束
|
||||
if (data === '[DONE]') {
|
||||
sawDone = true;
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.6.4: 结构化类型承载帧内容(替代 JSON.parse 的隐式 any,杜绝字段漂移)
|
||||
let chunk: SseStreamFrame;
|
||||
try {
|
||||
chunk = JSON.parse(data) as SseStreamFrame;
|
||||
} catch (parseErr) {
|
||||
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
|
||||
log.warn(
|
||||
`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`,
|
||||
line.slice(0, 200),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// v0.6.4: 上游错误帧检测(根治"错误帧黑洞")。解析失败直接 throw,
|
||||
// 异常沿 chatStreamWithRetry 的 catch 走重试/故障转移通道;
|
||||
// 工具调用缓冲不 flush —— 重试会从零重建整个响应。
|
||||
const upstreamError = extractUpstreamErrorFrame(chunk);
|
||||
if (upstreamError) {
|
||||
log.warn(
|
||||
`[SSE] Upstream error frame received: code=${upstreamError.code ?? 'n/a'} status=${upstreamError.status ?? 'n/a'} message=${upstreamError.message.slice(0, 300)} — throwing for retry/failover handling`,
|
||||
);
|
||||
throw makeUpstreamThrowable(upstreamError);
|
||||
}
|
||||
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
|
||||
// 文本内容增量
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: delta.content,
|
||||
};
|
||||
}
|
||||
|
||||
// 推理内容增量(Thinking 模式)
|
||||
if (delta?.reasoning_content) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.REASONING_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: delta.reasoning_content,
|
||||
};
|
||||
}
|
||||
|
||||
// 工具调用增量 — 缓冲拼接
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!toolCallsBuffer.has(idx)) {
|
||||
toolCallsBuffer.set(idx, { name: tc.function?.name ?? '', argsBuffer: '' });
|
||||
}
|
||||
const buf = toolCallsBuffer.get(idx)!;
|
||||
if (tc.function?.name) buf.name = tc.function.name;
|
||||
if (tc.function?.arguments) buf.argsBuffer += tc.function.arguments;
|
||||
// 流结束
|
||||
if (data === '[DONE]') {
|
||||
sawDone = true;
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
toolCallDelta: {
|
||||
index: idx,
|
||||
name: tc.function?.name,
|
||||
argsDelta: tc.function?.arguments,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.6.4: 结构化类型承载帧内容(替代 JSON.parse 的隐式 any,杜绝字段漂移)
|
||||
let chunk: SseStreamFrame;
|
||||
try {
|
||||
chunk = JSON.parse(data) as SseStreamFrame;
|
||||
} catch (parseErr) {
|
||||
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
|
||||
log.warn(
|
||||
`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`,
|
||||
line.slice(0, 200),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// v0.6.4: 上游错误帧检测(根治"错误帧黑洞")。解析失败直接 throw,
|
||||
// 异常沿 chatStreamWithRetry 的 catch 走重试/故障转移通道;
|
||||
// 工具调用缓冲不 flush —— 重试会从零重建整个响应。
|
||||
const upstreamError = extractUpstreamErrorFrame(chunk);
|
||||
if (upstreamError) {
|
||||
log.warn(
|
||||
`[SSE] Upstream error frame received: code=${upstreamError.code ?? 'n/a'} status=${upstreamError.status ?? 'n/a'} message=${upstreamError.message.slice(0, 300)} — throwing for retry/failover handling`,
|
||||
);
|
||||
throw makeUpstreamThrowable(upstreamError);
|
||||
}
|
||||
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
|
||||
// 文本内容增量
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: delta.content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Token 使用统计 / finish_reason
|
||||
const usageRaw = chunk.usage;
|
||||
if (usageRaw) {
|
||||
const usage: MetonaTokenUsage = {
|
||||
inputTokens: usageRaw.prompt_tokens ?? 0,
|
||||
outputTokens: usageRaw.completion_tokens ?? 0,
|
||||
totalTokens: usageRaw.total_tokens ?? 0,
|
||||
reasoningTokens: usageRaw.completion_tokens_details?.reasoning_tokens,
|
||||
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// MiMo: prompt_tokens_details.cached_tokens
|
||||
cacheHitTokens:
|
||||
usageRaw.prompt_cache_hit_tokens ?? usageRaw.prompt_tokens_details?.cached_tokens,
|
||||
cacheMissTokens: usageRaw.prompt_cache_miss_tokens,
|
||||
};
|
||||
// 推理内容增量(Thinking 模式)
|
||||
if (delta?.reasoning_content) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.REASONING_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: delta.reasoning_content,
|
||||
};
|
||||
}
|
||||
|
||||
yield {
|
||||
type: MetonaStreamEventType.USAGE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
usage,
|
||||
};
|
||||
}
|
||||
// 工具调用增量 — 缓冲拼接
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!toolCallsBuffer.has(idx)) {
|
||||
toolCallsBuffer.set(idx, { name: tc.function?.name ?? '', argsBuffer: '' });
|
||||
}
|
||||
const buf = toolCallsBuffer.get(idx)!;
|
||||
if (tc.function?.name) buf.name = tc.function.name;
|
||||
if (tc.function?.arguments) buf.argsBuffer += tc.function.arguments;
|
||||
|
||||
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
|
||||
if (finishReason === 'tool_calls') {
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
}
|
||||
// v0.6.3 归因: 输出 token 上限截断(长工具参数/长文本的常见根因)显式落日志
|
||||
if (finishReason === 'length') {
|
||||
log.warn(
|
||||
`[SSE] finish_reason=length — output truncated by max_tokens limit ` +
|
||||
`(accumulated argsBuffer: ${[...toolCallsBuffer.values()].reduce((n, b) => n + b.argsBuffer.length, 0)} chars, ` +
|
||||
`model may retry with smaller output)`,
|
||||
);
|
||||
}
|
||||
// v0.6.4: 流式 content_filter 终止映射 —— 非流式路径早已支持
|
||||
// (throwHttpError → ContentFilterError),流式此前既不映射也不打日志,
|
||||
// 引擎拿到普通结束、用户看不到拦截原因。抛专用类型使 finish() 映射为
|
||||
// CONTENT_FILTERED 错误码 + 友好提示,且不会被重试逻辑反复重放。
|
||||
if (finishReason === 'content_filter') {
|
||||
log.warn('[SSE] finish_reason=content_filter — provider safety filter terminated the response');
|
||||
throw new ContentFilterError(
|
||||
'流式响应被 Provider 安全审核终止',
|
||||
'SSE stream (finish_reason=content_filter)',
|
||||
);
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
toolCallDelta: {
|
||||
index: idx,
|
||||
name: tc.function?.name,
|
||||
argsDelta: tc.function?.arguments,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Token 使用统计 / finish_reason
|
||||
const usageRaw = chunk.usage;
|
||||
if (usageRaw) {
|
||||
const usage: MetonaTokenUsage = {
|
||||
inputTokens: usageRaw.prompt_tokens ?? 0,
|
||||
outputTokens: usageRaw.completion_tokens ?? 0,
|
||||
totalTokens: usageRaw.total_tokens ?? 0,
|
||||
reasoningTokens: usageRaw.completion_tokens_details?.reasoning_tokens,
|
||||
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// MiMo: prompt_tokens_details.cached_tokens
|
||||
cacheHitTokens:
|
||||
usageRaw.prompt_cache_hit_tokens ?? usageRaw.prompt_tokens_details?.cached_tokens,
|
||||
cacheMissTokens: usageRaw.prompt_cache_miss_tokens,
|
||||
};
|
||||
|
||||
yield {
|
||||
type: MetonaStreamEventType.USAGE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
|
||||
if (finishReason === 'tool_calls') {
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
}
|
||||
// v0.6.3 归因: 输出 token 上限截断(长工具参数/长文本的常见根因)显式落日志
|
||||
if (finishReason === 'length') {
|
||||
log.warn(
|
||||
`[SSE] finish_reason=length — output truncated by max_tokens limit ` +
|
||||
`(accumulated argsBuffer: ${[...toolCallsBuffer.values()].reduce((n, b) => n + b.argsBuffer.length, 0)} chars, ` +
|
||||
`model may retry with smaller output)`,
|
||||
);
|
||||
}
|
||||
// v0.6.4: 流式 content_filter 终止映射 —— 非流式路径早已支持
|
||||
// (throwHttpError → ContentFilterError),流式此前既不映射也不打日志,
|
||||
// 引擎拿到普通结束、用户看不到拦截原因。抛专用类型使 finish() 映射为
|
||||
// CONTENT_FILTERED 错误码 + 友好提示,且不会被重试逻辑反复重放。
|
||||
if (finishReason === 'content_filter') {
|
||||
log.warn(
|
||||
'[SSE] finish_reason=content_filter — provider safety filter terminated the response',
|
||||
);
|
||||
throw new ContentFilterError(
|
||||
'流式响应被 Provider 安全审核终止',
|
||||
'SSE stream (finish_reason=content_filter)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 空闲计时器已由 readStreamChunkWithIdleTimeout 内部清理,此处仅防御残留
|
||||
//(无需额外处理——辅助函数 try/finally 保证清理)
|
||||
}
|
||||
|
||||
// v0.6.3 流断开兜底: read() done 但从未收到 [DONE](连接中断/服务端异常收尾)。
|
||||
|
||||
Reference in New Issue
Block a user