/** * Provider 请求形态测试矩阵(v0.6.4 P2-6) * * 此前 ollama(599 行)/ 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"、xhigh→"xhigh"、max/true→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> } { const bodies: Array> = []; // 兼容三家的非流式解析所需的最小字段集: // OpenAI 兼容(agnes): choices[].message/finish_reason;Anthropic: 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); return new Response(JSON.stringify(genericBody), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }); vi.stubGlobal('fetch', fetchMock); return { bodies }; } function makeRequest(overrides?: Partial): 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> }>; // 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 原样透传(v0.8.1:模型钳制已废除,设置面板是唯一上限来源)', 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()); // v0.8.1: 设置面板「最大输出上限」对一切模型原样透传,无任何按模型钳制 expect(bodies[0].max_tokens).toBe(63_488); expect(bodies[1].max_tokens).toBe(63_488); }); it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024(v0.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; 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"、xhigh→"xhigh"、max/true→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); // v0.8.3: xhigh 原样透传(Qwen3 等思考模板原生档);true → 布尔 true(模型默认档) await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'xhigh', }, }), ); expect(bodies[2].think).toBe('xhigh'); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'true', }, }), ); expect(bodies[3].think).toBe(true); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false }, }), ); expect(bodies[4].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>; const userMsg = messages[messages.length - 1]; expect(userMsg.images).toEqual(['iVBORw0KGgoAAAANSU']); }); }); // ===== MiMo providerOptions(v0.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>; 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) ?? {}).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) ?? {}).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) ?? {}).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>; 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>; 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>; 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], ['xhigh', 24576], // v0.8.3: 介于 high 与 max 之间 // v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768 ['max', 32768], ['true', 16384], // v0.8.3: 模型默认档按 high 同档预算 ] 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 透传矩阵(v0.8.1 无钳制)', () => { it.each([ ['claude-sonnet-4-5', 63_488, 63_488], ['claude-sonnet-4-5', 70_000, 70_000], // 超过任何旧元信息上限 → 原样透传 ['claude-opus-4-1', 63_488, 63_488], ['claude-haiku-4-5', 63_488, 63_488], ['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>; expect(tools[0].name).toBe('read_file'); expect(tools[0].input_schema).toBeDefined(); expect((tools[0].input_schema as Record).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'], ['xhigh', 'high'], // v0.8.3: DeepSeek 无 xhigh 档,就近映射 high ['max', 'max'], ['true', 'high'], // v0.8.3: 模型默认档映射 high ] as const)( 'effort=%s → reasoning_effort=%s(low/medium/xhigh/true 归一 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 未配置 → 显式 disabled(v0.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: [''], }, }), ); expect(bodies[0].temperature).toBe(0.5); expect(bodies[0].stop).toEqual(['']); }); it('max_tokens 原样透传(v0.8.1:pro/vision 均不钳制)', 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(500_000); expect(bodies[1].max_tokens).toBe(63_488); }); }); // ===== Agnes enable_thinking 对称性扩展 ===== describe('AgnesAdapter — enable_thinking 对称性矩阵', () => { function makeAdapter(): AgnesAdapter { return new AgnesAdapter({ provider: 'agnes', baseURL: 'http://g.test/v1', apiKey: 'k', defaultModel: 'agnes-2.0-flash', }); } it.each([ ['high', true], ['medium', true], ['max', true], ['low', false], ] as const)('effort=%s → enable_thinking=%s(low 映射为关闭)', async (effort, expected) => { const adapter = makeAdapter(); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: effort, }, }), ); expect( ((bodies[0].chat_template_kwargs as Record) ?? {}).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) ?? {}).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); // v0.8.1: 原样透传,无 65536 钳制 expect(bodies[0].max_tokens).toBe(70_000); }); }); // ===== MiMo thinking 开关与 providerOptions 扩展 ===== describe('MimoAdapter — thinking 显式开关', () => { function makeAdapter(overrides: Record = {}): 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); }); // v0.8.2 P2-6 修订:未配置时显式 disabled(与 DeepSeek/Agnes 的"用户意图优先"对齐; // 旧行为隐式 enabled 会静默吞掉用户 temperature/top_p —— 服务端思考模式强制覆盖) it('thinkingEnabled 未配置 → 显式 {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 } }), ); expect(bodies[0].thinking).toEqual({ type: 'disabled' }); expect(bodies[0].temperature).toBe(0.7); expect(bodies[0].top_p).toBe(0.8); }); it('max_completion_tokens 原样透传(v0.8.1:pro/standard 均不钳制)', 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(200_000); expect(bodies[1].max_completion_tokens).toBe(63_488); }); it('thinking 未关闭时未配置 maxTokens → 不下发该字段(v0.8.1:无写死兜底值)', 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).toBeUndefined(); }); 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>; expect(tools).toHaveLength(2); expect(tools[0].type).toBe('function'); expect(tools[1]).toEqual({ type: 'web_search' }); expect(bodies[0].tool_choice).toBe('auto'); }); it('responseFormatJson + thinking 默认开启可共存(response_format 独立于 thinking)', async () => { const adapter = makeAdapter({ providerOptions: { responseFormatJson: true } }); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true }, }), ); expect(bodies[0].response_format).toEqual({ type: 'json_object' }); expect(bodies[0].thinking).toEqual({ type: 'enabled' }); }); }); // ===== OpenAI reasoning_effort 与 maxTokens 路由 ===== describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1)', () => { function makeAdapter(model: string): OpenAIAdapter { return new OpenAIAdapter({ provider: 'openai', baseURL: 'https://api.openai.com/v1', apiKey: 'k', defaultModel: model, }); } it.each([ ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['max', 'high'], ] as const)( 'o3-mini effort=%s → reasoning_effort=%s(max 归一 high)', async (effort, expected) => { const adapter = makeAdapter('o3-mini'); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: effort, }, }), ); expect(bodies[0].reasoning_effort).toBe(expected); expect(bodies[0].max_completion_tokens).toBe(4096); // o 系列用新字段名 }, ); it('o3-mini thinking 未开启 → 不传 reasoning_effort 也不传 temperature(o 系列不支持温度)', async () => { const adapter = makeAdapter('o3-mini'); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false }, }), ); expect(bodies[0].reasoning_effort).toBeUndefined(); expect(bodies[0].temperature).toBeUndefined(); }); it('非推理模型 gpt-4o → max_tokens 字段 + temperature 透传', async () => { const adapter = makeAdapter('gpt-4o'); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 4096, temperature: 0.5, stream: false } }), ); expect(bodies[0].max_tokens).toBe(4096); expect(bodies[0].max_completion_tokens).toBeUndefined(); expect(bodies[0].temperature).toBe(0.5); expect(bodies[0].reasoning_effort).toBeUndefined(); }); it('gpt-4.1 → getContextWindow 返回设置面板配置值(v0.8.1:元信息不再承载窗口)', () => { const adapter = new OpenAIAdapter({ provider: 'openai', baseURL: 'http://o.test', apiKey: 'k', defaultModel: 'gpt-4.1', contextWindow: 1_000_000, }); expect(adapter.getContextWindow()).toBe(1_000_000); // 未配置 → 0(引擎跳过压缩判定),无任何写死兜底 const noCfg = makeAdapter('gpt-4.1'); expect(noCfg.getContextWindow()).toBe(0); }); it('o3-mini max_completion_tokens 原样透传(v0.8.1:无 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(200_000); }); it('gpt-4o max_tokens 原样透传(v0.8.1:无 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(63_488); }); }); // ===== 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; 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>; expect(tools[0]).toMatchObject({ type: 'function' }); expect((tools[0].function as Record).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>; const assistantMsg = messages.find((m) => m.role === 'assistant') as { tool_calls: Array>; }; const fn = assistantMsg.tool_calls[0].function as Record; 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>; 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>; 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>; const userMsg = messages[messages.length - 1]; expect(userMsg.images).toEqual(['iVBORw0KGgoAAAANSUhEUg']); }); it('工具结果失败时 error 字段优先作为 content(CE-2)', async () => { const adapter = makeOllama(); const { bodies } = captureFetch(); await adapter.send( makeRequest({ messages: [ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'assistant', content: null, toolCalls: [ { id: 'tc_e', name: 'run', args: {}, iteration: 1, timestamp: Date.now(), }, ], timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_e', toolName: 'run', result: null, success: false, error: 'exit code 2', durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ], }), ); const messages = bodies[0].messages as Array>; const toolMsg = messages.find((m) => m.role === 'tool') as { content: string }; expect(toolMsg.content).toBe('exit code 2'); }); }); // ===== 跨 Provider maxTokens 钳制矩阵 ===== describe('跨 Provider — maxTokens 透传矩阵汇总(v0.8.1 无钳制)', () => { it.each([ ['anthropic', 'claude-opus-4-1', 100_000, 100_000], ['anthropic', 'claude-sonnet-4-5', 100_000, 100_000], ['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 100_000], ['agnes', 'agnes-2.0-flash', 100_000, 100_000], ['mimo', 'mimo-v2.5', 100_000, 100_000], ['openai', 'gpt-4o', 100_000, 100_000], ] as const)( '%s %s maxTokens=100000 → 原样透传 %d', async (provider, model, requested, expected) => { const adapterMap: Record = { 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 }; const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }), ); const body = bodies[bodies.length - 1] as Record; expect(body.max_tokens ?? body.max_completion_tokens).toBe(expected); }, ); });