This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

871
tests/unit/ai.test.js Normal file
View File

@@ -0,0 +1,871 @@
// main/ai.js —— AI 代理的协议分支测试
//
// 覆盖:
// - aiProvider 默认走 OpenAI 分支(旧版配置 / 没填 provider
// - aiProvider='openai' → POST /chat/completions · Bearer 鉴权
// - aiProvider='anthropic' → POST /v1/messages · x-api-key + anthropic-version
// - 未配置 / 超时 / 取消 / HTTP 错误 / 响应格式错误的稳定错误码
// - OpenAI / Anthropic 各自的截断信号finish_reason / stop_reason
//
// 不依赖 Electronai.js 只用 Node 内置 fetch通过 deps.fetchImpl 注入
// 假 fetch 验证 URL / headers / body避免真实网络。
import { describe, it, expect } from 'vitest';
const { createAiProxy } = require('../../main/ai.js');
/**
* 构造一个 mock fetch断言请求、返回预设响应。
* @param {object} opts
* @param {(url:string, init:object) => void} [opts.onCall] 每次调用都跑一次
* @param {Response|Error} [opts.response] 默认 200 + 合法 OpenAI 回复
*/
function mockFetch({ onCall, response } = {}) {
/** @type {Array<{url:string, init:object}>} */
const calls = [];
const fn = async (url, init = {}) => {
calls.push({ url, init });
if (onCall) onCall(url, init);
if (response instanceof Error) throw response;
return response || new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
return { fn, calls };
}
describe('createAiProxy.runEdit —— Provider 分发', () => {
it('aiProvider 缺省 / 未知 → 走 OpenAI 分支(向后兼容)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-x', aiModel: 'gpt-x' }),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r1' });
expect(r.ok).toBe(true);
expect(captured.url).toBe('https://x/v1/chat/completions');
expect(captured.init.headers.Authorization).toBe('Bearer sk-x');
});
it('aiProvider=openai → POST /chat/completions · Bearer', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: 'plain reply' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://api.openai.com/v1',
aiApiKey: 'sk-test',
aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r2' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(captured.url).toBe('https://api.openai.com/v1/chat/completions');
expect(captured.init.method).toBe('POST');
expect(captured.init.headers.Authorization).toBe('Bearer sk-test');
expect(captured.init.headers['x-api-key']).toBeUndefined();
// body 应含 messages 数组system + user
const body = JSON.parse(captured.init.body);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages[0].role).toBe('system');
expect(body.messages[1].role).toBe('user');
});
it('aiProvider=anthropic → POST /v1/messages · x-api-key + anthropic-version', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"hello"}' }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://api.anthropic.com',
aiApiKey: 'sk-ant-test',
aiModel: 'claude-3-5-sonnet-latest',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hello');
expect(captured.url).toBe('https://api.anthropic.com/v1/messages');
expect(captured.init.headers['x-api-key']).toBe('sk-ant-test');
expect(captured.init.headers['anthropic-version']).toBe('2023-06-01');
expect(captured.init.headers.Authorization).toBeUndefined();
const body = JSON.parse(captured.init.body);
// Anthropic: system 在顶层messages 只有 user
expect(body.system).toBeDefined();
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBe(1);
expect(body.messages[0].role).toBe('user');
});
});
describe('createAiProxy.runEdit —— 错误码', () => {
it('未配置 → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: '', aiApiKey: '', aiModel: '' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
});
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理Ollama / LM Studio / vllm走 http://localhost / 127.0.0.1 不需要 Key
// 留空 apiKey 即过这里测的是「Key 存在 + 非loopback 明文」必须被拒。
it('OpenAI 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED防 Key 泄露)', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://evil.example.com/v1', aiApiKey: 'sk-real-key', aiModel: 'gpt-4' }),
fetchImpl: async () => { throw new Error('should not call — 明文 HTTP + 非loopback 必须在 fetch 前拒绝'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
expect(r.message).toMatch(/https/);
});
it('Anthropic 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'http://gateway.example.com', aiApiKey: 'sk-ant-key', aiModel: 'claude-3-5-sonnet' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
});
it('明文 http + localhost + 已配 Key → 仍放过(本地代理合法场景)', async () => {
// localhost 上 Key 即使被同机嗅探到也比公网风险低且本地代理Ollama
// 走 http 是惯例。这里验证 validateBaseUrl + http 守卫不误伤。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://localhost:11434/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('明文 http + 127.0.0.1 + 已配 Key → 仍放过', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://127.0.0.1:1234/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('OpenAI 401 → AI_PROVIDER_ERROR"API Key 无效或没有权限"', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'bad', aiModel: 'gpt' }),
fetchImpl: async () => new Response('Unauthorized', { status: 401 }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/API Key/);
});
it('Anthropic 400 + invalid_request_error → 透出 error.message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
type: 'error',
error: { type: 'invalid_request_error', message: 'max_tokens: too large' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/max_tokens: too large/);
});
it('OpenAI finish_reason=length → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"abc' }, finish_reason: 'length' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('Anthropic stop_reason=max_tokens → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"trunc' }],
stop_reason: 'max_tokens',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('网络错误 → AI_PROVIDER_ERROR带原始 message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNREFUSED'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/ECONNREFUSED/);
});
});
describe('createAiProxy —— timer / pending Map cleanup', () => {
// audit fixpostJson 之前只 clearTimeout 没从 timers Map delete entry
// 长时间使用会内存泄漏。这里通过重复 runEdit 验证 timer/pending 都已清空。
it('连续 10 次成功请求后 timers / pending Map 都为空', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
for (let i = 0; i < 10; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `r${i}` });
expect(r.ok).toBe(true);
}
// 通过 cancelAll 的清理量反推cancelAll 应该不需要清任何东西(如果还有泄漏,会 abort 已完成的 controller
proxy.cancelAll();
// 重复 cancelAll 不应该抛错(说明 Map 已空)
expect(() => proxy.cancelAll()).not.toThrow();
});
it('失败响应HTTP 400后 timer Map 已清', async () => {
let count = 0;
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => {
count++;
return new Response('Bad Request', { status: 400 });
},
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `err${i}` });
expect(r.ok).toBe(false);
}
expect(count).toBe(5);
// 后续 cancelAll 无副作用说明没有累积
expect(() => proxy.cancelAll()).not.toThrow();
});
it('网络异常fetch reject后 timer Map 已清', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNRESET'); },
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `net${i}` });
expect(r.ok).toBe(false);
}
expect(() => proxy.cancelAll()).not.toThrow();
});
// looksLikeEditJson 是 main/ai.js 的内部函数,没导出。这里通过 runEdit 行为
// 反推 —— 当 AI 返回「带嵌套 JSON 但格式损坏」的文本时,应得到 AI_BAD_RESPONSE
// (提示不完整)而不是 raw 回退(让用户看到残缺 JSON 当作文本)。
describe('createAiProxy.runEdit —— looksLikeEditJson嵌套 JSON 截断识别)', () => {
// 这些测试间接覆盖嵌套对象场景:原来的正则 `/\{[^{}]*"content"\s*:[^{}]*\}/`
// 在 content 后跟嵌套 {} 时会失配,导致 looksLikeEditJson 误报 false
// normalizeAssistantText 走 raw 回退 —— AI 返回半截带嵌套对象的 JSON 时,
// 用户看到的是残缺 JSON 当文本,不是「请缩小文档」的提示。
function makeProxyWithText(aiText) {
const fetchImpl = async () => new Response(JSON.stringify({
choices: [{ message: { content: aiText }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
return createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
}
it('AI 返回半截嵌套 JSON外层 {} 未闭合)→ raw 回退responseFormat=raw', async () => {
// 模拟 AI 想要返回 {content:"...", patches:[...]} 但 token 耗尽、整个对象都
// 没闭合tryParseJson 失败、extractFirstJsonObject 也找不到配对对象、
// looksLikeEditJson 返回 false → 走 raw 路径把残文本返回给用户。
// 这是设计raw 回退让用户至少能看到 AI 输出了什么TRUNCATED 是 OpenAI /
// Anthropic 自己的 finish_reason=length / stop_reason=max_tokens 触发的,
// 不靠正文里的 {} 配对判断。)
const truncated = '{"content":"hi","patches":[{"op":"replace","old":"a","new":"b"},';
const proxy = makeProxyWithText(truncated);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt1' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(truncated);
});
it('AI 返回半截 JSON 但 brace-pair slice 仍能拿到合法对象 → 解析成功', async () => {
// 复杂 case文本里有 prose + JSON 截断(缺尾 `}`),但第一个 { 到最后一个 }
// 之间存在合法 JSON。这种情况应当被 getJsonCandidates 的 brace-pair slice
// 提取并解析 —— 验证新 extractFirstJsonObject 与既有 brace-pair slice
// 路径都不破坏嵌套解析。
const mixed = 'Here is the edit:\n{"content":"abc","meta":{"note":"done"}}\n[truncated';
const proxy = makeProxyWithText(mixed);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt2' });
expect(r.ok).toBe(true);
expect(r.content).toBe('abc');
expect(r.responseFormat).toBe('json');
});
it('AI 返回完整嵌套 JSON合法→ ok=true', async () => {
// 完整嵌套 JSON 应当被 tryParseJson 解析通过、走 responseFormat='json'。
const valid = '{"content":"hi","patches":[{"op":"replace"}]}';
const proxy = makeProxyWithText(valid);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hi');
expect(r.responseFormat).toBe('json');
});
it('用户提示词纯文本里出现 "content" 子串 → 不误判 TRUNCATED', async () => {
// 老正则 `"content"\s*:` 在纯文本里有 `please update the "content" of section` 也会命中。
// 新实现要求体内有真配对的 JSON 对象 + `"content"` 键,纯文本不会触发。
const prose = '请把 "content" 字段里那个段落的标题改了。';
const proxy = makeProxyWithText(prose);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt4' });
// 不是 JSON → responseFormat='raw',当成普通回复返回,不应报错。
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(prose);
});
it('AI 返回带 fenced code block 的 JSON含嵌套 → ok=true', async () => {
// ```json\n{...}\n``` 也应被 tryParseJson 处理,新实现的
// extractFirstJsonObject 在 fenced block 内也能正常工作(先 trim 后扫描)。
const fenced = '```json\n{"content":"fenced ok","meta":{"k":"v"}}\n```';
const proxy = makeProxyWithText(fenced);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt5' });
expect(r.ok).toBe(true);
expect(r.content).toBe('fenced ok');
});
});
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = (url) => {
callCount++;
// 第一次永远不返回(模拟长任务)
if (callCount === 1) return new Promise(() => {});
// 第二次正常返回
return Promise.resolve(new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
// 第一次请求(永远 hang
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
// 第二次同 requestId → 应该 cancel 第一次并发新请求
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
// p1 应该被 cancel不需要 resolvecancelAll 会清掉)
proxy.cancelAll();
// 等 p1 真的 reject被 abort 后 fetch promise 不会自己结束)
// 加个超时兜底防止测试挂住
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
});
// audit F1cleanup 必须 identity-check 后再 delete详细注释见 main/ai.js 的 cleanup
describe('createAiProxy —— requestId 重入取消', () => {
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = () => {
callCount++;
if (callCount === 1) return new Promise(() => {});
return Promise.resolve(new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
proxy.cancelAll();
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
it('同 requestId 重新发起后,新请求仍可被 cancel(requestId) 单独取消', async () => {
const inFlight = [];
const fetchImpl = (url, opts) => {
return new Promise((resolve, reject) => {
inFlight.push({ opts });
// 必须监听 abort 信号;否则 catch 永远不触发,测试会 timeout
if (opts && opts.signal) {
if (opts.signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
opts.signal.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
}
});
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
proxy.cancel('dup');
// 等两个都 settleaborted
await Promise.all([
p1.catch(() => {}),
p2.catch(() => {}),
]);
expect(inFlight).toHaveLength(2);
// 关键断言:第二个请求的 signal.aborted 必须是 true
// —— F1 修复前:旧 cleanup 把 controller 从 Map 抹掉proxy.cancel('dup') 是 no-op
expect(inFlight[1].opts.signal.aborted).toBe(true);
});
});
// audit M1+M2+M3 回归测试API Key 脱敏 + URL userinfo 剥除 + JSON-wrapped header 修复
describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => {
// M1 fixfetch 抛错时 message 里携带 key 也必须脱敏。
// 真实场景fetch reject 的 Error.message 偶尔会包含 URL含 query key
// 自定义 fetch 包装层把请求 headers 拼进 message。
it('M1网络错误 message 含 sk- 前缀的 key → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('fetch failed for Authorization: Bearer sk-realsk1234567890abcdef'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).not.toMatch(/sk-realsk1234567890abcdef/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
// M1 follow-up非 sk- 前缀的 key自定义 token / API secret也应被某条规则盖住。
it('M1网络错误 message 含通用 token=xxx → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('api_key=hunter2_real_secret_was_here'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1b' });
expect(r.ok).toBe(false);
expect(r.message).not.toMatch(/hunter2_real_secret_was_here/);
expect(r.message).toMatch(/\[REDACTED\]/);
});
// M2 fixbaseURL 含 userinfohttps://user:pass@host必须剥掉。
// 真实场景:中转服务在 baseURL 里塞 userinfo 简化配置fetch 会把 userinfo
// 当 Basic Auth 自动发出去,凭据泄露到第三方。
it('M2baseURL 含 userinfo → 实际 fetch 的 URL 已剥 userinfo', async () => {
let captured = null;
const fetchImpl = async (url) => {
captured = url;
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://hunter2:secret@relay.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2' });
expect(r.ok).toBe(true);
expect(captured).toBe('https://relay.example.com/v1/chat/completions');
expect(captured).not.toMatch(/hunter2/);
expect(captured).not.toMatch(/secret/);
expect(captured).not.toMatch(/@/);
});
// M2 follow-upURL 里 path 段含 sk-xxx 的极端情况也要在 sanitizeUrl 输出里被遮罩。
// 不依赖 joinUrl防御深度sanitizeUrl 单独处理原始 URL 时也应当剥 userinfo。
it('M2sanitizeUrl 输出HTTP 错误日志)已剥 userinfo + path 段 key', async () => {
/** @type {Array<{event:string, data:any}>} */
const logs = [];
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://user:pass@api.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl: async () => new Response('oops', { status: 500 }),
log: (event, data) => logs.push({ event, data }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2b' });
expect(r.ok).toBe(false);
const httpErrLog = logs.find((l) => l.event === 'ai:http_error');
expect(httpErrLog).toBeDefined();
expect(httpErrLog.data.url).not.toMatch(/user/);
expect(httpErrLog.data.url).not.toMatch(/pass/);
expect(httpErrLog.data.url).not.toMatch(/@/);
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
});
// M3 fixHTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
// (错误信息里把 header 序列化成字符串AI proxy / 网关常见),旧 regex 遇到 `"`
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
// 这里使用非 sk- 前缀的 key确保测试通过 sk- 正则 fail只覆盖 JSON-wrapped 修复路径)。
it('M3JSON-wrapped authorization header非 sk- 前缀)→ 已脱敏', async () => {
const SENSITIVE = 'eyJhbGciOiJIUzI1NiJ9.payload.signature_xxx_no_sk_prefix';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: `upstream returned: "authorization": "Bearer ${SENSITIVE}"` },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3' });
expect(r.ok).toBe(false);
// 关键断言JWT 内容不再出现在 toast message 里
expect(r.message).not.toMatch(/eyJhbGciOiJIUzI1NiJ9/);
expect(r.message).not.toMatch(/payload\.signature_xxx_no_sk_prefix/);
expect(r.message).toMatch(/REDACTED/);
});
// M3 follow-up短 key 阈值 8 → 5 后,截断的 sk- 也能被捕获。
// 状态码选 400 且不带 invalid_request_error.type —— 走 main/ai.js 末尾
// 「AI 请求失败HTTP 400{detail}」路径detail 经 sanitizeDetail 脱敏后
// 进入 result.message。401/403/404/429/5xx 都有早退固定文案,不展示上游 detail。
it('M3截断的 sk-5 字符后缀)→ 已脱敏为 [API_KEY]', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: 'invalid token: sk-12345abc (truncated for security)' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3b' });
expect(r.ok).toBe(false);
// 不应出现真实截断的 key 段
expect(r.message).not.toMatch(/sk-12345abc/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
});
// audit shared-M11 + shared-M13归一化层对「非字符串 content」和「非文本块」做了区分
describe('createAiProxy.runEdit —— shared-M11 / shared-M13 归一化分支', () => {
// M11Anthropic 返回的 content 全部是 tool_use / image无 text 块)——
// 旧版会误报「AI 返回了空内容」让用户重试;新版提示「无法识别」。
it('M11Anthropic 仅返回 tool_use 块(无 text→ 明确提示 tool_use / 图像块', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'BJ' } },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11a' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
// 提示里要让用户知道是「格式不识别」而不是「内容为空」
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic 仅返回 image 块(无 text→ 同样提示无法识别', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11b' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic content 数组空 → 仍报「空内容」(不是 tool_use 路径)', async () => {
// 兜底:旧分支 'AI 返回了空内容' 仍保留,区分「真的没回」和「回了非文本」。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11c' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toBe('AI 返回了空内容');
});
it('M11Anthropic 混合 text + tool_use → 只提取 textresponseFormat=json', async () => {
// 混合块:正常路径 —— text 被拼接tool_use 跳过,但不影响 ok=true。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 't1', name: 'noop', input: {} },
{ type: 'text', text: '{"content":"mixed ok"}' },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11d' });
expect(r.ok).toBe(true);
expect(r.content).toBe('mixed ok');
expect(r.responseFormat).toBe('json');
});
// M13模型按规矩返回了 JSON 对象但 content 不是字符串(数组 / 对象 / null / 数字)。
// 旧逻辑走「output 截断」分支误导用户;新版把 message 整体当 raw 回退给用户。
it('M13OpenAI 返回的 JSON 解析后 content 是数组 → responseFormat=raw回退原 message', async () => {
const rawMessage = '{"content":[]}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13a' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
// 回退:原 message 整体作为 content让用户看到模型实际输出
expect(r.content).toBe(rawMessage);
});
it('M13OpenAI 返回的 JSON 解析后 content 是 null → responseFormat=raw', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":null,"meta":"x"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13b' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe('{"content":null,"meta":"x"}');
});
it('M13OpenAI 返回的 JSON 解析后 content 是嵌套对象 → responseFormat=raw', async () => {
const rawMessage = '{"content":{"ops":[{"op":"replace","old":"a","new":"b"}]}}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13c' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(rawMessage);
});
});
// ---------------------------------------------------------------------------
// audit round-13Anthropic / OpenAI max_tokens 上限路由
//
// 回归背景pickAnthropicTokenConfig 的正则要求 `claude-` 后紧跟已知片段,
// 于是 claude-opus-5 / claude-sonnet-5 / claude-haiku-4-5 全部漏网 → 回退 8192
// 比 claude-opus-4-816384还低。「越新的模型拿到越小的 max_tokens」
// 长笔记改写在 8k 被截断 → stop_reason: max_tokens → 报「AI 修改结果不完整」。
// ---------------------------------------------------------------------------
describe('max_tokens 上限按模型路由', () => {
/**
* 跑一次 runEdit返回实际发出的请求 body。
* @param {'openai'|'anthropic'} provider
* @param {string} model
*/
async function capturedBody(provider, model) {
let captured = null;
const okBody = provider === 'anthropic'
? { content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn' }
: { choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }] };
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: provider,
aiBaseUrl: provider === 'anthropic' ? 'https://x' : 'https://x/v1',
aiApiKey: 'k',
aiModel: model,
}),
fetchImpl: async (url, init) => {
captured = JSON.parse(init.body);
return new Response(JSON.stringify(okBody), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
},
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'cap' });
expect(r.ok).toBe(true);
return captured;
}
// ---- Anthropic5 系与 4.6+ 拿到 32k ----
it.each([
'claude-opus-5',
'claude-sonnet-5',
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-sonnet-4-6',
])('Anthropic %s → max_tokens 32000官方 128k 上限,保守取值)', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(32_000);
});
// ---- Anthropic其余 4 系 / 3-5 / 3-7 拿到 16k ----
it.each([
'claude-haiku-4-5',
'claude-sonnet-4-5',
'claude-opus-4-5',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
])('Anthropic %s → max_tokens 16384', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(16_384);
});
// ---- Anthropicclaude-3 老家族与未知模型保守 8k ----
it.each([
'claude-3-opus-20240229',
'claude-3-haiku-20240307',
])('Anthropic %sclaude-3 老家族)→ max_tokens 8192', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(8_192);
});
it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192最大限度兼容', async () => {
const body = await capturedBody('anthropic', 'my-local-llm-v2');
expect(body.max_tokens).toBe(8_192);
});
// 关键不变式:新模型的上限不得低于老模型(这正是本轮修的 bug
it('回归claude-opus-5 的 max_tokens 不低于 claude-opus-4-8', async () => {
const five = await capturedBody('anthropic', 'claude-opus-5');
const four = await capturedBody('anthropic', 'claude-opus-4-8');
expect(five.max_tokens).toBeGreaterThanOrEqual(four.max_tokens);
});
it('回归claude-haiku-4-5 的 max_tokens 高于 claude-3 老家族', async () => {
const haiku45 = await capturedBody('anthropic', 'claude-haiku-4-5');
const old3 = await capturedBody('anthropic', 'claude-3-haiku-20240307');
expect(haiku45.max_tokens).toBeGreaterThan(old3.max_tokens);
});
// ---- OpenAI 侧token 字段路由不受影响(防回归) ----
it('OpenAI gpt-5 → max_completion_tokens 且不带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-5');
expect(body.max_completion_tokens).toBe(32_000);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBeUndefined();
});
it('OpenAI gpt-4o → max_tokens 16384 且带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-4o');
expect(body.max_tokens).toBe(16_384);
expect(body.max_completion_tokens).toBeUndefined();
expect(typeof body.temperature).toBe('number');
});
});
// ---------------------------------------------------------------------------
// audit round-13 Sec-H3fetch 必须 redirect:'manual' 防 API key 跟着 302 走
//
// 回归背景Anthropic 用 x-api-key 自定义头(不在 fetch 规范 CORS 非通配脱敏集
// 合里undici 默认 follow 重定向到不同 origin 时会原样复传 header。
// 用户配置的中转 / 网关一旦 302 到攻击者域sk-ant-... 跟当前笔记全文会被转发。
// ---------------------------------------------------------------------------
describe('createAiProxy —— redirect: manual 防 API Key 跟随重定向', () => {
it('fetch 调用必带 redirect:"manual"OpenAI 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-1' });
expect(captured.init.redirect).toBe('manual');
});
it('fetch 调用必带 redirect:"manual"Anthropic 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://x', aiApiKey: 'sk-ant-test', aiModel: 'claude-opus-5',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-2' });
expect(captured.init.redirect).toBe('manual');
});
it('3xx 重定向 → opaqueredirect → AI_PROVIDER_ERROR + 提示用户改 Base URL', async () => {
// 模拟 undici 在 manual 模式下返回的 opaqueredirect Responsestatus=0、type='opaqueredirect'
const fetchImpl = async () => {
const res = new Response(null, { status: 302 });
// 模拟 opaqueredirect 的关键属性status 0 + type 'opaqueredirect'
Object.defineProperty(res, 'status', { value: 0 });
Object.defineProperty(res, 'type', { value: 'opaqueredirect' });
return res;
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://evil-redirect.example/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-3' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/发生了重定向/);
expect(r.message).toMatch(/不会自动跟随重定向/);
});
});