BREAKING CHANGE(vercel-ai-sdk): migrate to Vercel AI SDK v6 and introduce provider registry (getModel) returning LanguageModelV3
This commit is contained in:
390
test/test.ollama.ts
Normal file
390
test/test.ollama.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { createOllamaModel } from '../ts/smartai.provider.ollama.js';
|
||||
import type { ISmartAiOptions } from '../ts/smartai.interfaces.js';
|
||||
|
||||
tap.test('createOllamaModel returns valid LanguageModelV3', async () => {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
ollamaOptions: { think: true, num_ctx: 4096 },
|
||||
});
|
||||
|
||||
expect(model.specificationVersion).toEqual('v3');
|
||||
expect(model.provider).toEqual('ollama');
|
||||
expect(model.modelId).toEqual('qwen3:8b');
|
||||
expect(model).toHaveProperty('doGenerate');
|
||||
expect(model).toHaveProperty('doStream');
|
||||
});
|
||||
|
||||
tap.test('Qwen models get default temperature 0.55', async () => {
|
||||
// Mock fetch to capture the request body
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init?.body as string);
|
||||
return new Response(JSON.stringify({
|
||||
message: { content: 'test response', role: 'assistant' },
|
||||
done: true,
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
});
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
inputFormat: 'prompt',
|
||||
} as any);
|
||||
|
||||
expect(capturedBody).toBeTruthy();
|
||||
// Temperature 0.55 should be in the options
|
||||
expect((capturedBody!.options as Record<string, unknown>).temperature).toEqual(0.55);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('think option is passed at top level of request body', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init?.body as string);
|
||||
return new Response(JSON.stringify({
|
||||
message: { content: 'test', role: 'assistant', thinking: 'let me think...' },
|
||||
done: true,
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
ollamaOptions: { think: true, num_ctx: 4096 },
|
||||
});
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
inputFormat: 'prompt',
|
||||
} as any);
|
||||
|
||||
expect(capturedBody).toBeTruthy();
|
||||
// think should be at top level, not inside options
|
||||
expect(capturedBody!.think).toEqual(true);
|
||||
// num_ctx should be in options
|
||||
expect((capturedBody!.options as Record<string, unknown>).num_ctx).toEqual(4096);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Non-qwen models do not get default temperature', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init?.body as string);
|
||||
return new Response(JSON.stringify({
|
||||
message: { content: 'test', role: 'assistant' },
|
||||
done: true,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'llama3:8b',
|
||||
});
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
inputFormat: 'prompt',
|
||||
} as any);
|
||||
|
||||
expect(capturedBody).toBeTruthy();
|
||||
// No temperature should be set
|
||||
expect((capturedBody!.options as Record<string, unknown>).temperature).toBeUndefined();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('doGenerate parses reasoning/thinking from response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return new Response(JSON.stringify({
|
||||
message: {
|
||||
content: 'The answer is 42.',
|
||||
role: 'assistant',
|
||||
thinking: 'Let me reason about this carefully...',
|
||||
},
|
||||
done: true,
|
||||
prompt_eval_count: 20,
|
||||
eval_count: 15,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
ollamaOptions: { think: true },
|
||||
});
|
||||
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'What is the meaning of life?' }] }],
|
||||
} as any);
|
||||
|
||||
// Should have both reasoning and text content
|
||||
const reasoningParts = result.content.filter(c => c.type === 'reasoning');
|
||||
const textParts = result.content.filter(c => c.type === 'text');
|
||||
|
||||
expect(reasoningParts.length).toEqual(1);
|
||||
expect((reasoningParts[0] as any).text).toEqual('Let me reason about this carefully...');
|
||||
expect(textParts.length).toEqual(1);
|
||||
expect((textParts[0] as any).text).toEqual('The answer is 42.');
|
||||
expect(result.finishReason.unified).toEqual('stop');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('doGenerate parses tool calls from response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return new Response(JSON.stringify({
|
||||
message: {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
arguments: { location: 'London', unit: 'celsius' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
done: true,
|
||||
prompt_eval_count: 30,
|
||||
eval_count: 10,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
});
|
||||
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'What is the weather in London?' }] }],
|
||||
tools: [{
|
||||
type: 'function' as const,
|
||||
name: 'get_weather',
|
||||
description: 'Get weather for a location',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' },
|
||||
unit: { type: 'string' },
|
||||
},
|
||||
},
|
||||
}],
|
||||
} as any);
|
||||
|
||||
const toolCalls = result.content.filter(c => c.type === 'tool-call');
|
||||
expect(toolCalls.length).toEqual(1);
|
||||
expect((toolCalls[0] as any).toolName).toEqual('get_weather');
|
||||
expect(JSON.parse((toolCalls[0] as any).input)).toEqual({ location: 'London', unit: 'celsius' });
|
||||
expect(result.finishReason.unified).toEqual('tool-calls');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('doStream produces correct stream parts', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// Simulate Ollama's newline-delimited JSON streaming
|
||||
const chunks = [
|
||||
JSON.stringify({ message: { content: 'Hello', role: 'assistant' }, done: false }) + '\n',
|
||||
JSON.stringify({ message: { content: ' world', role: 'assistant' }, done: false }) + '\n',
|
||||
JSON.stringify({ message: { content: '!', role: 'assistant' }, done: true, prompt_eval_count: 5, eval_count: 3 }) + '\n',
|
||||
];
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'llama3:8b',
|
||||
});
|
||||
|
||||
const result = await model.doStream({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const parts: any[] = [];
|
||||
const reader = result.stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
parts.push(value);
|
||||
}
|
||||
|
||||
// Should have: text-start, text-delta x3, text-end, finish
|
||||
const textDeltas = parts.filter(p => p.type === 'text-delta');
|
||||
const finishParts = parts.filter(p => p.type === 'finish');
|
||||
const textStarts = parts.filter(p => p.type === 'text-start');
|
||||
const textEnds = parts.filter(p => p.type === 'text-end');
|
||||
|
||||
expect(textStarts.length).toEqual(1);
|
||||
expect(textDeltas.length).toEqual(3);
|
||||
expect(textDeltas.map((d: any) => d.delta).join('')).toEqual('Hello world!');
|
||||
expect(textEnds.length).toEqual(1);
|
||||
expect(finishParts.length).toEqual(1);
|
||||
expect(finishParts[0].finishReason.unified).toEqual('stop');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('doStream handles thinking/reasoning in stream', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const chunks = [
|
||||
JSON.stringify({ message: { thinking: 'Let me think...', content: '', role: 'assistant' }, done: false }) + '\n',
|
||||
JSON.stringify({ message: { thinking: ' about this.', content: '', role: 'assistant' }, done: false }) + '\n',
|
||||
JSON.stringify({ message: { content: 'The answer.', role: 'assistant' }, done: false }) + '\n',
|
||||
JSON.stringify({ message: { content: '', role: 'assistant' }, done: true, prompt_eval_count: 10, eval_count: 8 }) + '\n',
|
||||
];
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'qwen3:8b',
|
||||
ollamaOptions: { think: true },
|
||||
});
|
||||
|
||||
const result = await model.doStream({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'think about this' }] }],
|
||||
} as any);
|
||||
|
||||
const parts: any[] = [];
|
||||
const reader = result.stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
parts.push(value);
|
||||
}
|
||||
|
||||
const reasoningStarts = parts.filter(p => p.type === 'reasoning-start');
|
||||
const reasoningDeltas = parts.filter(p => p.type === 'reasoning-delta');
|
||||
const reasoningEnds = parts.filter(p => p.type === 'reasoning-end');
|
||||
const textDeltas = parts.filter(p => p.type === 'text-delta');
|
||||
|
||||
expect(reasoningStarts.length).toEqual(1);
|
||||
expect(reasoningDeltas.length).toEqual(2);
|
||||
expect(reasoningDeltas.map((d: any) => d.delta).join('')).toEqual('Let me think... about this.');
|
||||
expect(reasoningEnds.length).toEqual(1);
|
||||
expect(textDeltas.length).toEqual(1);
|
||||
expect(textDeltas[0].delta).toEqual('The answer.');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('message conversion handles system, assistant, and tool messages', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init?.body as string);
|
||||
return new Response(JSON.stringify({
|
||||
message: { content: 'response', role: 'assistant' },
|
||||
done: true,
|
||||
}), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const model = createOllamaModel({
|
||||
provider: 'ollama',
|
||||
model: 'llama3:8b',
|
||||
});
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check.' },
|
||||
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: '{"q":"test"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: 'tc1', output: { type: 'text', value: 'result data' } },
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'What did you find?' }] },
|
||||
],
|
||||
} as any);
|
||||
|
||||
const messages = capturedBody!.messages as Array<Record<string, unknown>>;
|
||||
expect(messages.length).toEqual(5);
|
||||
expect(messages[0].role).toEqual('system');
|
||||
expect(messages[0].content).toEqual('You are helpful.');
|
||||
expect(messages[1].role).toEqual('user');
|
||||
expect(messages[1].content).toEqual('Hi');
|
||||
expect(messages[2].role).toEqual('assistant');
|
||||
expect(messages[2].content).toEqual('Let me check.');
|
||||
expect((messages[2].tool_calls as any[]).length).toEqual(1);
|
||||
expect((messages[2].tool_calls as any[])[0].function.name).toEqual('search');
|
||||
expect(messages[3].role).toEqual('tool');
|
||||
expect(messages[3].content).toEqual('result data');
|
||||
expect(messages[4].role).toEqual('user');
|
||||
expect(messages[4].content).toEqual('What did you find?');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user