feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -30,6 +30,8 @@ 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 捕获器:记录每次请求体并返回一个三家协议都能解析的合成响应 */
|
||||
@@ -426,3 +428,880 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4)', () => {
|
||||
).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=%s(low/medium 归一 high)',
|
||||
async (effort, expected) => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
|
||||
expect(bodies[0].reasoning_effort).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('thinkingEnabled=false → 显式 {type:disabled}(服务端默认开启,必须显式关闭)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
|
||||
expect(bodies[0].reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('thinkingEnabled 未配置 → 不发送 thinking 字段', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
|
||||
expect(bodies[0].thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it('effort 未配置缺省 high → reasoning_effort=high', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].reasoning_effort).toBe('high');
|
||||
});
|
||||
|
||||
it('temperature 与 stop 序列原样传递', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
params: {
|
||||
maxTokens: 4096,
|
||||
temperature: 0.5,
|
||||
stream: false,
|
||||
stopSequences: ['<END>'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bodies[0].temperature).toBe(0.5);
|
||||
expect(bodies[0].stop).toEqual(['<END>']);
|
||||
});
|
||||
|
||||
it('max_tokens 按模型钳制(pro 384000 / vision 8192)', async () => {
|
||||
const pro = makeAdapter('deepseek-v4-pro');
|
||||
const vision = makeAdapter('deepseek-v4-flash-vision-exp');
|
||||
const { bodies } = captureFetch();
|
||||
await pro.send(makeRequest({ params: { maxTokens: 500_000, temperature: 0, stream: false } }));
|
||||
await vision.send(
|
||||
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].max_tokens).toBe(384_000);
|
||||
expect(bodies[1].max_tokens).toBe(8_192);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Agnes enable_thinking 对称性扩展 =====
|
||||
|
||||
describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
|
||||
function makeAdapter(): AgnesAdapter {
|
||||
return new AgnesAdapter({
|
||||
provider: 'agnes',
|
||||
baseURL: 'http://g.test/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'agnes-2.0-flash',
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['high', true],
|
||||
['medium', true],
|
||||
['max', true],
|
||||
['low', false],
|
||||
] as const)('effort=%s → enable_thinking=%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<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_p(API 强制覆盖)', 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=%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 长上下文 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 字段优先作为 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<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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user