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:
@@ -0,0 +1,968 @@
|
||||
/**
|
||||
* AnthropicAdapter 独立测试(v0.7.4 P1-2 / P3-1 差异点锁定)
|
||||
*
|
||||
* 覆盖契约:
|
||||
* - sendStream:cache_control 保留、message_start input_tokens、thinking budget 钳制、
|
||||
* pendingToolUseIds 孤立 tool_result 过滤、断流 flush、错误码归一化矩阵
|
||||
* - send:system 块数组、图片 base64 转换(data URI / URL 下载 / 失败降级)
|
||||
* - 非流式响应:多 thinking 块累加、tool_use 解析、stop_reason → finishReason 映射
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AnthropicAdapter } from '../anthropic.adapter';
|
||||
import { ContentFilterError } from '../base-adapter';
|
||||
import type { MetonaRequest, MetonaStreamEvent } from '../../types';
|
||||
import { MetonaFinishReason, MetonaStreamEventType } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
function makeAdapter(
|
||||
model = 'claude-sonnet-4-5',
|
||||
overrides: Record<string, unknown> = {},
|
||||
): AnthropicAdapter {
|
||||
return new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: 'sk-ant-test',
|
||||
defaultModel: model,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
requestId: 'r1',
|
||||
timestamp: Date.now(),
|
||||
agentVersion: 'test',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are Metona.',
|
||||
outputConstraints: '',
|
||||
safetyGuidelines: '',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ssePayload(lines: string[]): string {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function mockStreamResponse(lines: string[]): void {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(ssePayload(lines)));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
}
|
||||
|
||||
async function collectStream(
|
||||
adapter: AnthropicAdapter,
|
||||
request: MetonaRequest,
|
||||
): Promise<MetonaStreamEvent[]> {
|
||||
const events: MetonaStreamEvent[] = [];
|
||||
for await (const ev of adapter.sendStream(request)) events.push(ev);
|
||||
return events;
|
||||
}
|
||||
|
||||
function jsonLine(obj: unknown): string {
|
||||
return `data: ${JSON.stringify(obj)}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ===== 请求体:system 块数组 + cache_control =====
|
||||
|
||||
describe('AnthropicAdapter — 请求体 cache_control 与消息转换', () => {
|
||||
function okResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
|
||||
return JSON.parse(String(call[1].body));
|
||||
}
|
||||
|
||||
it('请求头携带 x-api-key 与 anthropic-version(非 Bearer)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest());
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['x-api-key']).toBe('sk-ant-test');
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01');
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('system 打 cache_control ephemeral(稳定前缀提示缓存)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest());
|
||||
const body = lastBody();
|
||||
expect(body.system).toEqual([
|
||||
{ type: 'text', text: 'You are Metona.', cache_control: { type: 'ephemeral' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('历史以 assistant 开头时补占位 user 消息(首条必须 user)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'assistant', content: '先说话', timestamp: Date.now() },
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||
expect(msgs[0].role).toBe('user');
|
||||
expect(msgs[0].content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: '[Conversation history follows]',
|
||||
});
|
||||
// 占位后原 assistant 消息仍在
|
||||
expect(msgs[1].role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('连续同角色消息合并(user/user → 单条 user 多块)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: '第一条', timestamp: Date.now() },
|
||||
{ role: 'user', content: '第二条', timestamp: Date.now() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||
const userMsgs = msgs.filter((m) => m.role === 'user');
|
||||
expect(userMsgs).toHaveLength(1);
|
||||
expect(userMsgs[0].content.map((c) => c.text)).toEqual(['第一条', '第二条']);
|
||||
});
|
||||
|
||||
it('system 角色消息被跳过(不进入 messages)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'system', content: 'system content', timestamp: Date.now() },
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const msgs = body.messages as Array<{ role: string }>;
|
||||
expect(msgs.some((m) => m.role === 'system')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 图片 base64 转换 =====
|
||||
|
||||
describe('AnthropicAdapter — 图片块转换', () => {
|
||||
function okResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
|
||||
return JSON.parse(String(call[1].body));
|
||||
}
|
||||
|
||||
it('data URI 图片 → {type:image, source:{base64, media_type, data}}', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'data:image/png;base64,iVBORw0KGgo=', detail: 'auto' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
const imageBlock = userMsg.content.find((c) => c.type === 'image');
|
||||
expect(imageBlock).toEqual({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
|
||||
});
|
||||
});
|
||||
|
||||
it('http URL 图片 → 下载后转 base64(content-type 作为 media_type)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const imageBytes = new TextEncoder().encode('PNG-DATA');
|
||||
// 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'image/jpeg' },
|
||||
arrayBuffer: async () => imageBytes.buffer,
|
||||
} as unknown as Response)
|
||||
.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.jpg' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
const imageBlock = userMsg.content.find((c) => c.type === 'image');
|
||||
expect(imageBlock).toMatchObject({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/jpeg' },
|
||||
});
|
||||
const source = (imageBlock as { source: { data: string } }).source;
|
||||
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
|
||||
});
|
||||
|
||||
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.jpg' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await adapter.send(request);
|
||||
expect(res.content).toBe('ok'); // 请求未被阻断
|
||||
});
|
||||
|
||||
it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'data:image/png,RAW' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
expect(userMsg.content.some((c) => c.type === 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('无图片消息 content 保持 text 块', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
await adapter.send(makeRequest());
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
expect(userMsg.content).toEqual([{ type: 'text', text: 'hi' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== sendStream:message_start input_tokens + USAGE 汇总 =====
|
||||
|
||||
describe('AnthropicAdapter — sendStream 事件机', () => {
|
||||
it('message_start 的 input_tokens 汇总进 message_delta 的 USAGE(含 cache 字段)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: message_start',
|
||||
jsonLine({
|
||||
type: 'message_start',
|
||||
message: { role: 'assistant', usage: { input_tokens: 100 } },
|
||||
}),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: 'hi' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'hello' },
|
||||
}),
|
||||
'event: message_delta',
|
||||
jsonLine({
|
||||
type: 'message_delta',
|
||||
usage: {
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 80,
|
||||
cache_creation_input_tokens: 20,
|
||||
},
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
expect(
|
||||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'hello'),
|
||||
).toBe(true);
|
||||
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
|
||||
expect(usageEvent?.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
cacheHitTokens: 80,
|
||||
cacheMissTokens: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('thinking_delta → REASONING_DELTA 事件', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'let me think' },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
|
||||
expect(reasoning?.delta).toBe('let me think');
|
||||
});
|
||||
|
||||
it('input_json_delta → TOOL_CALL_DELTA 事件并缓冲拼接', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'tool_use', id: 'toolu_1', name: 'read' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: '{"pa' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: 'th":"a"}' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_DELTA);
|
||||
expect(deltas).toHaveLength(2);
|
||||
const complete = events.find((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(complete?.toolCall).toMatchObject({ id: 'toolu_1', name: 'read', args: { path: 'a' } });
|
||||
});
|
||||
|
||||
it('多个 tool block 并行缓冲(index 隔离)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'tool_use', id: 't0', name: 'a' },
|
||||
}),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'tool_use', id: 't1', name: 'b' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 1,
|
||||
delta: { type: 'input_json_delta', partial_json: '{"b":2}' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: '{"a":1}' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 1 }),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(2);
|
||||
const argsById = new Map(completes.map((c) => [c.toolCall!.id, c.toolCall!.args]));
|
||||
expect(argsById.get('t0')).toEqual({ a: 1 });
|
||||
expect(argsById.get('t1')).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it('上游 error 事件 → 抛出归一化 status 异常(映射矩阵)', async () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
['overloaded_error', 529],
|
||||
['rate_limit_error', 429],
|
||||
['api_error', 500],
|
||||
['timeout_error', 504],
|
||||
['authentication_error', 401],
|
||||
['permission_error', 403],
|
||||
['not_found_error', 404],
|
||||
['invalid_request_error', 400],
|
||||
['request_too_large', 400],
|
||||
['unknown_error_type', 500],
|
||||
];
|
||||
for (const [code, expectedStatus] of cases) {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: error',
|
||||
jsonLine({ type: 'error', error: { type: code, message: 'boom' } }),
|
||||
]);
|
||||
const err = await collectStream(adapter, makeRequest({ params: { stream: true } })).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect((err as Error & { status?: number }).status).toBe(expectedStatus);
|
||||
expect((err as Error).message).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('上游 error 事件 code=content_filter_error → ContentFilterError', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: error',
|
||||
jsonLine({
|
||||
type: 'error',
|
||||
error: { type: 'content_filter_error', message: 'blocked by safety' },
|
||||
}),
|
||||
]);
|
||||
const err = await collectStream(adapter, makeRequest({ params: { stream: true } })).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(ContentFilterError);
|
||||
});
|
||||
|
||||
it('坏 JSON 的 SSE 行被跳过不中断流', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_delta',
|
||||
'data: {broken json',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'ok' },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
expect(
|
||||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'ok'),
|
||||
).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('[DONE] 数据行被忽略(Anthropic 事件机不用 [DONE] 结束)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'data: [DONE]',
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'after done' },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||||
});
|
||||
|
||||
it('无 message_start 时 input_tokens 缺省 0(USAGE 汇总不炸)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: message_delta',
|
||||
jsonLine({ type: 'message_delta', usage: { output_tokens: 7 } }),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
|
||||
expect(usageEvent?.usage?.inputTokens).toBe(0);
|
||||
expect(usageEvent?.usage?.outputTokens).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== sendStream:断流 flush =====
|
||||
|
||||
describe('AnthropicAdapter — 断流 flush(v0.6.4 缺口 B)', () => {
|
||||
it('流在 message_stop 前断开 → 补发 DONE(无未完成块)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'partial' },
|
||||
}),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('断流时已完成块正常产出;未完成块 flush 为自愈调用', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockStreamResponse([
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'tool_use', id: 'toolu_done', name: 'ok' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: '{"a":1}' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'tool_use', id: 'toolu_orphan', name: 'x' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 1,
|
||||
delta: { type: 'input_json_delta', partial_json: '{bad' },
|
||||
}),
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(2);
|
||||
expect(completes[0].toolCall!.id).toBe('toolu_done');
|
||||
expect(completes[0].toolCall!.args).toEqual({ a: 1 });
|
||||
expect(completes[1].toolCall!.id).toBe('toolu_orphan');
|
||||
expect((completes[1].toolCall!.args as Record<string, unknown>)._truncatedArguments).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== thinking budget 钳制 =====
|
||||
|
||||
describe('AnthropicAdapter — thinking budget_tokens 钳制', () => {
|
||||
function okResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
|
||||
return JSON.parse(String(call[1].body));
|
||||
}
|
||||
|
||||
it('thinking 开启时 max_tokens 提升到 ≥2048 且 budget < max_tokens', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 1500,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'high',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
expect(body.max_tokens).toBeGreaterThanOrEqual(2048);
|
||||
const thinking = body.thinking as { budget_tokens: number };
|
||||
expect(thinking.budget_tokens).toBeLessThan(body.max_tokens as number);
|
||||
expect(thinking.budget_tokens).toBeGreaterThanOrEqual(1024);
|
||||
});
|
||||
|
||||
it('budget 不超过 max_tokens 的一半(协议约束)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 10_000,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'max',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const thinking = body.thinking as { budget_tokens: number };
|
||||
expect(thinking.budget_tokens).toBeLessThanOrEqual(Math.floor((body.max_tokens as number) / 2));
|
||||
});
|
||||
|
||||
it('thinking 关闭时不发 thinking 字段且 temperature 透传', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0.5, stream: false, thinkingEnabled: false },
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
expect(body.thinking).toBeUndefined();
|
||||
expect(body.temperature).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 非流式响应组装 =====
|
||||
|
||||
describe('AnthropicAdapter — 非流式响应组装', () => {
|
||||
it('多个 thinking 块累加(不互相覆盖)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: '第一段思考' },
|
||||
{ type: 'thinking', thinking: '第二段思考' },
|
||||
{ type: 'text', text: 'final answer' },
|
||||
],
|
||||
usage: { input_tokens: 5, output_tokens: 3 },
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.content).toBe('final answer');
|
||||
expect(res.reasoningContent).toBe('第一段思考\n\n第二段思考');
|
||||
});
|
||||
|
||||
it('tool_use 块解析为 toolCalls(含对象型 args)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'tool_use', id: 'toolu_9', name: 'read_file', input: { path: 'a.txt' } }],
|
||||
usage: {},
|
||||
stop_reason: 'tool_use',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe(MetonaFinishReason.TOOL_CALLS);
|
||||
expect(res.toolCalls).toHaveLength(1);
|
||||
expect(res.toolCalls![0]).toMatchObject({
|
||||
id: 'toolu_9',
|
||||
name: 'read_file',
|
||||
args: { path: 'a.txt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('stop_reason=max_tokens → LENGTH finishReason', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'truncated' }],
|
||||
usage: {},
|
||||
stop_reason: 'max_tokens',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe(MetonaFinishReason.LENGTH);
|
||||
});
|
||||
|
||||
it('stop_reason=refusal → CONTENT_FILTER finishReason(不再折叠为 STOP)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: '' }],
|
||||
usage: {},
|
||||
stop_reason: 'refusal',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe(MetonaFinishReason.CONTENT_FILTER);
|
||||
});
|
||||
|
||||
it('usage cache 字段透传到响应', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
cache_read_input_tokens: 8,
|
||||
cache_creation_input_tokens: 2,
|
||||
},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.usage.cacheHitTokens).toBe(8);
|
||||
expect(res.usage.cacheMissTokens).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 孤立 tool_result 过滤(toNativeRequest 侧) =====
|
||||
|
||||
describe('AnthropicAdapter — 孤立 tool_result 过滤', () => {
|
||||
function okResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
|
||||
return JSON.parse(String(call[1].body));
|
||||
}
|
||||
|
||||
it('tool_result 顺序乱序时按配对顺序映射(pending set 出队)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'go', timestamp: Date.now() },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{ id: 'tc_a', name: 'a', args: {}, iteration: 1, timestamp: Date.now() },
|
||||
{ id: 'tc_b', name: 'b', args: {}, iteration: 1, timestamp: Date.now() },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_b',
|
||||
toolName: 'b',
|
||||
result: 'B',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_a',
|
||||
toolName: 'a',
|
||||
result: 'A',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const toolResults = (
|
||||
body.messages as Array<{ content: Array<Record<string, unknown>> }>
|
||||
).flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
|
||||
expect(toolResults).toHaveLength(2);
|
||||
// 乱序结果保留在各自的 user 消息块中(顺序按消息流,非配对顺序)
|
||||
const ids = toolResults.map((t) => (t as { tool_use_id: string }).tool_use_id);
|
||||
expect(ids).toEqual(['tc_b', 'tc_a']);
|
||||
});
|
||||
|
||||
it('孤立 tool_result(前面无 tool_use)被过滤,不触达 API', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_ghost',
|
||||
toolName: 'x',
|
||||
result: 'r',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const allBlocks = (body.messages as Array<{ content: Array<Record<string, unknown>> }>).flatMap(
|
||||
(m) => m.content.filter((c) => c.type === 'tool_result'),
|
||||
);
|
||||
expect(allBlocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('工具失败时 error 字段作为 tool_result content', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
toolCalls: [{ id: 'tc_e', name: 'run', args: {}, iteration: 1, timestamp: Date.now() }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_e',
|
||||
toolName: 'run',
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'command failed',
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const toolMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[2];
|
||||
expect(toolMsg.content[0]).toMatchObject({ type: 'tool_result', content: 'command failed' });
|
||||
});
|
||||
});
|
||||
|
||||
// ===== getContextWindow / listModels =====
|
||||
|
||||
describe('AnthropicAdapter — getContextWindow / listModels', () => {
|
||||
it('config.contextWindow 优先', () => {
|
||||
const adapter = makeAdapter('claude-sonnet-4-5', { contextWindow: 50_000 });
|
||||
expect(adapter.getContextWindow()).toBe(50_000);
|
||||
});
|
||||
|
||||
it('未知模型 → 兜底 200K', () => {
|
||||
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(200_000);
|
||||
});
|
||||
|
||||
it('listModels 返回本地模型元信息(无网络请求)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const models = await adapter.listModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
'claude-sonnet-4-5',
|
||||
'claude-opus-4-1',
|
||||
'claude-haiku-4-5',
|
||||
]);
|
||||
expect(models[0]).toMatchObject({ contextWindow: 200_000, maxOutputTokens: 64_000 });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -130,9 +130,10 @@ describe('BaseAdapter — getContextWindow', () => {
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
it('未配置时返回兜底默认值 1M(子类应覆盖真实窗口)', () => {
|
||||
// v0.7.4 P4-5: 兜底从 1M 降至 128K(未知模型按最保守主流窗口预算,防 413)
|
||||
it('未配置时返回兜底默认值 128K(子类应覆盖真实窗口)', () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(adapter.getContextWindow()).toBe(1_000_000);
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,3 +239,234 @@ describe('BaseAdapter — 接口契约', () => {
|
||||
expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:fetchWithTimeout 更多边界 =====
|
||||
|
||||
describe('BaseAdapter — fetchWithTimeout 补充边界', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function hangingFetch(signalKey = 'signal'): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((resolve, reject) => {
|
||||
const signal = init[signalKey as 'signal'];
|
||||
// 模拟真实 fetch:信号已提前 abort 时同步拒绝
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('This operation was aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('This operation was aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('底层网络错误(非超时非外部 abort)原样抛出', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('socket hang up'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000),
|
||||
).rejects.toThrow('socket hang up');
|
||||
});
|
||||
|
||||
it('外部信号已提前 abort → 直接 AbortError,不注册监听', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
adapter.setAbortSignal(controller.signal);
|
||||
const addListenerSpy = vi.spyOn(controller.signal, 'addEventListener');
|
||||
const fetchMock = hangingFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await adapter
|
||||
.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000)
|
||||
.catch((e: Error) => {
|
||||
expect(e.name).toBe('AbortError');
|
||||
});
|
||||
// 已 aborted 的信号走 controller.abort() 分支,不再注册监听
|
||||
expect(addListenerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('外部 abort 后 listener 被移除(无监听泄漏)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const controller = new AbortController();
|
||||
adapter.setAbortSignal(controller.signal);
|
||||
const removeListenerSpy = vi.spyOn(controller.signal, 'removeEventListener');
|
||||
const fetchMock = hangingFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
|
||||
controller.abort();
|
||||
await promise.catch(() => undefined);
|
||||
expect(removeListenerSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('自身超时但外部信号同时触发 → 外部 abort 优先,抛 AbortError 而非 ETIMEDOUT', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const controller = new AbortController();
|
||||
adapter.setAbortSignal(controller.signal);
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = hangingFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
|
||||
const assertion = expect(promise).rejects.toSatisfy((e: Error & { code?: string }) => {
|
||||
expect(e.name).toBe('AbortError');
|
||||
expect(e.code).not.toBe('ETIMEDOUT');
|
||||
return true;
|
||||
});
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it('请求成功后 timer 已清理(fake timers 验证无残留定时器)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{"ok":true}'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('超时后 timer 已清理(无泄漏)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = hangingFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
|
||||
const assertion = expect(promise).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
await assertion;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:throwHttpError 错误体解析矩阵 =====
|
||||
|
||||
describe('BaseAdapter — throwHttpError 错误体解析', () => {
|
||||
function makeResponse(status: number, body: string): Response {
|
||||
return new Response(body, { status, statusText: 'Status' });
|
||||
}
|
||||
|
||||
it('OpenAI 格式 {error:{code,message}} 携带 code 时识别 content_filter(大小写不敏感)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
for (const code of ['content_filter', 'Content_Filter', 'CONTENT_FILTER']) {
|
||||
const body = JSON.stringify({ error: { code, message: 'high risk' } });
|
||||
await expect(
|
||||
adapter.throwHttpErrorPublic(makeResponse(400, body), 'Ctx'),
|
||||
).rejects.toBeInstanceOf(ContentFilterError);
|
||||
}
|
||||
});
|
||||
|
||||
it('type 字段承载错误码时同样识别 content_filter(MiMo 兼容)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = JSON.stringify({ error: { type: 'content_filter', message: 'blocked' } });
|
||||
const err = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(403, body), 'MiMo')
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ContentFilterError);
|
||||
expect((err as ContentFilterError).providerMessage).toBe('blocked');
|
||||
});
|
||||
|
||||
it('content_filter 无 message → 默认提示文案', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = JSON.stringify({ error: { code: 'content_filter' } });
|
||||
const err = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
|
||||
.catch((e: unknown) => e);
|
||||
expect((err as ContentFilterError).providerMessage).toBe('内容触发安全过滤策略');
|
||||
});
|
||||
|
||||
it('顶层 error 结构(无嵌套)识别 content_filter', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = JSON.stringify({ code: 'content_filter', message: 'rejected' });
|
||||
const err = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ContentFilterError);
|
||||
});
|
||||
|
||||
it('非 JSON 错误体 → 普通 Error 携带 status', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const err = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(502, '<html>bad gateway</html>'), 'GW')
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).not.toBeInstanceOf(ContentFilterError);
|
||||
expect((err as Error & { status?: number }).status).toBe(502);
|
||||
expect((err as Error).message).toContain('<html>bad gateway</html>');
|
||||
});
|
||||
|
||||
it('错误体恰好 500 字符不截断;超过 500 字符截断并标注原长度', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const exactly500 = 'x'.repeat(500);
|
||||
const err500 = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(500, exactly500), 'Ctx')
|
||||
.catch((e: unknown) => e);
|
||||
expect((err500 as Error).message).toContain(exactly500);
|
||||
expect((err500 as Error).message).not.toContain('[truncated');
|
||||
|
||||
const over = 'x'.repeat(10_000);
|
||||
const errOver = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(500, over), 'Ctx')
|
||||
.catch((e: unknown) => e);
|
||||
expect((errOver as Error).message).toContain('[truncated 10000 chars]');
|
||||
expect((errOver as Error).message.length).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('错误体为响应头 JSON 但带尾随空白 → 仍能解析(trim 后 JSON.parse 成功路径)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = `{"error":{"code":"content_filter","message":"blocked"}} `;
|
||||
const err = await adapter
|
||||
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
|
||||
.catch((e: unknown) => e);
|
||||
// response.text() 返回原文,JSON.parse 对尾随空白容忍 → 走 content_filter 分支
|
||||
expect(err).toBeInstanceOf(ContentFilterError);
|
||||
});
|
||||
|
||||
it('statusText 拼接进错误消息', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const res = new Response('', { status: 429, statusText: 'Too Many Requests' });
|
||||
const err = await adapter.throwHttpErrorPublic(res, 'Ctx').catch((e: unknown) => e);
|
||||
expect((err as Error).message).toContain('429 Too Many Requests');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 追加:getContextWindow / listModels / healthCheck =====
|
||||
|
||||
describe('BaseAdapter — getContextWindow 回退链', () => {
|
||||
it('contextWindow=0 视为未配置(需 >0)', () => {
|
||||
const adapter = makeAdapter({ contextWindow: 0 });
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
it('contextWindow 为负数视为未配置', () => {
|
||||
const adapter = makeAdapter({ contextWindow: -1 });
|
||||
expect(adapter.getContextWindow()).toBe(128_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseAdapter — listModels 默认映射与 healthCheck', () => {
|
||||
it('listModels 映射保留默认模型顺序', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const models = await adapter.listModels();
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0]).toEqual({ id: 'test-model-a' });
|
||||
expect(models[1]).toEqual({ id: 'test-model-b' });
|
||||
});
|
||||
|
||||
it('healthCheck 在 listModels 抛错时返回 false', async () => {
|
||||
const adapter = makeAdapter();
|
||||
vi.spyOn(adapter, 'listModels').mockRejectedValue(new Error('API down'));
|
||||
expect(await adapter.healthCheck()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,4 +110,206 @@ describe('DeepSeekAdapter.getBalance — 响应格式解析(v0.5.2)', () =>
|
||||
mockFetch.mockRejectedValue(new Error('network down'));
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('balance_infos 多币种时取第一项', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({
|
||||
balance_infos: [
|
||||
{ currency: 'CNY', total_balance: '100.00' },
|
||||
{ currency: 'USD', total_balance: '13.00' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.currency).toBe('CNY');
|
||||
expect(balance!.totalBalance).toBe('100.00');
|
||||
});
|
||||
|
||||
it('balance_infos 为空数组 → 回退扁平字段(无第一项可取)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({ balance_infos: [], currency: 'EUR', total_balance: '9.99' }),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.currency).toBe('EUR');
|
||||
expect(balance!.totalBalance).toBe('9.99');
|
||||
});
|
||||
|
||||
it('balance_infos 首项字段缺失 → 默认 CNY / 0 兜底', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{}] }));
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance).toEqual({
|
||||
currency: 'CNY',
|
||||
totalBalance: '0',
|
||||
grantedBalance: '0',
|
||||
toppedUpBalance: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('is_available=false 仍返回余额(可用性由上层决定,解析不短路)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({
|
||||
is_available: false,
|
||||
balance_infos: [{ currency: 'CNY', total_balance: '0.5' }],
|
||||
}),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.totalBalance).toBe('0.5');
|
||||
});
|
||||
|
||||
it('json 解析失败(非法响应体)→ 返回 null', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('Unexpected token');
|
||||
},
|
||||
} as unknown as Response);
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('带 /v1 且带尾斜杠的 baseURL 规范化为根路径', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter('https://api.deepseek.com/v1/').getBalance();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.deepseek.com/user/balance',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('多级尾斜杠 baseURL 规范化为根路径', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter('https://api.deepseek.com///').getBalance();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.deepseek.com/user/balance',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('无 /v1 前缀的 baseURL 原样拼接(不重复插入)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter('https://api.deepseek.com').getBalance();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.deepseek.com/user/balance',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('子路径 baseURL(非 /v1 结尾)保留子路径', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter('https://proxy.example.com/gw/v2').getBalance();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://proxy.example.com/gw/v2/user/balance',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('请求头携带 Bearer apiKey', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter().getBalance();
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test-key');
|
||||
});
|
||||
|
||||
it('网络异常与解析异常不抛出,统一返回 null(调用方安全)', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error('bad json');
|
||||
},
|
||||
} as unknown as Response);
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('currency 保留字符串原样(不做数值转换)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({ balance_infos: [{ currency: 'CNY', total_balance: '110.55' }] }),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.currency).toBe('CNY');
|
||||
expect(typeof balance!.totalBalance).toBe('string');
|
||||
});
|
||||
|
||||
it('请求 URL 不含 /v1 时余额端点不受影响(契约回归)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter('https://api.deepseek.com/v1').getBalance();
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).not.toContain('/v1');
|
||||
expect(calledUrl).toBe('https://api.deepseek.com/user/balance');
|
||||
});
|
||||
|
||||
it('HTTP 429 返回 null(限流不视为余额数据)', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 429 } as unknown as Response);
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('HTTP 500 返回 null', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 } as unknown as Response);
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('HTTP 403 返回 null(鉴权失败)', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 403 } as unknown as Response);
|
||||
expect(await makeAdapter().getBalance()).toBeNull();
|
||||
});
|
||||
|
||||
it('空 apiKey 时发送空 Bearer 头(v0.7.4 修正命名 —— 原"不含 Authorization"与实际断言相反)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await makeAdapter().getBalance();
|
||||
// 适配器配置始终携带 apiKey;此处验证 apiKey 为空串时头部仍带 'Bearer '(空值)
|
||||
const noKeyAdapter = new DeepSeekAdapter({ ...CONFIG, apiKey: '' });
|
||||
mockFetch.mockClear();
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
await noKeyAdapter.getBalance();
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer ');
|
||||
});
|
||||
|
||||
it('数字型 total_balance(非字符串)原样透传', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({ balance_infos: [{ currency: 'CNY', total_balance: 88 }] }),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.totalBalance).toBe(88);
|
||||
});
|
||||
|
||||
it('响应为空对象 → 全部默认值', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({}));
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance).toEqual({
|
||||
currency: 'CNY',
|
||||
totalBalance: '0',
|
||||
grantedBalance: '0',
|
||||
toppedUpBalance: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('granted_balance 缺失仅缺省为 0(不影响其余字段)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({
|
||||
balance_infos: [{ currency: 'USD', total_balance: '5.0', topped_up_balance: '5.0' }],
|
||||
}),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.grantedBalance).toBe('0');
|
||||
expect(balance!.totalBalance).toBe('5.0');
|
||||
});
|
||||
|
||||
it('balance_infos 元素为 null → 回退扁平字段(防御空条目)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({ balance_infos: [null], currency: 'JPY', total_balance: '1000' }),
|
||||
);
|
||||
const balance = await makeAdapter().getBalance();
|
||||
expect(balance!.currency).toBe('JPY');
|
||||
expect(balance!.totalBalance).toBe('1000');
|
||||
});
|
||||
|
||||
it('多次调用互不影响(每次独立 fetch)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ balance_infos: [{ total_balance: '1' }] }));
|
||||
const adapter = makeAdapter();
|
||||
await adapter.getBalance();
|
||||
await adapter.getBalance();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('electron-log', () => ({
|
||||
}));
|
||||
|
||||
import { DeepSeekAdapter } from '../deepseek.adapter';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
import type { MetonaRequest, MetonaImageContent } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
@@ -37,7 +37,7 @@ function makeAdapter(model: string): DeepSeekAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequest(images?: Array<{ url: string }>): MetonaRequest {
|
||||
function makeRequest(images?: MetonaImageContent[]): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's',
|
||||
@@ -60,7 +60,12 @@ function okResponse(): Response {
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function requestBody(): { model: string; messages: Array<Record<string, unknown>> } {
|
||||
// toNativeRequest 返回 Record<string, unknown>;这里收敛为「已知字段 + 任意扩展字段」
|
||||
// 的交叉类型,测试可直接断言 max_tokens/thinking/temperature/stop/stream 等协议字段。
|
||||
function requestBody(): { model: string; messages: Array<Record<string, unknown>> } & Record<
|
||||
string,
|
||||
unknown
|
||||
> {
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return JSON.parse(init.body as string);
|
||||
}
|
||||
@@ -122,4 +127,275 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||||
const userMsg = body.messages[1];
|
||||
expect(userMsg.content).toBe('这张图片里有什么?');
|
||||
});
|
||||
|
||||
it('vision 模型:多张图片全部转为 image_url parts(保持顺序)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send(
|
||||
makeRequest([
|
||||
{ url: 'data:image/png;base64,AAA' },
|
||||
{ url: 'data:image/png;base64,BBB' },
|
||||
{ url: 'data:image/jpeg;base64,CCC' },
|
||||
]),
|
||||
);
|
||||
|
||||
const body = requestBody();
|
||||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||||
expect(parts).toHaveLength(4); // 1 text + 3 image
|
||||
expect(parts[1]).toMatchObject({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,AAA' },
|
||||
});
|
||||
expect(parts[2]).toMatchObject({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,BBB' },
|
||||
});
|
||||
expect(parts[3]).toMatchObject({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,CCC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('vision 模型:无文本消息时仍生成 image parts(不丢弃图片)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: null,
|
||||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||||
// 无文本 → 只有 image_url part(text part 不生成)
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].type).toBe('image_url');
|
||||
});
|
||||
|
||||
it('非 vision 模型:即使 content 为 null 也不转换图片(纯 null content)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-pro');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: null,
|
||||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
const userMsg = body.messages[1];
|
||||
expect(userMsg.content).toBeNull();
|
||||
});
|
||||
|
||||
it('vision 模型 detail 字段被丢弃(image_url 仅保留 url)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA', detail: 'high' }]));
|
||||
|
||||
const body = requestBody();
|
||||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||||
expect(parts[1]).toEqual({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,AAA' },
|
||||
});
|
||||
});
|
||||
|
||||
it('vision 模型 max_tokens 未配置 → 默认 8192(MODEL_INFO 上限)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest(),
|
||||
params: { temperature: 0, stream: false },
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
expect(body.max_tokens).toBe(8_192);
|
||||
});
|
||||
|
||||
it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
// vision 模型 supportsThinking=false,但参数仍透传 thinking enabled(引擎决定)
|
||||
expect(body.thinking).toEqual({ type: 'enabled' });
|
||||
});
|
||||
|
||||
it('vision 模型 messages 数组首位始终为 system 消息', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||||
const body = requestBody();
|
||||
expect(body.messages[0].role).toBe('system');
|
||||
expect(body.messages[0].content as string).toContain('sys');
|
||||
});
|
||||
|
||||
it('tool 结果消息不被 images 转换影响(role=tool 保留 tool_call_id)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请读图',
|
||||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tc1',
|
||||
name: 'view_image',
|
||||
args: { path: 'a.png' },
|
||||
iteration: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'view_image',
|
||||
result: { path: 'a.png' },
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
const toolMsg = body.messages[3];
|
||||
expect(toolMsg.role).toBe('tool');
|
||||
expect(toolMsg.tool_call_id).toBe('tc1');
|
||||
expect(toolMsg.content).toBe('{"path":"a.png"}');
|
||||
});
|
||||
|
||||
it('孤立 tool 消息被过滤(无前置 tool_calls)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest(),
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_orphan',
|
||||
toolName: 'x',
|
||||
result: 'r',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
// system + user,孤立 tool 被剔除
|
||||
expect(body.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('temperature 与 stop 序列透传', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest(),
|
||||
params: { maxTokens: 4096, temperature: 0.4, stream: false, stopSequences: ['<END>'] },
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
expect(body.temperature).toBe(0.4);
|
||||
expect(body.stop).toEqual(['<END>']);
|
||||
});
|
||||
|
||||
it('send(非流式路径)强制 stream=false 且不附加 stream_options', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||||
params: { maxTokens: 4096, temperature: 0, stream: true },
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
// send() 契约:无论请求参数如何,非流式路径强制 stream=false,stream_options 仅流式路径注入
|
||||
expect(body.stream).toBe(false);
|
||||
expect(body.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it('非 vision 模型带图片不产生 image_url(content 保持字符串)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash');
|
||||
|
||||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||||
const body = requestBody();
|
||||
const userMsg = body.messages[1];
|
||||
expect(typeof userMsg.content).toBe('string');
|
||||
expect(userMsg.content).not.toContain('image_url');
|
||||
});
|
||||
|
||||
it('vision 模型 base64 data URI 原样保留在 image_url 中', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
const dataUri = 'data:image/png;base64,' + 'Z'.repeat(50);
|
||||
await adapter.send(makeRequest([{ url: dataUri }]));
|
||||
const body = requestBody();
|
||||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||||
expect(parts[1]).toMatchObject({ image_url: { url: dataUri } });
|
||||
});
|
||||
|
||||
it('vision 模型 reasoning_content 在 assistant 历史中保留', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
|
||||
await adapter.send({
|
||||
...makeRequest(),
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{ role: 'assistant', content: 'answer', reasoningContent: 'trace', timestamp: Date.now() },
|
||||
],
|
||||
} as MetonaRequest);
|
||||
|
||||
const body = requestBody();
|
||||
const assistantMsg = body.messages[2] as Record<string, unknown>;
|
||||
expect(assistantMsg.reasoning_content).toBe('trace');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* OllamaAdapter 独立测试
|
||||
*
|
||||
* 覆盖契约:
|
||||
* - NDJSON 流式解析(thinking/content/tool_calls/done usage)
|
||||
* - 工具参数坏 JSON 自愈(_truncatedArguments)
|
||||
* - pullModel 进度回调 / 取消信号 / 非 JSON 行跳过
|
||||
* - probeCapabilities / showModel / listModels / getVersion / listRunning
|
||||
* - 图片 URL → base64 下载、data URI 剥前缀、失败降级
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OllamaAdapter } from '../ollama.adapter';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
function makeAdapter(model = 'qwen3', overrides: Record<string, unknown> = {}): OllamaAdapter {
|
||||
return new OllamaAdapter({
|
||||
provider: 'ollama',
|
||||
baseURL: 'http://localhost:11434',
|
||||
defaultModel: model,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
requestId: 'r1',
|
||||
timestamp: Date.now(),
|
||||
agentVersion: 'test',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are Metona.',
|
||||
outputConstraints: '',
|
||||
safetyGuidelines: '',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||
params: { maxTokens: 4096, temperature: 0, stream: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockNDJSON(lines: unknown[]): void {
|
||||
const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
}
|
||||
|
||||
async function collectStream(
|
||||
adapter: OllamaAdapter,
|
||||
request: MetonaRequest,
|
||||
): Promise<
|
||||
Array<{
|
||||
type: string;
|
||||
delta?: string;
|
||||
usage?: Record<string, unknown>;
|
||||
toolCall?: Record<string, unknown>;
|
||||
}>
|
||||
> {
|
||||
const events: Array<{
|
||||
type: string;
|
||||
delta?: string;
|
||||
usage?: Record<string, unknown>;
|
||||
toolCall?: Record<string, unknown>;
|
||||
}> = [];
|
||||
for await (const ev of adapter.sendStream(request)) {
|
||||
events.push({
|
||||
type: ev.type,
|
||||
delta: ev.delta,
|
||||
usage: ev.usage as Record<string, unknown> | undefined,
|
||||
toolCall: ev.toolCall as Record<string, unknown> | undefined,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ===== NDJSON 流式解析 =====
|
||||
|
||||
describe('OllamaAdapter — NDJSON 流式解析', () => {
|
||||
it('thinking + content 逐行产出 REASONING_DELTA / TEXT_DELTA', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([
|
||||
{ model: 'qwen3', message: { role: 'assistant', thinking: '思考一' } },
|
||||
{ model: 'qwen3', message: { role: 'assistant', content: '正文一' } },
|
||||
{ model: 'qwen3', message: { role: 'assistant', thinking: '思考二' } },
|
||||
{ model: 'qwen3', message: { role: 'assistant', content: '正文二' } },
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: { role: 'assistant', content: '' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 3,
|
||||
eval_count: 2,
|
||||
},
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
const reasoning = events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
|
||||
expect(reasoning.map((e) => e.delta)).toEqual(['思考一', '思考二']);
|
||||
const text = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||||
expect(text.map((e) => e.delta)).toEqual(['正文一', '正文二']);
|
||||
});
|
||||
|
||||
it('tool_calls 整块返回 → TOOL_CALL_COMPLETE(字符串 arguments 解析)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{ function: { name: 'read_file', arguments: '{"path":"a.txt"}' } },
|
||||
{ function: { name: 'list_dir', arguments: '{"path":"."}' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: { role: 'assistant', content: '' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 1,
|
||||
eval_count: 1,
|
||||
},
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(2);
|
||||
expect(completes[0].toolCall!.name).toBe('read_file');
|
||||
expect(completes[0].toolCall!.args).toEqual({ path: 'a.txt' });
|
||||
expect(completes[1].toolCall!.name).toBe('list_dir');
|
||||
expect(completes[1].toolCall!.id).toMatch(/^tc_/);
|
||||
});
|
||||
|
||||
it('done chunk → USAGE(prompt_eval_count/eval_count)+ DONE', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([
|
||||
{ model: 'qwen3', message: { role: 'assistant', content: 'x' } },
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: { role: 'assistant', content: '' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 11,
|
||||
eval_count: 7,
|
||||
},
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage;
|
||||
expect(usage).toMatchObject({ inputTokens: 11, outputTokens: 7, totalTokens: 18 });
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('坏 NDJSON 行被跳过不中断流(记录警告)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode('{bad json\n'));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({ model: 'm', message: { role: 'assistant', content: 'ok' } }) +
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
model: 'm',
|
||||
message: { role: 'assistant', content: '' },
|
||||
done: true,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
expect(
|
||||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'ok'),
|
||||
).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('流断开(无 done 行)→ 补发 DONE 防止挂起', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([{ model: 'm', message: { role: 'assistant', content: 'partial' } }]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('工具参数坏 JSON → _truncatedArguments 自愈,且同 chunk 后续 done 仍处理', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ function: { name: 'write', arguments: '{"path":"a","cont' } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
model: 'qwen3',
|
||||
message: { role: 'assistant', content: '' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 5,
|
||||
eval_count: 1,
|
||||
},
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
const args = completes[0].toolCall!.args as Record<string, unknown>;
|
||||
expect(args._truncatedArguments).toBe(true);
|
||||
expect(String(args._truncatedReason)).toContain('truncated');
|
||||
// done/USAGE 未被吞
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.USAGE)).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
});
|
||||
|
||||
it('工具参数为对象类型 → 原样保留', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockNDJSON([
|
||||
{
|
||||
model: 'm',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ function: { name: 'f', arguments: { a: 1 } } }],
|
||||
},
|
||||
},
|
||||
{ model: 'm', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' },
|
||||
]);
|
||||
const events = await collectStream(adapter, makeRequest());
|
||||
const complete = events.find((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(complete?.toolCall?.args).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 非流式 send 响应 =====
|
||||
|
||||
describe('OllamaAdapter — 非流式 send', () => {
|
||||
function mockJsonResponse(body: unknown): void {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
it('send 组装 MetonaResponse(含 perfStats / done_reason 映射)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockJsonResponse({
|
||||
model: 'qwen3',
|
||||
message: { role: 'assistant', content: 'answer', thinking: 'trace' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 4,
|
||||
eval_count: 3,
|
||||
load_duration: 1_000_000,
|
||||
eval_duration: 2_000_000_000,
|
||||
});
|
||||
const res = await adapter.send(makeRequest({ params: { stream: false } }));
|
||||
expect(res.content).toBe('answer');
|
||||
expect(res.reasoningContent).toBe('trace');
|
||||
expect(res.usage.totalTokens).toBe(7);
|
||||
expect(res.finishReason).toBe('stop');
|
||||
expect(res.meta.perfStats).toMatchObject({
|
||||
loadDurationMs: 1,
|
||||
evalDurationMs: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('done_reason=length → LENGTH finishReason', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockJsonResponse({
|
||||
message: { role: 'assistant', content: 'cut' },
|
||||
done: true,
|
||||
done_reason: 'length',
|
||||
prompt_eval_count: 1,
|
||||
eval_count: 100,
|
||||
});
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe('length');
|
||||
});
|
||||
|
||||
it('message.tool_calls 存在 → TOOL_CALLS finishReason(done_reason 无关)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockJsonResponse({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ function: { name: 'f', arguments: '{"a":1}' } }],
|
||||
},
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
prompt_eval_count: 0,
|
||||
eval_count: 0,
|
||||
});
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe('tool_calls');
|
||||
expect(res.toolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('非流式坏参 → _truncatedArguments 自愈', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockJsonResponse({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ function: { name: 'f', arguments: '{bad' } }],
|
||||
},
|
||||
done: true,
|
||||
done_reason: 'tool_calls',
|
||||
prompt_eval_count: 0,
|
||||
eval_count: 0,
|
||||
});
|
||||
const res = await adapter.send(makeRequest());
|
||||
const args = res.toolCalls![0].args as Record<string, unknown>;
|
||||
expect(args._truncatedArguments).toBe(true);
|
||||
});
|
||||
|
||||
it('HTTP 错误 → throwHttpError 携带 status', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'oops',
|
||||
} as unknown as Response);
|
||||
const err = await adapter.send(makeRequest()).catch((e: unknown) => e);
|
||||
expect((err as Error & { status?: number }).status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== pullModel =====
|
||||
|
||||
describe('OllamaAdapter — pullModel 进度 / 取消', () => {
|
||||
it('进度逐行回调(status/completed/total),结束后 resolve', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const lines = [
|
||||
{ status: 'pulling manifest' },
|
||||
{ status: 'downloading', completed: 50, total: 100 },
|
||||
{ status: 'downloading', completed: 100, total: 100 },
|
||||
{ status: 'success', completed: 100, total: 100 },
|
||||
];
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const l of lines) controller.enqueue(encoder.encode(JSON.stringify(l) + '\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
|
||||
const progress: Array<Record<string, unknown>> = [];
|
||||
await adapter.pullModel('qwen3:8b', (p) => progress.push(p as Record<string, unknown>));
|
||||
|
||||
expect(progress).toHaveLength(4);
|
||||
expect(progress[1]).toEqual({ status: 'downloading', completed: 50, total: 100 });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/pull',
|
||||
expect.objectContaining({ body: JSON.stringify({ model: 'qwen3:8b', stream: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('非 JSON 行(进度提示)被跳过不中断', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode('some text line\n'));
|
||||
controller.enqueue(encoder.encode(JSON.stringify({ status: 'success' }) + '\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
const progress: Array<Record<string, unknown>> = [];
|
||||
await adapter.pullModel('m', (p) => progress.push(p as Record<string, unknown>));
|
||||
expect(progress).toEqual([{ status: 'success' }]);
|
||||
});
|
||||
|
||||
it('取消信号中止底层 fetch(signal 透传)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const controller = new AbortController();
|
||||
const signalSpy = vi.fn();
|
||||
mockFetch.mockImplementation((_url: string, init: RequestInit) => {
|
||||
init.signal?.addEventListener('abort', signalSpy);
|
||||
return new Promise<Response>((_r, rej) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
rej(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
});
|
||||
});
|
||||
const promise = adapter.pullModel('qwen3:8b', undefined, controller.signal);
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(signalSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('HTTP 非 2xx → 抛 Ollama pull error', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404, body: null } as unknown as Response);
|
||||
await expect(adapter.pullModel('nope')).rejects.toThrow('Ollama pull error: 404');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== probeCapabilities / showModel / listModels =====
|
||||
|
||||
describe('OllamaAdapter — 能力探测', () => {
|
||||
it('probeCapabilities 从 capabilities[] 解析三能力', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
parameters: '',
|
||||
template: '',
|
||||
capabilities: ['tools', 'vision', 'thinking'],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const caps = await adapter.probeCapabilities('qwen3');
|
||||
expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true });
|
||||
});
|
||||
|
||||
it('capabilities 为空数组(showModel 缺省 [])→ 三能力全 false(非 null)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: '', template: '' }),
|
||||
} as unknown as Response);
|
||||
expect(await adapter.probeCapabilities('qwen3')).toEqual({
|
||||
supportsTools: false,
|
||||
supportsVision: false,
|
||||
supportsThinking: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('showModel 失败 → probeCapabilities 返回 null(调用方回退保守 true)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValue(new Error('down'));
|
||||
expect(await adapter.probeCapabilities('qwen3')).toBeNull();
|
||||
});
|
||||
|
||||
it('showModel 非 2xx → null', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 } as unknown as Response);
|
||||
expect(await adapter.showModel('m')).toBeNull();
|
||||
});
|
||||
|
||||
it('showModel 网络异常 → null(不抛出)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
expect(await adapter.showModel('m')).toBeNull();
|
||||
});
|
||||
|
||||
it('listModels:/api/tags 成功 → 逐模型探测并组装元信息', async () => {
|
||||
// 构造时的 fire-and-forget /api/show 探测会先消费第一个 mock → 先给良性响应
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: '' }),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{
|
||||
name: 'qwen3',
|
||||
size: 123,
|
||||
details: { family: 'qwen', parameter_size: '8B', quantization_level: 'Q4' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: '', template: '', capabilities: ['tools', 'vision'] }),
|
||||
} as unknown as Response);
|
||||
const adapter = makeAdapter();
|
||||
const models = await adapter.listModels();
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]).toMatchObject({
|
||||
id: 'qwen3',
|
||||
supportsToolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsThinking: false,
|
||||
});
|
||||
expect(models[0].description).toContain('qwen / 8B / Q4');
|
||||
});
|
||||
|
||||
it('listModels:API 失败 → 降级 supportedModels', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValue(new Error('network'));
|
||||
const models = await adapter.listModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
'qwen3:latest',
|
||||
'gemma3:latest',
|
||||
'deepseek-r1:latest',
|
||||
]);
|
||||
});
|
||||
|
||||
it('getVersion 成功返回版本号;失败返回 unknown', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ version: '0.5.1' }),
|
||||
} as unknown as Response);
|
||||
expect(await adapter.getVersion()).toBe('0.5.1');
|
||||
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockRejectedValue(new Error('down'));
|
||||
expect(await adapter.getVersion()).toBe('unknown');
|
||||
});
|
||||
|
||||
it('listRunning 映射运行中模型;失败返回空数组', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [{ name: 'qwen3', size: 100, size_vram: 90, context_length: 4096 }],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const running = await adapter.listRunning();
|
||||
expect(running[0]).toEqual({ name: 'qwen3', size: 100, sizeVram: 90, contextLength: 4096 });
|
||||
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockRejectedValue(new Error('down'));
|
||||
expect(await adapter.listRunning()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 图片 URL → base64 =====
|
||||
|
||||
describe('OllamaAdapter — 图片归一化', () => {
|
||||
function okChatResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
done: true,
|
||||
done_reason: 'stop',
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const calls = mockFetch.mock.calls;
|
||||
const chatCall = calls.find(([url]) => String(url).includes('/api/chat')) as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
return JSON.parse(String(chatCall[1].body));
|
||||
}
|
||||
|
||||
it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const imageBytes = new TextEncoder().encode('IMG-BYTES');
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => imageBytes.buffer,
|
||||
} as unknown as Response)
|
||||
.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
|
||||
});
|
||||
|
||||
it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(okChatResponse());
|
||||
const res = await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.content).toBe('ok');
|
||||
});
|
||||
|
||||
it('data URI 图片剥前缀;纯 base64 原样保留', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [
|
||||
{ url: 'data:image/png;base64,AAAABBBB' },
|
||||
{ url: 'iVBORw0KGgo', detail: 'high' },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual(['AAAABBBB', 'iVBORw0KGgo']);
|
||||
});
|
||||
|
||||
it('HTTP 下载响应非 2xx → 图片降级忽略', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response)
|
||||
.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/missing.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== getContextWindow =====
|
||||
|
||||
describe('OllamaAdapter — getContextWindow', () => {
|
||||
it('未探测到时返回默认 4096', () => {
|
||||
const adapter = makeAdapter();
|
||||
expect(adapter.getContextWindow()).toBe(4096);
|
||||
});
|
||||
|
||||
it('探测到 num_ctx 后返回实测值(机会主义缓存收敛)', async () => {
|
||||
// 需在构造前就位:构造时的 fire-and-forget 探测消费首个 fetch
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }),
|
||||
} as unknown as Response);
|
||||
const adapter = makeAdapter();
|
||||
// 等待构造时的探测完成并写入缓存
|
||||
await vi.waitFor(() => expect(adapter.getContextWindow()).toBe(32768));
|
||||
});
|
||||
|
||||
it('探测失败(网络错误)→ 保持默认 4096', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('down'));
|
||||
const adapter = makeAdapter();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(adapter.getContextWindow()).toBe(4096);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* OpenAIAdapter 独立测试(v0.6.4 P3-1 收敛后差异点)
|
||||
*
|
||||
* OpenAIAdapter 继承 OpenAICompatibleAdapter,本文件锁定 OpenAI 独有契约:
|
||||
* - 推理模型(o 系列 / gpt-5)字段路由:reasoning_effort / max_completion_tokens
|
||||
* - 推理模型拒图:ModelCapabilityError(status=400)
|
||||
* - 非推理模型 temperature / max_tokens 路由
|
||||
* - gpt-4.1 1M 上下文窗口(getContextWindow 回退链)
|
||||
* - listModels 动态 /models 合并与降级
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OpenAIAdapter } from '../openai.adapter';
|
||||
import { ModelCapabilityError } from '../shared/openai-compatible-base';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
function makeAdapter(model: string, overrides: Record<string, unknown> = {}): OpenAIAdapter {
|
||||
return new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
defaultModel: model,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
|
||||
return {
|
||||
meta: {
|
||||
sessionId: 's1',
|
||||
iteration: 1,
|
||||
requestId: 'r1',
|
||||
timestamp: Date.now(),
|
||||
agentVersion: 'test',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are Metona.',
|
||||
outputConstraints: '',
|
||||
safetyGuidelines: '',
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function okResponse(
|
||||
body: Record<string, unknown> = { choices: [{ message: { content: 'ok' } }] },
|
||||
): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function lastBody(): Record<string, unknown> {
|
||||
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
|
||||
return JSON.parse(String(call[1].body));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ===== reasoning_effort 映射 =====
|
||||
|
||||
describe('OpenAIAdapter — 推理模型 reasoning_effort', () => {
|
||||
it.each([
|
||||
['low', 'low'],
|
||||
['medium', 'medium'],
|
||||
['high', 'high'],
|
||||
['max', 'high'], // max 归一 high
|
||||
] as const)('o3-mini effort=%s → reasoning_effort=%s', async (effort, expected) => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(lastBody().reasoning_effort).toBe(expected);
|
||||
});
|
||||
|
||||
it('o3-mini thinking 关闭 → 不传 reasoning_effort(可关闭服务端默认思考)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||
}),
|
||||
);
|
||||
expect(lastBody().reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('o3-mini thinking 未配置 → 不传 reasoning_effort', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
|
||||
expect(lastBody().reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('o3-mini thinking 未配置 effort → 缺省 high', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(lastBody().reasoning_effort).toBe('high');
|
||||
});
|
||||
|
||||
it('非推理模型 gpt-4o 即使 thinkingEnabled=true 也不传 reasoning_effort(忽略思考参数)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0.3, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(lastBody().reasoning_effort).toBeUndefined();
|
||||
// 非推理模型仍传 temperature
|
||||
expect(lastBody().temperature).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 推理模型拒图 =====
|
||||
|
||||
describe('OpenAIAdapter — 推理模型拒图(ModelCapabilityError)', () => {
|
||||
it.each(['o3-mini', 'o1', 'gpt-5.1'])(
|
||||
'%s 带图片 → 抛 ModelCapabilityError(status=400)',
|
||||
async (model) => {
|
||||
const adapter = makeAdapter(model);
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
const err = await adapter.send(request).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ModelCapabilityError);
|
||||
expect((err as ModelCapabilityError).status).toBe(400);
|
||||
expect((err as Error).message).toContain('does not support');
|
||||
// 拒图不发出网络请求
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('o3-mini 无图片 → 正常发送(不误拒)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest());
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('非推理模型 gpt-4o 带图片 → 正常发送(多模态允许)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const body = lastBody();
|
||||
// 图片转为 image_url parts
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>)[1];
|
||||
expect(userMsg.content).toEqual([
|
||||
{ type: 'text', text: '看图' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== max_completion_tokens / max_tokens 路由 =====
|
||||
|
||||
describe('OpenAIAdapter — token 参数路由', () => {
|
||||
it.each([
|
||||
['o3-mini', 63_488, 63_488, 'max_completion_tokens'],
|
||||
['o3-mini', 200_000, 100_000, 'max_completion_tokens'], // 上限 100000
|
||||
['gpt-4o', 63_488, 16_384, 'max_tokens'],
|
||||
['gpt-4.1', 63_488, 32_768, 'max_tokens'],
|
||||
] as const)('%s maxTokens=%d → %s=%d', async (model, requested, expected, field) => {
|
||||
const adapter = makeAdapter(model);
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }),
|
||||
);
|
||||
const body = lastBody();
|
||||
expect(body[field]).toBe(expected);
|
||||
// 另一个字段不出现
|
||||
const other = field === 'max_completion_tokens' ? 'max_tokens' : 'max_completion_tokens';
|
||||
expect(body[other]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('o3-mini 未配置 maxTokens → 默认 32768(thinking 场景安全值)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(lastBody().max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
|
||||
it('非推理模型未配置 maxTokens → 默认模型上限', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(lastBody().max_tokens).toBe(16_384);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== temperature 传递 =====
|
||||
|
||||
describe('OpenAIAdapter — temperature 路由', () => {
|
||||
it('非推理模型 temperature 逐值透传', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
for (const t of [0, 0.7, 1.0]) {
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: t, stream: false } }),
|
||||
);
|
||||
}
|
||||
expect(lastBody().temperature).toBe(1.0);
|
||||
const bodies = mockFetch.mock.calls.map((c) => JSON.parse(String((c[1] as RequestInit).body)));
|
||||
expect(bodies.map((b) => b.temperature)).toEqual([0, 0.7, 1.0]);
|
||||
});
|
||||
|
||||
it('推理模型 o3-mini 不传 temperature(o 系列不支持)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false } }),
|
||||
);
|
||||
expect(lastBody().temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gpt-5 系列同样不传 temperature(推理家族)', async () => {
|
||||
const adapter = makeAdapter('gpt-5');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.5, stream: false } }),
|
||||
);
|
||||
expect(lastBody().temperature).toBeUndefined();
|
||||
expect(lastBody().max_completion_tokens).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== getContextWindow 回退链 =====
|
||||
|
||||
describe('OpenAIAdapter — getContextWindow 回退链', () => {
|
||||
it('gpt-4.1 返回 1M 上下文', () => {
|
||||
expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it('o3-mini 返回 200K', () => {
|
||||
expect(makeAdapter('o3-mini').getContextWindow()).toBe(200_000);
|
||||
});
|
||||
|
||||
it('未知模型 → 兜底 128K(v0.7.4 P4-5 从 1M 降级)', () => {
|
||||
expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(128_000);
|
||||
});
|
||||
|
||||
it('config.contextWindow 显式配置优先', () => {
|
||||
const adapter = makeAdapter('gpt-4o', { contextWindow: 64_000 });
|
||||
expect(adapter.getContextWindow()).toBe(64_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== listModels =====
|
||||
|
||||
describe('OpenAIAdapter — listModels 动态发现与降级', () => {
|
||||
it('API 成功 → 合并本地元信息(已知模型带 name,未知模型裸 id)', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }, { id: 'custom-model' }] }));
|
||||
const models = await makeAdapter('gpt-4o').listModels();
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0]).toMatchObject({ id: 'gpt-4o', contextWindow: 128_000 });
|
||||
expect(models[1]).toEqual({ id: 'custom-model' });
|
||||
// /models 请求头携带 Bearer
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test');
|
||||
});
|
||||
|
||||
it('API 失败 → 降级到 supportedModels(带元信息)', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('network'));
|
||||
const models = await makeAdapter('gpt-4o').listModels();
|
||||
expect(models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini']);
|
||||
});
|
||||
|
||||
it('API 返回空 data → 降级到 supportedModels', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ data: [] }));
|
||||
const models = await makeAdapter('gpt-4o').listModels();
|
||||
expect(models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('healthCheck 基于 listModels 成功返回 true', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }] }));
|
||||
expect(await makeAdapter('gpt-4o').healthCheck()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 非流式响应组装 =====
|
||||
|
||||
describe('OpenAIAdapter — 非流式响应组装', () => {
|
||||
it('send 返回 MetonaResponse(content/usage/finishReason 映射)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse({
|
||||
id: 'cmpl-1',
|
||||
model: 'gpt-4o',
|
||||
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}),
|
||||
);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.content).toBe('hello');
|
||||
expect(res.finishReason).toBe('stop');
|
||||
expect(res.usage.totalTokens).toBe(15);
|
||||
expect(res.meta.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('HTTP 非 2xx → 抛出带 status 的 Error', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => '{"error":{"message":"rate limited"}}',
|
||||
} as unknown as Response);
|
||||
const err = await adapter.send(makeRequest()).catch((e: unknown) => e);
|
||||
expect((err as Error & { status?: number }).status).toBe(429);
|
||||
});
|
||||
|
||||
it('content_filter 错误体 → ContentFilterError 实例', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
text: async () => '{"error":{"code":"content_filter","message":"blocked"}}',
|
||||
} as unknown as Response);
|
||||
const err = await adapter.send(makeRequest()).catch((e: unknown) => e);
|
||||
expect((err as { name: string }).name).toBe('ContentFilterError');
|
||||
});
|
||||
|
||||
it('sendStream 走 SSE 解析([DONE] 结束)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
const payload =
|
||||
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}\n\n' +
|
||||
'data: [DONE]\n\n';
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
const events: string[] = [];
|
||||
for await (const ev of adapter.sendStream(makeRequest({ params: { stream: true } }))) {
|
||||
events.push(ev.type);
|
||||
}
|
||||
expect(events[0]).toBe('text_delta');
|
||||
expect(events[events.length - 1]).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== stop 序列 =====
|
||||
|
||||
describe('OpenAIAdapter — stop 序列透传', () => {
|
||||
it('stopSequences 透传为 stop 数组(o 系列已知边界透传)', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, stopSequences: ['END'] },
|
||||
}),
|
||||
);
|
||||
expect(lastBody().stop).toEqual(['END']);
|
||||
});
|
||||
|
||||
it('未配置 stopSequences 不发送 stop', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(makeRequest());
|
||||
expect(lastBody().stop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ import { AnthropicAdapter } from '../anthropic.adapter';
|
||||
import { OllamaAdapter } from '../ollama.adapter';
|
||||
import { MimoAdapter } from '../mimo.adapter';
|
||||
import { AgnesAdapter } from '../agnes-ai.adapter';
|
||||
import { DeepSeekAdapter } from '../deepseek.adapter';
|
||||
import { OpenAIAdapter } from '../openai.adapter';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
|
||||
/** 安装全局 fetch 捕获器:记录每次请求体并返回一个三家协议都能解析的合成响应 */
|
||||
@@ -426,3 +428,880 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4)', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Anthropic 追加:system 四态 / thinking budget 矩阵 / maxTokens 钳制 =====
|
||||
|
||||
describe('AnthropicAdapter — system 块数组与 cache_control 四态', () => {
|
||||
function makeAdapter(model = 'claude-sonnet-4-5'): AnthropicAdapter {
|
||||
return new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
});
|
||||
}
|
||||
|
||||
it('system 四段全部填充 → 单一 text 块 + cache_control ephemeral(稳定前缀提示缓存)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(makeRequest());
|
||||
const system = bodies[0].system as Array<Record<string, unknown>>;
|
||||
expect(system).toHaveLength(1);
|
||||
expect(system[0].type).toBe('text');
|
||||
expect(system[0].text).toContain('You are Metona.');
|
||||
expect(system[0].text).toContain('Be concise.');
|
||||
expect(system[0].text).toContain('Stay safe.');
|
||||
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('system 部分段为空 → 过滤后拼接,仍打 cache_control', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
systemPrompt: {
|
||||
roleDefinition: 'Only role',
|
||||
outputConstraints: '',
|
||||
safetyGuidelines: '',
|
||||
dynamicReminders: '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const system = bodies[0].system as Array<Record<string, unknown>>;
|
||||
expect(system).toHaveLength(1);
|
||||
expect(system[0].text).toBe('Only role');
|
||||
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('system 全部为空 → 不发块数组,透传空字符串(无 cache_control)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].system).toBe('');
|
||||
});
|
||||
|
||||
it('动态提醒 dynamicReminders 被拼入 system 块', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
systemPrompt: {
|
||||
roleDefinition: 'rd',
|
||||
outputConstraints: '',
|
||||
safetyGuidelines: '',
|
||||
dynamicReminders: 'Remember X',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const system = bodies[0].system as Array<Record<string, unknown>>;
|
||||
expect(system[0].text).toContain('Remember X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
|
||||
function makeAdapter(model = 'claude-sonnet-4-5'): AnthropicAdapter {
|
||||
return new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['low', 1024],
|
||||
['medium', 4096],
|
||||
['high', 16384],
|
||||
// max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000
|
||||
['max', 32000],
|
||||
] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 100_000,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const thinking = bodies[0].thinking as { type: string; budget_tokens: number };
|
||||
expect(thinking.type).toBe('enabled');
|
||||
expect(thinking.budget_tokens).toBe(expectBudget);
|
||||
expect(thinking.budget_tokens).toBeLessThan(bodies[0].max_tokens as number);
|
||||
});
|
||||
|
||||
it('effort 未配置时缺省 high → budget 16384', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 100_000, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
const thinking = bodies[0].thinking as { budget_tokens: number };
|
||||
expect(thinking.budget_tokens).toBe(16384);
|
||||
});
|
||||
|
||||
it('小 max_tokens 时 budget 被 max_tokens/2 二次钳制(budget < max_tokens 协议约束)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'high',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const thinking = bodies[0].thinking as { budget_tokens: number };
|
||||
// effort high=16384 但 max_tokens=2048 → budget 钳到 floor(2048/2)=1024
|
||||
expect(thinking.budget_tokens).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnthropicAdapter — max_tokens 钳制矩阵', () => {
|
||||
it.each([
|
||||
['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样
|
||||
['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000
|
||||
['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000
|
||||
['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000
|
||||
['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样
|
||||
])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(expected);
|
||||
});
|
||||
|
||||
it('thinking 开启时小 maxTokens 被抬升到安全下限 2048', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 800,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(2048);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnthropicAdapter — temperature 传递与停止序列', () => {
|
||||
it('thinking 关闭时 temperature 逐值透传', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
for (const t of [0, 0.2, 1.0]) {
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: t, stream: false } }),
|
||||
);
|
||||
}
|
||||
expect(bodies[0].temperature).toBe(0);
|
||||
expect(bodies[1].temperature).toBe(0.2);
|
||||
expect(bodies[2].temperature).toBe(1.0);
|
||||
});
|
||||
|
||||
it('stopSequences 映射为 stop_sequences 数组', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, stopSequences: ['END', 'STOP'] },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].stop_sequences).toEqual(['END', 'STOP']);
|
||||
});
|
||||
|
||||
it('未配置 stopSequences 时不发送 stop_sequences', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(makeRequest());
|
||||
expect(bodies[0].stop_sequences).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tools 定义映射为 input_schema 命名空间', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
tools: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string', description: 'file path' } },
|
||||
required: ['path'],
|
||||
},
|
||||
category: 'filesystem' as never,
|
||||
riskLevel: 'low' as never,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const tools = bodies[0].tools as Array<Record<string, unknown>>;
|
||||
expect(tools[0].name).toBe('read_file');
|
||||
expect(tools[0].input_schema).toBeDefined();
|
||||
expect((tools[0].input_schema as Record<string, unknown>).required).toEqual(['path']);
|
||||
});
|
||||
|
||||
it('无 tools 时不发送 tools 字段', async () => {
|
||||
const adapter = new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a.test',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'claude-sonnet-4-5',
|
||||
});
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(makeRequest());
|
||||
expect(bodies[0].tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== DeepSeek thinking 映射矩阵(v0.6.4) =====
|
||||
|
||||
describe('DeepSeekAdapter — thinking 映射矩阵', () => {
|
||||
function makeAdapter(model = 'deepseek-v4-pro'): DeepSeekAdapter {
|
||||
return new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['low', 'high'],
|
||||
['medium', 'high'],
|
||||
['high', 'high'],
|
||||
['max', 'max'],
|
||||
] as const)(
|
||||
'effort=%s → reasoning_effort=%s(low/medium 归一 high)',
|
||||
async (effort, expected) => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
|
||||
expect(bodies[0].reasoning_effort).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('thinkingEnabled=false → 显式 {type:disabled}(服务端默认开启,必须显式关闭)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
|
||||
expect(bodies[0].reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('thinkingEnabled 未配置 → 不发送 thinking 字段', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
|
||||
expect(bodies[0].thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it('effort 未配置缺省 high → reasoning_effort=high', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].reasoning_effort).toBe('high');
|
||||
});
|
||||
|
||||
it('temperature 与 stop 序列原样传递', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0.5,
|
||||
stream: false,
|
||||
stopSequences: ['<END>'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].temperature).toBe(0.5);
|
||||
expect(bodies[0].stop).toEqual(['<END>']);
|
||||
});
|
||||
|
||||
it('max_tokens 按模型钳制(pro 384000 / vision 8192)', async () => {
|
||||
const pro = makeAdapter('deepseek-v4-pro');
|
||||
const vision = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { maxTokens: 500_000, temperature: 0, stream: false } }));
|
||||
await vision.send(
|
||||
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(384_000);
|
||||
expect(bodies[1].max_tokens).toBe(8_192);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Agnes enable_thinking 对称性扩展 =====
|
||||
|
||||
describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
|
||||
function makeAdapter(): AgnesAdapter {
|
||||
return new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
baseURL: 'http://g.test/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'agnes-2.0-flash',
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['high', true],
|
||||
['medium', true],
|
||||
['max', true],
|
||||
['low', false],
|
||||
] as const)('effort=%s → enable_thinking=%s(low 映射为关闭)', async (effort, expected) => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('thinkingEnabled=true 但 effort 未配置 → 缺省 high → enable_thinking true', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('temperature 与 max_tokens 同时传递(Agnes 支持 temperature)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].temperature).toBe(0.9);
|
||||
// 65536 上限钳制
|
||||
expect(bodies[0].max_tokens).toBe(65_536);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== MiMo thinking 开关与 providerOptions 扩展 =====
|
||||
|
||||
describe('MimoAdapter — thinking 显式开关', () => {
|
||||
function makeAdapter(overrides: Record<string, unknown> = {}): MimoAdapter {
|
||||
return new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'http://m.test/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it('thinkingEnabled=false → {type:disabled} + temperature/top_p 显式传递(非思考模式有效)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
topP: 0.8,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
|
||||
expect(bodies[0].temperature).toBe(0.7);
|
||||
expect(bodies[0].top_p).toBe(0.8);
|
||||
});
|
||||
|
||||
it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_p(API 强制覆盖)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
|
||||
expect(bodies[0].temperature).toBeUndefined();
|
||||
expect(bodies[0].top_p).toBeUndefined();
|
||||
});
|
||||
|
||||
it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768)', async () => {
|
||||
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
|
||||
const std = makeAdapter({ defaultModel: 'mimo-v2.5' });
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }));
|
||||
await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }));
|
||||
expect(bodies[0].max_completion_tokens).toBe(131_072);
|
||||
expect(bodies[1].max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
|
||||
it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断)', async () => {
|
||||
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { temperature: 0, stream: false } }));
|
||||
expect(bodies[0].max_completion_tokens).toBe(32_768);
|
||||
});
|
||||
|
||||
it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => {
|
||||
const adapter = makeAdapter({ providerOptions: { enableWebSearch: true } });
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
tools: [
|
||||
{
|
||||
name: 'fs',
|
||||
description: 'd',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
category: 'filesystem' as never,
|
||||
riskLevel: 'low' as never,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const tools = bodies[0].tools as Array<Record<string, unknown>>;
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0].type).toBe('function');
|
||||
expect(tools[1]).toEqual({ type: 'web_search' });
|
||||
expect(bodies[0].tool_choice).toBe('auto');
|
||||
});
|
||||
|
||||
it('responseFormatJson + thinking 默认开启可共存(response_format 独立于 thinking)', async () => {
|
||||
const adapter = makeAdapter({ providerOptions: { responseFormatJson: true } });
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].response_format).toEqual({ type: 'json_object' });
|
||||
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
|
||||
});
|
||||
});
|
||||
|
||||
// ===== OpenAI reasoning_effort 与 maxTokens 路由 =====
|
||||
|
||||
describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1)', () => {
|
||||
function makeAdapter(model: string): OpenAIAdapter {
|
||||
return new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['low', 'low'],
|
||||
['medium', 'medium'],
|
||||
['high', 'high'],
|
||||
['max', 'high'],
|
||||
] as const)(
|
||||
'o3-mini effort=%s → reasoning_effort=%s(max 归一 high)',
|
||||
async (effort, expected) => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].reasoning_effort).toBe(expected);
|
||||
expect(bodies[0].max_completion_tokens).toBe(4096); // o 系列用新字段名
|
||||
},
|
||||
);
|
||||
|
||||
it('o3-mini thinking 未开启 → 不传 reasoning_effort 也不传 temperature(o 系列不支持温度)', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].reasoning_effort).toBeUndefined();
|
||||
expect(bodies[0].temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('非推理模型 gpt-4o → max_tokens 字段 + temperature 透传', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.5, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(4096);
|
||||
expect(bodies[0].max_completion_tokens).toBeUndefined();
|
||||
expect(bodies[0].temperature).toBe(0.5);
|
||||
expect(bodies[0].reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表)', () => {
|
||||
const adapter = makeAdapter('gpt-4.1');
|
||||
expect(adapter.getContextWindow()).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it('o3-mini max_completion_tokens 钳制到 100000', async () => {
|
||||
const adapter = makeAdapter('o3-mini');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_completion_tokens).toBe(100_000);
|
||||
});
|
||||
|
||||
it('gpt-4o max_tokens 钳制到 16384', async () => {
|
||||
const adapter = makeAdapter('gpt-4o');
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(16_384);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Ollama options 缺省与工具映射 =====
|
||||
|
||||
describe('OllamaAdapter — options 缺省与工具定义', () => {
|
||||
function makeOllama(): OllamaAdapter {
|
||||
return new OllamaAdapter({
|
||||
provider: 'ollama',
|
||||
baseURL: 'http://localhost:11434',
|
||||
defaultModel: 'qwen3',
|
||||
});
|
||||
}
|
||||
|
||||
it('未配置 topP/contextLength/stop 时 options 仅含 temperature/num_predict', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.2, stream: false } }),
|
||||
);
|
||||
const options = bodies[0].options as Record<string, unknown>;
|
||||
expect(Object.keys(options).sort()).toEqual(['num_predict', 'temperature']);
|
||||
expect(options.num_predict).toBe(4096);
|
||||
});
|
||||
|
||||
it('tools 定义为 {type:function,function:{...}} 形态', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
tools: [
|
||||
{
|
||||
name: 'calc',
|
||||
description: 'calc',
|
||||
parameters: { type: 'object', properties: { a: { type: 'number', description: 'a' } } },
|
||||
category: 'calculation' as never,
|
||||
riskLevel: 'safe' as never,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const tools = bodies[0].tools as Array<Record<string, unknown>>;
|
||||
expect(tools[0]).toMatchObject({ type: 'function' });
|
||||
expect((tools[0].function as Record<string, unknown>).name).toBe('calc');
|
||||
});
|
||||
|
||||
it('assistant 工具调用参数序列化为 JSON 字符串(Ollama REST 要求)', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tc1',
|
||||
name: 'read',
|
||||
args: { path: 'a.txt', lines: [1, 2] },
|
||||
iteration: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'read',
|
||||
result: 'data',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = bodies[0].messages as Array<Record<string, unknown>>;
|
||||
const assistantMsg = messages.find((m) => m.role === 'assistant') as {
|
||||
tool_calls: Array<Record<string, unknown>>;
|
||||
};
|
||||
const fn = assistantMsg.tool_calls[0].function as Record<string, unknown>;
|
||||
expect(fn.arguments).toBe(JSON.stringify({ path: 'a.txt', lines: [1, 2] }));
|
||||
// tool 消息映射 tool_call_id + 结果文本
|
||||
const toolMsg = messages.find((m) => m.role === 'tool') as {
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
};
|
||||
expect(toolMsg.tool_call_id).toBe('tc1');
|
||||
expect(toolMsg.content).toBe('data');
|
||||
});
|
||||
|
||||
it('assistant reasoning_content 回传保持推理链完整', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoningContent: 'thinking trace',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = bodies[0].messages as Array<Record<string, unknown>>;
|
||||
const assistantMsg = messages.find((m) => m.role === 'assistant') as {
|
||||
reasoning_content?: string;
|
||||
};
|
||||
expect(assistantMsg.reasoning_content).toBe('thinking trace');
|
||||
});
|
||||
|
||||
it('assistant 无 content 时映射为空字符串(Ollama 不支持 null content)', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{ role: 'assistant', content: null, timestamp: Date.now() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = bodies[0].messages as Array<Record<string, unknown>>;
|
||||
const assistantMsg = messages.find((m) => m.role === 'assistant') as { content: unknown };
|
||||
expect(assistantMsg.content).toBe('');
|
||||
});
|
||||
|
||||
it('纯 base64 图片(无 data: 前缀)原样透传', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'iVBORw0KGgoAAAANSUhEUg', detail: 'auto' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = bodies[0].messages as Array<Record<string, unknown>>;
|
||||
const userMsg = messages[messages.length - 1];
|
||||
expect(userMsg.images).toEqual(['iVBORw0KGgoAAAANSUhEUg']);
|
||||
});
|
||||
|
||||
it('工具结果失败时 error 字段优先作为 content(CE-2)', async () => {
|
||||
const adapter = makeOllama();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tc_e',
|
||||
name: 'run',
|
||||
args: {},
|
||||
iteration: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolResult: {
|
||||
toolCallId: 'tc_e',
|
||||
toolName: 'run',
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'exit code 2',
|
||||
durationMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = bodies[0].messages as Array<Record<string, unknown>>;
|
||||
const toolMsg = messages.find((m) => m.role === 'tool') as { content: string };
|
||||
expect(toolMsg.content).toBe('exit code 2');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 跨 Provider maxTokens 钳制矩阵 =====
|
||||
|
||||
describe('跨 Provider — maxTokens 钳制矩阵汇总', () => {
|
||||
it.each([
|
||||
['anthropic', 'claude-opus-4-1', 100_000, 32_000],
|
||||
['anthropic', 'claude-sonnet-4-5', 100_000, 64_000],
|
||||
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192],
|
||||
['agnes', 'agnes-2.0-flash', 100_000, 65_536],
|
||||
['mimo', 'mimo-v2.5', 100_000, 32_768],
|
||||
['openai', 'gpt-4o', 100_000, 16_384],
|
||||
] as const)(
|
||||
'%s %s maxTokens=100000 → 钳制为 %d',
|
||||
async (provider, model, requested, expected) => {
|
||||
const adapterMap: Record<string, unknown> = {
|
||||
anthropic: new AnthropicAdapter({
|
||||
provider: 'anthropic',
|
||||
baseURL: 'http://a',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
}),
|
||||
deepseek: new DeepSeekAdapter({
|
||||
provider: 'deepseek',
|
||||
baseURL: 'http://d',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
}),
|
||||
agnes: new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
baseURL: 'http://g',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
}),
|
||||
mimo: new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'http://m',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
}),
|
||||
openai: new OpenAIAdapter({
|
||||
provider: 'openai',
|
||||
baseURL: 'http://o',
|
||||
apiKey: 'k',
|
||||
defaultModel: model,
|
||||
}),
|
||||
};
|
||||
const adapter = adapterMap[provider] as { send: (r: MetonaRequest) => Promise<unknown> };
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }),
|
||||
);
|
||||
const body = bodies[bodies.length - 1] as Record<string, unknown>;
|
||||
expect(body.max_tokens ?? body.max_completion_tokens).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { BaseAdapter, ContentFilterError } from './base-adapter';
|
||||
import { truncatedArgumentsPayload } from './shared/sse-stream';
|
||||
import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream';
|
||||
import log from 'electron-log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
@@ -284,7 +284,9 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
// v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能
|
||||
// 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
@@ -78,8 +78,10 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
// 优先使用 AdapterConfig.contextWindow(如果存在)
|
||||
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
|
||||
if (typeof ctx === 'number' && ctx > 0) return ctx;
|
||||
// 默认值仅是兜底 —— 子类应声明真实的模型级窗口,避免压缩阈值计算失真
|
||||
return 1_000_000;
|
||||
// v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时
|
||||
// (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。
|
||||
// 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。
|
||||
return 128_000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,9 +216,13 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
|
||||
// v0.6.4: 巨大 HTML 错误页整体拼进消息会造成日志/事件载荷爆炸 —— 截断到合理长度
|
||||
const safeBody =
|
||||
errorBody.length > 500 ? `${errorBody.slice(0, 500)}…[truncated ${errorBody.length} chars]` : errorBody;
|
||||
errorBody.length > 500
|
||||
? `${errorBody.slice(0, 500)}…[truncated ${errorBody.length} chars]`
|
||||
: errorBody;
|
||||
|
||||
const error = new Error(`${context}: ${response.status} ${response.statusText}${safeBody ? ` - ${safeBody}` : ''}`);
|
||||
const error = new Error(
|
||||
`${context}: ${response.status} ${response.statusText}${safeBody ? ` - ${safeBody}` : ''}`,
|
||||
);
|
||||
(error as Error & { status: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { BaseAdapter } from './base-adapter';
|
||||
import { truncatedArgumentsPayload } from './shared/sse-stream';
|
||||
import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream';
|
||||
import log from 'electron-log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
@@ -105,7 +105,9 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
let streamEndedNormally = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
// v0.7.4 P1-2: 空闲超时 — 本地模型加载/推理期间服务器可能长时间不推数据,
|
||||
// 共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
@@ -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