/** * DeepSeekAdapter 多模态(vision 模型)请求格式测试(v0.5.4) * * 背景:DeepSeek 新增 vision 实验模型 deepseek-v4-flash-vision-exp * (OpenAI image_url content parts 格式)。适配器行为: * - vision 模型:带 images 的消息 content 转换为 [{type:'text'},{type:'image_url'}] parts * - 非 vision 模型:images 静默丢弃(共享层行为,防 API 400) * * 测试策略(契约级):mock fetch 记录真实请求体断言。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { DeepSeekAdapter } from '../deepseek.adapter'; import type { MetonaRequest, MetonaImageContent } from '../../types'; const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); beforeEach(() => { mockFetch.mockReset(); }); afterEach(() => { mockFetch.mockReset(); }); function makeAdapter(model: string): DeepSeekAdapter { return new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'sk-test', defaultModel: model, }); } function makeRequest(images?: MetonaImageContent[]): MetonaRequest { return { meta: { sessionId: 's', iteration: 1, requestId: 'r', timestamp: Date.now(), agentVersion: '1', }, systemPrompt: { roleDefinition: 'sys', outputConstraints: '', safetyGuidelines: '' }, messages: [{ role: 'user', content: '这张图片里有什么?', images, timestamp: Date.now() }], params: { temperature: 0, stream: false, thinkingEnabled: false }, }; } function okResponse(): Response { return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: 'ok' } }], usage: {} }), } as unknown as Response; } // toNativeRequest 返回 Record;这里收敛为「已知字段 + 任意扩展字段」 // 的交叉类型,测试可直接断言 max_tokens/thinking/temperature/stop/stream 等协议字段。 function requestBody(): { model: string; messages: Array> } & Record< string, unknown > { const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; return JSON.parse(init.body as string); } describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { it('vision 模型:带图片的消息转换为 image_url content parts', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }])); const body = requestBody(); expect(body.model).toBe('deepseek-v4-flash-vision-exp'); // system 消息 + user 消息(含 content parts) expect(body.messages).toHaveLength(2); const userMsg = body.messages[1]; expect(userMsg.role).toBe('user'); expect(Array.isArray(userMsg.content)).toBe(true); const parts = userMsg.content as Array>; expect(parts[0]).toEqual({ type: 'text', text: '这张图片里有什么?' }); expect(parts[1]).toEqual({ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,TESTPIC' }, }); }); it('非 vision 模型:images 被静默丢弃(content 保持纯文本)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-pro'); await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }])); const body = requestBody(); const userMsg = body.messages[1]; // 非 vision 模型 content 保持字符串(不转 parts,不发图片 → 不会 400) expect(userMsg.content).toBe('这张图片里有什么?'); }); it('vision 模型 max_tokens 钳制到 8192(MODEL_INFO 上限)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest(), params: { maxTokens: 63_488, temperature: 0, stream: false }, } as MetonaRequest); const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string); expect(body.max_tokens).toBe(8_192); }); it('vision 模型无图片时不转换(content 保持纯文本)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send(makeRequest(undefined)); const body = requestBody(); const userMsg = body.messages[1]; expect(userMsg.content).toBe('这张图片里有什么?'); }); it('vision 模型:多张图片全部转为 image_url parts(保持顺序)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send( makeRequest([ { url: 'data:image/png;base64,AAA' }, { url: 'data:image/png;base64,BBB' }, { url: 'data:image/jpeg;base64,CCC' }, ]), ); const body = requestBody(); const parts = body.messages[1].content as Array>; expect(parts).toHaveLength(4); // 1 text + 3 image expect(parts[1]).toMatchObject({ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' }, }); expect(parts[2]).toMatchObject({ type: 'image_url', image_url: { url: 'data:image/png;base64,BBB' }, }); expect(parts[3]).toMatchObject({ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,CCC' }, }); }); it('vision 模型:无文本消息时仍生成 image parts(不丢弃图片)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest([{ url: 'data:image/png;base64,AAA' }]), messages: [ { role: 'user', content: null, images: [{ url: 'data:image/png;base64,AAA' }], timestamp: Date.now(), }, ], } as MetonaRequest); const body = requestBody(); const parts = body.messages[1].content as Array>; // 无文本 → 只有 image_url part(text part 不生成) expect(parts).toHaveLength(1); expect(parts[0].type).toBe('image_url'); }); it('非 vision 模型:即使 content 为 null 也不转换图片(纯 null content)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-pro'); await adapter.send({ ...makeRequest([{ url: 'data:image/png;base64,AAA' }]), messages: [ { role: 'user', content: null, images: [{ url: 'data:image/png;base64,AAA' }], timestamp: Date.now(), }, ], } as MetonaRequest); const body = requestBody(); const userMsg = body.messages[1]; expect(userMsg.content).toBeNull(); }); it('vision 模型 detail 字段被丢弃(image_url 仅保留 url)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA', detail: 'high' }])); const body = requestBody(); const parts = body.messages[1].content as Array>; expect(parts[1]).toEqual({ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' }, }); }); it('vision 模型 max_tokens 未配置 → 默认 8192(MODEL_INFO 上限)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest(), params: { temperature: 0, stream: false }, } as MetonaRequest); const body = requestBody(); expect(body.max_tokens).toBe(8_192); }); it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest([{ url: 'data:image/png;base64,AAA' }]), params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true }, } as MetonaRequest); const body = requestBody(); // vision 模型 supportsThinking=false,但参数仍透传 thinking enabled(引擎决定) expect(body.thinking).toEqual({ type: 'enabled' }); }); it('vision 模型 messages 数组首位始终为 system 消息', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }])); const body = requestBody(); expect(body.messages[0].role).toBe('system'); expect(body.messages[0].content as string).toContain('sys'); }); it('tool 结果消息不被 images 转换影响(role=tool 保留 tool_call_id)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest([{ url: 'data:image/png;base64,AAA' }]), messages: [ { role: 'user', content: '请读图', images: [{ url: 'data:image/png;base64,AAA' }], timestamp: Date.now(), }, { role: 'assistant', content: null, toolCalls: [ { id: 'tc1', name: 'view_image', args: { path: 'a.png' }, iteration: 1, timestamp: Date.now(), }, ], timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc1', toolName: 'view_image', result: { path: 'a.png' }, success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ], } as MetonaRequest); const body = requestBody(); const toolMsg = body.messages[3]; expect(toolMsg.role).toBe('tool'); expect(toolMsg.tool_call_id).toBe('tc1'); expect(toolMsg.content).toBe('{"path":"a.png"}'); }); it('孤立 tool 消息被过滤(无前置 tool_calls)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest(), messages: [ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'x', result: 'r', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ], } as MetonaRequest); const body = requestBody(); // system + user,孤立 tool 被剔除 expect(body.messages).toHaveLength(2); }); it('temperature 与 stop 序列透传', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest(), params: { maxTokens: 4096, temperature: 0.4, stream: false, stopSequences: [''] }, } as MetonaRequest); const body = requestBody(); expect(body.temperature).toBe(0.4); expect(body.stop).toEqual(['']); }); it('send(非流式路径)强制 stream=false 且不附加 stream_options', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest([{ url: 'data:image/png;base64,AAA' }]), params: { maxTokens: 4096, temperature: 0, stream: true }, } as MetonaRequest); const body = requestBody(); // send() 契约:无论请求参数如何,非流式路径强制 stream=false,stream_options 仅流式路径注入 expect(body.stream).toBe(false); expect(body.stream_options).toBeUndefined(); }); it('非 vision 模型带图片不产生 image_url(content 保持字符串)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash'); await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }])); const body = requestBody(); const userMsg = body.messages[1]; expect(typeof userMsg.content).toBe('string'); expect(userMsg.content).not.toContain('image_url'); }); it('vision 模型 base64 data URI 原样保留在 image_url 中', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); const dataUri = 'data:image/png;base64,' + 'Z'.repeat(50); await adapter.send(makeRequest([{ url: dataUri }])); const body = requestBody(); const parts = body.messages[1].content as Array>; expect(parts[1]).toMatchObject({ image_url: { url: dataUri } }); }); it('vision 模型 reasoning_content 在 assistant 历史中保留', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); await adapter.send({ ...makeRequest(), messages: [ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'assistant', content: 'answer', reasoningContent: 'trace', timestamp: Date.now() }, ], } as MetonaRequest); const body = requestBody(); const assistantMsg = body.messages[2] as Record; expect(assistantMsg.reasoning_content).toBe('trace'); }); });