Files
Notes/tests/unit/ai.test.js
2026-09-12 14:15:26 +08:00

872 lines
43 KiB
JavaScript
Raw Permalink 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.
// 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(/不会自动跟随重定向/);
});
});