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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user