/** * 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"、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 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 按模型上限钳制(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 不跌破协议下限 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"、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>; 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); }); });