refactor: migrate NativeOllamaHandler to AI SDK (#11355)

This commit is contained in:
Daniel 2026-02-10 12:36:28 -05:00 committed by GitHub
parent 4272a69726
commit cdd1a4dabf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 402 additions and 574 deletions

77
pnpm-lock.yaml generated
View file

@ -905,9 +905,9 @@ importers:
node-ipc:
specifier: ^12.0.0
version: 12.0.0
ollama:
specifier: ^0.5.17
version: 0.5.17
ollama-ai-provider-v2:
specifier: ^3.0.4
version: 3.0.4(ai@6.0.77(zod@3.25.76))(zod@3.25.76)
openai:
specifier: ^5.12.2
version: 5.12.2(ws@8.18.3)(zod@3.25.76)
@ -8565,8 +8565,12 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
ollama@0.5.17:
resolution: {integrity: sha512-q5LmPtk6GLFouS+3aURIVl+qcAOPC4+Msmx7uBb3pd+fxI55WnGjmLZ0yijI/CYy79x0QPGx3BwC3u5zv9fBvQ==}
ollama-ai-provider-v2@3.0.4:
resolution: {integrity: sha512-gtvqJcw1z9O/uYXgXdNAbm8roTJekJlZs5UMU62iElrsz/osUOGSuQxnSb5fU1SDPDWlZJ2xKotrAZU7+p2kqg==}
engines: {node: '>=18'}
peerDependencies:
ai: ^5.0.0 || ^6.0.0
zod: 3.25.76
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
@ -10744,9 +10748,6 @@ packages:
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
@ -12581,7 +12582,7 @@ snapshots:
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -13044,7 +13045,7 @@ snapshots:
'@puppeteer/browsers@2.10.5':
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@ -13057,7 +13058,7 @@ snapshots:
'@puppeteer/browsers@2.6.1':
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@ -15436,7 +15437,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
http-errors: 2.0.0
iconv-lite: 0.6.3
on-finished: 2.4.1
@ -16168,6 +16169,10 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@8.1.1):
dependencies:
ms: 2.1.3
@ -16254,8 +16259,7 @@ snapshots:
detect-libc@2.0.4: {}
detect-libc@2.1.2:
optional: true
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -16920,7 +16924,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -16956,7 +16960,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@ -17057,7 +17061,7 @@ snapshots:
finalhandler@2.1.0:
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@ -17285,7 +17289,7 @@ snapshots:
dependencies:
basic-ftp: 5.0.5
data-uri-to-buffer: 6.0.2
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -17580,14 +17584,14 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -18313,7 +18317,7 @@ snapshots:
lightningcss@1.30.1:
dependencies:
detect-libc: 2.0.4
detect-libc: 2.1.2
optionalDependencies:
lightningcss-darwin-arm64: 1.30.1
lightningcss-darwin-x64: 1.30.1
@ -19298,9 +19302,12 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
ollama@0.5.17:
ollama-ai-provider-v2@3.0.4(ai@6.0.77(zod@3.25.76))(zod@3.25.76):
dependencies:
whatwg-fetch: 3.6.20
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.14(zod@3.25.76)
ai: 6.0.77(zod@3.25.76)
zod: 3.25.76
on-finished@2.4.1:
dependencies:
@ -19462,7 +19469,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.3
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
get-uri: 6.0.4
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@ -19814,7 +19821,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.3
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@ -19855,7 +19862,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.6.1
chromium-bidi: 0.11.0(devtools-protocol@0.0.1367902)
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
devtools-protocol: 0.0.1367902
typed-query-selector: 2.12.0
ws: 8.18.2
@ -19869,7 +19876,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.10.5
chromium-bidi: 5.1.0(devtools-protocol@0.0.1452169)
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
devtools-protocol: 0.0.1452169
typed-query-selector: 2.12.0
ws: 8.18.2
@ -20390,7 +20397,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@ -20507,7 +20514,7 @@ snapshots:
send@1.2.0:
dependencies:
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -20679,7 +20686,7 @@ snapshots:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -20728,7 +20735,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.3
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
socks: 2.8.4
transitivePeerDependencies:
- supports-color
@ -21238,7 +21245,7 @@ snapshots:
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
esbuild: 0.25.9
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
@ -21639,7 +21646,7 @@ snapshots:
vite-node@3.2.4(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
@ -21806,7 +21813,7 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1(supports-color@8.1.1)
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
@ -21983,8 +21990,6 @@ snapshots:
dependencies:
iconv-lite: 0.6.3
whatwg-fetch@3.6.20: {}
whatwg-mimetype@4.0.0: {}
whatwg-url@14.2.0:

View file

@ -1,34 +1,46 @@
// npx vitest run api/providers/__tests__/native-ollama.spec.ts
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
}
})
vi.mock("ollama-ai-provider-v2", () => ({
createOllama: vi.fn(() => {
return vi.fn(() => ({
modelId: "llama2",
provider: "ollama",
}))
}),
}))
// Mock the getOllamaModels function
vi.mock("../fetchers/ollama", () => ({
getOllamaModels: vi.fn(),
}))
import { NativeOllamaHandler } from "../native-ollama"
import { ApiHandlerOptions } from "../../../shared/api"
import { getOllamaModels } from "../fetchers/ollama"
// Mock the ollama package
const mockChat = vitest.fn()
vitest.mock("ollama", () => {
return {
Ollama: vitest.fn().mockImplementation(() => ({
chat: mockChat,
})),
Message: vitest.fn(),
}
})
// Mock the getOllamaModels function
vitest.mock("../fetchers/ollama", () => ({
getOllamaModels: vitest.fn(),
}))
const mockGetOllamaModels = vitest.mocked(getOllamaModels)
const mockGetOllamaModels = vi.mocked(getOllamaModels)
describe("NativeOllamaHandler", () => {
let handler: NativeOllamaHandler
beforeEach(() => {
vitest.clearAllMocks()
vi.clearAllMocks()
// Default mock for getOllamaModels
mockGetOllamaModels.mockResolvedValue({
llama2: {
contextWindow: 4096,
@ -49,18 +61,14 @@ describe("NativeOllamaHandler", () => {
describe("createMessage", () => {
it("should stream messages from Ollama", async () => {
// Mock the chat response as an async generator
mockChat.mockImplementation(async function* () {
yield {
message: { content: "Hello" },
eval_count: undefined,
prompt_eval_count: undefined,
}
yield {
message: { content: " world" },
eval_count: 2,
prompt_eval_count: 10,
}
async function* mockFullStream() {
yield { type: "text-delta", text: "Hello" }
yield { type: "text-delta", text: " world" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 2 }),
})
const systemPrompt = "You are a helpful assistant"
@ -79,57 +87,57 @@ describe("NativeOllamaHandler", () => {
expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 })
})
it("should not include num_ctx by default", async () => {
// Mock the chat response
mockChat.mockImplementation(async function* () {
yield { message: { content: "Response" } }
it("should not include providerOptions by default (no num_ctx)", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
// Consume the stream
for await (const _ of stream) {
// consume stream
}
// Verify that num_ctx was NOT included in the options
expect(mockChat).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.objectContaining({
num_ctx: expect.anything(),
}),
expect(mockStreamText).toHaveBeenCalledWith(
expect.not.objectContaining({
providerOptions: expect.anything(),
}),
)
})
it("should include num_ctx when explicitly set via ollamaNumCtx", async () => {
it("should include num_ctx via providerOptions when explicitly set via ollamaNumCtx", async () => {
const options: ApiHandlerOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
ollamaBaseUrl: "http://localhost:11434",
ollamaNumCtx: 8192, // Explicitly set num_ctx
ollamaNumCtx: 8192,
}
handler = new NativeOllamaHandler(options)
// Mock the chat response
mockChat.mockImplementation(async function* () {
yield { message: { content: "Response" } }
async function* mockFullStream() {
yield { type: "text-delta", text: "Response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
// Consume the stream
for await (const _ of stream) {
// consume stream
}
// Verify that num_ctx was included with the specified value
expect(mockChat).toHaveBeenCalledWith(
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
num_ctx: 8192,
}),
providerOptions: { ollama: { options: { num_ctx: 8192 } } },
}),
)
})
@ -143,11 +151,14 @@ describe("NativeOllamaHandler", () => {
handler = new NativeOllamaHandler(options)
// Mock response with thinking tags
mockChat.mockImplementation(async function* () {
yield { message: { content: "<think>Let me think" } }
yield { message: { content: " about this</think>" } }
yield { message: { content: "The answer is 42" } }
async function* mockFullStream() {
yield { type: "reasoning-delta", text: "Let me think about this" }
yield { type: "text-delta", text: "The answer is 42" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Question?" }])
@ -157,70 +168,67 @@ describe("NativeOllamaHandler", () => {
results.push(chunk)
}
// Should detect reasoning vs regular text
expect(results.some((r) => r.type === "reasoning")).toBe(true)
expect(results.some((r) => r.type === "text")).toBe(true)
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
providerOptions: { ollama: { think: true } },
}),
)
})
})
describe("completePrompt", () => {
it("should complete a prompt without streaming", async () => {
mockChat.mockResolvedValue({
message: { content: "This is the response" },
mockGenerateText.mockResolvedValue({
text: "This is the response",
})
const result = await handler.completePrompt("Tell me a joke")
expect(mockChat).toHaveBeenCalledWith({
model: "llama2",
messages: [{ role: "user", content: "Tell me a joke" }],
stream: false,
options: {
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Tell me a joke",
temperature: 0,
},
})
}),
)
expect(result).toBe("This is the response")
})
it("should not include num_ctx in completePrompt by default", async () => {
mockChat.mockResolvedValue({
message: { content: "Response" },
it("should not include providerOptions in completePrompt by default", async () => {
mockGenerateText.mockResolvedValue({
text: "Response",
})
await handler.completePrompt("Test prompt")
// Verify that num_ctx was NOT included in the options
expect(mockChat).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.objectContaining({
num_ctx: expect.anything(),
}),
expect(mockGenerateText).toHaveBeenCalledWith(
expect.not.objectContaining({
providerOptions: expect.anything(),
}),
)
})
it("should include num_ctx in completePrompt when explicitly set", async () => {
it("should include num_ctx via providerOptions in completePrompt when explicitly set", async () => {
const options: ApiHandlerOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
ollamaBaseUrl: "http://localhost:11434",
ollamaNumCtx: 4096, // Explicitly set num_ctx
ollamaNumCtx: 4096,
}
handler = new NativeOllamaHandler(options)
mockChat.mockResolvedValue({
message: { content: "Response" },
mockGenerateText.mockResolvedValue({
text: "Response",
})
await handler.completePrompt("Test prompt")
// Verify that num_ctx was included with the specified value
expect(mockChat).toHaveBeenCalledWith(
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
num_ctx: 4096,
}),
providerOptions: { ollama: { options: { num_ctx: 4096 } } },
}),
)
})
@ -230,7 +238,17 @@ describe("NativeOllamaHandler", () => {
it("should handle connection refused errors", async () => {
const error = new Error("ECONNREFUSED") as any
error.code = "ECONNREFUSED"
mockChat.mockRejectedValue(error)
const mockFullStream = {
[Symbol.asyncIterator]: () => ({
next: () => Promise.reject(error),
}),
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
@ -244,7 +262,17 @@ describe("NativeOllamaHandler", () => {
it("should handle model not found errors", async () => {
const error = new Error("Not found") as any
error.status = 404
mockChat.mockRejectedValue(error)
const mockFullStream = {
[Symbol.asyncIterator]: () => ({
next: () => Promise.reject(error),
}),
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
@ -264,9 +292,14 @@ describe("NativeOllamaHandler", () => {
})
})
describe("isAiSdkProvider", () => {
it("should return true", () => {
expect(handler.isAiSdkProvider()).toBe(true)
})
})
describe("tool calling", () => {
it("should include tools when tools are provided", async () => {
// Model metadata should not gate tool inclusion; metadata.tools controls it.
it("should pass tools via AI SDK when tools are provided", async () => {
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
@ -284,9 +317,13 @@ describe("NativeOllamaHandler", () => {
handler = new NativeOllamaHandler(options)
// Mock the chat response
mockChat.mockImplementation(async function* () {
yield { message: { content: "I will use the tool" } }
async function* mockFullStream() {
yield { type: "text-delta", text: "I will use the tool" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const tools = [
@ -312,36 +349,18 @@ describe("NativeOllamaHandler", () => {
{ taskId: "test", tools },
)
// Consume the stream
for await (const _ of stream) {
// consume stream
}
// Verify tools were passed to the API
expect(mockChat).toHaveBeenCalledWith(
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "The city name" },
},
required: ["location"],
},
},
},
],
tools: expect.any(Object),
}),
)
})
it("should include tools even when model metadata doesn't advertise tool support", async () => {
// Model metadata should not gate tool inclusion; metadata.tools controls it.
it("should pass tools even when model metadata doesn't advertise tool support", async () => {
mockGetOllamaModels.mockResolvedValue({
llama2: {
contextWindow: 4096,
@ -351,9 +370,13 @@ describe("NativeOllamaHandler", () => {
},
})
// Mock the chat response
mockChat.mockImplementation(async function* () {
yield { message: { content: "Response without tools" } }
async function* mockFullStream() {
yield { type: "text-delta", text: "Response without tools" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const tools = [
@ -372,21 +395,18 @@ describe("NativeOllamaHandler", () => {
tools,
})
// Consume the stream
for await (const _ of stream) {
// consume stream
}
// Verify tools were passed
expect(mockChat).toHaveBeenCalledWith(
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.any(Array),
tools: expect.any(Object),
}),
)
})
it("should not include tools when no tools are provided", async () => {
// Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
@ -404,30 +424,31 @@ describe("NativeOllamaHandler", () => {
handler = new NativeOllamaHandler(options)
// Mock the chat response
mockChat.mockImplementation(async function* () {
yield { message: { content: "Response" } }
async function* mockFullStream() {
yield { type: "text-delta", text: "Response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], {
taskId: "test",
})
// Consume the stream
for await (const _ of stream) {
// consume stream
}
// Verify tools were NOT passed
expect(mockChat).toHaveBeenCalledWith(
expect.not.objectContaining({
tools: expect.anything(),
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: undefined,
}),
)
})
it("should yield tool_call_partial when model returns tool calls", async () => {
// Model metadata should not gate tool inclusion; metadata.tools controls it.
it("should yield tool call events when model returns tool calls", async () => {
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
@ -445,21 +466,26 @@ describe("NativeOllamaHandler", () => {
handler = new NativeOllamaHandler(options)
// Mock the chat response with tool calls
mockChat.mockImplementation(async function* () {
async function* mockFullStream() {
yield {
message: {
content: "",
tool_calls: [
{
function: {
name: "get_weather",
arguments: { location: "San Francisco" },
},
},
],
},
type: "tool-input-start",
id: "tool-call-1",
toolName: "get_weather",
}
yield {
type: "tool-input-delta",
id: "tool-call-1",
delta: '{"location":"San Francisco"}',
}
yield {
type: "tool-input-end",
id: "tool-call-1",
}
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const tools = [
@ -490,20 +516,26 @@ describe("NativeOllamaHandler", () => {
results.push(chunk)
}
// Should yield a tool_call_partial chunk
const toolCallChunk = results.find((r) => r.type === "tool_call_partial")
expect(toolCallChunk).toBeDefined()
expect(toolCallChunk).toEqual({
type: "tool_call_partial",
index: 0,
id: "ollama-tool-0",
const toolCallStart = results.find((r) => r.type === "tool_call_start")
expect(toolCallStart).toBeDefined()
expect(toolCallStart).toEqual({
type: "tool_call_start",
id: "tool-call-1",
name: "get_weather",
arguments: JSON.stringify({ location: "San Francisco" }),
})
const toolCallDelta = results.find((r) => r.type === "tool_call_delta")
expect(toolCallDelta).toBeDefined()
const toolCallEnd = results.find((r) => r.type === "tool_call_end")
expect(toolCallEnd).toBeDefined()
expect(toolCallEnd).toEqual({
type: "tool_call_end",
id: "tool-call-1",
})
})
it("should yield tool_call_end events after tool_call_partial chunks", async () => {
// Model metadata should not gate tool inclusion; metadata.tools controls it.
it("should yield tool_call_end events after tool_call_start for multiple tools", async () => {
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
@ -521,27 +553,18 @@ describe("NativeOllamaHandler", () => {
handler = new NativeOllamaHandler(options)
// Mock the chat response with multiple tool calls
mockChat.mockImplementation(async function* () {
yield {
message: {
content: "",
tool_calls: [
{
function: {
name: "get_weather",
arguments: { location: "San Francisco" },
},
},
{
function: {
name: "get_time",
arguments: { timezone: "PST" },
},
},
],
},
}
async function* mockFullStream() {
yield { type: "tool-input-start", id: "tool-0", toolName: "get_weather" }
yield { type: "tool-input-delta", id: "tool-0", delta: '{"location":"SF"}' }
yield { type: "tool-input-end", id: "tool-0" }
yield { type: "tool-input-start", id: "tool-1", toolName: "get_time" }
yield { type: "tool-input-delta", id: "tool-1", delta: '{"timezone":"PST"}' }
yield { type: "tool-input-end", id: "tool-1" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
})
const tools = [
@ -582,27 +605,18 @@ describe("NativeOllamaHandler", () => {
results.push(chunk)
}
// Should yield tool_call_partial chunks
const toolCallPartials = results.filter((r) => r.type === "tool_call_partial")
expect(toolCallPartials).toHaveLength(2)
const toolCallStarts = results.filter((r) => r.type === "tool_call_start")
expect(toolCallStarts).toHaveLength(2)
// Should yield tool_call_end events for each tool call
const toolCallEnds = results.filter((r) => r.type === "tool_call_end")
expect(toolCallEnds).toHaveLength(2)
expect(toolCallEnds[0]).toEqual({ type: "tool_call_end", id: "ollama-tool-0" })
expect(toolCallEnds[1]).toEqual({ type: "tool_call_end", id: "ollama-tool-1" })
expect(toolCallEnds[0]).toEqual({ type: "tool_call_end", id: "tool-0" })
expect(toolCallEnds[1]).toEqual({ type: "tool_call_end", id: "tool-1" })
// tool_call_end should come after tool_call_partial
// Find the last tool_call_partial index
let lastPartialIndex = -1
for (let i = results.length - 1; i >= 0; i--) {
if (results[i].type === "tool_call_partial") {
lastPartialIndex = i
break
}
}
// tool_call_end should come after corresponding tool_call_start
const firstStartIndex = results.findIndex((r) => r.type === "tool_call_start")
const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end")
expect(firstEndIndex).toBeGreaterThan(lastPartialIndex)
expect(firstEndIndex).toBeGreaterThan(firstStartIndex)
})
})
})

View file

@ -1,339 +1,43 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Message, Ollama, Tool as OllamaTool, type Config as OllamaOptions } from "ollama"
import { createOllama } from "ollama-ai-provider-v2"
import { streamText, generateText, ToolSet } from "ai"
import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import { getOllamaModels } from "./fetchers/ollama"
import { TagMatcher } from "../../utils/tag-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
interface OllamaChatOptions {
temperature: number
num_ctx?: number
}
function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
// Handle base64 images only (Anthropic SDK uses base64)
// Ollama expects raw base64 strings, not data URLs
if ("source" in part && part.source.type === "base64") {
toolResultImages.push(part.source.data)
}
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
// Separate text and images for Ollama
const textContent = nonToolMessages
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
const imageData: string[] = []
nonToolMessages.forEach((part) => {
if (part.type === "image" && "source" in part && part.source.type === "base64") {
// Ollama expects raw base64 strings, not data URLs
imageData.push(part.source.data)
}
})
ollamaMessages.push({
role: "user",
content: textContent,
images: imageData.length > 0 ? imageData : undefined,
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
// Convert tool_use blocks to Ollama tool_calls format
const toolCalls =
toolMessages.length > 0
? toolMessages.map((tool) => ({
function: {
name: tool.name,
arguments: tool.input as Record<string, unknown>,
},
}))
: undefined
ollamaMessages.push({
role: "assistant",
content,
tool_calls: toolCalls,
})
}
}
}
return ollamaMessages
}
/**
* NativeOllamaHandler using the ollama-ai-provider-v2 AI SDK community provider.
* Communicates with Ollama via its HTTP API through the AI SDK abstraction.
*/
export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: Ollama | undefined
protected provider: ReturnType<typeof createOllama>
protected models: Record<string, ModelInfo> = {}
constructor(options: ApiHandlerOptions) {
super()
this.options = options
}
private ensureClient(): Ollama {
if (!this.client) {
try {
const clientOptions: OllamaOptions = {
host: this.options.ollamaBaseUrl || "http://localhost:11434",
// Note: The ollama npm package handles timeouts internally
}
const baseUrl = options.ollamaBaseUrl || "http://localhost:11434"
// Add API key if provided (for Ollama cloud or authenticated instances)
if (this.options.ollamaApiKey) {
clientOptions.headers = {
Authorization: `Bearer ${this.options.ollamaApiKey}`,
}
}
this.client = new Ollama(clientOptions)
} catch (error: any) {
throw new Error(`Error creating Ollama client: ${error.message}`)
}
}
return this.client
}
/**
* Converts OpenAI-format tools to Ollama's native tool format.
* This allows NativeOllamaHandler to use the same tool definitions
* that are passed to OpenAI-compatible providers.
*/
private convertToolsToOllama(tools: OpenAI.Chat.ChatCompletionTool[] | undefined): OllamaTool[] | undefined {
if (!tools || tools.length === 0) {
return undefined
}
return tools
.filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function" } => tool.type === "function")
.map((tool) => ({
type: tool.type,
function: {
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters as OllamaTool["function"]["parameters"],
},
}))
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const client = this.ensureClient()
const { id: modelId } = await this.fetchModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const ollamaMessages: Message[] = [
{ role: "system", content: systemPrompt },
...convertToOllamaMessages(messages),
]
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
try {
// Build options object conditionally
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
}
// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
}
// Create the actual API request promise
const stream = await client.chat({
model: modelId,
messages: ollamaMessages,
stream: true,
options: chatOptions,
tools: this.convertToolsToOllama(metadata?.tools),
})
let totalInputTokens = 0
let totalOutputTokens = 0
// Track tool calls across chunks (Ollama may send complete tool_calls in final chunk)
let toolCallIndex = 0
// Track tool call IDs for emitting end events
const toolCallIds: string[] = []
try {
for await (const chunk of stream) {
if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) {
// Process content through matcher for reasoning detection
for (const matcherChunk of matcher.update(chunk.message.content)) {
yield matcherChunk
}
}
// Handle tool calls - emit partial chunks for NativeToolCallParser compatibility
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
for (const toolCall of chunk.message.tool_calls) {
// Generate a unique ID for this tool call
const toolCallId = `ollama-tool-${toolCallIndex}`
toolCallIds.push(toolCallId)
yield {
type: "tool_call_partial",
index: toolCallIndex,
id: toolCallId,
name: toolCall.function.name,
arguments: JSON.stringify(toolCall.function.arguments),
}
toolCallIndex++
}
}
// Handle token usage if available
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
if (chunk.prompt_eval_count) {
totalInputTokens = chunk.prompt_eval_count
}
if (chunk.eval_count) {
totalOutputTokens = chunk.eval_count
}
}
}
// Yield any remaining content from the matcher
for (const chunk of matcher.final()) {
yield chunk
}
for (const toolCallId of toolCallIds) {
yield {
type: "tool_call_end",
id: toolCallId,
}
}
// Yield usage information if available
if (totalInputTokens > 0 || totalOutputTokens > 0) {
yield {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
}
}
} catch (streamError: any) {
console.error("Error processing Ollama stream:", streamError)
throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`)
}
} catch (error: any) {
// Enhance error reporting
const statusCode = error.status || error.statusCode
const errorMessage = error.message || "Unknown error"
if (error.code === "ECONNREFUSED") {
throw new Error(
`Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`,
)
} else if (statusCode === 404) {
throw new Error(
`Model ${this.getModel().id} not found in Ollama. Please pull the model first with: ollama pull ${this.getModel().id}`,
)
}
console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
throw error
}
}
async fetchModel() {
this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey)
return this.getModel()
this.provider = createOllama({
baseURL: `${baseUrl}/api`,
headers: options.ollamaApiKey ? { Authorization: `Bearer ${options.ollamaApiKey}` } : undefined,
})
}
override getModel(): { id: string; info: ModelInfo } {
@ -344,35 +48,140 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
}
}
async completePrompt(prompt: string): Promise<string> {
async fetchModel() {
this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey)
return this.getModel()
}
protected getLanguageModel() {
const { id } = this.getModel()
return this.provider(id)
}
/**
* Build ollama-specific providerOptions based on model settings.
* The ollama-ai-provider-v2 schema expects:
* { ollama: { think?: boolean, options?: { num_ctx?: number, ... } } }
*/
private buildProviderOptions(useR1Format: boolean): Record<string, any> | undefined {
const ollamaOpts: Record<string, any> = {}
if (useR1Format) {
ollamaOpts.think = true
}
if (this.options.ollamaNumCtx !== undefined) {
ollamaOpts.options = { num_ctx: this.options.ollamaNumCtx }
}
if (Object.keys(ollamaOpts).length === 0) {
return undefined
}
return { ollama: ollamaOpts }
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
await this.fetchModel()
const { id: modelId } = this.getModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const temperature = this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0)
const languageModel = this.getLanguageModel()
const aiSdkMessages = convertToAiSdkMessages(messages)
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
const providerOptions = this.buildProviderOptions(useR1Format)
const requestOptions: Parameters<typeof streamText>[0] = {
model: languageModel,
system: systemPrompt,
messages: aiSdkMessages,
temperature,
tools: aiSdkTools,
toolChoice: mapToolChoice(metadata?.tool_choice),
...(providerOptions && { providerOptions }),
}
const result = streamText(requestOptions)
try {
const client = this.ensureClient()
const { id: modelId } = await this.fetchModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
// Build options object conditionally
const chatOptions: OllamaChatOptions = {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
for await (const part of result.fullStream) {
for (const chunk of processAiSdkStreamPart(part)) {
yield chunk
}
}
// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
const usage = await result.usage
if (usage) {
yield {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
}
}
const response = await client.chat({
model: modelId,
messages: [{ role: "user", content: prompt }],
stream: false,
options: chatOptions,
})
return response.message?.content || ""
} catch (error) {
if (error instanceof Error) {
throw new Error(`Ollama completion error: ${error.message}`)
}
throw error
this.handleOllamaError(error, modelId)
}
}
async completePrompt(prompt: string): Promise<string> {
try {
await this.fetchModel()
const { id: modelId } = this.getModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const temperature = this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0)
const languageModel = this.getLanguageModel()
const providerOptions = this.buildProviderOptions(useR1Format)
const { text } = await generateText({
model: languageModel,
prompt,
temperature,
...(providerOptions && { providerOptions }),
})
return text
} catch (error) {
const { id: modelId } = this.getModel()
this.handleOllamaError(error, modelId)
}
}
/**
* Handle Ollama-specific errors (ECONNREFUSED, 404) with user-friendly messages,
* falling back to the standard AI SDK error handler.
*/
private handleOllamaError(error: unknown, modelId: string): never {
const anyError = error as any
const errorMessage = anyError?.message || ""
const statusCode = anyError?.status || anyError?.statusCode || anyError?.lastError?.status
if (anyError?.code === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
throw new Error(
`Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`,
)
}
if (statusCode === 404 || errorMessage.includes("404")) {
throw new Error(
`Model ${modelId} not found in Ollama. Please pull the model first with: ollama pull ${modelId}`,
)
}
throw handleAiSdkError(error, "Ollama")
}
override isAiSdkProvider(): boolean {
return true
}
}

View file

@ -503,7 +503,7 @@
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"node-cache": "^5.1.2",
"node-ipc": "^12.0.0",
"ollama": "^0.5.17",
"ollama-ai-provider-v2": "^3.0.4",
"openai": "^5.12.2",
"os-name": "^6.0.0",
"p-limit": "^6.2.0",