754 lines
25 KiB
TypeScript
754 lines
25 KiB
TypeScript
/**
|
||
* OllamaAdapter 独立测试
|
||
*
|
||
* 覆盖契约:
|
||
* - NDJSON 流式解析(thinking/content/tool_calls/done usage)
|
||
* - 工具参数坏 JSON 自愈(_truncatedArguments)
|
||
* - pullModel 进度回调 / 取消信号 / 非 JSON 行跳过
|
||
* - probeCapabilities / showModel / listModels / getVersion / listRunning
|
||
* - 图片 URL → base64 下载、data URI 剥前缀、失败降级
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
import { 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';
|
||
|
||
const mockFetch = vi.fn();
|
||
|
||
function makeAdapter(model = 'qwen3', overrides: Record<string, unknown> = {}): OllamaAdapter {
|
||
return new OllamaAdapter({
|
||
provider: 'ollama',
|
||
baseURL: 'http://localhost:11434',
|
||
defaultModel: model,
|
||
...overrides,
|
||
});
|
||
}
|
||
|
||
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
|
||
return {
|
||
meta: {
|
||
sessionId: 's1',
|
||
iteration: 1,
|
||
requestId: 'r1',
|
||
timestamp: Date.now(),
|
||
agentVersion: 'test',
|
||
},
|
||
systemPrompt: {
|
||
roleDefinition: 'You are Metona.',
|
||
outputConstraints: '',
|
||
safetyGuidelines: '',
|
||
},
|
||
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||
params: { maxTokens: 4096, temperature: 0, stream: true },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function mockNDJSON(lines: unknown[]): void {
|
||
const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(new TextEncoder().encode(payload));
|
||
controller.close();
|
||
},
|
||
});
|
||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||
}
|
||
|
||
async function collectStream(
|
||
adapter: OllamaAdapter,
|
||
request: MetonaRequest,
|
||
): Promise<
|
||
Array<{
|
||
type: string;
|
||
delta?: string;
|
||
usage?: Record<string, unknown>;
|
||
toolCall?: Record<string, unknown>;
|
||
}>
|
||
> {
|
||
const events: Array<{
|
||
type: string;
|
||
delta?: string;
|
||
usage?: Record<string, unknown>;
|
||
toolCall?: Record<string, unknown>;
|
||
}> = [];
|
||
for await (const ev of adapter.sendStream(request)) {
|
||
events.push({
|
||
type: ev.type,
|
||
delta: ev.delta,
|
||
usage: ev.usage as Record<string, unknown> | undefined,
|
||
toolCall: ev.toolCall as Record<string, unknown> | undefined,
|
||
});
|
||
}
|
||
return events;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
mockFetch.mockReset();
|
||
vi.stubGlobal('fetch', mockFetch);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
// ===== NDJSON 流式解析 =====
|
||
|
||
describe('OllamaAdapter — NDJSON 流式解析', () => {
|
||
it('thinking + content 逐行产出 REASONING_DELTA / TEXT_DELTA', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([
|
||
{ model: 'qwen3', message: { role: 'assistant', thinking: '思考一' } },
|
||
{ model: 'qwen3', message: { role: 'assistant', content: '正文一' } },
|
||
{ model: 'qwen3', message: { role: 'assistant', thinking: '思考二' } },
|
||
{ model: 'qwen3', message: { role: 'assistant', content: '正文二' } },
|
||
{
|
||
model: 'qwen3',
|
||
message: { role: 'assistant', content: '' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 3,
|
||
eval_count: 2,
|
||
},
|
||
]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
const reasoning = events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
|
||
expect(reasoning.map((e) => e.delta)).toEqual(['思考一', '思考二']);
|
||
const text = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||
expect(text.map((e) => e.delta)).toEqual(['正文一', '正文二']);
|
||
});
|
||
|
||
it('tool_calls 整块返回 → TOOL_CALL_COMPLETE(字符串 arguments 解析)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([
|
||
{
|
||
model: 'qwen3',
|
||
message: {
|
||
role: 'assistant',
|
||
content: '',
|
||
tool_calls: [
|
||
{ function: { name: 'read_file', arguments: '{"path":"a.txt"}' } },
|
||
{ function: { name: 'list_dir', arguments: '{"path":"."}' } },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
message: { role: 'assistant', content: '' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 1,
|
||
eval_count: 1,
|
||
},
|
||
]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(2);
|
||
expect(completes[0].toolCall!.name).toBe('read_file');
|
||
expect(completes[0].toolCall!.args).toEqual({ path: 'a.txt' });
|
||
expect(completes[1].toolCall!.name).toBe('list_dir');
|
||
expect(completes[1].toolCall!.id).toMatch(/^tc_/);
|
||
});
|
||
|
||
it('done chunk → USAGE(prompt_eval_count/eval_count)+ DONE', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([
|
||
{ model: 'qwen3', message: { role: 'assistant', content: 'x' } },
|
||
{
|
||
model: 'qwen3',
|
||
message: { role: 'assistant', content: '' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 11,
|
||
eval_count: 7,
|
||
},
|
||
]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage;
|
||
expect(usage).toMatchObject({ inputTokens: 11, outputTokens: 7, totalTokens: 18 });
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('坏 NDJSON 行被跳过不中断流(记录警告)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue(
|
||
new Response(
|
||
new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
const encoder = new TextEncoder();
|
||
controller.enqueue(encoder.encode('{bad json\n'));
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
JSON.stringify({ model: 'm', message: { role: 'assistant', content: 'ok' } }) +
|
||
'\n',
|
||
),
|
||
);
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
JSON.stringify({
|
||
model: 'm',
|
||
message: { role: 'assistant', content: '' },
|
||
done: true,
|
||
}) + '\n',
|
||
),
|
||
);
|
||
controller.close();
|
||
},
|
||
}),
|
||
{ status: 200 },
|
||
),
|
||
);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
expect(
|
||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'ok'),
|
||
).toBe(true);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('流断开(无 done 行)→ 补发 DONE 防止挂起', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([{ model: 'm', message: { role: 'assistant', content: 'partial' } }]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('工具参数坏 JSON → _truncatedArguments 自愈,且同 chunk 后续 done 仍处理', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([
|
||
{
|
||
model: 'qwen3',
|
||
message: {
|
||
role: 'assistant',
|
||
tool_calls: [{ function: { name: 'write', arguments: '{"path":"a","cont' } }],
|
||
},
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
message: { role: 'assistant', content: '' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 5,
|
||
eval_count: 1,
|
||
},
|
||
]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(1);
|
||
const args = completes[0].toolCall!.args as Record<string, unknown>;
|
||
expect(args._truncatedArguments).toBe(true);
|
||
expect(String(args._truncatedReason)).toContain('truncated');
|
||
// done/USAGE 未被吞
|
||
expect(events.some((e) => e.type === MetonaStreamEventType.USAGE)).toBe(true);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('工具参数为对象类型 → 原样保留', async () => {
|
||
const adapter = makeAdapter();
|
||
mockNDJSON([
|
||
{
|
||
model: 'm',
|
||
message: {
|
||
role: 'assistant',
|
||
tool_calls: [{ function: { name: 'f', arguments: { a: 1 } } }],
|
||
},
|
||
},
|
||
{ model: 'm', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' },
|
||
]);
|
||
const events = await collectStream(adapter, makeRequest());
|
||
const complete = events.find((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(complete?.toolCall?.args).toEqual({ a: 1 });
|
||
});
|
||
});
|
||
|
||
// ===== 非流式 send 响应 =====
|
||
|
||
describe('OllamaAdapter — 非流式 send', () => {
|
||
function mockJsonResponse(body: unknown): void {
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => body,
|
||
} as unknown as Response);
|
||
}
|
||
|
||
it('send 组装 MetonaResponse(含 perfStats / done_reason 映射)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockJsonResponse({
|
||
model: 'qwen3',
|
||
message: { role: 'assistant', content: 'answer', thinking: 'trace' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 4,
|
||
eval_count: 3,
|
||
load_duration: 1_000_000,
|
||
eval_duration: 2_000_000_000,
|
||
});
|
||
const res = await adapter.send(makeRequest({ params: { stream: false } }));
|
||
expect(res.content).toBe('answer');
|
||
expect(res.reasoningContent).toBe('trace');
|
||
expect(res.usage.totalTokens).toBe(7);
|
||
expect(res.finishReason).toBe('stop');
|
||
expect(res.meta.perfStats).toMatchObject({
|
||
loadDurationMs: 1,
|
||
evalDurationMs: 2000,
|
||
});
|
||
});
|
||
|
||
it('done_reason=length → LENGTH finishReason', async () => {
|
||
const adapter = makeAdapter();
|
||
mockJsonResponse({
|
||
message: { role: 'assistant', content: 'cut' },
|
||
done: true,
|
||
done_reason: 'length',
|
||
prompt_eval_count: 1,
|
||
eval_count: 100,
|
||
});
|
||
const res = await adapter.send(makeRequest());
|
||
expect(res.finishReason).toBe('length');
|
||
});
|
||
|
||
it('message.tool_calls 存在 → TOOL_CALLS finishReason(done_reason 无关)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockJsonResponse({
|
||
message: {
|
||
role: 'assistant',
|
||
content: '',
|
||
tool_calls: [{ function: { name: 'f', arguments: '{"a":1}' } }],
|
||
},
|
||
done: true,
|
||
done_reason: 'stop',
|
||
prompt_eval_count: 0,
|
||
eval_count: 0,
|
||
});
|
||
const res = await adapter.send(makeRequest());
|
||
expect(res.finishReason).toBe('tool_calls');
|
||
expect(res.toolCalls).toHaveLength(1);
|
||
});
|
||
|
||
it('非流式坏参 → _truncatedArguments 自愈', async () => {
|
||
const adapter = makeAdapter();
|
||
mockJsonResponse({
|
||
message: {
|
||
role: 'assistant',
|
||
content: '',
|
||
tool_calls: [{ function: { name: 'f', arguments: '{bad' } }],
|
||
},
|
||
done: true,
|
||
done_reason: 'tool_calls',
|
||
prompt_eval_count: 0,
|
||
eval_count: 0,
|
||
});
|
||
const res = await adapter.send(makeRequest());
|
||
const args = res.toolCalls![0].args as Record<string, unknown>;
|
||
expect(args._truncatedArguments).toBe(true);
|
||
});
|
||
|
||
it('HTTP 错误 → throwHttpError 携带 status', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
statusText: 'Internal Server Error',
|
||
text: async () => 'oops',
|
||
} as unknown as Response);
|
||
const err = await adapter.send(makeRequest()).catch((e: unknown) => e);
|
||
expect((err as Error & { status?: number }).status).toBe(500);
|
||
});
|
||
});
|
||
|
||
// ===== pullModel =====
|
||
|
||
describe('OllamaAdapter — pullModel 进度 / 取消', () => {
|
||
it('进度逐行回调(status/completed/total),结束后 resolve', async () => {
|
||
const adapter = makeAdapter();
|
||
const lines = [
|
||
{ status: 'pulling manifest' },
|
||
{ status: 'downloading', completed: 50, total: 100 },
|
||
{ status: 'downloading', completed: 100, total: 100 },
|
||
{ status: 'success', completed: 100, total: 100 },
|
||
];
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
const encoder = new TextEncoder();
|
||
for (const l of lines) controller.enqueue(encoder.encode(JSON.stringify(l) + '\n'));
|
||
controller.close();
|
||
},
|
||
});
|
||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||
|
||
const progress: Array<Record<string, unknown>> = [];
|
||
await adapter.pullModel('qwen3:8b', (p) => progress.push(p as Record<string, unknown>));
|
||
|
||
expect(progress).toHaveLength(4);
|
||
expect(progress[1]).toEqual({ status: 'downloading', completed: 50, total: 100 });
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
'http://localhost:11434/api/pull',
|
||
expect.objectContaining({ body: JSON.stringify({ model: 'qwen3:8b', stream: true }) }),
|
||
);
|
||
});
|
||
|
||
it('非 JSON 行(进度提示)被跳过不中断', async () => {
|
||
const adapter = makeAdapter();
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
const encoder = new TextEncoder();
|
||
controller.enqueue(encoder.encode('some text line\n'));
|
||
controller.enqueue(encoder.encode(JSON.stringify({ status: 'success' }) + '\n'));
|
||
controller.close();
|
||
},
|
||
});
|
||
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
|
||
const progress: Array<Record<string, unknown>> = [];
|
||
await adapter.pullModel('m', (p) => progress.push(p as Record<string, unknown>));
|
||
expect(progress).toEqual([{ status: 'success' }]);
|
||
});
|
||
|
||
it('取消信号中止底层 fetch(signal 透传)', async () => {
|
||
const adapter = makeAdapter();
|
||
const controller = new AbortController();
|
||
const signalSpy = vi.fn();
|
||
mockFetch.mockImplementation((_url: string, init: RequestInit) => {
|
||
init.signal?.addEventListener('abort', signalSpy);
|
||
return new Promise<Response>((_r, rej) => {
|
||
init.signal?.addEventListener('abort', () =>
|
||
rej(new DOMException('aborted', 'AbortError')),
|
||
);
|
||
});
|
||
});
|
||
const promise = adapter.pullModel('qwen3:8b', undefined, controller.signal);
|
||
controller.abort();
|
||
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
|
||
expect(signalSpy).toHaveBeenCalled();
|
||
});
|
||
|
||
it('HTTP 非 2xx → 抛 Ollama pull error', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({ ok: false, status: 404, body: null } as unknown as Response);
|
||
await expect(adapter.pullModel('nope')).rejects.toThrow('Ollama pull error: 404');
|
||
});
|
||
});
|
||
|
||
// ===== probeCapabilities / showModel / listModels =====
|
||
|
||
describe('OllamaAdapter — 能力探测', () => {
|
||
it('probeCapabilities 从 capabilities[] 解析三能力', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
parameters: '',
|
||
template: '',
|
||
capabilities: ['tools', 'vision', 'thinking'],
|
||
}),
|
||
} as unknown as Response);
|
||
const caps = await adapter.probeCapabilities('qwen3');
|
||
expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true });
|
||
});
|
||
|
||
// v0.8.1: showModel 保留 undefined 语义 —— 响应缺 capabilities 字段 = 未知 → null
|
||
//(fail-open),不再把"字段缺失"与"权威空"混同(探测竞态下曾误判不支持思考)
|
||
it('capabilities 字段缺失 → 返回 null(未知,fail-open)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ parameters: '', template: '' }),
|
||
} as unknown as Response);
|
||
expect(await adapter.probeCapabilities('qwen3')).toBeNull();
|
||
});
|
||
|
||
it('capabilities 为显式空数组(服务端权威无能力)→ 三能力全 false', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ parameters: '', template: '', capabilities: [] }),
|
||
} as unknown as Response);
|
||
expect(await adapter.probeCapabilities('qwen3')).toEqual({
|
||
supportsTools: false,
|
||
supportsVision: false,
|
||
supportsThinking: false,
|
||
});
|
||
});
|
||
|
||
it('showModel 失败 → probeCapabilities 返回 null(调用方回退保守 true)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockRejectedValue(new Error('down'));
|
||
expect(await adapter.probeCapabilities('qwen3')).toBeNull();
|
||
});
|
||
|
||
it('showModel 非 2xx → null', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({ ok: false, status: 500 } as unknown as Response);
|
||
expect(await adapter.showModel('m')).toBeNull();
|
||
});
|
||
|
||
it('showModel 网络异常 → null(不抛出)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||
expect(await adapter.showModel('m')).toBeNull();
|
||
});
|
||
|
||
it('listModels:/api/tags 成功 → 逐模型探测并组装元信息', async () => {
|
||
// 构造时的 fire-and-forget /api/show 探测会先消费第一个 mock → 先给良性响应
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ parameters: '' }),
|
||
} as unknown as Response)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
models: [
|
||
{
|
||
name: 'qwen3',
|
||
size: 123,
|
||
details: { family: 'qwen', parameter_size: '8B', quantization_level: 'Q4' },
|
||
},
|
||
],
|
||
}),
|
||
} as unknown as Response)
|
||
.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ parameters: '', template: '', capabilities: ['tools', 'vision'] }),
|
||
} as unknown as Response);
|
||
const adapter = makeAdapter();
|
||
const models = await adapter.listModels();
|
||
expect(models).toHaveLength(1);
|
||
expect(models[0]).toMatchObject({
|
||
id: 'qwen3',
|
||
supportsToolCalling: true,
|
||
supportsVision: true,
|
||
supportsThinking: false,
|
||
});
|
||
expect(models[0].description).toContain('qwen / 8B / Q4');
|
||
});
|
||
|
||
it('listModels:API 失败 → 降级 supportedModels', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockRejectedValue(new Error('network'));
|
||
const models = await adapter.listModels();
|
||
expect(models.map((m) => m.id)).toEqual([
|
||
'qwen3:latest',
|
||
'gemma3:latest',
|
||
'deepseek-r1:latest',
|
||
]);
|
||
});
|
||
|
||
it('getVersion 成功返回版本号;失败返回 unknown', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ version: '0.5.1' }),
|
||
} as unknown as Response);
|
||
expect(await adapter.getVersion()).toBe('0.5.1');
|
||
|
||
mockFetch.mockReset();
|
||
mockFetch.mockRejectedValue(new Error('down'));
|
||
expect(await adapter.getVersion()).toBe('unknown');
|
||
});
|
||
|
||
it('listRunning 映射运行中模型;失败返回空数组', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
models: [{ name: 'qwen3', size: 100, size_vram: 90, context_length: 4096 }],
|
||
}),
|
||
} as unknown as Response);
|
||
const running = await adapter.listRunning();
|
||
expect(running[0]).toEqual({ name: 'qwen3', size: 100, sizeVram: 90, contextLength: 4096 });
|
||
|
||
mockFetch.mockReset();
|
||
mockFetch.mockRejectedValue(new Error('down'));
|
||
expect(await adapter.listRunning()).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ===== 图片 URL → base64 =====
|
||
|
||
describe('OllamaAdapter — 图片归一化', () => {
|
||
function okChatResponse(): Response {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
message: { role: 'assistant', content: 'ok' },
|
||
done: true,
|
||
done_reason: 'stop',
|
||
}),
|
||
} as unknown as Response;
|
||
}
|
||
|
||
function lastBody(): Record<string, unknown> {
|
||
const calls = mockFetch.mock.calls;
|
||
const chatCall = calls.find(([url]) => String(url).includes('/api/chat')) as [
|
||
string,
|
||
RequestInit,
|
||
];
|
||
return JSON.parse(String(chatCall[1].body));
|
||
}
|
||
|
||
it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => {
|
||
const adapter = makeAdapter();
|
||
const imageBytes = new TextEncoder().encode('IMG-BYTES');
|
||
// v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器)
|
||
const restore = __imageFetcher.current;
|
||
__imageFetcher.current = (async () =>
|
||
new Response(imageBytes, {
|
||
status: 200,
|
||
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();
|
||
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 () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue(okChatResponse());
|
||
await adapter.send(
|
||
makeRequest({
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: '看图',
|
||
images: [
|
||
{ url: 'data:image/png;base64,AAAABBBB' },
|
||
{ url: 'iVBORw0KGgo', detail: 'high' },
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
const body = lastBody();
|
||
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
|
||
(m) => m.role === 'user',
|
||
);
|
||
expect(userMsg!.images).toEqual(['AAAABBBB', 'iVBORw0KGgo']);
|
||
});
|
||
|
||
it('HTTP 下载响应非 2xx → 图片降级忽略', async () => {
|
||
const adapter = makeAdapter();
|
||
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;
|
||
}
|
||
});
|
||
});
|
||
|
||
// ===== getContextWindow =====
|
||
|
||
describe('OllamaAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => {
|
||
it('未配置 contextWindow → 返回 0(无 4096 写死兜底,引擎跳过压缩判定)', () => {
|
||
const adapter = makeAdapter();
|
||
expect(adapter.getContextWindow()).toBe(0);
|
||
});
|
||
|
||
it('config.contextWindow(llm.contextWindow 注入)→ 返回配置值', () => {
|
||
const adapter = makeAdapter('qwen3', { contextWindow: 32_768 });
|
||
expect(adapter.getContextWindow()).toBe(32_768);
|
||
});
|
||
|
||
it('/api/show 探测不再缓存窗口数值(num_ctx 由引擎 contextLength 下发)', async () => {
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }),
|
||
} as unknown as Response);
|
||
const adapter = makeAdapter();
|
||
// 探测完成(含失败路径)后窗口仍为 0 —— 探测只服务于能力门控
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(adapter.getContextWindow()).toBe(0);
|
||
});
|
||
|
||
it('探测失败(网络错误)→ 仍返回配置值/0,不抛错不阻塞', async () => {
|
||
mockFetch.mockRejectedValue(new Error('down'));
|
||
const adapter = makeAdapter();
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(adapter.getContextWindow()).toBe(0);
|
||
});
|
||
});
|