Files
metona-ai-desktop/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

1308 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Provider 请求形态测试矩阵(v0.6.4 P2-6
*
* 此前 ollama599 行)/ anthropic(532 行)两个最复杂的适配器零测试 —— 恰好也是
* 本轮审计中缺陷密度最高的文件。本文件通过 mock fetch 记录真实请求体,
* 锁定以下契约:
*
* Anthropic:
* A1 消息转换(system 顶层 / user-assistant-tool 三角色映射 / 孤立 tool_result 过滤)
* A2 max_tokens 按模型钳制(引擎默认 63488 → sonnet 64000 / opus 32000
* A3 thinking 预算下限保护(小 maxTokens 场景 budget≥1024 且 < max_tokens,此前 API 400
* A4 thinking 开启时不传 temperature;关闭时显式传递
*
* Ollama:
* O1 options 映射(num_predict=numTokens、num_ctx=contextLength、stop、top_p
* O2 think 参数 effort 映射(low→"low"、max→true)与未配置时缺省
* O3 图片归一化(data URI 剥前缀;无 URL 触发下载分支时零网络请求)
*
* Agnes:
* G1 思考模式对称性 —— thinkingEnabled=false 必须显式发送 enable_thinking:false
*/
import { describe, it, expect, vi } 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 { 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 捕获器:记录每次请求体并返回一个三家协议都能解析的合成响应 */
function captureFetch(): { bodies: Array<Record<string, unknown>> } {
const bodies: Array<Record<string, unknown>> = [];
// 兼容三家的非流式解析所需的最小字段集:
// OpenAI 兼容(agnes): choices[].message/finish_reasonAnthropic: content[]/usage/stop_reason
// Ollama: message/done/prompt_eval_count/eval_count
const genericBody = {
id: 'cmpl-test',
object: 'chat.completion',
created: Date.now(),
model: 'test-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
content: [],
usage: {
prompt_tokens: 3,
completion_tokens: 2,
total_tokens: 5,
input_tokens: 3,
output_tokens: 2,
prompt_eval_count: 3,
eval_count: 2,
},
stop_reason: 'end_turn',
message: { role: 'assistant', content: 'ok' },
done: true,
};
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>);
return new Response(JSON.stringify(genericBody), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
return { bodies };
}
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: 'Be concise.',
safetyGuidelines: 'Stay safe.',
dynamicReminders: '',
},
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
params: { maxTokens: 63_488, temperature: 0, stream: false },
...overrides,
};
}
// ===== Anthropic =====
describe('AnthropicAdapter — 请求体契约', () => {
it('A1: system 拼为顶层字段;tool 结果映射为 user 角色 tool_result 块', 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({
messages: [
{ role: 'user', content: 'read it', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{
id: 'tc_1',
name: 'read_file',
args: { path: 'a.txt' },
iteration: 1,
timestamp: Date.now(),
},
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
// 孤立 tool_result(前面没有对应 tool_use)应被过滤
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_orphan',
toolName: 'x',
result: '',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{ role: 'user', content: 'next?', timestamp: Date.now() },
],
}),
);
const body = bodies[0];
// v0.7.3 P1-1: system 转为块数组并打 cache_control 断言(稳定前缀 prompt cache
const system = body.system as Array<{
type: string;
text: string;
cache_control: { type: string };
}>;
expect(Array.isArray(system)).toBe(true);
expect(system[0].text).toContain('You are Metona.');
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
expect(Array.isArray(body.messages)).toBe(true);
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
// tool_use 的 assistant 消息存在且携带 id/name
const assistantToolMsg = msgs.find((m) => m.role === 'assistant');
expect(assistantToolMsg?.content[0]).toMatchObject({
type: 'tool_use',
id: 'tc_1',
name: 'read_file',
});
// tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃
const toolResultBlocks = msgs.flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
expect(toolResultBlocks).toHaveLength(1);
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
});
it('A2: max_tokens 按模型上限钳制(63488 → sonnet 64000 / opus 32000', async () => {
const sonnet = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const opus = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-opus-4-1',
});
const { bodies } = captureFetch();
await sonnet.send(makeRequest());
await opus.send(makeRequest());
// 引擎默认 63488 低于 sonnet 上限 64000 → 原样保留;opus 上限 32000 → 钳制生效
expect(bodies[0].max_tokens).toBe(63_488);
expect(bodies[1].max_tokens).toBe(32_000);
});
it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024v0.6.4 边界加固)', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-haiku-4-5',
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 1500,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'low',
},
}),
);
const body = bodies[0];
const thinking = body.thinking as { type: string; budget_tokens: number };
// max_tokens 被抬升到安全下限,budget 落在 [1024, max_tokens/2] 区间内
expect(body.max_tokens as number).toBeGreaterThanOrEqual(2048);
expect(thinking.budget_tokens).toBeGreaterThanOrEqual(1024);
expect(thinking.budget_tokens).toBeLessThanOrEqual((body.max_tokens as number) / 2);
});
it('A4: thinking 开启不传 temperature;关闭时显式传递', 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.7, stream: false, thinkingEnabled: true },
}),
);
expect(bodies[0].temperature).toBeUndefined();
expect(bodies[0].thinking).toBeDefined();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[1].temperature).toBe(0.7);
expect(bodies[1].thinking).toBeUndefined();
});
});
// ===== Ollama =====
describe('OllamaAdapter — 请求体契约', () => {
function makeOllama(): OllamaAdapter {
return new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
defaultModel: 'qwen3',
});
}
it('O1: options 映射 num_predict/num_ctx/stop/top_p/temperature', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 8192,
temperature: 0.3,
topP: 0.9,
stream: false,
contextLength: 16384,
stopSequences: ['STOP'],
},
}),
);
const options = bodies[0].options as Record<string, unknown>;
expect(options.num_predict).toBe(8192);
expect(options.num_ctx).toBe(16384);
expect(options.temperature).toBe(0.3);
expect(options.top_p).toBe(0.9);
expect(options.stop).toEqual(['STOP']);
});
it('O2: think 参数 effort 映射(low→"low"、max→true);未开启思考时缺省', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'low',
},
}),
);
expect(bodies[0].think).toBe('low');
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'max',
},
}),
);
expect(bodies[1].think).toBe(true);
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[2].think).toBeUndefined();
});
it('O3: data URI 图片剥前缀转纯 base64 数组(无网络下载路径触发)', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'data:image/png;base64,iVBORw0KGgoAAAANSU', 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(['iVBORw0KGgoAAAANSU']);
});
});
// ===== MiMo providerOptionsv0.6.4 P4-3 =====
describe('MimoAdapter — 服务端能力扩展(providerOptions', () => {
it('enableWebSearch 开启时附加 {type:web_search} 服务端工具', async () => {
const adapter = new MimoAdapter({
provider: 'mimo',
baseURL: 'http://m.test/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5',
providerOptions: { enableWebSearch: true },
});
const { bodies } = captureFetch();
await adapter.send(makeRequest());
const tools = bodies[0].tools as Array<Record<string, unknown>>;
expect(tools.some((tc) => (tc as { type?: string }).type === 'web_search')).toBe(true);
expect(bodies[0].tool_choice).toBe('auto');
});
it('responseFormatJson 开启时写入 response_format json_object;默认不写', async () => {
const on = new MimoAdapter({
provider: 'mimo',
baseURL: 'http://m.test/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5',
providerOptions: { responseFormatJson: true },
});
const off = new MimoAdapter({
provider: 'mimo',
baseURL: 'http://m.test/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5',
});
const { bodies } = captureFetch();
await on.send(makeRequest());
await off.send(makeRequest());
expect(bodies[0].response_format).toEqual({ type: 'json_object' });
expect(bodies[1].response_format).toBeUndefined();
});
});
// ===== Agnes =====
describe('AgnesAdapter — 思考模式对称性(v0.6.4', () => {
it('G1: thinkingEnabled=false 显式发送 enable_thinking:false(此前无法关闭服务端默认思考)', async () => {
const adapter = new AgnesAdapter({
provider: 'agnes',
baseURL: 'http://g.test/v1',
apiKey: 'k',
defaultModel: 'agnes-2.0-flash',
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'high',
},
}),
);
expect(
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(true);
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}),
);
expect(
((bodies[1].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(false);
// 未配置 thinkingEnabled 同样视为关闭(显式 disabled 保持与服务端默认的确定性)
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
expect(
((bodies[2].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).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);
},
);
});