feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -15,6 +15,7 @@ vi.mock('electron-log', () => ({
|
||||
}));
|
||||
|
||||
import { AnthropicAdapter } from '../anthropic.adapter';
|
||||
import { __imageFetcher } from '../shared/ssrf-image-fetch';
|
||||
import { ContentFilterError } from '../base-adapter';
|
||||
import type { MetonaRequest, MetonaStreamEvent } from '../../types';
|
||||
import { MetonaFinishReason, MetonaStreamEventType } from '../../types';
|
||||
@@ -235,17 +236,60 @@ describe('AnthropicAdapter — 图片块转换', () => {
|
||||
it('http URL 图片 → 下载后转 base64(content-type 作为 media_type)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const imageBytes = new TextEncoder().encode('PNG-DATA');
|
||||
// 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
// v0.8.2 P0-1: 图片下载改走 SSRF 安全通道(独立下载器注入点,不走 global fetch)
|
||||
const fetchRestore = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
new Response(imageBytes, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
})) as typeof __imageFetcher.current;
|
||||
try {
|
||||
// chat 请求返回 ok
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.jpg' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
const imageBlock = userMsg.content.find((c) => c.type === 'image');
|
||||
expect(imageBlock).toMatchObject({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/jpeg' },
|
||||
});
|
||||
const source = (imageBlock as { source: { data: string } }).source;
|
||||
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
|
||||
} finally {
|
||||
__imageFetcher.current = fetchRestore;
|
||||
}
|
||||
});
|
||||
|
||||
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
// v0.8.2 P0-1: 下载失败同样走注入点(SSRF 拒绝/网络失败均降级为跳过图片)
|
||||
const fetchRestore = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as typeof __imageFetcher.current;
|
||||
try {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'image/jpeg' },
|
||||
arrayBuffer: async () => imageBytes.buffer,
|
||||
} as unknown as Response)
|
||||
.mockResolvedValue(okResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -254,42 +298,12 @@ describe('AnthropicAdapter — 图片块转换', () => {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
|
||||
const imageBlock = userMsg.content.find((c) => c.type === 'image');
|
||||
expect(imageBlock).toMatchObject({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/jpeg' },
|
||||
});
|
||||
const source = (imageBlock as { source: { data: string } }).source;
|
||||
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
|
||||
});
|
||||
|
||||
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {},
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.jpg' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await adapter.send(request);
|
||||
expect(res.content).toBe('ok'); // 请求未被阻断
|
||||
});
|
||||
const res = await adapter.send(request);
|
||||
expect(res.content).toBe('ok'); // 请求未被阻断
|
||||
} finally {
|
||||
__imageFetcher.current = fetchRestore;
|
||||
}
|
||||
});
|
||||
|
||||
it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => {
|
||||
@@ -970,3 +984,281 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== v0.8.2 P1-1: thinking 块回传 + pause_turn 续传 =====
|
||||
|
||||
describe('AnthropicAdapter — v0.8.2 P1-1', () => {
|
||||
function okJson(data: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function sse(lines: string[]): Response {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(lines.join('\n') + '\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
}
|
||||
|
||||
it('流式 DONE 携带带签名的 thinking 块(redacted 原样)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockResolvedValue(
|
||||
sse([
|
||||
'event: message_start',
|
||||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 10 } } }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'thinking', thinking: '' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'deep thought' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'signature_delta', signature: 'sig-abc' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'redacted_thinking', data: 'opaque' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 1 }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 2,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 2,
|
||||
delta: { type: 'text_delta', text: 'Answer' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 2 }),
|
||||
'event: message_delta',
|
||||
jsonLine({
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn' },
|
||||
usage: { output_tokens: 20 },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]),
|
||||
);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const done = events.find((e) => e.type === MetonaStreamEventType.DONE);
|
||||
expect(done).toBeDefined();
|
||||
expect(done!.finishReason).toBe('stop');
|
||||
expect(done!.thinkingBlocks).toEqual([
|
||||
{ type: 'thinking', thinking: 'deep thought', signature: 'sig-abc' },
|
||||
{ type: 'redacted_thinking', data: 'opaque' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('下一轮请求按协议回传 thinking 块(thinking 开启时块前置;关闭时不回传)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const thinkingBlocks = [
|
||||
{ type: 'thinking' as const, thinking: 'deep', signature: 'sig' },
|
||||
{ type: 'redacted_thinking' as const, data: 'opaque' },
|
||||
];
|
||||
const withHistory = makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||||
messages: [
|
||||
{ role: 'user', content: 'q', timestamp: 1 },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
thinkingBlocks,
|
||||
toolCalls: [{ id: 'tc1', name: 'read_file', args: {}, iteration: 1, timestamp: 1 }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'data',
|
||||
toolResult: {
|
||||
toolCallId: 'tc1',
|
||||
toolName: 'read_file',
|
||||
result: 'data',
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
timestamp: 1,
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
{ role: 'user', content: 'go on', timestamp: 2 },
|
||||
],
|
||||
});
|
||||
mockFetch.mockResolvedValue(
|
||||
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
|
||||
);
|
||||
await adapter.send(withHistory);
|
||||
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const assistant = (
|
||||
body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
||||
).find(
|
||||
(m) =>
|
||||
m.role === 'assistant' &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((c) => c.type === 'tool_use'),
|
||||
);
|
||||
expect(assistant).toBeDefined();
|
||||
// thinking 块位于 assistant content 首位(协议要求)
|
||||
expect(assistant!.content[0]).toEqual({ type: 'thinking', thinking: 'deep', signature: 'sig' });
|
||||
expect(assistant!.content[1]).toEqual({ type: 'redacted_thinking', data: 'opaque' });
|
||||
|
||||
// 思考关闭(降级重试路径)→ 不回传 thinking 块
|
||||
const disabled = makeRequest({
|
||||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||
messages: withHistory.messages,
|
||||
});
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue(
|
||||
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
|
||||
);
|
||||
await adapter.send(disabled);
|
||||
const body2 = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const assistant2 = (
|
||||
body2.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
||||
).find(
|
||||
(m) =>
|
||||
m.role === 'assistant' &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((c) => c.type === 'tool_use'),
|
||||
);
|
||||
expect(
|
||||
assistant2!.content.some((c) => c.type === 'thinking' || c.type === 'redacted_thinking'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('pause_turn 流式自动续传:两段文本拼接、单次 DONE、续传请求原样携带本段 content', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
sse([
|
||||
'event: message_start',
|
||||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'part1' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'pause_turn' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 1 }),
|
||||
'event: message_delta',
|
||||
jsonLine({
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'pause_turn' },
|
||||
usage: { output_tokens: 10 },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
sse([
|
||||
'event: message_start',
|
||||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
|
||||
'event: content_block_start',
|
||||
jsonLine({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
'event: content_block_delta',
|
||||
jsonLine({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'part2' },
|
||||
}),
|
||||
'event: content_block_stop',
|
||||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||||
'event: message_delta',
|
||||
jsonLine({
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn' },
|
||||
usage: { output_tokens: 10 },
|
||||
}),
|
||||
'event: message_stop',
|
||||
jsonLine({ type: 'message_stop' }),
|
||||
]),
|
||||
);
|
||||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||||
const texts = events
|
||||
.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)
|
||||
.map((e) => e.delta);
|
||||
expect(texts.join('')).toBe('part1part2');
|
||||
const dones = events.filter((e) => e.type === MetonaStreamEventType.DONE);
|
||||
expect(dones).toHaveLength(1);
|
||||
expect(dones[0].finishReason).toBe('stop');
|
||||
|
||||
// 第二次请求体应把第一段 content(含 pause_turn 块)原样追加为 assistant 消息
|
||||
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
|
||||
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
const carried = secondBody.messages[secondBody.messages.length - 1];
|
||||
expect(carried.role).toBe('assistant');
|
||||
expect(carried.content.some((c) => c.type === 'text' && c.text === 'part1')).toBe(true);
|
||||
expect(carried.content.some((c) => c.type === 'pause_turn')).toBe(true);
|
||||
});
|
||||
|
||||
it('非流式 pause_turn 同样续传至自然结束', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
okJson({
|
||||
content: [{ type: 'text', text: 'half' }, { type: 'pause_turn' }],
|
||||
usage: {},
|
||||
stop_reason: 'pause_turn',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
okJson({ content: [{ type: 'text', text: 'done' }], usage: {}, stop_reason: 'end_turn' }),
|
||||
);
|
||||
const res = await adapter.send(makeRequest());
|
||||
expect(res.finishReason).toBe(MetonaFinishReason.STOP);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
|
||||
messages: Array<{ role: string }>;
|
||||
};
|
||||
expect(secondBody.messages[secondBody.messages.length - 1].role).toBe('assistant');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock('electron-log', () => ({
|
||||
}));
|
||||
|
||||
import { OllamaAdapter } from '../ollama.adapter';
|
||||
// v0.8.2 P0-1: 图片下载走 SSRF 安全通道(注入点替代 global fetch 打桩)
|
||||
import { __imageFetcher } from '../shared/ssrf-image-fetch';
|
||||
import type { ImageFetcher } from '../shared/ssrf-image-fetch';
|
||||
import type { MetonaRequest } from '../../types';
|
||||
import { MetonaStreamEventType } from '../../types';
|
||||
|
||||
@@ -604,48 +607,61 @@ describe('OllamaAdapter — 图片归一化', () => {
|
||||
it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const imageBytes = new TextEncoder().encode('IMG-BYTES');
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
// v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器)
|
||||
const restore = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
new Response(imageBytes, {
|
||||
status: 200,
|
||||
arrayBuffer: async () => imageBytes.buffer,
|
||||
} as unknown as Response)
|
||||
.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
|
||||
headers: { 'content-type': 'image/png' },
|
||||
})) as unknown as ImageFetcher;
|
||||
try {
|
||||
mockFetch.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
|
||||
} finally {
|
||||
__imageFetcher.current = restore;
|
||||
}
|
||||
});
|
||||
|
||||
it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(okChatResponse());
|
||||
const res = await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.content).toBe('ok');
|
||||
const restore = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as unknown as ImageFetcher;
|
||||
try {
|
||||
mockFetch.mockResolvedValue(okChatResponse());
|
||||
const res = await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/pic.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.content).toBe('ok');
|
||||
} finally {
|
||||
__imageFetcher.current = restore;
|
||||
}
|
||||
});
|
||||
|
||||
it('data URI 图片剥前缀;纯 base64 原样保留', async () => {
|
||||
@@ -675,26 +691,31 @@ describe('OllamaAdapter — 图片归一化', () => {
|
||||
|
||||
it('HTTP 下载响应非 2xx → 图片降级忽略', async () => {
|
||||
const adapter = makeAdapter();
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response)
|
||||
.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/missing.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([]);
|
||||
const restore = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
new Response(null, { status: 404 })) as unknown as ImageFetcher;
|
||||
try {
|
||||
mockFetch.mockResolvedValue(okChatResponse());
|
||||
await adapter.send(
|
||||
makeRequest({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '看图',
|
||||
images: [{ url: 'https://example.com/missing.png' }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = lastBody();
|
||||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||||
(m) => m.role === 'user',
|
||||
);
|
||||
expect(userMsg!.images).toEqual([]);
|
||||
} finally {
|
||||
__imageFetcher.current = restore;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -901,15 +901,17 @@ describe('MimoAdapter — thinking 显式开关', () => {
|
||||
expect(bodies[0].top_p).toBe(0.8);
|
||||
});
|
||||
|
||||
it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_p(API 强制覆盖)', async () => {
|
||||
// v0.8.2 P2-6 修订:未配置时显式 disabled(与 DeepSeek/Agnes 的"用户意图优先"对齐;
|
||||
// 旧行为隐式 enabled 会静默吞掉用户 temperature/top_p —— 服务端思考模式强制覆盖)
|
||||
it('thinkingEnabled 未配置 → 显式 {type:disabled} 且 temperature/top_p 透传', async () => {
|
||||
const adapter = makeAdapter();
|
||||
const { bodies } = captureFetch();
|
||||
await adapter.send(
|
||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }),
|
||||
);
|
||||
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
|
||||
expect(bodies[0].temperature).toBeUndefined();
|
||||
expect(bodies[0].top_p).toBeUndefined();
|
||||
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
|
||||
expect(bodies[0].temperature).toBe(0.7);
|
||||
expect(bodies[0].top_p).toBe(0.8);
|
||||
});
|
||||
|
||||
it('max_completion_tokens 原样透传(v0.8.1:pro/standard 均不钳制)', async () => {
|
||||
|
||||
@@ -710,3 +710,51 @@ describe('parseOpenAICompatibleResponse — 补充形态', () => {
|
||||
expect(result.toolCalls![0].id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSSEStream — v0.8.2 P2-6 MiMo 联网搜索引用回填', () => {
|
||||
it('message.annotations 引用以 TEXT_DELTA 回填正文(DONE 前)', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"content":"今天的新闻"}}],"annotations":[]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":null}],"annotations":[{"title":"科技日报","url":"https://news.example.com/a","site_name":"example"}]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const citation = events.filter(
|
||||
(e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'),
|
||||
);
|
||||
expect(citation).toHaveLength(1);
|
||||
expect(String(citation[0].delta)).toContain('https://news.example.com/a');
|
||||
expect(String(citation[0].delta)).toContain('科技日报');
|
||||
// 引用块必须在 DONE 之前
|
||||
expect(events.indexOf(citation[0])).toBeLessThan(
|
||||
events.findIndex((e) => e.type === MetonaStreamEventType.DONE),
|
||||
);
|
||||
});
|
||||
|
||||
it('按 url 去重;非流式路径同样回填', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"annotations":[{"title":"a","url":"https://x.example/1"},{"title":"a-dup","url":"https://x.example/1"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
const citation = events.find(
|
||||
(e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'),
|
||||
);
|
||||
expect(citation).toBeDefined();
|
||||
expect(String(citation!.delta).match(/https:\/\/x.example\/1/g)).toHaveLength(1);
|
||||
|
||||
const res = parseOpenAICompatibleResponse({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'answer', annotations: [{ title: 'b', url: 'https://x.example/2' }] },
|
||||
},
|
||||
],
|
||||
usage: {},
|
||||
});
|
||||
expect(res.content).toContain('answer');
|
||||
expect(res.content).toContain('https://x.example/2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,18 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
|
||||
expect(body.temperature).toBe(0);
|
||||
});
|
||||
|
||||
it('MiMo: 未配置(undefined)→ 显式 disabled(v0.8.2 P2-6 与 DeepSeek/Agnes 对齐,不隐式吞用户 temperature)', async () => {
|
||||
const adapter = new MimoAdapter({
|
||||
provider: 'mimo',
|
||||
baseURL: 'https://api.xiaomimimo.com/v1',
|
||||
apiKey: 'k',
|
||||
defaultModel: 'mimo-v2.5-pro',
|
||||
});
|
||||
const body = asNative(adapter)(makeRequest({ thinkingEnabled: undefined }), false);
|
||||
expect(body.thinking).toEqual({ type: 'disabled' });
|
||||
expect(body.temperature).toBe(0);
|
||||
});
|
||||
|
||||
it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => {
|
||||
const adapter = new OllamaAdapter({
|
||||
provider: 'ollama',
|
||||
|
||||
Reference in New Issue
Block a user