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',
|
||||
|
||||
@@ -20,13 +20,31 @@ import { BaseAdapter, ContentFilterError } from './base-adapter';
|
||||
import { truncatedArgumentsPayload, readStreamChunkWithIdleTimeout } from './shared/sse-stream';
|
||||
import log from 'electron-log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
|
||||
import type {
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
MetonaThinkingBlock,
|
||||
} from '../types';
|
||||
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
|
||||
/**
|
||||
* v0.8.2 P1-1: pause_turn 单次响应允许的最大续传次数。
|
||||
* Anthropic 长回复以 pause_turn 分段返回,每段需把 content 原样回传继续;
|
||||
* 预算耗尽仍 pause_turn 时按 length(输出截断)语义收尾,防止无限续传。
|
||||
*/
|
||||
const MAX_PAUSE_CONTINUATIONS = 5;
|
||||
|
||||
/**
|
||||
* v0.8.0 P0-1: Anthropic stop_reason → 归一化 OpenAI 语义(与 MetonaFinishReason
|
||||
* 的非流式映射语义一致)。pause_turn(长回复暂停续传标记)视为自然停止。
|
||||
* 的非流式映射语义一致)。
|
||||
*
|
||||
* v0.8.2 P1-1: `pause_turn` 不再折叠为 stop —— 此前长回复的暂停续传标记被当作
|
||||
* 自然结束,引擎不发起续传,长输出静默截断。现 pause_turn 由 sendStream/send 的
|
||||
* 续传循环在协议层消费(把本段 content 原样作为 assistant 消息回传并继续请求,
|
||||
* 见 MAX_PAUSE_CONTINUATIONS);仅在续传预算耗尽时按截断语义(length)收尾,
|
||||
* 前端据此展示"输出可能截断"提示而非无声缺失。
|
||||
*/
|
||||
function mapAnthropicStopReason(reason: string): string {
|
||||
switch (reason) {
|
||||
@@ -37,8 +55,10 @@ function mapAnthropicStopReason(reason: string): string {
|
||||
case 'refusal':
|
||||
case 'content_filter':
|
||||
return 'content_filter';
|
||||
case 'end_turn':
|
||||
case 'pause_turn':
|
||||
// 续传预算耗尽的兜底语义:按输出截断处理(不可静默当自然结束)
|
||||
return 'length';
|
||||
case 'end_turn':
|
||||
case 'stop_sequence':
|
||||
return 'stop';
|
||||
default:
|
||||
@@ -90,64 +110,59 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
// ===== POST /v1/messages(非流式) =====
|
||||
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = await this.toNativeRequest(request, false);
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.config.baseURL}/v1/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
this.config.timeoutMs ?? 120_000,
|
||||
);
|
||||
// v0.8.2 P1-1: pause_turn 续传循环(与流式路径同语义)—— 本段 content 原样
|
||||
// 作为 assistant 消息追加后重发,直到自然结束或续传预算耗尽
|
||||
let body = await this.toNativeRequest(request, false);
|
||||
for (let continuation = 0; ; continuation++) {
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.config.baseURL}/v1/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
this.config.timeoutMs ?? 120_000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Anthropic API error');
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Anthropic API error');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
if (data.stop_reason === 'pause_turn' && continuation < MAX_PAUSE_CONTINUATIONS) {
|
||||
log.info(`[Anthropic] pause_turn — continuing non-stream turn (#${continuation + 1})`);
|
||||
body = {
|
||||
...body,
|
||||
messages: [
|
||||
...((body.messages as Array<Record<string, unknown>>) ?? []),
|
||||
{ role: 'assistant', content: (data.content as Array<unknown>) ?? [] },
|
||||
],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
return this.toMetonaResponse(data, request.meta.requestId);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return this.toMetonaResponse(data, request.meta.requestId);
|
||||
}
|
||||
|
||||
// ===== POST /v1/messages(流式) =====
|
||||
|
||||
/**
|
||||
* 流式响应(v0.8.2 P1-1 重构:pause_turn 续传主循环 + thinking 块采集)。
|
||||
*
|
||||
* 结构:外层为续传循环 —— 收到 stop_reason=pause_turn 时,把本段 content 块
|
||||
* **原样**(含 pause_turn 块与已完成的 thinking/tool_use 块)作为 assistant
|
||||
* 消息追加到 messages 后重发,直到自然结束或续传预算耗尽(按 length 收尾)。
|
||||
* 内层为单段响应的 SSE 消费(事件机处理与 v0.6.x/v0.8.0 契约一致)。
|
||||
*
|
||||
* thinking 块采集:thinking/redacted_thinking 块在 content_block_stop 时收敛
|
||||
* (签名完备的块才进入 collectedThinkingBlocks),随最终 DONE 事件携带,
|
||||
* 引擎透传到 assistant 消息实现协议回传(MetonaMessage.thinkingBlocks)。
|
||||
*/
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = await this.toNativeRequest(request, true);
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.config.baseURL}/v1/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
let body = await this.toNativeRequest(request, true);
|
||||
const collectedThinkingBlocks: MetonaThinkingBlock[] = [];
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Anthropic stream error');
|
||||
}
|
||||
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let seq = 0;
|
||||
let buffer = '';
|
||||
let eventName = '';
|
||||
let streamEndedNormally = false;
|
||||
// v0.8.0 P0-1: 采集 message_delta.delta.stop_reason —— Anthropic 的停止原因
|
||||
// 在 message_delta(而非 message_stop)事件携带;旧实现只读 usage,
|
||||
// max_tokens 截断在流式路径完全不可见(与 OpenAI 共享层 finish_reason 缺口同源)
|
||||
let streamStopReason: string | undefined;
|
||||
|
||||
// 工具调用缓冲:content block index → { id, name, argsBuffer }
|
||||
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
|
||||
|
||||
// v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。
|
||||
// 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的
|
||||
// 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话
|
||||
// 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。
|
||||
let messageStartInputTokens = 0;
|
||||
|
||||
const base = () => ({
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
@@ -187,65 +202,285 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
}
|
||||
};
|
||||
|
||||
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
|
||||
const events: MetonaStreamEvent[] = [];
|
||||
switch (name) {
|
||||
case 'content_block_start': {
|
||||
const block = data.content_block as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (block?.type === 'tool_use') {
|
||||
toolBlocks.set(index, {
|
||||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||||
name: (block.name as string) ?? '',
|
||||
argsBuffer: '',
|
||||
});
|
||||
// ===== pause_turn 续传主循环 =====
|
||||
for (let continuation = 0; continuation <= MAX_PAUSE_CONTINUATIONS; continuation++) {
|
||||
const response = await this.fetchWithTimeout(
|
||||
`${this.config.baseURL}/v1/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
this.config.timeoutMs ?? 300_000,
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Anthropic stream error');
|
||||
}
|
||||
|
||||
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventName = '';
|
||||
|
||||
// ===== 本段响应的局部状态(续传时全部重置;seq 跨段连续) =====
|
||||
// v0.6.4 竞态修复: message_start 捕获的 input_tokens 用局部闭包变量(fallback
|
||||
// adapter 是跨引擎共享单例,实例字段会跨会话串号)
|
||||
let messageStartInputTokens = 0;
|
||||
// v0.8.0 P0-1: 采集 message_delta.delta.stop_reason 的**原始值**
|
||||
//(pause_turn 判定与最终映射都在本层完成)
|
||||
let rawStopReason: string | undefined;
|
||||
let messageStopSeen = false;
|
||||
// 工具调用缓冲:content block index → { id, name, argsBuffer }
|
||||
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
|
||||
/** 本段原始 content 块(pause_turn 续传需按协议原样回传) */
|
||||
const rawBlocks: Array<Record<string, unknown> | null> = [];
|
||||
/** thinking 块签名(content_block_delta.signature_delta 累积) */
|
||||
const thinkingSignatures = new Map<number, string>();
|
||||
|
||||
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
|
||||
const events: MetonaStreamEvent[] = [];
|
||||
switch (name) {
|
||||
case 'content_block_start': {
|
||||
const block = data.content_block as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (block?.type === 'tool_use') {
|
||||
toolBlocks.set(index, {
|
||||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||||
name: (block.name as string) ?? '',
|
||||
argsBuffer: '',
|
||||
});
|
||||
rawBlocks[index] = {
|
||||
type: 'tool_use',
|
||||
id: (block.id as string) ?? `tc_${nanoid(8)}`,
|
||||
name: (block.name as string) ?? '',
|
||||
input: {},
|
||||
};
|
||||
} else if (block?.type === 'text') {
|
||||
rawBlocks[index] = { type: 'text', text: '' };
|
||||
} else if (block?.type === 'thinking') {
|
||||
rawBlocks[index] = { type: 'thinking', thinking: '' };
|
||||
} else if (block?.type === 'redacted_thinking') {
|
||||
// redacted_thinking 整块到达(data 不透明载荷),原样保留并直接收集
|
||||
const rb: MetonaThinkingBlock = {
|
||||
type: 'redacted_thinking',
|
||||
data: (block.data as string) ?? '',
|
||||
};
|
||||
rawBlocks[index] = rb as unknown as Record<string, unknown>;
|
||||
collectedThinkingBlocks.push(rb);
|
||||
} else if (block?.type) {
|
||||
// server_tool_use 等未知块:原样保留(pause_turn 续传保真)
|
||||
rawBlocks[index] = { ...block };
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
const delta = data.delta as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||||
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
|
||||
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
||||
events.push({
|
||||
type: MetonaStreamEventType.REASONING_DELTA,
|
||||
...base(),
|
||||
delta: delta.thinking,
|
||||
});
|
||||
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
||||
case 'content_block_delta': {
|
||||
const delta = data.delta as Record<string, unknown> | undefined;
|
||||
const index = (data.index as number) ?? 0;
|
||||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||||
const rb = rawBlocks[index];
|
||||
if (rb?.type === 'text') rb.text = ((rb.text as string) ?? '') + delta.text;
|
||||
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
|
||||
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
||||
const rb = rawBlocks[index];
|
||||
if (rb?.type === 'thinking')
|
||||
rb.thinking = ((rb.thinking as string) ?? '') + delta.thinking;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.REASONING_DELTA,
|
||||
...base(),
|
||||
delta: delta.thinking,
|
||||
});
|
||||
} else if (delta?.type === 'signature_delta' && typeof delta.signature === 'string') {
|
||||
// v0.8.2 P1-1: thinking 块签名增量(回传校验必需)
|
||||
thinkingSignatures.set(
|
||||
index,
|
||||
(thinkingSignatures.get(index) ?? '') + delta.signature,
|
||||
);
|
||||
} else if (
|
||||
delta?.type === 'input_json_delta' &&
|
||||
typeof delta.partial_json === 'string'
|
||||
) {
|
||||
const block = toolBlocks.get(index);
|
||||
if (block) {
|
||||
block.argsBuffer += delta.partial_json;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||||
...base(),
|
||||
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
const index = (data.index as number) ?? 0;
|
||||
const block = toolBlocks.get(index);
|
||||
if (block) {
|
||||
block.argsBuffer += delta.partial_json;
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||||
} catch (err) {
|
||||
// v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致
|
||||
// JSON 半截)—— 统一转为 _truncatedArguments 错误参数触发模型自愈
|
||||
//(与共享层同源、同文案契约)。
|
||||
const sample = block.argsBuffer.slice(-120);
|
||||
log.warn(
|
||||
`[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`,
|
||||
);
|
||||
args = truncatedArgumentsPayload((err as Error).message, sample);
|
||||
}
|
||||
const rb = rawBlocks[index];
|
||||
if (rb?.type === 'tool_use') rb.input = args;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.TOOL_CALL_DELTA,
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
...base(),
|
||||
toolCallDelta: { index, name: block.name, argsDelta: delta.partial_json },
|
||||
toolCall: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args,
|
||||
iteration: request.meta.iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
toolBlocks.delete(index);
|
||||
} else {
|
||||
// thinking 块收敛:签名完备才收集(协议回传要求;缺失签名的块回传必 400)
|
||||
const rb = rawBlocks[index];
|
||||
if (rb?.type === 'thinking') {
|
||||
const signature = thinkingSignatures.get(index);
|
||||
if (signature) {
|
||||
rb.signature = signature;
|
||||
collectedThinkingBlocks.push(rb as unknown as MetonaThinkingBlock);
|
||||
} else {
|
||||
log.warn(
|
||||
'[Anthropic] thinking block finished without signature — dropped from round-trip',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_delta': {
|
||||
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
|
||||
const usage = data.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
events.push({
|
||||
type: MetonaStreamEventType.USAGE,
|
||||
...base(),
|
||||
usage: {
|
||||
inputTokens: messageStartInputTokens,
|
||||
outputTokens: (usage.output_tokens as number) ?? 0,
|
||||
totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
|
||||
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
|
||||
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
|
||||
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
|
||||
cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
// v0.8.0 P0-1: 采集停止原因原始值(映射移到 DONE 发射点)
|
||||
const delta = data.delta as Record<string, unknown> | undefined;
|
||||
if (delta && typeof delta.stop_reason === 'string') {
|
||||
rawStopReason = delta.stop_reason;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_stop': {
|
||||
// v0.8.2 P1-1: DONE 不再在此处发射 —— pause_turn 判定与续传在主循环层,
|
||||
// 最终 DONE 由循环层统一发射(含原始停止原因映射与思考块)
|
||||
messageStopSeen = true;
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const err = data.error as Record<string, unknown> | undefined;
|
||||
const code = (err?.type as string) ?? 'api_error';
|
||||
const message = (err?.message as string) ?? 'Anthropic stream error';
|
||||
const status = anthropicErrorCodeToStatus(code);
|
||||
log.warn(
|
||||
`[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`,
|
||||
);
|
||||
if (code === 'content_filter_error') {
|
||||
throw new ContentFilterError(message, 'Anthropic SSE error event');
|
||||
}
|
||||
const throwable = new Error(`anthropic_stream_error (${code}): ${message}`);
|
||||
(throwable as Error & { status: number }).status = status;
|
||||
throw throwable;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
const index = (data.index as number) ?? 0;
|
||||
const block = toolBlocks.get(index);
|
||||
if (block) {
|
||||
return events;
|
||||
};
|
||||
|
||||
// ===== 单段响应的 SSE 消费 =====
|
||||
while (true) {
|
||||
// v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能
|
||||
// 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(
|
||||
reader,
|
||||
60_000,
|
||||
this.getExternalAbortSignal(),
|
||||
);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('event:')) {
|
||||
eventName = trimmed.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const dataStr = trimmed.slice(5).trim();
|
||||
if (dataStr === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr) as Record<string, unknown>;
|
||||
// message_start 携带 input_tokens
|
||||
if (eventName === 'message_start') {
|
||||
const msg = data.message as Record<string, unknown> | undefined;
|
||||
const usage = msg?.usage as Record<string, unknown> | undefined;
|
||||
messageStartInputTokens = (usage?.input_tokens as number) ?? 0;
|
||||
continue;
|
||||
}
|
||||
for (const ev of processEvent(eventName, data)) {
|
||||
yield ev;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传
|
||||
if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr;
|
||||
log.warn(
|
||||
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
|
||||
trimmed.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 段结束处理 =====
|
||||
if (!messageStopSeen) {
|
||||
// v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。
|
||||
// 在补发 DONE 之前,将所有未完成块按截断契约转为 _truncatedArguments
|
||||
// 自愈 tool call(解析成功的则正常产出)。
|
||||
const unfinished = [...toolBlocks.entries()];
|
||||
if (unfinished.length > 0) {
|
||||
log.warn(
|
||||
`[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`,
|
||||
);
|
||||
for (const [, block] of unfinished) {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||||
} catch (err) {
|
||||
// v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致
|
||||
// JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层
|
||||
// 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断,
|
||||
// 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为
|
||||
// _truncatedArguments 错误参数触发模型自愈(与共享层同源、同文案契约)。
|
||||
const sample = block.argsBuffer.slice(-120);
|
||||
log.warn(
|
||||
`[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`,
|
||||
args = truncatedArgumentsPayload(
|
||||
(err as Error).message,
|
||||
block.argsBuffer.slice(-120),
|
||||
);
|
||||
args = truncatedArgumentsPayload((err as Error).message, sample);
|
||||
}
|
||||
events.push({
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
...base(),
|
||||
toolCall: {
|
||||
@@ -255,153 +490,71 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
iteration: request.meta.iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
toolBlocks.delete(index);
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_delta': {
|
||||
// 结束时的 usage 统计(output_tokens 增量在此事件携带)
|
||||
const usage = data.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
events.push({
|
||||
type: MetonaStreamEventType.USAGE,
|
||||
...base(),
|
||||
usage: {
|
||||
inputTokens: messageStartInputTokens,
|
||||
outputTokens: (usage.output_tokens as number) ?? 0,
|
||||
totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
|
||||
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
|
||||
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
|
||||
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
|
||||
cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
// v0.8.0 P0-1: 采集停止原因(message_delta.delta.stop_reason,可能在
|
||||
// 多个 message_delta 中重复出现,取任一即可;归一化为 OpenAI 语义)
|
||||
const delta = data.delta as Record<string, unknown> | undefined;
|
||||
if (delta && typeof delta.stop_reason === 'string') {
|
||||
streamStopReason = mapAnthropicStopReason(delta.stop_reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_stop': {
|
||||
streamEndedNormally = true;
|
||||
events.push({
|
||||
type: MetonaStreamEventType.DONE,
|
||||
...base(),
|
||||
// v0.8.0 P0-1: 携带归一化停止原因
|
||||
...(streamStopReason ? { finishReason: streamStopReason } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const err = data.error as Record<string, unknown> | undefined;
|
||||
const code = (err?.type as string) ?? 'api_error';
|
||||
const message = (err?.message as string) ?? 'Anthropic stream error';
|
||||
const status = anthropicErrorCodeToStatus(code);
|
||||
log.warn(
|
||||
`[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`,
|
||||
);
|
||||
if (code === 'content_filter_error') {
|
||||
throw new ContentFilterError(message, 'Anthropic SSE error event');
|
||||
}
|
||||
const throwable = new Error(`anthropic_stream_error (${code}): ${message}`);
|
||||
(throwable as Error & { status: number }).status = status;
|
||||
throw throwable;
|
||||
} else {
|
||||
log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
|
||||
}
|
||||
toolBlocks.clear();
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
...base(),
|
||||
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因
|
||||
//(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束)
|
||||
...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}),
|
||||
...(collectedThinkingBlocks.length > 0
|
||||
? { thinkingBlocks: collectedThinkingBlocks.slice() }
|
||||
: {}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
return events;
|
||||
};
|
||||
|
||||
// message_start 事件携带 input_tokens(记录到 this.lastInputTokens 供 USAGE 汇总)
|
||||
while (true) {
|
||||
// v0.7.4 P1-2: 空闲超时 — Anthropic 思考模式(extended thinking)期间可能
|
||||
// 长时间无数据推送,共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('event:')) {
|
||||
eventName = trimmed.slice(6).trim();
|
||||
if (rawStopReason === 'pause_turn') {
|
||||
if (continuation < MAX_PAUSE_CONTINUATIONS) {
|
||||
// 协议续传:本段 content 原样(含 pause_turn 块、已完成 thinking/tool_use)
|
||||
// 作为 assistant 消息追加后重发。无签名的 thinking 块剔除(回传必 400)。
|
||||
const contentForContinuation = rawBlocks.filter((b) => {
|
||||
if (!b) return false;
|
||||
if (b.type === 'thinking' && !b.signature) return false;
|
||||
return true;
|
||||
}) as Array<Record<string, unknown>>;
|
||||
log.info(
|
||||
`[Anthropic] pause_turn — continuing stream turn (#${continuation + 1}, ${contentForContinuation.length} block(s) carried over)`,
|
||||
);
|
||||
body = {
|
||||
...body,
|
||||
messages: [
|
||||
...((body.messages as Array<Record<string, unknown>>) ?? []),
|
||||
{ role: 'assistant', content: contentForContinuation },
|
||||
],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const dataStr = trimmed.slice(5).trim();
|
||||
if (dataStr === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr) as Record<string, unknown>;
|
||||
// message_start 携带 input_tokens
|
||||
if (eventName === 'message_start') {
|
||||
const msg = data.message as Record<string, unknown> | undefined;
|
||||
const usage = msg?.usage as Record<string, unknown> | undefined;
|
||||
messageStartInputTokens = (usage?.input_tokens as number) ?? 0;
|
||||
continue;
|
||||
}
|
||||
for (const ev of processEvent(eventName, data)) {
|
||||
yield ev;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传
|
||||
if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr;
|
||||
log.warn(
|
||||
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
|
||||
trimmed.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。
|
||||
// 原实现在 content_block_start 与 content_block_stop 之间断连时,toolBlocks 里
|
||||
// 未完成的 block 既不产生 TOOL_CALL_COMPLETE、也不 flush —— 引擎看到"零工具调用
|
||||
// + 零文本"→ 误判 COMPLETED 空回复 → 会话无声停止(正是 v0.6.3 宣称根治、但在
|
||||
// Anthropic 流上仍然存活的场景)。现于补发 DONE 之前,将所有未完成块按截断契约
|
||||
// 转为 _truncatedArguments 自愈 tool call(解析成功的则正常产出)。
|
||||
if (!streamEndedNormally) {
|
||||
const unfinished = [...toolBlocks.entries()];
|
||||
if (unfinished.length > 0) {
|
||||
// 续传预算耗尽:按输出截断语义收尾(前端展示"输出可能截断",不静默丢失)
|
||||
log.warn(
|
||||
`[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`,
|
||||
`[Anthropic] pause_turn continuation budget exhausted (${MAX_PAUSE_CONTINUATIONS}) — finishing as length`,
|
||||
);
|
||||
for (const [, block] of unfinished) {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||||
} catch (err) {
|
||||
args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120));
|
||||
}
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
...base(),
|
||||
toolCall: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args,
|
||||
iteration: request.meta.iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
...base(),
|
||||
finishReason: mapAnthropicStopReason('pause_turn'),
|
||||
...(collectedThinkingBlocks.length > 0
|
||||
? { thinkingBlocks: collectedThinkingBlocks.slice() }
|
||||
: {}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
toolBlocks.clear();
|
||||
|
||||
// 自然结束:发射最终 DONE(映射原始停止原因 + 思考块)
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
...base(),
|
||||
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因
|
||||
//(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束)
|
||||
...(streamStopReason ? { finishReason: streamStopReason } : {}),
|
||||
...(rawStopReason ? { finishReason: mapAnthropicStopReason(rawStopReason) } : {}),
|
||||
...(collectedThinkingBlocks.length > 0
|
||||
? { thinkingBlocks: collectedThinkingBlocks.slice() }
|
||||
: {}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +633,19 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
|
||||
if (m.role === 'assistant') {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
// v0.8.2 P1-1: thinking 块协议回传 —— extended thinking + tool use 的多轮
|
||||
// 请求要求 assistant 消息携带原始 thinking/redacted_thinking 块(含签名),
|
||||
// 且必须位于 content 首位。仅在本次请求开启 thinking 时回传(thinking 关闭
|
||||
// 的降级重试路径携带 thinking 块会 400);签名不完备的块直接丢弃。
|
||||
if (thinkingRequested) {
|
||||
for (const tb of m.thinkingBlocks ?? []) {
|
||||
if (tb.type === 'redacted_thinking') {
|
||||
if (tb.data) content.push({ type: 'redacted_thinking', data: tb.data });
|
||||
} else if (tb.thinking && tb.signature) {
|
||||
content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.content) content.push({ type: 'text', text: m.content });
|
||||
for (const tc of m.toolCalls ?? []) {
|
||||
pendingToolUseIds.add(tc.id);
|
||||
@@ -600,13 +766,12 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } };
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const res = await this.fetchWithTimeout(url, {}, 30_000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const contentType = res.headers.get('content-type') ?? 'image/png';
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验,
|
||||
// 可被诱导回读内网数据;现含 DNS pinning/重定向复检/10MB 上限/类型白名单)
|
||||
const { base64, mediaType } = await this.fetchImageAsBase64(url, 30_000);
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: contentType, data: buf.toString('base64') },
|
||||
source: { type: 'base64', media_type: mediaType, data: base64 },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -621,6 +786,8 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
const contentBlocks = (data.content as Array<Record<string, unknown>>) ?? [];
|
||||
let text = '';
|
||||
let reasoningContent: string | undefined;
|
||||
// v0.8.2 P1-1: 原始思考块收集(非流式路径,供引擎透传实现协议回传)
|
||||
const thinkingBlocks: MetonaThinkingBlock[] = [];
|
||||
const toolCalls: MetonaResponse['toolCalls'] = [];
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
@@ -631,6 +798,15 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
if (thinking) {
|
||||
reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : thinking;
|
||||
}
|
||||
// 签名完备才收集(协议回传要求)
|
||||
const signature = block.signature as string | undefined;
|
||||
if (thinking && signature) {
|
||||
thinkingBlocks.push({ type: 'thinking', thinking, signature });
|
||||
}
|
||||
} else if (block.type === 'redacted_thinking') {
|
||||
// redacted_thinking 原样透传(回传协议要求)
|
||||
const redactedData = block.data as string | undefined;
|
||||
if (redactedData) thinkingBlocks.push({ type: 'redacted_thinking', data: redactedData });
|
||||
} else if (block.type === 'tool_use') {
|
||||
let args: Record<string, unknown> = {};
|
||||
const rawInput = block.input;
|
||||
@@ -649,14 +825,18 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
const stopReason = (data.stop_reason as string) ?? 'end_turn';
|
||||
// v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把
|
||||
// "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路
|
||||
// v0.8.2 P1-1: pause_turn 映射为 LENGTH(续传预算耗尽的兜底语义,正常路径
|
||||
// 已在 send() 内被续传循环消费,不会带 pause_turn 到达此处)
|
||||
const finishReason: MetonaFinishReason =
|
||||
stopReason === 'tool_use'
|
||||
? MetonaFinishReason.TOOL_CALLS
|
||||
: stopReason === 'max_tokens'
|
||||
? MetonaFinishReason.LENGTH
|
||||
: stopReason === 'refusal' || stopReason === 'content_filter'
|
||||
? MetonaFinishReason.CONTENT_FILTER
|
||||
: MetonaFinishReason.STOP;
|
||||
: stopReason === 'pause_turn'
|
||||
? MetonaFinishReason.LENGTH
|
||||
: stopReason === 'refusal' || stopReason === 'content_filter'
|
||||
? MetonaFinishReason.CONTENT_FILTER
|
||||
: MetonaFinishReason.STOP;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
@@ -668,6 +848,7 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
},
|
||||
content: text,
|
||||
reasoningContent,
|
||||
...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}),
|
||||
toolCalls,
|
||||
usage: {
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
MetonaStreamEvent,
|
||||
} from '../types';
|
||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||
import { fetchImageAsBase64 } from './shared/ssrf-image-fetch';
|
||||
|
||||
/**
|
||||
* 内容审核错误 — Provider 的安全过滤策略触发的错误
|
||||
@@ -88,6 +89,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
this.externalAbortSignal = signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 P3-3: 读取外部中断信号(流式消费阶段的中断贯通)。
|
||||
* 子类的 sendStream 把它传入流读取辅助 —— fetch 头阶段的 abort 由
|
||||
* fetchWithTimeout 处理,流体消费阶段的 abort 由 readStreamChunkWithIdleTimeout
|
||||
* 竞速处理(此前 reader.read() 对用户中断无感,挂起流无法被"中断"按钮终止)。
|
||||
*/
|
||||
protected getExternalAbortSignal(): AbortSignal | undefined {
|
||||
return this.externalAbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
|
||||
*
|
||||
@@ -155,6 +166,22 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 P0-1: SSRF 安全的图片下载通道(Anthropic / Ollama 图片 URL 共用)
|
||||
*
|
||||
* 此前子类直接 fetchWithTimeout 下载消息里的 http(s) 图片 URL —— 无 SSRF 校验、
|
||||
* 无字节上限,且下载结果以 base64 进入模型上下文(**数据可回读**的外泄通道)。
|
||||
* 现统一走 fetchImageAsBase64:resolvePublicAddresses + DNS pinning 校验与连接
|
||||
* 同源、逐跳重定向复检、10MB 字节上限、png/jpeg/gif/webp 类型白名单;
|
||||
* 外部 abort 信号(用户中断)照常透传。失败时调用方按"跳过该图"降级。
|
||||
*/
|
||||
protected async fetchImageAsBase64(
|
||||
url: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ base64: string; mediaType: string }> {
|
||||
return fetchImageAsBase64(url, { timeoutMs, signal: this.externalAbortSignal });
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.listModels();
|
||||
|
||||
@@ -100,8 +100,10 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
}
|
||||
|
||||
// v0.6.4 P4-3: MiMo 服务端内置工具透出 —— config.providerOptions.enableWebSearch
|
||||
// 开启后附加 {type:'web_search'} 服务端搜索工具(annotations 引用随响应返回,
|
||||
// 由上层归并为文本内容展示)。与客户端 tools 定义互不影响。
|
||||
// 开启后附加 {type:'web_search'} 服务端搜索工具。与客户端 tools 定义互不影响。
|
||||
// v0.8.2 P2-6: 引用注释(annotations)的采集与回填实现在共享层
|
||||
// sse-stream.ts(流式 [DONE]/断流兜底 + 非流式解析统一回填 Markdown 引用列表),
|
||||
// 此前注释宣称"由上层归并展示"但全链路无读取方,引用信息丢失。
|
||||
const providerOptions = this.config.providerOptions as Record<string, unknown> | undefined;
|
||||
if (providerOptions?.['enableWebSearch'] === true) {
|
||||
const serverTools = body.tools
|
||||
@@ -118,18 +120,22 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
|
||||
}
|
||||
|
||||
// Thinking 模式(与 DeepSeek 参数结构一致)
|
||||
// MiMo API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭
|
||||
// v0.8.0 修订(用户意图优先): 思考参数完全遵循用户配置,不再按
|
||||
// supportsThinking 元信息硬门控 —— 预算耗尽风险由引擎空响应守卫的
|
||||
// 降级重试链路兜底;元信息与配置不符时仅告警不拦截。
|
||||
const wantThinking = request.params.thinkingEnabled !== false;
|
||||
//
|
||||
// v0.8.2 P2-6 根治: 未配置(undefined)时默认 **disabled** —— 与 DeepSeek
|
||||
// (显式 disabled)/ Agnes(显式 false)一致。旧实现 `!== false` 使 MiMo 在
|
||||
// UI「未开启思考」状态下隐式进入思考模式,且思考模式下服务端强制
|
||||
// temperature=1.0/top_p=0.95,用户的温度配置被静默吞掉。
|
||||
const wantThinking = request.params.thinkingEnabled === true;
|
||||
if (!wantThinking) {
|
||||
// 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效)
|
||||
body.thinking = { type: 'disabled' };
|
||||
body.temperature = request.params.temperature;
|
||||
body.top_p = request.params.topP;
|
||||
} else {
|
||||
// 启用思考(包括 undefined,因为 MiMo 默认 enabled)
|
||||
// 启用思考(仅显式 true —— 未配置已在上分支显式 disabled,见 P2-6 注)
|
||||
// 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传
|
||||
body.thinking = { type: 'enabled' };
|
||||
// 元信息标注不支持思考但用户开启 —— 告知降级兜底路径(不拦截)
|
||||
|
||||
@@ -137,7 +137,11 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
while (true) {
|
||||
// v0.7.4 P1-2: 空闲超时 — 本地模型加载/推理期间服务器可能长时间不推数据,
|
||||
// 共享辅助在连续 60s 无数据时抛 SseUpstreamError(504) 进重试通道
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader);
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(
|
||||
reader,
|
||||
60_000,
|
||||
this.getExternalAbortSignal(),
|
||||
);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
@@ -578,16 +582,11 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
*/
|
||||
private async resolveImageToBase64(url: string): Promise<string> {
|
||||
try {
|
||||
// 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时,
|
||||
// 避免用户中断时图片下载最多阻塞 30s×N(externalAbortSignal 是 BaseAdapter 的
|
||||
// private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout,
|
||||
// 该方法同时处理了 listener 泄漏问题)
|
||||
const res = await this.fetchWithTimeout(url, {}, 30_000);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return buf.toString('base64');
|
||||
// v0.8.2 P0-1: 图片 URL 下载收口到 SSRF 安全通道(此前直连 fetch 无校验、
|
||||
// 无大小上限;现含 DNS pinning/重定向复检/10MB 上限/类型白名单),外部
|
||||
// 中断信号由 BaseAdapter.fetchImageAsBase64 透传。
|
||||
const { base64 } = await this.fetchImageAsBase64(url, 30_000);
|
||||
return base64;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`,
|
||||
|
||||
@@ -102,8 +102,12 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
// 推理模型检测(o 系列使用新参数名)
|
||||
// v0.8.2 P2-6: 正则补边界 — 旧 /^(o\d|gpt-5)/ 对命名变体存在两处漏判/误判:
|
||||
// ① `chatgpt-5-*` 系列(gpt-5 非前缀)漏判;② 未来 `o10` 等多位数编号无影响
|
||||
// 但 `o\d` 会把 `openai/...` 路径形态误判(当前配置层不透传路径,防御性加边界)。
|
||||
// 现改为带词边界的枚举前缀匹配(o<digits> / gpt-5 / chatgpt-5)。
|
||||
const model = this.config.defaultModel;
|
||||
const isReasoningModel = /^(o\d|gpt-5)/.test(model);
|
||||
const isReasoningModel = /^(o\d+(?:[-.][\w.]+)?|gpt-5[\w.-]*|chatgpt-5[\w.-]*)/.test(model);
|
||||
|
||||
// 推理模型不支持图片输入 — 前置校验
|
||||
// v0.6.4 升级: 原实现抛裸 Error 落入 UNKNOWN 错误码;现在抛 ModelCapabilityError
|
||||
@@ -165,10 +169,18 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
|
||||
}
|
||||
|
||||
// 停止序列
|
||||
// 已知边界(协议限制,待上游放开后移除此注释):o 系列不支持 stop 参数,
|
||||
// 当前仍透传 —— 若推理模型 + stop 组合触发 400 属上游约束而非本层缺陷。
|
||||
// v0.8.2 P2-6 根治: 推理模型不支持 stop 参数 —— 旧实现"仍透传,400 属上游约束"
|
||||
// 的注释性放弃改为前置拦截:推理模型请求丢弃 stop 并告警(与拒图的
|
||||
// ModelCapabilityError 同思路,但 stop 是可选增强参数,静默丢弃 + 告警优于
|
||||
// 让整个请求 400 失败)。
|
||||
if (request.params.stopSequences?.length) {
|
||||
body.stop = request.params.stopSequences;
|
||||
if (isReasoningModel) {
|
||||
log.warn(
|
||||
`[OpenAI] stop sequences are not supported by reasoning model "${model}" — dropping ${request.params.stopSequences.length} stop sequence(s) for this request`,
|
||||
);
|
||||
} else {
|
||||
body.stop = request.params.stopSequences;
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* v0.8.2 P0-1: 适配器图片下载 SSRF 安全通道测试
|
||||
*
|
||||
* 锁定单元:
|
||||
* - sniffImageMediaType 魔数嗅探
|
||||
* - 协议白名单(仅 http/https)
|
||||
* - IP 直连私网/云元数据拒绝(resolvePublicAddresses 单一事实来源)
|
||||
* - DNS 解析到私网拒绝(rebinding 形态)
|
||||
* - 成功路径:下载 → base64 + 类型钳制(png/jpeg/gif/webp 白名单)
|
||||
* - 非图片 content-type 拒绝
|
||||
* - 字节上限(maxBytes + content-length 双闸)
|
||||
* - 逐跳重定向复检(每一跳重新进入完整校验)
|
||||
*/
|
||||
|
||||
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() },
|
||||
}));
|
||||
|
||||
const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) }));
|
||||
vi.mock('../../../../utils/network-proxy', () => ({
|
||||
isProxyActive: proxyMock.isProxyActive,
|
||||
}));
|
||||
|
||||
// undici mock:ssrfPinnedFetch 的 pinned 路径需要 Agent + fetch
|
||||
const undiciMock = vi.hoisted(() => {
|
||||
class FakeAgent {
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
const fetch = vi.fn();
|
||||
return { FakeAgent, fetch };
|
||||
});
|
||||
vi.mock('undici', () => ({
|
||||
Agent: undiciMock.FakeAgent,
|
||||
fetch: undiciMock.fetch,
|
||||
}));
|
||||
|
||||
import { fetchImageAsBase64, sniffImageMediaType, __imageFetcher } from '../ssrf-image-fetch';
|
||||
import type { ImageFetcher } from '../ssrf-image-fetch';
|
||||
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
|
||||
|
||||
function imageResponse(bytes: Uint8Array, contentType = 'image/png'): Response {
|
||||
return new Response(bytes, { status: 200, headers: { 'content-type': contentType } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
undiciMock.fetch.mockReset();
|
||||
proxyMock.isProxyActive.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('sniffImageMediaType', () => {
|
||||
it('识别 PNG / JPEG / GIF / WEBP 魔数', () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(sniffImageMediaType(png)).toBe('image/png');
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
|
||||
expect(sniffImageMediaType(jpeg)).toBe('image/jpeg');
|
||||
const gif = Buffer.from('GIF89a');
|
||||
expect(sniffImageMediaType(gif)).toBe('image/gif');
|
||||
const webp = Buffer.concat([
|
||||
Buffer.from('RIFF'),
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00]),
|
||||
Buffer.from('WEBP'),
|
||||
]);
|
||||
expect(sniffImageMediaType(webp)).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('非图片内容返回 null', () => {
|
||||
expect(sniffImageMediaType(Buffer.from('hello world, plain text'))).toBeNull();
|
||||
expect(sniffImageMediaType(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchImageAsBase64 — 校验拒绝矩阵', () => {
|
||||
it('拒绝非 http/https 协议', async () => {
|
||||
await expect(fetchImageAsBase64('file:///etc/passwd')).rejects.toThrow(/protocol not allowed/);
|
||||
await expect(fetchImageAsBase64('ftp://example.com/a.png')).rejects.toThrow(
|
||||
/protocol not allowed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('拒绝 IP 直连私网/回环/云元数据', async () => {
|
||||
await expect(fetchImageAsBase64('http://127.0.0.1/img.png')).rejects.toThrow(/Blocked SSRF/);
|
||||
await expect(fetchImageAsBase64('http://10.1.2.3/img.png')).rejects.toThrow(/Blocked SSRF/);
|
||||
await expect(fetchImageAsBase64('http://192.168.1.1/img.png')).rejects.toThrow(/Blocked SSRF/);
|
||||
await expect(fetchImageAsBase64('http://172.16.0.9/img.png')).rejects.toThrow(/Blocked SSRF/);
|
||||
// 可回读通道的核心威胁:云元数据
|
||||
await expect(fetchImageAsBase64('http://169.254.169.254/latest/meta-data')).rejects.toThrow(
|
||||
/Blocked SSRF/,
|
||||
);
|
||||
});
|
||||
|
||||
it('拒绝域名解析到私网(DNS rebinding 形态)', async () => {
|
||||
await expect(fetchImageAsBase64('http://nx.invalid.example/img.png')).rejects.toThrow(
|
||||
/Blocked SSRF/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchImageAsBase64 — 成功与钳制路径', () => {
|
||||
it('公网图片下载 → base64 + mediaType(IP 直连,无需 DNS)', async () => {
|
||||
const original = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () => imageResponse(PNG_MAGIC)) as unknown as ImageFetcher;
|
||||
try {
|
||||
const r = await fetchImageAsBase64('http://93.184.216.34/a.png');
|
||||
expect(r.mediaType).toBe('image/png');
|
||||
expect(Buffer.from(r.base64, 'base64').equals(PNG_MAGIC)).toBe(true);
|
||||
} finally {
|
||||
__imageFetcher.current = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('非图片 content-type(text/html)拒绝', async () => {
|
||||
const original = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
imageResponse(Buffer.from('<html></html>'), 'text/html')) as unknown as ImageFetcher;
|
||||
try {
|
||||
await expect(fetchImageAsBase64('http://93.184.216.34/a.png')).rejects.toThrow(
|
||||
/non-image content-type/,
|
||||
);
|
||||
} finally {
|
||||
__imageFetcher.current = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('白名单外图片类型(image/bmp)拒绝', async () => {
|
||||
const original = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
imageResponse(Buffer.from('BMxx'), 'image/bmp')) as unknown as ImageFetcher;
|
||||
try {
|
||||
await expect(fetchImageAsBase64('http://93.184.216.34/a.bmp')).rejects.toThrow(
|
||||
/unsupported media type/,
|
||||
);
|
||||
} finally {
|
||||
__imageFetcher.current = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('字节上限:超过 maxBytes 拒绝(防大图内存峰值)', async () => {
|
||||
const original = __imageFetcher.current;
|
||||
__imageFetcher.current = (async () =>
|
||||
imageResponse(Buffer.alloc(64, 1))) as unknown as ImageFetcher;
|
||||
try {
|
||||
await expect(
|
||||
fetchImageAsBase64('http://93.184.216.34/a.png', { maxBytes: 8 }),
|
||||
).rejects.toThrow(/size limit/);
|
||||
} finally {
|
||||
__imageFetcher.current = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('逐跳重定向复检:302 跳转后以下一跳 URL 再次下载,终图可用', async () => {
|
||||
const original = __imageFetcher.current;
|
||||
const seen: string[] = [];
|
||||
const fetcher: ImageFetcher = async (url: string) => {
|
||||
seen.push(url);
|
||||
if (seen.length === 1) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'http://93.184.216.34/final.png' },
|
||||
});
|
||||
}
|
||||
return imageResponse(PNG_MAGIC);
|
||||
};
|
||||
__imageFetcher.current = fetcher;
|
||||
try {
|
||||
const r = await fetchImageAsBase64('http://93.184.216.34/redirect.png');
|
||||
expect(r.mediaType).toBe('image/png');
|
||||
expect(seen).toEqual(['http://93.184.216.34/redirect.png', 'http://93.184.216.34/final.png']);
|
||||
} finally {
|
||||
__imageFetcher.current = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,8 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter {
|
||||
request.meta.requestId,
|
||||
request.meta.sessionId,
|
||||
request.meta.iteration,
|
||||
// v0.8.2 P3-3: 流式消费阶段的中断贯通
|
||||
this.getExternalAbortSignal(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,22 @@ export class SseUpstreamError extends Error {
|
||||
* 引擎 chatStreamWithRetry 的 catch,自动走既有重试/故障转移通道。
|
||||
* SSE / Ollama NDJSON / Anthropic 事件机三处读循环共用,杜绝三份重复实现漂移。
|
||||
*
|
||||
* v0.8.2 P3-3 根治: 新增外部中断贯通 —— 此前 abort 监听只在 fetch 头阶段有效
|
||||
* (BaseAdapter.fetchWithTimeout 在响应头返回后解除监听),流式消费阶段的
|
||||
* reader.read() 对用户中断完全无感:配合保活/心跳型上游,"中断"按钮无法真正
|
||||
* 终止挂起的 run(E2E 中断链路实测暴露)。现把外部 signal 传入本辅助:
|
||||
* abort 触发时 cancel reader 并抛 AbortError —— 引擎 chatStreamWithRetry 由
|
||||
* this.aborted 拦截原样抛出,executeRunStream 以 USER_INTERRUPT 收尾。
|
||||
*
|
||||
* @param reader 流的 reader
|
||||
* @param idleTimeoutMs 空闲超时(默认 60s — 慢速思考模型正常 chunk 间隔可达数十秒)
|
||||
* @param externalSignal 外部中断信号(引擎 abortController;可选)
|
||||
* @returns { done, value },done=true 表示流正常结束
|
||||
*/
|
||||
export async function readStreamChunkWithIdleTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
idleTimeoutMs = 60_000,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<{ done: boolean; value: Uint8Array | undefined }> {
|
||||
let idleExpired = false;
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
@@ -70,8 +79,37 @@ export async function readStreamChunkWithIdleTimeout(
|
||||
}, idleTimeoutMs);
|
||||
});
|
||||
|
||||
// 外部中断竞速(流式消费阶段的中断贯通)
|
||||
let onExternalAbort: (() => void) | null = null;
|
||||
const abortController = externalSignal
|
||||
? new Promise<never>((_, reject) => {
|
||||
if (externalSignal.aborted) {
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
onExternalAbort = () => reject(new Error('Aborted'));
|
||||
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||
})
|
||||
: null;
|
||||
|
||||
const abortError = (): Error => {
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
void reader.cancel().catch(() => {
|
||||
/* 连接销毁时 cancel 可能失败,忽略 */
|
||||
});
|
||||
return err;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await Promise.race([reader.read(), idleController]);
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
idleController,
|
||||
...(abortController ? [abortController] : []),
|
||||
]);
|
||||
if (externalSignal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
if (idleExpired) {
|
||||
throw new SseUpstreamError(
|
||||
`Stream idle timeout after ${idleTimeoutMs}ms (no data received)`,
|
||||
@@ -79,8 +117,17 @@ export async function readStreamChunkWithIdleTimeout(
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
// 中断竞速赢时把底层读错误替换为标准 AbortError(reader.read 会因 cancel 拒绝)
|
||||
if (externalSignal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (externalSignal && onExternalAbort) {
|
||||
externalSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +137,18 @@ interface SseStreamFrame {
|
||||
delta?: {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
annotations?: unknown;
|
||||
tool_calls?: Array<{
|
||||
index?: number;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
/** 部分网关在最后一个 chunk 附带完整 message(含 annotations) */
|
||||
message?: { annotations?: unknown };
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
/** v0.8.2 P2-6: MiMo 联网搜索引用注释(服务端 web_search 工具) */
|
||||
annotations?: unknown;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
@@ -108,6 +160,54 @@ interface SseStreamFrame {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 P2-6 根治: MiMo 联网搜索引用(annotations)全链路丢失的收口。
|
||||
*
|
||||
*MiMo enableWebSearch 服务端工具在响应中返回 annotations[].{title,url,site_name,...}
|
||||
*(非流式在 choices[].message.annotations;流式按文档"其余字段与非流式相同",
|
||||
* 可能出现在最后一个 chunk 的顶层 / message / delta)。此前 sse-stream 完全不
|
||||
* 读取该字段 —— mimo.adapter 注释宣称"由上层归并为文本内容展示"实为断头链路,
|
||||
* 引用信息全链路丢失。
|
||||
*
|
||||
* 采集策略:按 url 去重累积;流结束时([DONE] / 断流兜底)把引用格式化为
|
||||
* Markdown 列表以 TEXT_DELTA 追加到正文 —— 引擎/渲染层按既有文本管线自然
|
||||
* 消费,无需新事件类型。
|
||||
*/
|
||||
function collectAnnotations(
|
||||
annotations: unknown,
|
||||
sink: Map<string, { title: string; url: string; siteName?: string }>,
|
||||
): void {
|
||||
if (!Array.isArray(annotations)) return;
|
||||
for (const item of annotations) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
const url = typeof rec.url === 'string' ? rec.url : '';
|
||||
if (!url || sink.has(url)) continue;
|
||||
sink.set(url, {
|
||||
title: typeof rec.title === 'string' && rec.title ? rec.title : url,
|
||||
url,
|
||||
siteName: typeof rec.site_name === 'string' ? rec.site_name : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnnotationsBlock(
|
||||
sink: Map<string, { title: string; url: string; siteName?: string }>,
|
||||
): string | null {
|
||||
if (sink.size === 0) return null;
|
||||
const MAX_CITATIONS = 20;
|
||||
const lines: string[] = ['', '', '**References**', ''];
|
||||
let count = 0;
|
||||
for (const { title, url, siteName } of sink.values()) {
|
||||
if (count >= MAX_CITATIONS) break;
|
||||
const safeUrl = /^https?:\/\//i.test(url) ? url : '';
|
||||
if (!safeUrl) continue;
|
||||
lines.push(`- [${title}](${safeUrl})${siteName ? ` — ${siteName}` : ''}`);
|
||||
count++;
|
||||
}
|
||||
return count > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一条已 JSON.parse 的 SSE 数据帧中提取上游错误信息。
|
||||
* 兼容三种形态:
|
||||
@@ -312,6 +412,7 @@ function* flushToolCallBuffer(
|
||||
* @param requestId - 对应的请求 ID
|
||||
* @param sessionId - 会话 ID
|
||||
* @param iteration - 当前迭代轮次
|
||||
* @param externalSignal - 外部中断信号(v0.8.2 P3-3: 流式消费阶段的中断贯通,可选)
|
||||
* @yields MetonaStreamEvent
|
||||
*/
|
||||
export async function* parseSSEStream(
|
||||
@@ -319,6 +420,7 @@ export async function* parseSSEStream(
|
||||
requestId: string,
|
||||
sessionId: string,
|
||||
iteration: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): AsyncGenerator<MetonaStreamEvent> {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -341,11 +443,17 @@ export async function* parseSSEStream(
|
||||
|
||||
// 工具调用缓冲区:index → { name, argsBuffer }
|
||||
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
|
||||
// v0.8.2 P2-6: MiMo 联网搜索引用采集(按 url 去重,流结束时回填正文)
|
||||
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// 数据到达即重置空闲窗口(辅助函数内部实现)
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(reader, IDLE_TIMEOUT_MS);
|
||||
const { done, value } = await readStreamChunkWithIdleTimeout(
|
||||
reader,
|
||||
IDLE_TIMEOUT_MS,
|
||||
externalSignal,
|
||||
);
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
@@ -366,6 +474,20 @@ export async function* parseSSEStream(
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
|
||||
// v0.8.2 P2-6: 引用注释回填正文(在 DONE 之前以 TEXT_DELTA 追加)
|
||||
const citationBlock = formatAnnotationsBlock(annotationsSink);
|
||||
if (citationBlock) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: citationBlock,
|
||||
};
|
||||
}
|
||||
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId,
|
||||
@@ -405,6 +527,11 @@ export async function* parseSSEStream(
|
||||
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
|
||||
// v0.8.2 P2-6: 采集引用注释(顶层 / message / delta 三处兼容)
|
||||
collectAnnotations(chunk.annotations, annotationsSink);
|
||||
collectAnnotations(chunk.choices?.[0]?.message?.annotations, annotationsSink);
|
||||
collectAnnotations(delta?.annotations, annotationsSink);
|
||||
|
||||
// 文本内容增量
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
@@ -532,6 +659,19 @@ export async function* parseSSEStream(
|
||||
'[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)',
|
||||
);
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
// v0.8.2 P2-6: 断流兜底路径同样回填引用注释
|
||||
const citationBlock = formatAnnotationsBlock(annotationsSink);
|
||||
if (citationBlock) {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
delta: citationBlock,
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId,
|
||||
@@ -568,8 +708,16 @@ export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
|
||||
const usage = data.usage as Record<string, unknown> | undefined;
|
||||
const rawToolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
// v0.8.2 P2-6: 非流式路径的引用注释回填(MiMo 联网搜索)
|
||||
const annotationsSink = new Map<string, { title: string; url: string; siteName?: string }>();
|
||||
collectAnnotations(message?.annotations, annotationsSink);
|
||||
collectAnnotations(data.annotations, annotationsSink);
|
||||
let content = (message?.content as string) ?? '';
|
||||
const citationBlock = formatAnnotationsBlock(annotationsSink);
|
||||
if (citationBlock) content += citationBlock;
|
||||
|
||||
return {
|
||||
content: (message?.content as string) ?? '',
|
||||
content,
|
||||
reasoningContent: message?.reasoning_content as string | undefined,
|
||||
toolCalls: rawToolCalls?.map((tc) => {
|
||||
const fn = tc.function as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* v0.8.2 P0-1: 适配器图片下载的 SSRF 安全通道(Anthropic / Ollama 共用)
|
||||
*
|
||||
* 背景:此前 anthropic.adapter.toImageBlock 与 ollama.adapter.resolveImageToBase64
|
||||
* 对消息里的 http(s) 图片 URL 直接 fetchWithTimeout 下载转 base64 —— 未接 SSRF
|
||||
* 校验、无字节上限、无 content-type 约束。与其他工具"SSRF 拦截即断"不同,
|
||||
* 该通道的下载结果会**以图片块进入模型上下文(数据可回读)**:模型可诱导用户
|
||||
* 发送 http://169.254.169.254/... 图片链接回读内网数据,属于可回读外泄通道;
|
||||
* 同时无上限的 arrayBuffer 会造成内存峰值。
|
||||
*
|
||||
* 收口方案(根治):
|
||||
* - 校验与连接同源:resolvePublicAddresses(ssrf-guard 单一事实来源)+ DNS
|
||||
* pinning(ssrfPinnedFetch),代理激活时按既有语义退化为"仅入口校验";
|
||||
* - 逐跳重定向复检:redirect:'manual' + resolveRedirectTarget,每一跳都重新
|
||||
* 走完整校验(对齐 web_fetch 的逐跳语义),最多 3 跳;
|
||||
* - 字节上限:content-length 预检 + 流式增量累计双闸(10MB,防大图内存峰值);
|
||||
* - 类型白名单:content-type 必须 image/*(或缺失/二进制时按魔数嗅探),
|
||||
* 并钳制到 Provider 实际支持集合(png/jpeg/gif/webp)。
|
||||
*
|
||||
* 失败语义由调用方决定:适配器保持"跳过该图、不阻断请求"(log.warn + 占位)。
|
||||
*/
|
||||
|
||||
import { ssrfPinnedFetch, resolveRedirectTarget } from '../../tools/built-in/ssrf-dispatcher';
|
||||
|
||||
/** 实际下载器签名(与 ssrfPinnedFetch 对齐) */
|
||||
export type ImageFetcher = (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* v0.8.2 P0-1: 下载器注入点 —— 生产恒为 ssrfPinnedFetch(校验与连接同源);
|
||||
* 单元测试通过替换 current 注入受控下载器(undici 客户端无法经 global fetch 打桩)。
|
||||
*/
|
||||
export const __imageFetcher: { current: ImageFetcher } = { current: ssrfPinnedFetch };
|
||||
|
||||
/** 单张图片下载字节上限(10MB,对齐 view_image 工具 5MB×2 的量级) */
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** 允许的图片 media type(Anthropic Messages API 支持集合;Ollama 同样接受) */
|
||||
const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
export interface ImageFetchOptions {
|
||||
timeoutMs?: number;
|
||||
maxBytes?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ImageFetchResult {
|
||||
base64: string;
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
/** 魔数嗅探:content-type 缺失或为通用二进制类型时判定真实图片类型 */
|
||||
export function sniffImageMediaType(buf: Buffer): string | null {
|
||||
if (buf.length >= 8 && buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (buf.length >= 6 && buf.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buf.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 流式增量读取并强制字节上限(content-length 可伪造,以实际累计为准) */
|
||||
async function readBodyWithCap(res: Response, maxBytes: number): Promise<Buffer> {
|
||||
const declared = res.headers.get('content-length');
|
||||
const declaredBytes = declared ? Number(declared) : NaN;
|
||||
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
||||
throw new Error(`Image exceeds size limit: ${declaredBytes} > ${maxBytes} bytes`);
|
||||
}
|
||||
const body = res.body;
|
||||
if (!body) {
|
||||
throw new Error('Image response has no body');
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`Image exceeds size limit: > ${maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF 安全的图片下载:校验 → pinned 连接 → 逐跳重定向复检 → 类型/尺寸钳制。
|
||||
*
|
||||
* @throws 任何校验失败/网络失败/超限/类型不符均抛 Error(消息含 Blocked SSRF /
|
||||
* Image exceeds / non-image 等),由调用方按"跳过该图"处理。
|
||||
*/
|
||||
export async function fetchImageAsBase64(
|
||||
url: string,
|
||||
options: ImageFetchOptions = {},
|
||||
): Promise<ImageFetchResult> {
|
||||
const { timeoutMs = 30_000, maxBytes = MAX_IMAGE_BYTES, signal } = options;
|
||||
|
||||
if (!/^https?:/i.test(url)) {
|
||||
throw new Error(`Blocked image fetch: protocol not allowed (url=${url.slice(0, 120)})`);
|
||||
}
|
||||
|
||||
let currentUrl = url;
|
||||
let res: Response | null = null;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
// __imageFetcher(生产 = ssrfPinnedFetch)内部先 resolvePublicAddresses 全量
|
||||
// 校验再 pinned 连接;每一跳都进入本调用 —— 重定向目标同样受完整 SSRF 校验。
|
||||
res = await __imageFetcher.current(currentUrl, { redirect: 'manual' }, timeoutMs, signal);
|
||||
const next = resolveRedirectTarget(
|
||||
{ status: res.status, headers: { get: (n: string) => res!.headers.get(n) } },
|
||||
currentUrl,
|
||||
);
|
||||
if (!next) break;
|
||||
if (hop === MAX_REDIRECTS) {
|
||||
throw new Error(`Image fetch exceeded ${MAX_REDIRECTS} redirects`);
|
||||
}
|
||||
currentUrl = next;
|
||||
}
|
||||
if (!res) throw new Error('Image fetch failed: no response');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Image fetch failed: HTTP ${res.status} (url=${currentUrl.slice(0, 120)})`);
|
||||
}
|
||||
|
||||
const buf = await readBodyWithCap(res, maxBytes);
|
||||
if (buf.length === 0) {
|
||||
throw new Error('Image fetch failed: empty body');
|
||||
}
|
||||
|
||||
// 类型钳制:content-type 声明优先(必须 image/*),缺失/通用二进制按魔数嗅探;
|
||||
// 明确的非图片类型(text/html、application/json 等)直接拒绝。
|
||||
const declaredType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
||||
let mediaType: string | null = null;
|
||||
if (
|
||||
declaredType === '' ||
|
||||
declaredType === 'application/octet-stream' ||
|
||||
declaredType === 'binary/octet-stream'
|
||||
) {
|
||||
mediaType = sniffImageMediaType(buf);
|
||||
} else if (declaredType.startsWith('image/')) {
|
||||
mediaType = declaredType;
|
||||
} else {
|
||||
throw new Error(`Blocked image fetch: non-image content-type "${declaredType}"`);
|
||||
}
|
||||
if (!mediaType || !ALLOWED_MEDIA_TYPES.has(mediaType)) {
|
||||
throw new Error(
|
||||
`Blocked image fetch: unsupported media type "${mediaType ?? declaredType}" (allowed: png/jpeg/gif/webp)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { base64: buf.toString('base64'), mediaType };
|
||||
}
|
||||
Reference in New Issue
Block a user