/** * 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 } 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?: Array<{ url: string }>): 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; } function requestBody(): { model: string; messages: Array> } { 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('这张图片里有什么?'); }); });