812 lines
32 KiB
JavaScript
812 lines
32 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const h = require('./helpers');
|
||
|
||
h.installFetchStub();
|
||
|
||
const cfgPath = require.resolve('../reader/ai-config.js');
|
||
const clientPath = require.resolve('../reader/ai-client.js');
|
||
|
||
const dirs = [];
|
||
function tmp() {
|
||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-ai-'));
|
||
dirs.push(d);
|
||
return d;
|
||
}
|
||
test.after(() => {
|
||
for (const d of dirs) {
|
||
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||
}
|
||
});
|
||
|
||
const storage = {
|
||
isEncryptionAvailable: () => true,
|
||
encryptString: (s) => Buffer.from('E' + s),
|
||
decryptString: (b) => b.toString().slice(1)
|
||
};
|
||
|
||
function setup({
|
||
protocol = 'chat-completions',
|
||
baseUrl = 'https://api.test.com/v1',
|
||
model = 'm',
|
||
apiKey = 'sk-1',
|
||
vision = false
|
||
} = {}) {
|
||
delete require.cache[cfgPath];
|
||
delete require.cache[clientPath];
|
||
const cfg = require(cfgPath);
|
||
cfg.init(tmp(), storage);
|
||
cfg.save({ protocol, baseUrl, model, apiKey, vision });
|
||
return require(clientPath);
|
||
}
|
||
|
||
function visualContext(overrides = {}) {
|
||
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||
return {
|
||
kind: 'page',
|
||
image: {
|
||
mimeType: 'image/png',
|
||
base64,
|
||
width: 1,
|
||
height: 1,
|
||
bytes: Buffer.from(base64, 'base64').length
|
||
},
|
||
ocr: { status: 'idle', text: '', include: false },
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
function sseBody(chunks, { done = true } = {}) {
|
||
const lines = chunks.map((c) => `data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
|
||
if (done) lines.push('data: [DONE]\n\n');
|
||
return lines.join('');
|
||
}
|
||
|
||
// 把字符串切成多个 chunk,模拟真实网络分片(含跨 chunk 断行)
|
||
function streamResponse(text, { status = 200, pieces = 3 } = {}) {
|
||
const buf = Buffer.from(text, 'utf8');
|
||
const size = Math.ceil(buf.length / pieces);
|
||
const parts = [];
|
||
for (let i = 0; i < buf.length; i += size) parts.push(buf.subarray(i, i + size));
|
||
return {
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
headers: { get: () => null, getSetCookie: () => [] },
|
||
text: async () => text,
|
||
json: async () => JSON.parse(text),
|
||
body: (async function* () { for (const p of parts) yield p; })()
|
||
};
|
||
}
|
||
|
||
test('流式增量按顺序回调并拼出完整文本', async () => {
|
||
const ai = setup();
|
||
h.setHandler(() => streamResponse(sseBody(['你', '好', '世界']), { pieces: 5 }));
|
||
const seen = [];
|
||
const full = await ai.stream({ task: 'translate', text: 'hello', onDelta: (d) => seen.push(d) });
|
||
assert.strictEqual(full, '你好世界');
|
||
assert.deepStrictEqual(seen, ['你', '好', '世界']);
|
||
});
|
||
|
||
test('SSE 分片跨 chunk 断开也能正确解析', async () => {
|
||
const ai = setup();
|
||
// 每个字节一个 chunk,保证 data: 行被切碎
|
||
h.setHandler(() => streamResponse(sseBody(['abc', 'def']), { pieces: 200 }));
|
||
const full = await ai.stream({ task: 'explain', text: 'x' });
|
||
assert.strictEqual(full, 'abcdef');
|
||
});
|
||
|
||
test('遇到 [DONE] 立即结束,不解析后续内容', async () => {
|
||
const ai = setup();
|
||
const body = sseBody(['一'], { done: true }) + sseBody(['不该出现'], { done: false });
|
||
h.setHandler(() => streamResponse(body));
|
||
assert.strictEqual(await ai.stream({ task: 'summarize', text: 'x' }), '一');
|
||
});
|
||
|
||
test('HTTP 错误体里的 message 会被提取为中文可读错误', async () => {
|
||
const ai = setup();
|
||
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: 'model not found' } }), { status: 404 }));
|
||
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /model not found/);
|
||
});
|
||
|
||
test('401 无 JSON 体时给出可读提示', async () => {
|
||
const ai = setup();
|
||
h.setHandler(() => streamResponse('Unauthorized', { status: 401 }));
|
||
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key 无效/);
|
||
});
|
||
|
||
test('流内返回 error 字段也会抛出', async () => {
|
||
const ai = setup();
|
||
h.setHandler(() => streamResponse('data: ' + JSON.stringify({ error: { message: '额度不足' } }) + '\n\n'));
|
||
await assert.rejects(() => ai.stream({ task: 'ask', text: 'x', question: 'q' }), /额度不足/);
|
||
});
|
||
|
||
test('未配置 Key 且非本地端点时拒绝请求', async () => {
|
||
const ai = setup({ apiKey: '' });
|
||
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key/);
|
||
});
|
||
|
||
test('本地端点无 Key 也允许请求,且不带 Authorization 头', async () => {
|
||
const ai = setup({ baseUrl: 'http://localhost:11434/v1', apiKey: '' });
|
||
let seenHeaders = null;
|
||
h.setHandler((_u, o) => { seenHeaders = o.headers; return streamResponse(sseBody(['ok'])); });
|
||
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
|
||
assert.ok(!seenHeaders.Authorization, '本地模型不该发送 Authorization');
|
||
});
|
||
|
||
test('请求体包含模型名与 stream 标志,且 Key 放在头里', async () => {
|
||
const ai = setup({ model: 'deepseek-chat', apiKey: 'sk-abc' });
|
||
let seen = null;
|
||
h.setHandler((u, o) => { seen = { u, o }; return streamResponse(sseBody(['x'])); });
|
||
await ai.stream({ task: 'translate', text: 'hi' });
|
||
const body = JSON.parse(seen.o.body);
|
||
assert.strictEqual(body.model, 'deepseek-chat');
|
||
assert.strictEqual(body.stream, true);
|
||
assert.strictEqual(seen.o.headers.Authorization, 'Bearer sk-abc');
|
||
assert.ok(seen.u.endsWith('/chat/completions'), '端点拼接错误: ' + seen.u);
|
||
assert.ok(!seen.u.includes('sk-abc'), 'Key 不该出现在 URL 中');
|
||
});
|
||
|
||
test('启用图像输入后使用 OpenAI 兼容的 image_url 消息', async () => {
|
||
const ai = setup({ vision: true });
|
||
let body = null;
|
||
h.setHandler((_u, options) => {
|
||
body = JSON.parse(options.body);
|
||
return streamResponse(sseBody(['看到了']));
|
||
});
|
||
const full = await ai.stream({
|
||
task: 'ask',
|
||
text: '',
|
||
question: '图中是什么?',
|
||
visualContexts: [visualContext()]
|
||
});
|
||
assert.strictEqual(full, '看到了');
|
||
assert.ok(Array.isArray(body.messages[1].content));
|
||
assert.strictEqual(body.messages[1].content[0].type, 'text');
|
||
assert.strictEqual(body.messages[1].content[1].type, 'image_url');
|
||
assert.match(body.messages[1].content[1].image_url.url, /^data:image\/png;base64,/);
|
||
assert.deepStrictEqual(Object.keys(body.messages[1].content[1].image_url), ['url']);
|
||
});
|
||
|
||
test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', async () => {
|
||
const ai = setup({ protocol: 'anthropic', vision: true });
|
||
let seen = null;
|
||
h.setHandler((url, options) => {
|
||
seen = { url, options, body: JSON.parse(options.body) };
|
||
return streamResponse([
|
||
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '识别' } })}\n\n`,
|
||
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '成功' } })}\n\n`,
|
||
`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||
].join(''), { pieces: 11 });
|
||
});
|
||
const full = await ai.stream({
|
||
task: 'ask',
|
||
text: '',
|
||
question: '图中是什么?',
|
||
visualContexts: [visualContext()]
|
||
});
|
||
assert.strictEqual(full, '识别成功');
|
||
assert.ok(seen.url.endsWith('/messages'), seen.url);
|
||
assert.strictEqual(seen.options.headers['x-api-key'], 'sk-1');
|
||
assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
|
||
assert.ok(!seen.options.headers.Authorization);
|
||
assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
|
||
assert.strictEqual(seen.body.messages.length, 3);
|
||
assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
|
||
const image = seen.body.messages[0].content[1];
|
||
assert.strictEqual(image.type, 'image');
|
||
assert.deepStrictEqual(Object.keys(image.source), ['type', 'media_type', 'data']);
|
||
assert.strictEqual(image.source.type, 'base64');
|
||
assert.strictEqual(image.source.media_type, 'image/png');
|
||
assert.ok(image.source.data.length > 0);
|
||
assert.deepStrictEqual(
|
||
seen.body.messages[0].content.at(-1).cache_control,
|
||
{ type: 'ephemeral' },
|
||
'稳定资料前缀末尾必须声明 Anthropic 缓存断点'
|
||
);
|
||
assert.strictEqual(seen.body.messages[2].content, '问题:图中是什么?');
|
||
});
|
||
|
||
test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
|
||
const ai = setup({ protocol: 'openai-responses', vision: true });
|
||
let seen = null;
|
||
h.setHandler((url, options) => {
|
||
seen = { url, options, body: JSON.parse(options.body) };
|
||
return streamResponse([
|
||
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '看见' })}\n\n`,
|
||
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '图片' })}\n\n`,
|
||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`
|
||
].join(''), { pieces: 13 });
|
||
});
|
||
const full = await ai.stream({
|
||
task: 'ask',
|
||
text: '',
|
||
question: '图中是什么?',
|
||
visualContexts: [visualContext()]
|
||
});
|
||
assert.strictEqual(full, '看见图片');
|
||
assert.ok(seen.url.endsWith('/responses'), seen.url);
|
||
assert.strictEqual(seen.options.headers.Authorization, 'Bearer sk-1');
|
||
assert.strictEqual(seen.body.instructions.includes('文档页面图像'), true);
|
||
assert.strictEqual(seen.body.max_output_tokens, 1024);
|
||
assert.strictEqual(seen.body.store, false);
|
||
assert.strictEqual(seen.body.input[0].content[0].type, 'input_text');
|
||
const image = seen.body.input[0].content[1];
|
||
assert.strictEqual(image.type, 'input_image');
|
||
assert.match(image.image_url, /^data:image\/png;base64,/);
|
||
});
|
||
|
||
test('OpenAI Responses 失败事件不会被当作空回答', async () => {
|
||
const ai = setup({ protocol: 'openai-responses' });
|
||
h.setHandler(() => streamResponse(
|
||
`event: response.failed\ndata: ${JSON.stringify({
|
||
type: 'response.failed',
|
||
response: { error: { message: 'responses failed' } }
|
||
})}\n\n`
|
||
));
|
||
await assert.rejects(
|
||
() => ai.stream({ task: 'translate', text: 'x' }),
|
||
/responses failed/
|
||
);
|
||
});
|
||
|
||
test('协议端点追加在查询参数之前并保留参数', async () => {
|
||
const ai = setup({
|
||
protocol: 'openai-responses',
|
||
baseUrl: 'https://gateway.example.com/v1?api-version=2026-01-01'
|
||
});
|
||
let requestUrl = '';
|
||
h.setHandler((url) => {
|
||
requestUrl = url;
|
||
return streamResponse(
|
||
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'ok' })}\n\n`
|
||
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||
);
|
||
});
|
||
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
|
||
const url = new URL(requestUrl);
|
||
assert.strictEqual(url.pathname, '/v1/responses');
|
||
assert.strictEqual(url.searchParams.get('api-version'), '2026-01-01');
|
||
});
|
||
|
||
test('未显式启用图像能力时拒绝发送图片', async () => {
|
||
const ai = setup({ vision: false });
|
||
await assert.rejects(
|
||
() => ai.stream({
|
||
task: 'ask',
|
||
text: '',
|
||
question: '图中是什么?',
|
||
visualContexts: [visualContext()]
|
||
}),
|
||
/未启用图像输入/
|
||
);
|
||
});
|
||
|
||
test('图像上下文拒绝伪造尺寸、远程地址和多图输入', () => {
|
||
const ai = setup({ vision: true });
|
||
const badSize = visualContext();
|
||
badSize.image.width = 2;
|
||
assert.throws(() => ai.buildMessages('ask', '', 'q', [badSize]), /声明尺寸不匹配/);
|
||
assert.throws(
|
||
() => ai.buildMessages('ask', '', 'q', [{ kind: 'page', image: { url: 'https://example.com/a.png' } }]),
|
||
/JPEG 或 PNG/
|
||
);
|
||
assert.throws(
|
||
() => ai.buildMessages('ask', '', 'q', [visualContext(), visualContext()]),
|
||
/最多发送 1 张/
|
||
);
|
||
});
|
||
|
||
test('OCR 预留契约仅在识别完成且勾选后附加文字', () => {
|
||
const ai = setup({ vision: true });
|
||
const context = visualContext({
|
||
ocr: { status: 'ready', text: '校对后的 OCR 内容', include: true }
|
||
});
|
||
const messages = ai.buildMessages('ask', '', '这是什么?', [context]);
|
||
const textPart = messages[1].content.find((part) => part.type === 'text');
|
||
assert.match(textPart.text, /OCR 识别文字/);
|
||
assert.match(textPart.text, /校对后的 OCR 内容/);
|
||
});
|
||
|
||
test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
|
||
const ai = setup({ vision: false });
|
||
let body = null;
|
||
h.setHandler((_url, options) => {
|
||
body = JSON.parse(options.body);
|
||
return streamResponse(sseBody(['文字回答']));
|
||
});
|
||
const context = visualContext({
|
||
includeImage: false,
|
||
ocr: { status: 'ready', text: '仅发送 OCR', include: true }
|
||
});
|
||
assert.strictEqual(await ai.stream({
|
||
task: 'ask',
|
||
text: '',
|
||
question: '内容是什么?',
|
||
visualContexts: [context]
|
||
}), '文字回答');
|
||
assert.strictEqual(typeof body.messages[1].content, 'string');
|
||
assert.match(body.messages[1].content, /仅发送 OCR/);
|
||
assert.doesNotMatch(body.messages[1].content, /data:image/);
|
||
});
|
||
|
||
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
|
||
const ai = setup();
|
||
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
|
||
const clipped = ai.clipContext(long, 2000);
|
||
assert.ok(clipped.length < long.length);
|
||
assert.ok(clipped.startsWith('A'), '开头丢失');
|
||
assert.ok(clipped.includes('TAIL_MARK'), '结尾丢失了,结论性内容会被切掉');
|
||
assert.ok(clipped.includes('省略'), '未标注截断');
|
||
});
|
||
|
||
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
|
||
const ai = setup();
|
||
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
|
||
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
|
||
const msgs = ai.buildMessages('ask', body, '这讲了什么');
|
||
assert.ok(msgs[1].content.includes(body), '正文被截断了');
|
||
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
|
||
assert.ok(msgs[1].content.includes('TAIL_MARK'));
|
||
});
|
||
|
||
test('接口报的上下文超限被转成可操作的中文提示', async () => {
|
||
const ai = setup();
|
||
for (const [status, raw] of [
|
||
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
|
||
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
|
||
[413, 'Payload Too Large']
|
||
]) {
|
||
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
|
||
await assert.rejects(
|
||
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||
(err) => {
|
||
assert.match(err.message, /上下文超出模型窗口/);
|
||
assert.match(err.message, /范围改小|更大窗口/);
|
||
return true;
|
||
},
|
||
`HTTP ${status} 未被识别为上下文超限`
|
||
);
|
||
}
|
||
|
||
// 普通错误不应被误判成超限
|
||
h.setHandler(() => streamResponse(
|
||
JSON.stringify({ error: { message: 'invalid temperature value' } }),
|
||
{ status: 400 }
|
||
));
|
||
await assert.rejects(
|
||
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||
(err) => {
|
||
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
|
||
return true;
|
||
}
|
||
);
|
||
});
|
||
|
||
test('不支持的任务类型被拒绝', () => {
|
||
const ai = setup();
|
||
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
|
||
});
|
||
|
||
test('ask 任务把问题与片段一起送出', () => {
|
||
const ai = setup();
|
||
const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
|
||
assert.strictEqual(msgs.length, 4);
|
||
assert.ok(msgs[1].content.includes('文档内容'));
|
||
assert.ok(msgs[3].content.includes('这讲了什么'));
|
||
assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
|
||
});
|
||
|
||
test('取消请求时抛出 AbortError 而不是静默返回', async () => {
|
||
const ai = setup();
|
||
const ctl = new AbortController();
|
||
h.setHandler(() => { ctl.abort(); return streamResponse(sseBody(['x'])); });
|
||
await assert.rejects(
|
||
() => ai.stream({ task: 'translate', text: 'x', signal: ctl.signal }),
|
||
(e) => e.name === 'AbortError'
|
||
);
|
||
});
|
||
|
||
// 多轮对话历史
|
||
const PROTOCOL_STREAMS = {
|
||
'chat-completions': () => streamResponse(sseBody(['答'])),
|
||
anthropic: () => streamResponse(
|
||
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
|
||
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||
),
|
||
'openai-responses': () => streamResponse(
|
||
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
|
||
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||
)
|
||
};
|
||
|
||
async function captureBody(protocol, args, options) {
|
||
const ai = setup({ protocol, ...options });
|
||
let body = null;
|
||
h.setHandler((_url, opts) => {
|
||
body = JSON.parse(opts.body);
|
||
return PROTOCOL_STREAMS[protocol]();
|
||
});
|
||
await ai.stream(args);
|
||
return body;
|
||
}
|
||
|
||
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
|
||
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
|
||
// 写死资料与当前问题的分层,只比对"传与不传 history"会同时被同一个 bug 污染
|
||
const expectedContext = '文档片段:\n"""\n正文\n"""';
|
||
const expectedQuestion = '问题:问题';
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
|
||
if (protocol === 'chat-completions') {
|
||
assert.deepStrictEqual(base.messages.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
|
||
assert.strictEqual(base.messages[1].content, expectedContext);
|
||
assert.strictEqual(base.messages[3].content, expectedQuestion);
|
||
} else if (protocol === 'anthropic') {
|
||
assert.deepStrictEqual(base.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||
assert.strictEqual(base.messages[0].content[0].text, expectedContext);
|
||
assert.strictEqual(base.messages[2].content, expectedQuestion);
|
||
} else {
|
||
assert.deepStrictEqual(base.input.map((m) => m.role), ['user', 'assistant', 'user']);
|
||
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedContext }]);
|
||
assert.deepStrictEqual(base.input[2].content, [{ type: 'input_text', text: expectedQuestion }]);
|
||
}
|
||
for (const history of [undefined, null, [], 'not-an-array', {}]) {
|
||
const withArg = await captureBody(
|
||
protocol,
|
||
{ task: 'ask', text: '正文', question: '问题', history }
|
||
);
|
||
assert.strictEqual(
|
||
JSON.stringify(withArg),
|
||
JSON.stringify(base),
|
||
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)})`
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
|
||
const body = await captureBody('chat-completions', {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '第三个问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '第一个问题' },
|
||
{ id: 'b', role: 'assistant', text: '第一个回答' },
|
||
{ id: 'c', role: 'user', text: '第二个问题' },
|
||
{ id: 'd', role: 'assistant', text: '第二个回答' }
|
||
]
|
||
});
|
||
assert.deepStrictEqual(
|
||
body.messages.map((m) => m.role),
|
||
['system', 'user', 'assistant', 'user', 'assistant', 'user', 'assistant', 'user'],
|
||
'资料前缀必须在历史之前,当前轮排最后'
|
||
);
|
||
assert.match(body.messages[1].content, /正文/, '稳定资料前缀丢失');
|
||
assert.strictEqual(body.messages[3].content, '第一个问题');
|
||
assert.strictEqual(body.messages[4].content, '第一个回答');
|
||
assert.strictEqual(body.messages[5].content, '第二个问题');
|
||
assert.strictEqual(body.messages[6].content, '第二个回答');
|
||
assert.match(body.messages[7].content, /第三个问题/, '当前轮问题丢失');
|
||
assert.doesNotMatch(body.messages[7].content, /正文/, '正文不应跟着每轮问题放到变化的尾部');
|
||
assert.ok(
|
||
!/第一个问题/.test(body.messages[0].content),
|
||
'历史不该被塞进 system,模型会把它当成指令'
|
||
);
|
||
});
|
||
|
||
test('anthropic 历史进 messages,system 仍在顶层', async () => {
|
||
const body = await captureBody('anthropic', {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '新问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '旧问题' },
|
||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||
]
|
||
});
|
||
assert.strictEqual(typeof body.system, 'string');
|
||
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
|
||
assert.deepStrictEqual(
|
||
body.messages.map((m) => m.role),
|
||
['user', 'assistant', 'user', 'assistant', 'user']
|
||
);
|
||
assert.match(body.messages[0].content[0].text, /正文/);
|
||
assert.strictEqual(body.messages[2].content, '旧问题');
|
||
assert.strictEqual(body.messages[3].content, '旧回答');
|
||
assert.match(body.messages[4].content, /新问题/);
|
||
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
|
||
});
|
||
|
||
test('openai-responses 历史进 input,instructions 与 store:false 保持', async () => {
|
||
const body = await captureBody('openai-responses', {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '新问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '旧问题' },
|
||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||
]
|
||
});
|
||
assert.strictEqual(typeof body.instructions, 'string');
|
||
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
|
||
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
|
||
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
|
||
assert.deepStrictEqual(
|
||
body.input.map((m) => m.role),
|
||
['user', 'assistant', 'user', 'assistant', 'user']
|
||
);
|
||
assert.match(body.input[0].content[0].text, /正文/);
|
||
assert.strictEqual(body.input[2].content, '旧问题');
|
||
assert.strictEqual(body.input[3].content, '旧回答');
|
||
assert.ok(Array.isArray(body.input[4].content), '当前轮仍用结构化 content');
|
||
assert.strictEqual(body.input[4].content[0].type, 'input_text');
|
||
assert.match(body.input[4].content[0].text, /新问题/);
|
||
});
|
||
|
||
test('三种协议都把未变化资料放在历史之前形成稳定缓存前缀', async () => {
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const first = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '固定全文资料',
|
||
question: '问题一',
|
||
history: []
|
||
});
|
||
const second = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '固定全文资料',
|
||
question: '问题二',
|
||
history: [
|
||
{ role: 'user', text: '问题一' },
|
||
{ role: 'assistant', text: '回答一' }
|
||
]
|
||
});
|
||
if (protocol === 'chat-completions') {
|
||
assert.deepStrictEqual(first.messages.slice(0, 3), second.messages.slice(0, 3));
|
||
assert.strictEqual(second.messages.at(-1).content, '问题:问题二');
|
||
} else if (protocol === 'anthropic') {
|
||
assert.deepStrictEqual(first.messages.slice(0, 2), second.messages.slice(0, 2));
|
||
assert.strictEqual(second.messages.at(-1).content, '问题:问题二');
|
||
} else {
|
||
assert.deepStrictEqual(first.input.slice(0, 2), second.input.slice(0, 2));
|
||
assert.strictEqual(second.input.at(-1).content[0].text, '问题:问题二');
|
||
}
|
||
}
|
||
});
|
||
|
||
test('OpenAI 官方 GPT-5.6 请求使用稳定缓存键和显式资料断点', async () => {
|
||
for (const protocol of ['chat-completions', 'openai-responses']) {
|
||
const options = {
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5.6'
|
||
};
|
||
const first = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '固定全文资料',
|
||
question: '问题一'
|
||
}, options);
|
||
const second = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '固定全文资料',
|
||
question: '问题二',
|
||
history: [
|
||
{ role: 'user', text: '问题一' },
|
||
{ role: 'assistant', text: '回答一' }
|
||
]
|
||
}, options);
|
||
const changed = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '另一份全文资料',
|
||
question: '问题三'
|
||
}, options);
|
||
|
||
assert.match(first.prompt_cache_key, /^peoplelib-[0-9a-f]{48}$/);
|
||
assert.strictEqual(first.prompt_cache_key, second.prompt_cache_key);
|
||
assert.notStrictEqual(first.prompt_cache_key, changed.prompt_cache_key);
|
||
assert.deepStrictEqual(first.prompt_cache_options, { mode: 'explicit' });
|
||
const firstItem = protocol === 'chat-completions' ? first.messages[1] : first.input[0];
|
||
assert.deepStrictEqual(
|
||
firstItem.content.at(-1).prompt_cache_breakpoint,
|
||
{ mode: 'explicit' }
|
||
);
|
||
}
|
||
});
|
||
|
||
test('OpenAI 兼容接口不接收官方专用缓存字段', async () => {
|
||
for (const protocol of ['chat-completions', 'openai-responses']) {
|
||
const body = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '固定全文资料',
|
||
question: '问题'
|
||
}, {
|
||
baseUrl: 'https://compatible.example.com/v1',
|
||
model: 'gpt-5.6'
|
||
});
|
||
assert.ok(!('prompt_cache_key' in body));
|
||
assert.ok(!('prompt_cache_options' in body));
|
||
assert.doesNotMatch(JSON.stringify(body), /prompt_cache_breakpoint/);
|
||
}
|
||
});
|
||
|
||
test('历史里的图像不被重发,只发当前轮的图', async () => {
|
||
const image = visualContext().image;
|
||
const history = [
|
||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||
];
|
||
const args = {
|
||
task: 'ask',
|
||
text: '',
|
||
question: '这张呢',
|
||
visualContexts: [visualContext()],
|
||
history
|
||
};
|
||
|
||
const chat = await captureBody('chat-completions', args, { vision: true });
|
||
const chatImages = chat.messages.flatMap(
|
||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
|
||
);
|
||
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
|
||
|
||
const anthropic = await captureBody('anthropic', args, { vision: true });
|
||
const anthropicImages = anthropic.messages.flatMap(
|
||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
|
||
);
|
||
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
|
||
|
||
const responses = await captureBody('openai-responses', args, { vision: true });
|
||
const responseImages = responses.input.flatMap(
|
||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
|
||
);
|
||
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
|
||
|
||
const historyJson = JSON.stringify(history);
|
||
assert.strictEqual(historyJson, JSON.stringify([
|
||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||
]), '不该原地改写调用方传进来的历史数组');
|
||
});
|
||
|
||
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
|
||
const body = await captureBody('anthropic', {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '新问题',
|
||
history: [
|
||
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
|
||
{ id: 'b', role: 'user', text: '真正的第一问' },
|
||
{ id: 'c', role: 'assistant', text: '第一答' }
|
||
]
|
||
});
|
||
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
|
||
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
|
||
assert.deepStrictEqual(
|
||
body.messages.map((m) => m.role),
|
||
['user', 'assistant', 'user', 'assistant', 'user']
|
||
);
|
||
assert.strictEqual(body.messages[2].content, '真正的第一问');
|
||
});
|
||
|
||
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const body = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '当前问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '上一问' },
|
||
{ id: 'b', role: 'assistant', text: '上一答' },
|
||
{ id: 'c', role: 'user', text: '没等到回答的追问' }
|
||
]
|
||
});
|
||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||
for (let i = 1; i < roles.length; i++) {
|
||
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
|
||
}
|
||
const flat = JSON.stringify(items);
|
||
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
|
||
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
|
||
}
|
||
});
|
||
|
||
test('历史内部相邻同角色被合并而不是丢弃', async () => {
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const body = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '当前问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '连问一' },
|
||
{ id: 'b', role: 'user', text: '连问二' },
|
||
{ id: 'c', role: 'assistant', text: '连答一' },
|
||
{ id: 'd', role: 'assistant', text: '连答二' }
|
||
]
|
||
});
|
||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||
assert.deepStrictEqual(
|
||
roles,
|
||
['user', 'assistant', 'user', 'assistant', 'user'],
|
||
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
|
||
);
|
||
const flat = JSON.stringify(items);
|
||
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
|
||
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('历史含空文本时不产生空 content', async () => {
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const body = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '当前问题',
|
||
history: [
|
||
{ id: 'a', role: 'user', text: '有效提问' },
|
||
{ id: 'b', role: 'assistant', text: ' ' },
|
||
{ id: 'c', role: 'assistant', text: '' },
|
||
{ id: 'd', role: 'user', text: null },
|
||
{ id: 'e', role: 'assistant', text: '有效回答' },
|
||
null
|
||
]
|
||
});
|
||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||
for (const item of items) {
|
||
const text = typeof item.content === 'string'
|
||
? item.content
|
||
: JSON.stringify(item.content);
|
||
assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`);
|
||
}
|
||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||
assert.deepStrictEqual(
|
||
roles,
|
||
['user', 'assistant', 'user', 'assistant', 'user'],
|
||
`${protocol} 空消息未被过滤干净`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('历史按旧到新排列,当前轮在最后', async () => {
|
||
const history = [];
|
||
for (let i = 1; i <= 3; i++) {
|
||
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
|
||
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
|
||
}
|
||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||
const body = await captureBody(protocol, {
|
||
task: 'ask',
|
||
text: '正文',
|
||
question: '问题4',
|
||
history
|
||
});
|
||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
|
||
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
|
||
.map((mark) => flat.findIndex((s) => s.includes(mark)));
|
||
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
|
||
for (let i = 1; i < order.length; i++) {
|
||
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
|
||
}
|
||
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
|
||
}
|
||
});
|
||
|
||
test('buildMessages 也接受历史参数', () => {
|
||
const ai = setup();
|
||
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
|
||
{ role: 'user', text: '旧问题' },
|
||
{ role: 'assistant', text: '旧回答' }
|
||
]);
|
||
assert.deepStrictEqual(
|
||
msgs.map((m) => m.role),
|
||
['system', 'user', 'assistant', 'user', 'assistant', 'user']
|
||
);
|
||
assert.match(msgs[1].content, /正文/);
|
||
assert.strictEqual(msgs[3].content, '旧问题');
|
||
assert.match(msgs[5].content, /新问题/);
|
||
});
|