This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathnative-ollama.ts
More file actions
416 lines (365 loc) · 13.1 KB
/
Copy pathnative-ollama.ts
File metadata and controls
416 lines (365 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Message, Ollama, Tool as OllamaTool, type Config as OllamaOptions } from "ollama"
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 { getOllamaModels } from "./fetchers/ollama"
import { TagMatcher } from "../../utils/tag-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
interface OllamaChatOptions {
temperature: number
num_ctx?: number
}
// Default timeout for Ollama requests (5 minutes to accommodate slow model loading)
// Ollama models can take 30-60+ seconds to load into memory on first use
const DEFAULT_OLLAMA_TIMEOUT_MS = 300_000 // 5 minutes
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
}
export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: Ollama | undefined
protected models: Record<string, ModelInfo> = {}
constructor(options: ApiHandlerOptions) {
super()
this.options = options
}
private ensureClient(): Ollama {
if (!this.client) {
try {
const baseUrl = this.options.ollamaBaseUrl || "http://localhost:11434"
console.log(`[Ollama] Creating client for host: ${baseUrl}`)
const clientOptions: OllamaOptions = {
host: baseUrl,
}
// 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) {
console.error(`[Ollama] Error creating client: ${error.message}`)
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 baseUrl = this.options.ollamaBaseUrl || "http://localhost:11434"
const requestStartTime = Date.now()
console.log(`[Ollama] createMessage: Starting request at ${new Date().toISOString()}`)
const client = this.ensureClient()
console.log(`[Ollama] createMessage: Fetching model info...`)
const { id: modelId, info: modelInfo } = await this.fetchModel()
console.log(
`[Ollama] createMessage: Model '${modelId}' fetched in ${Date.now() - requestStartTime}ms, ` +
`found in cache: ${!!this.models[modelId]}`,
)
// Warn if model wasn't found in the tool-capable models list
if (!this.models[modelId]) {
console.warn(
`[Ollama] Warning: Model '${modelId}' was not found in the list of tool-capable models. ` +
`This may indicate the model does not support native tool calling, or your Ollama version ` +
`does not report capabilities. Check with: ollama show ${modelId}`,
)
}
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
}
const toolsToSend = this.convertToolsToOllama(metadata?.tools)
console.log(
`[Ollama] createMessage: Sending chat request to ${baseUrl}/api/chat with model '${modelId}', ` +
`${ollamaMessages.length} messages, ${toolsToSend?.length ?? 0} tools`,
)
const chatStartTime = Date.now()
// Create the actual API request promise
const stream = await client.chat({
model: modelId,
messages: ollamaMessages,
stream: true,
options: chatOptions,
tools: toolsToSend,
})
console.log(`[Ollama] createMessage: Stream started after ${Date.now() - chatStartTime}ms`)
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()
}
override getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.ollamaModelId || ""
return {
id: modelId,
info: this.models[modelId] || openAiModelInfoSaneDefaults,
}
}
async completePrompt(prompt: string): Promise<string> {
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),
}
// Only include num_ctx if explicitly set via ollamaNumCtx
if (this.options.ollamaNumCtx !== undefined) {
chatOptions.num_ctx = this.options.ollamaNumCtx
}
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
}
}
}