Files
metona-ai-desktop/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

1322 lines
44 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 原样透传(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 不跌破协议下限 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],
// v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768
['max', 32768],
] 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<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 原样透传(v0.8.1pro/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=%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);
// v0.8.1: 原样透传,无 65536 钳制
expect(bodies[0].max_tokens).toBe(70_000);
});
});
// ===== 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);
});
// 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.1pro/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<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 → 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<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 透传矩阵汇总(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<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);
},
);
});