- DeepSeek/MiMo/Agnes 移除 supportsThinking 元信息硬门控:思考参数完全遵循用户配置 (事故复盘中 vision-exp 元信息标注不支持思考、实际产生了 8189 token 推理内容, 元信息不可靠;预算耗尽由引擎降级重试兜底,元信息不符仅告警不拦截) - Ollama 保留 /api/show 能力探测门控(服务端硬协议约束:向不支持思考的模型发 think 每次请求 400,属协议正确性而非意图覆盖),探测失败 fail-open - LLM 设置提示文案修订:元信息不符仍按用户配置发送,降级重试自动兜底 - 测试契约反向钉住:vision-exp + 用户开启→照发 enabled+reasoning_effort; 关闭/未配置→显式 disabled;Ollama 探测 false→不发 think / null→fail-open - 补录 docs/v0.8.0-迭代实施清单.md(含逐项验证记录与本次修订记录; 首次提交时该文件因故未入库,本次补齐) - 验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2146 通过 / thinking 矩阵 101 用例全绿
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
||
* 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<string, unknown>;这里收敛为「已知字段 + 任意扩展字段」
|
||
// 的交叉类型,测试可直接断言 max_tokens/thinking/temperature/stop/stream 等协议字段。
|
||
function requestBody(): { model: string; messages: Array<Record<string, unknown>> } & 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<Record<string, unknown>>;
|
||
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<Record<string, unknown>>;
|
||
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<Record<string, unknown>>;
|
||
// 无文本 → 只有 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<Record<string, unknown>>;
|
||
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();
|
||
// v0.8.0 修订(用户意图优先): 元信息 supportsThinking=false 不再拦截 ——
|
||
// 用户开启思考则照发 enabled + reasoning_effort(事故复盘中该模型实际
|
||
// 产生了推理内容,元信息不可靠;预算耗尽由引擎降级重试兜底)
|
||
expect(body.thinking).toEqual({ type: 'enabled' });
|
||
expect(body.reasoning_effort).toBe('high');
|
||
});
|
||
|
||
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: ['<END>'] },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
expect(body.temperature).toBe(0.4);
|
||
expect(body.stop).toEqual(['<END>']);
|
||
});
|
||
|
||
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<Record<string, unknown>>;
|
||
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<string, unknown>;
|
||
expect(assistantMsg.reasoning_content).toBe('trace');
|
||
});
|
||
});
|