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:
@@ -4,8 +4,13 @@
|
||||
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
parseSSEStream,
|
||||
parseOpenAICompatibleResponse,
|
||||
readStreamChunkWithIdleTimeout,
|
||||
SseUpstreamError,
|
||||
} from '../shared/sse-stream';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
|
||||
/** 构造 SSE 测试流 */
|
||||
@@ -242,3 +247,446 @@ describe('parseOpenAICompatibleResponse — 非流式响应', () => {
|
||||
expect(result.finishReason).toBe('stop');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== v0.7.4 P1-2: 流空闲超时 =====
|
||||
|
||||
describe('parseSSEStream — 流空闲超时(P1-2)', () => {
|
||||
it('连续无数据超过空闲阈值时抛 SseUpstreamError(504)', async () => {
|
||||
const { readStreamChunkWithIdleTimeout } = await import('../shared/sse-stream');
|
||||
|
||||
// 构造一个永不返回数据的 reader(挂死流)
|
||||
const neverReader = {
|
||||
read: () => new Promise<{ done: boolean; value?: Uint8Array }>(() => {}),
|
||||
releaseLock: () => {},
|
||||
cancel: () => Promise.resolve(),
|
||||
closed: Promise.resolve(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
// 使用共享辅助函数直接验证:空闲超时抛 SseUpstreamError(504)
|
||||
await expect(readStreamChunkWithIdleTimeout(neverReader, 50)).rejects.toMatchObject({
|
||||
status: 504,
|
||||
});
|
||||
|
||||
// 数据到达时重置空闲窗口(不抛错)
|
||||
const delayedReader = {
|
||||
read: () =>
|
||||
new Promise<{ done: boolean; value?: Uint8Array }>((resolve) => {
|
||||
setTimeout(() => resolve({ done: true, value: undefined }), 10);
|
||||
}),
|
||||
releaseLock: () => {},
|
||||
cancel: () => Promise.resolve(),
|
||||
closed: Promise.resolve(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
const result = await readStreamChunkWithIdleTimeout(delayedReader, 200);
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:readStreamChunkWithIdleTimeout 专项 =====
|
||||
|
||||
describe('readStreamChunkWithIdleTimeout — 空闲超时语义', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeReader(
|
||||
reads: Array<{ delay: number; value?: string; done?: boolean }>,
|
||||
): ReadableStreamDefaultReader<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let i = 0;
|
||||
return {
|
||||
read: () =>
|
||||
new Promise<{ done: boolean; value?: Uint8Array }>((resolve) => {
|
||||
const spec = reads[Math.min(i, reads.length - 1)];
|
||||
i++;
|
||||
setTimeout(() => {
|
||||
if (spec.done) resolve({ done: true, value: undefined });
|
||||
else
|
||||
resolve({ done: false, value: spec.value ? encoder.encode(spec.value) : undefined });
|
||||
}, spec.delay);
|
||||
}),
|
||||
releaseLock: () => {},
|
||||
cancel: () => Promise.resolve(),
|
||||
closed: Promise.resolve(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
}
|
||||
|
||||
it('空闲超时抛 SseUpstreamError(504),消息含 idle timeout', async () => {
|
||||
const reader = makeReader([{ delay: 10_000 }]);
|
||||
const promise = readStreamChunkWithIdleTimeout(reader, 30);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
name: 'SseUpstreamError',
|
||||
status: 504,
|
||||
message: expect.stringContaining('idle timeout'),
|
||||
});
|
||||
});
|
||||
|
||||
it('数据在超时前到达 → 正常返回数据(空闲窗口被重置)', async () => {
|
||||
const reader = makeReader([
|
||||
{ delay: 5, value: 'data: {}' },
|
||||
{ done: true, delay: 1 },
|
||||
]);
|
||||
const first = await readStreamChunkWithIdleTimeout(reader, 100);
|
||||
expect(first.done).toBe(false);
|
||||
expect(new TextDecoder().decode(first.value)).toBe('data: {}');
|
||||
const second = await readStreamChunkWithIdleTimeout(reader, 100);
|
||||
expect(second.done).toBe(true);
|
||||
});
|
||||
|
||||
it('连续多次 read 均未超时(每次数据到达重置计时器)', async () => {
|
||||
const reader = makeReader([
|
||||
{ delay: 5, value: 'a' },
|
||||
{ delay: 5, value: 'b' },
|
||||
{ done: true, delay: 5 },
|
||||
]);
|
||||
for (const expectValue of ['a', 'b']) {
|
||||
const r = await readStreamChunkWithIdleTimeout(reader, 50);
|
||||
expect(new TextDecoder().decode(r.value)).toBe(expectValue);
|
||||
}
|
||||
const last = await readStreamChunkWithIdleTimeout(reader, 50);
|
||||
expect(last.done).toBe(true);
|
||||
});
|
||||
|
||||
it('超时后 timer 被清理(fake timers 验证无遗留定时器)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const reader = {
|
||||
read: () => new Promise<{ done: boolean; value?: Uint8Array }>(() => {}),
|
||||
releaseLock: () => {},
|
||||
cancel: () => Promise.resolve(),
|
||||
closed: Promise.resolve(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
const promise = readStreamChunkWithIdleTimeout(reader, 100);
|
||||
// 先挂上 rejection 处理器,再推进计时器,避免 unhandledRejection 竞态
|
||||
const assertion = expect(promise).rejects.toMatchObject({ status: 504 });
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
await assertion;
|
||||
// finally 清理后不应有残留 timer
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('数据到达正常结束后 timer 同样被清理', async () => {
|
||||
vi.useFakeTimers();
|
||||
const encoder = new TextEncoder();
|
||||
const reader = {
|
||||
read: vi.fn(
|
||||
() =>
|
||||
Promise.resolve({ done: true, value: undefined }) as Promise<{
|
||||
done: boolean;
|
||||
value?: Uint8Array;
|
||||
}>,
|
||||
),
|
||||
releaseLock: () => {},
|
||||
cancel: () => Promise.resolve(),
|
||||
closed: Promise.resolve(),
|
||||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
const r = await readStreamChunkWithIdleTimeout(reader, 100);
|
||||
expect(r.done).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
void encoder;
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:parseSSEStream 更多解析细节 =====
|
||||
|
||||
describe('parseSSEStream — 帧格式兼容扩展', () => {
|
||||
it('CRLF 行结束符(\\r\\n)正常解析', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"content":"crlf"}}]}\r\n\r\n',
|
||||
'data: [DONE]\r\n\r\n',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'crlf'),
|
||||
).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('同一 chunk 内连续多个 SSE 事件均被处理', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"content":"一"}}]}\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":"二"}}]}\n\n' +
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||||
expect(deltas.map((e) => e.delta)).toEqual(['一', '二']);
|
||||
});
|
||||
|
||||
it('一个 SSE 行被切成 4 个网络 chunk 仍能拼接', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"del',
|
||||
'ta":{"content":"跨',
|
||||
'chunk拼接成功',
|
||||
'"}}]}\n\ndata: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||||
expect(delta?.delta).toBe('跨chunk拼接成功');
|
||||
});
|
||||
|
||||
it('data: 后无空格且无内容的空 data 行被忽略', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data:\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('choices 存在但 delta 为空对象 → 不产生事件(静默帧)', async () => {
|
||||
const events = await collect(
|
||||
makeStream(['data: {"choices":[{"delta":{}}]}\n\n', 'data: [DONE]\n\n']),
|
||||
);
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(0);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('工具调用:arguments 被切成 5 段拼接为合法 JSON', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f","arguments":"{\\"a\\":"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1,"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"b\\":"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"[1,2]}"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
expect((completes[0].toolCall as { args: Record<string, unknown> }).args).toEqual({
|
||||
a: 1,
|
||||
b: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it('工具调用 arguments 为空 → 完成时 args 为 {}(无参工具合法)', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"noop"}}]}}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
expect((completes[0].toolCall as { args: Record<string, unknown> }).args).toEqual({});
|
||||
});
|
||||
|
||||
it('流断开(无 [DONE])时 flush 缓冲工具调用 + 补发 DONE', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"last_tool","arguments":"{\\"ok\\":true}"}}]}}]}\n\n',
|
||||
]),
|
||||
);
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:USAGE 扩展字段 =====
|
||||
|
||||
describe('parseSSEStream — USAGE 扩展字段', () => {
|
||||
it('MiMo prompt_tokens_details.cached_tokens 映射 cacheHitTokens', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(usage.cacheHitTokens).toBe(7);
|
||||
expect(usage.cacheMissTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DeepSeek reasoning_tokens 映射 reasoningTokens', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2,"completion_tokens_details":{"reasoning_tokens":99}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(usage.reasoningTokens).toBe(99);
|
||||
});
|
||||
|
||||
it('无 usage 字段的数据帧不产生 USAGE 事件', async () => {
|
||||
const events = await collect(
|
||||
makeStream(['data: {"choices":[{"delta":{"content":"x"}}]}\n\n', 'data: [DONE]\n\n']),
|
||||
);
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.USAGE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:上游错误帧 status 推断矩阵 =====
|
||||
|
||||
describe('parseSSEStream — 上游错误帧 status 推断矩阵', () => {
|
||||
async function collectError(json: unknown): Promise<Error> {
|
||||
const stream = makeStream([`data: ${JSON.stringify(json)}\n\n`]);
|
||||
try {
|
||||
for await (const _ev of parseSSEStream(stream, 'r', 's', 1)) void _ev;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
it.each([
|
||||
[{ error: { code: 'rate_limit_exceeded', message: 'rl' } }, 429],
|
||||
[{ error: { code: 'too_many_requests', message: 'rl' } }, 429],
|
||||
[{ error: { code: 'insufficient_quota', message: 'q' } }, 402],
|
||||
[{ error: { code: 'billing_error', message: 'q' } }, 402],
|
||||
[{ error: { code: 'exceeded_balance', message: 'q' } }, 402],
|
||||
[{ error: { code: 'invalid_api_key', message: 'auth' } }, 401],
|
||||
[{ error: { code: 'authentication_error', message: 'auth' } }, 401],
|
||||
[{ error: { code: 'forbidden', message: 'deny' } }, 403],
|
||||
[{ error: { code: 'permission_denied', message: 'deny' } }, 403],
|
||||
[{ error: { code: 'model_not_found', message: 'no model' } }, 404],
|
||||
[{ error: { code: 'overloaded', message: 'busy' } }, 503],
|
||||
[{ error: { code: 'capacity_exceeded', message: 'busy' } }, 503],
|
||||
[{ error: { code: 'server_error', message: '500' } }, 500],
|
||||
[{ error: { code: 'internal_error', message: '500' } }, 500],
|
||||
] as const)('providerCode %s → status %d', async (errorObj, expectedStatus) => {
|
||||
const err = await collectError(errorObj);
|
||||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||||
expect((err as SseUpstreamError).status).toBe(expectedStatus);
|
||||
});
|
||||
|
||||
it('无效参数类 code(无匹配规则)→ status undefined(非重试语义)', async () => {
|
||||
const err = await collectError({
|
||||
error: { code: 'invalid_request_error', message: 'bad param' },
|
||||
});
|
||||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||||
expect((err as SseUpstreamError).status).toBeUndefined();
|
||||
expect((err as SseUpstreamError).providerCode).toBe('invalid_request_error');
|
||||
});
|
||||
|
||||
it('status_code 数字字段被识别(非 status 命名)', async () => {
|
||||
const err = await collectError({
|
||||
error: { message: 'gateway timeout', status_code: 504 },
|
||||
});
|
||||
expect((err as SseUpstreamError).status).toBe(504);
|
||||
});
|
||||
|
||||
it('msg 字段作为错误消息(非 message 命名)', async () => {
|
||||
const err = await collectError({ error: { msg: 'custom msg', code: 'overloaded' } });
|
||||
expect((err as Error).message).toContain('custom msg');
|
||||
expect((err as SseUpstreamError).status).toBe(503);
|
||||
});
|
||||
|
||||
it('error 为空对象 → 回退 message 为 {}(JSON.stringify 兜底使其仍被识别为错误帧)', async () => {
|
||||
// 源码注明的防御意图是无害空对象不报错,但 message 兜底取 JSON.stringify({})='{}',
|
||||
// 恒真值使该防御分支失效 —— 此处锁定实际行为并留档(见报告)。
|
||||
const err = await collectError({ error: {} });
|
||||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||||
expect((err as Error).message).toContain('upstream_error');
|
||||
expect((err as SseUpstreamError).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('error 为空白字符串 → 不算错误帧', async () => {
|
||||
const events: string[] = [];
|
||||
for await (const ev of parseSSEStream(
|
||||
makeStream(['data: {"error":" "}\n\n', 'data: [DONE]\n\n']),
|
||||
'r',
|
||||
's',
|
||||
1,
|
||||
)) {
|
||||
events.push(ev.type);
|
||||
}
|
||||
expect(events[events.length - 1]).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:parseOpenAICompatibleResponse 更多形态 =====
|
||||
|
||||
describe('parseOpenAICompatibleResponse — 补充形态', () => {
|
||||
it('空 choices 数组 → content 空字符串 + finishReason stop + 无 toolCalls', () => {
|
||||
const result = parseOpenAICompatibleResponse({ choices: [], usage: {} });
|
||||
expect(result.content).toBe('');
|
||||
expect(result.finishReason).toBe('stop');
|
||||
expect(result.toolCalls).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无 message 的 choice → 安全回退', () => {
|
||||
const result = parseOpenAICompatibleResponse({ choices: [{ finish_reason: 'stop' }] });
|
||||
expect(result.content).toBe('');
|
||||
expect(result.reasoningContent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('usage 缺失 → 全 0 token', () => {
|
||||
const result = parseOpenAICompatibleResponse({ choices: [{ message: { content: 'x' } }] });
|
||||
expect(result.usage).toEqual({ inputTokens: 0, outputTokens: 0, totalTokens: 0 });
|
||||
});
|
||||
|
||||
it('usage cache 双格式回退(DeepSeek 命中 + MiMo cached_tokens)', () => {
|
||||
const ds = parseOpenAICompatibleResponse({
|
||||
choices: [{ message: { content: 'x' } }],
|
||||
usage: { prompt_cache_hit_tokens: 5, prompt_cache_miss_tokens: 6 },
|
||||
});
|
||||
expect(ds.usage.cacheHitTokens).toBe(5);
|
||||
expect(ds.usage.cacheMissTokens).toBe(6);
|
||||
|
||||
const mimo = parseOpenAICompatibleResponse({
|
||||
choices: [{ message: { content: 'x' } }],
|
||||
usage: { prompt_tokens_details: { cached_tokens: 9 } },
|
||||
});
|
||||
expect(mimo.usage.cacheHitTokens).toBe(9);
|
||||
});
|
||||
|
||||
it('arguments 为空字符串 → JSON.parse 失败走截断自愈载荷(与坏参同轨)', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: { tool_calls: [{ id: 'c', function: { name: 'f', arguments: '' } }] },
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((result.toolCalls![0].args as Record<string, unknown>)._truncatedArguments).toBe(true);
|
||||
});
|
||||
|
||||
it('arguments 为对象类型 → 原样保留(不解析)', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: { tool_calls: [{ id: 'c', function: { name: 'f', arguments: { a: 1 } } }] },
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.toolCalls![0].args).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('reasoning_tokens 透传到 usage.reasoningTokens', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [{ message: { content: 'x' } }],
|
||||
usage: { completion_tokens_details: { reasoning_tokens: 42 } },
|
||||
});
|
||||
expect(result.usage.reasoningTokens).toBe(42);
|
||||
});
|
||||
|
||||
it('tool_calls 元素缺 id → id undefined 不抛错', () => {
|
||||
const result = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: { tool_calls: [{ function: { name: 'f', arguments: '{}' } }] },
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls![0].id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user