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 桥契约
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* OpenAI 兼容 Provider 中间基类(v0.6.4 P3-1)
|
||||
*
|
||||
* 背景:deepseek / agnes-ai / mimo / openai 四家适配器各自复制了几乎逐字相同的
|
||||
* ~60 行传输样板 —— send/sendStream 的 fetchWithTimeout 调用、Bearer 头构建、
|
||||
* HTTP 错误桥接、非流式 JSON → MetonaResponse 的字段组装、SSE 流接入、以及
|
||||
* "config.contextWindow → MODEL_INFO → 兜底" 的上下文窗口回退链。
|
||||
* 任何行为修复都要改四处,是历史缺陷(如超时字段不一致)的直接来源。
|
||||
*
|
||||
* 收敛后职责划分:
|
||||
* - 本基类拥有:send / sendStream / buildHeaders / 响应组装 / finishReason 映射 /
|
||||
* getContextWindow 回退链;
|
||||
* - 子类只声明差异:chatCompletionsUrl、toNativeRequest(协议参数映射)、
|
||||
* sendTimeoutMs(个别 Provider 历史超时不同)、modelInfoTable。
|
||||
*
|
||||
* 外部类型穿透铁律不变:OpenAI 原生类型止步于本文件,向上只产出 Metona IR。
|
||||
*/
|
||||
|
||||
import type {
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
} from '../../types';
|
||||
import { MetonaFinishReason } from '../../types';
|
||||
import type { MetonaModelInfo } from '../../types/metona-adapter';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from './sse-stream';
|
||||
import { BaseAdapter } from '../base-adapter';
|
||||
|
||||
export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||||
/**
|
||||
* POST /chat/completions 的完整端点。
|
||||
* 绝大多数 Provider 为 `${baseURL}/chat/completions`;少数代理需要自定义。
|
||||
*/
|
||||
protected abstract chatCompletionsUrl(): string;
|
||||
|
||||
/**
|
||||
* 子类特有的请求体参数映射(messages/tools/thinking/max_tokens 等差异点)。
|
||||
* 返回不含 stream 字段的 body —— stream 由本基类统一注入。
|
||||
*/
|
||||
protected abstract toNativeRequest(
|
||||
request: MetonaRequest,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
|
||||
/** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */
|
||||
protected abstract sendTimeoutMs(): number;
|
||||
|
||||
/** 模型元信息表(子类持有;用于 getContextWindow 回退链与钳制) */
|
||||
protected abstract modelInfoTable(): Record<string, MetonaModelInfo>;
|
||||
|
||||
/** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */
|
||||
protected defaultContextWindowFallback(): number {
|
||||
return 1_000_000;
|
||||
}
|
||||
|
||||
// ===== 认证头 =====
|
||||
|
||||
protected buildHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
...this.config.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== POST {chatCompletionsUrl} (非流式) =====
|
||||
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const nativeRequest = await this.toNativeRequest(request, false);
|
||||
|
||||
const response = await this.fetchWithTimeout(
|
||||
this.chatCompletionsUrl(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({ ...nativeRequest, stream: false }),
|
||||
},
|
||||
this.config.timeoutMs ?? this.sendTimeoutMs(),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, `${this.providerLabel()} API error`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return this.toMetonaResponseFromOpenAI(data, request.meta.requestId);
|
||||
}
|
||||
|
||||
// ===== POST {chatCompletionsUrl} (流式) =====
|
||||
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const nativeRequest = await this.toNativeRequest(request, true);
|
||||
|
||||
const response = await this.fetchWithTimeout(
|
||||
this.chatCompletionsUrl(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||||
},
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, `${this.providerLabel()} stream error`);
|
||||
}
|
||||
|
||||
yield* parseSSEStream(
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
response.body!,
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 共享装配 =====
|
||||
|
||||
/** Provider 展示名(错误上下文用):默认取 providerId,子类可覆盖 */
|
||||
protected providerLabel(): string {
|
||||
return this.providerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式响应组装 —— OpenAI 原生结构到 MetonaResponse 的唯一映射点
|
||||
* (此前在四个子类各有一份逐字拷贝)
|
||||
*/
|
||||
private toMetonaResponseFromOpenAI(
|
||||
data: Record<string, unknown>,
|
||||
requestId: string,
|
||||
): MetonaResponse {
|
||||
const parsed = parseOpenAICompatibleResponse(data);
|
||||
return {
|
||||
meta: {
|
||||
requestId,
|
||||
provider: this.providerId,
|
||||
model: (data.model as string) ?? this.config.defaultModel,
|
||||
latencyMs: 0,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
content: parsed.content,
|
||||
reasoningContent: parsed.reasoningContent,
|
||||
toolCalls: parsed.toolCalls,
|
||||
usage: parsed.usage,
|
||||
finishReason: this.mapFinishReasonToMetona(parsed.finishReason),
|
||||
};
|
||||
}
|
||||
|
||||
/** parseOpenAIFinishReason 输出 → MetonaFinishReason 枚举(显式映射替代裸 as 断言) */
|
||||
private mapFinishReasonToMetona(reason: string): MetonaFinishReason {
|
||||
switch (reason) {
|
||||
case 'length':
|
||||
return MetonaFinishReason.LENGTH;
|
||||
case 'tool_calls':
|
||||
return MetonaFinishReason.TOOL_CALLS;
|
||||
case 'content_filter':
|
||||
return MetonaFinishReason.CONTENT_FILTER;
|
||||
case 'error':
|
||||
return MetonaFinishReason.ERROR;
|
||||
default:
|
||||
return MetonaFinishReason.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文窗口回退链(v0.6.3 一致化后的统一实现):
|
||||
* config.contextWindow(用户显式配置)→ 模型元信息 → Provider 兜底。
|
||||
*/
|
||||
override getContextWindow(): number {
|
||||
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||
return this.config.contextWindow;
|
||||
}
|
||||
const modelInfo = this.modelInfoTable()[this.config.defaultModel];
|
||||
return modelInfo?.contextWindow ?? this.defaultContextWindowFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型能力限制类错误(v0.6.4 升级:此前 OpenAI 推理模型拒图抛裸 Error,
|
||||
* 引擎分类落到 UNKNOWN,UI 无法区分"该模型不支持图"与一般故障)。
|
||||
* 携带 status=400 使引擎按"不可重试请求级错误"处理并直接展示原因。
|
||||
*/
|
||||
export class ModelCapabilityError extends Error {
|
||||
readonly status = 400;
|
||||
constructor(model: string, capability: string) {
|
||||
super(`Model "${model}" does not support ${capability}`);
|
||||
this.name = 'ModelCapabilityError';
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,158 @@ import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
import { ContentFilterError } from '../base-adapter';
|
||||
|
||||
/**
|
||||
* v0.6.4 根治「上游错误帧黑洞」:
|
||||
*
|
||||
* OpenAI 兼容网关常在中途发送 `data: {"error":{...}}` 数据帧(网关超时、限流、
|
||||
* 配额耗尽、鉴权失效等)。旧解析器整条处理链只从 `chunk.choices?.[0]?.delta` 取数,
|
||||
* 这类帧两个分支都不命中、零日志 —— 结果任何上游错误都伪装成"干净的空回复 +
|
||||
* 正常 DONE",且异常以普通事件而非异常形式出现,绕过了引擎 chatStreamWithRetry
|
||||
* 的重试/故障转移通道。
|
||||
*
|
||||
* 现契约:上游错误帧在解析层**直接抛出**携带结构化 status/providerCode 的
|
||||
* SseUpstreamError —— 异常沿 async generator 传播进 chatStreamWithRetry 的 catch,
|
||||
* 使 429/5xx 自动走指数退避重试、401 等不可重试错误走 fallback 故障转移,
|
||||
* 与 HTTP 状态码路径的行为完全对齐(错误分类单轨化的流式半边)。
|
||||
*/
|
||||
export class SseUpstreamError extends Error {
|
||||
/** 归一化后的 HTTP status(当帧内无数值 status 时按 providerCode 推断) */
|
||||
readonly status?: number;
|
||||
/** 上游原始错误码(如 "rate_limit_exceeded" / "insufficient_quota") */
|
||||
readonly providerCode?: string;
|
||||
|
||||
constructor(message: string, options?: { status?: number; providerCode?: string }) {
|
||||
super(message);
|
||||
this.name = 'SseUpstreamError';
|
||||
this.status = options?.status;
|
||||
this.providerCode = options?.providerCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.6.4: OpenAI 兼容流帧的最小结构化类型(仅承载本解析器实际消费的字段) */
|
||||
interface SseStreamFrame {
|
||||
choices?: Array<{
|
||||
delta?: {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: Array<{
|
||||
index?: number;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
prompt_cache_hit_tokens?: number;
|
||||
prompt_cache_miss_tokens?: number;
|
||||
completion_tokens_details?: { reasoning_tokens?: number };
|
||||
prompt_tokens_details?: { cached_tokens?: number };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一条已 JSON.parse 的 SSE 数据帧中提取上游错误信息。
|
||||
* 兼容三种形态:
|
||||
* 1. 顶层 `{error:{message,status}}` — OpenAI 兼容网关最常见
|
||||
* 2. `{choices:[{error:{...}}]}` — 少数代理的变体包装
|
||||
* 3. `{error:"plain string"}` — 极简实现
|
||||
* 返回 null 表示该帧不含错误(正常数据帧)。
|
||||
*/
|
||||
function extractUpstreamErrorFrame(
|
||||
chunk: unknown,
|
||||
): { message: string; status?: number; code?: string } | null {
|
||||
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
|
||||
) {
|
||||
errObj = (c.choices[0] as Record<string, unknown> | undefined)?.error;
|
||||
}
|
||||
|
||||
if (typeof errObj === 'string') {
|
||||
return errObj.trim() ? { message: errObj } : null;
|
||||
}
|
||||
if (!errObj || typeof errObj !== 'object') return null;
|
||||
|
||||
const e = errObj as Record<string, unknown>;
|
||||
const rawStatus =
|
||||
typeof e.status === 'number'
|
||||
? e.status
|
||||
: typeof e.status_code === 'number'
|
||||
? e.status_code
|
||||
: undefined;
|
||||
const rawCode = typeof e.code === 'string' ? e.code : typeof e.type === 'string' ? e.type : '';
|
||||
const message =
|
||||
typeof e.message === 'string'
|
||||
? e.message
|
||||
: typeof e.msg === 'string'
|
||||
? e.msg
|
||||
: JSON.stringify(errObj);
|
||||
|
||||
// 无消息且无状态码的无害空对象不算错误(防御性)
|
||||
if (!message && rawStatus === undefined && !rawCode) return null;
|
||||
return { message: message || `upstream error (${rawCode || rawStatus})`, status: rawStatus, code: rawCode || undefined };
|
||||
}
|
||||
|
||||
/** 上游字符串错误码 → 归一化 HTTP status(用于帧内缺失数值 status 时仍能驱动重试判定) */
|
||||
function providerCodeToStatus(code: string): number | undefined {
|
||||
const c = code.toLowerCase();
|
||||
if (/rate_limit|too_many/.test(c)) return 429;
|
||||
if (/quota|insufficient|billing|exceeded_balance/.test(c)) return 402;
|
||||
if (/invalid_api_key|api_key_invalid|unauthorized|authentication/.test(c)) return 401;
|
||||
if (/forbidden|permission/.test(c)) return 403;
|
||||
if (/model_not_found|no_such_model/.test(c)) return 404;
|
||||
if (/overloaded|capacity|unavailable/.test(c)) return 503;
|
||||
if (/server_error|internal_error|internal/.test(c)) return 500;
|
||||
// 请求级 400 家族(无效参数/上下文超限)— 不映射到可重试区间,保持非重试语义
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由提取出的错误信息构造待抛出的 SseUpstreamError /
|
||||
* ContentFilterError(content_filter 类直接复用既有专用类型)。
|
||||
*/
|
||||
function makeUpstreamThrowable(info: { message: string; status?: number; code?: string }): Error {
|
||||
if (info.code && info.code.toLowerCase().includes('content_filter')) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断参数自愈载荷的唯一构造点(v0.6.4: 非流式/Ollama NDJSON/Anthropic 全线共用)。
|
||||
*
|
||||
* 原 v0.6.3 只覆盖了 OpenAI 共享 SSE 层;此处抽出为共享函数后,所有协议路径的
|
||||
* 截断工具调用统一转为显式错误参数 —— 工具执行失败 → 错误结果回传模型 → 模型
|
||||
* 重试/分块写入(ReAct 自愈闭环),彻底消灭"静默丢弃 → 空回复 → 会话无声终止"。
|
||||
*/
|
||||
export function truncatedArgumentsPayload(
|
||||
parseErrorMessage: string,
|
||||
rawTailSample: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
_truncatedArguments: true,
|
||||
_truncatedReason:
|
||||
'The tool-call arguments JSON was truncated before completion ' +
|
||||
'(likely max_tokens output limit reached while generating this tool call). ' +
|
||||
'The original arguments are lost and cannot be recovered. Please retry with a ' +
|
||||
'smaller output (e.g. write the file in smaller chunks) — do NOT reuse or repeat ' +
|
||||
'the previous oversized arguments.' +
|
||||
` [parser: ${parseErrorMessage}; tail sample: ...${rawTailSample}]`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
|
||||
@@ -62,8 +214,9 @@ function* flushToolCallBuffer(
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// v0.6.3: 截断的工具调用转显式错误参数(不丢弃)— 工具执行失败后
|
||||
// 错误结果回传模型,触发重试/分块写入,替代"静默丢弃→空回复终止会话"
|
||||
// v0.6.3 → v0.6.4: 截断的工具调用转显式错误参数(不丢弃)— 工具执行失败后
|
||||
// 错误结果回传模型,触发重试/分块写入。载荷构造已收敛到共享的
|
||||
// truncatedArgumentsPayload(Ollama NDJSON / Anthropic 事件机 / 非流式同步复用)。
|
||||
const rawTail = buf.argsBuffer.slice(-120);
|
||||
log.warn(
|
||||
`[SSE] Tool call args truncated (unparseable JSON, ${(err as Error).message}). ` +
|
||||
@@ -79,14 +232,7 @@ function* flushToolCallBuffer(
|
||||
toolCall: {
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args: {
|
||||
_truncatedArguments: true,
|
||||
_truncatedReason:
|
||||
'The streamed arguments JSON was truncated before completion ' +
|
||||
'(likely max_tokens output limit reached while generating this tool call). ' +
|
||||
'The original arguments are lost. Please retry with smaller output ' +
|
||||
'(e.g. write the file in smaller chunks) — do NOT reuse the previous oversized arguments.',
|
||||
},
|
||||
args: truncatedArgumentsPayload((err as Error).message, rawTail),
|
||||
iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
@@ -149,8 +295,11 @@ export async function* parseSSEStream(
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
||||
const data = trimmed.slice(6);
|
||||
// v0.6.4: 兼容 `data:{...}`(无空格)变体 — 部分代理网关不带空格,
|
||||
// 原实现的 startsWith('data: ') 会将其整帧跳过
|
||||
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data) continue;
|
||||
|
||||
// 流结束
|
||||
if (data === '[DONE]') {
|
||||
@@ -169,109 +318,135 @@ export async function* parseSSEStream(
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.6.4: 结构化类型承载帧内容(替代 JSON.parse 的隐式 any,杜绝字段漂移)
|
||||
let chunk: SseStreamFrame;
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
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;
|
||||
|
||||
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
|
||||
if (chunk.usage) {
|
||||
const usage: MetonaTokenUsage = {
|
||||
inputTokens: chunk.usage.prompt_tokens ?? 0,
|
||||
outputTokens: chunk.usage.completion_tokens ?? 0,
|
||||
totalTokens: chunk.usage.total_tokens ?? 0,
|
||||
reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens,
|
||||
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// MiMo: prompt_tokens_details.cached_tokens
|
||||
cacheHitTokens:
|
||||
chunk.usage.prompt_cache_hit_tokens ??
|
||||
chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||
cacheMissTokens: chunk.usage.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)`,
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,8 +502,16 @@ export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
|
||||
if (typeof rawArgs === 'string') {
|
||||
try {
|
||||
args = JSON.parse(rawArgs);
|
||||
} catch {
|
||||
args = {};
|
||||
} catch (err) {
|
||||
// v0.6.4: 非流式路径与流式截断自愈对齐 —— 此前坏参静默降级 {} 与流式
|
||||
// 的显式自愈行为不一致(v0.6.3 只修了流式半边)。空参数会让工具以
|
||||
// "缺少必要参数"泛化失败,模型无法得知发生了截断;现在统一转为
|
||||
// _truncatedArguments 错误参数,触发模型分块重试。
|
||||
const sample = rawArgs.slice(-120);
|
||||
log.warn(
|
||||
`[SSE] Non-stream tool call args truncated (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`,
|
||||
);
|
||||
args = truncatedArgumentsPayload((err as Error).message, sample);
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
args = rawArgs as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user