Files
metona-ai-desktop/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

1311 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 未配置 → 显式 disabledv0.8.0 P0-3 确定性契约)', async () => {
// v0.8.0 P0-3 契约变更: DeepSeek 服务端默认 thinking.enabled,未配置即发请求
// 会得到隐式思考 —— 现在未配置一律显式 disabled,行为不依赖服务端隐式默认
//(与 Agnes/MiMo 的显式口径对齐;引擎路径恒传布尔值,此处为兜底确定性)。
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
});
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);
},
);
});