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, /新问题/); });