feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -0,0 +1,968 @@
/**
* AnthropicAdapter 独立测试(v0.7.4 P1-2 / P3-1 差异点锁定)
*
* 覆盖契约:
* - sendStreamcache_control 保留、message_start input_tokens、thinking budget 钳制、
* pendingToolUseIds 孤立 tool_result 过滤、断流 flush、错误码归一化矩阵
* - sendsystem 块数组、图片 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 图片 → 下载后转 base64content-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' }]);
});
});
// ===== sendStreammessage_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 缺省 0USAGE 汇总不炸)', 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 — 断流 flushv0.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_filterMiMo 兼容)', 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 parttext 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 未配置 → 默认 8192MODEL_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=falsestream_options 仅流式路径注入
expect(body.stream).toBe(false);
expect(body.stream_options).toBeUndefined();
});
it('非 vision 模型带图片不产生 image_urlcontent 保持字符串)', 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 → USAGEprompt_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 finishReasondone_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('取消信号中止底层 fetchsignal 透传)', 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('listModelsAPI 失败 → 降级 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
* - 推理模型拒图:ModelCapabilityErrorstatus=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 → 默认 32768thinking 场景安全值)', 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 不传 temperatureo 系列不支持)', 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('未知模型 → 兜底 128Kv0.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 返回 MetonaResponsecontent/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=%slow/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=%slow 映射为关闭)', 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_pAPI 强制覆盖)', 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=%smax 归一 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 也不传 temperatureo 系列不支持温度)', 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 字段优先作为 contentCE-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 });
+10 -4
View File
@@ -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;
}
+4 -2
View File
@@ -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;
}
// ===== 认证头 =====
+218 -157
View File
@@ -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](连接中断/服务端异常收尾)。