Files
peoplelib/src/_test/ai.test.js
T

812 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 历史进 messagessystem 仍在顶层', 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 历史进 inputinstructions 与 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} 出现空 contentAnthropic 不接受空字符串`);
}
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, /新问题/);
});