fix: use native Ollama API endpoints instead of OpenAI-compatible routes

- Replace OpenAI client with direct axios calls to Ollama native API
- Use /api/chat endpoint instead of /v1 OpenAI-compatible endpoint
- Update tests to reflect the new native API implementation
- Maintain backward compatibility with existing configurations

Fixes #7070
This commit is contained in:
Roo Code 2025-08-14 01:46:04 +00:00
parent dcbb7a673f
commit 14c33f8907
3 changed files with 590 additions and 175 deletions

View file

@ -1,40 +1,32 @@
// npx vitest run api/providers/__tests__/ollama-timeout.spec.ts
import { vi, describe, it, expect, beforeEach } from "vitest"
import axios from "axios"
import { Readable } from "stream"
import { OllamaHandler } from "../ollama"
import { ApiHandlerOptions } from "../../../shared/api"
import * as timeoutConfig from "../utils/timeout-config"
// Mock the timeout config utility
vitest.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vitest.fn(),
// Mock axios
vi.mock("axios")
const mockedAxios = axios as any
// Mock the timeout configuration module
vi.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vi.fn(),
}))
import { getApiRequestTimeout } from "../utils/timeout-config"
// Mock OpenAI
const mockOpenAIConstructor = vitest.fn()
vitest.mock("openai", () => {
return {
__esModule: true,
default: vitest.fn().mockImplementation((config) => {
mockOpenAIConstructor(config)
return {
chat: {
completions: {
create: vitest.fn(),
},
},
}
}),
}
})
describe("OllamaHandler timeout configuration", () => {
let mockGetApiRequestTimeout: any
beforeEach(() => {
vitest.clearAllMocks()
vi.clearAllMocks()
mockGetApiRequestTimeout = vi.mocked(timeoutConfig.getApiRequestTimeout)
})
it("should use default timeout of 600 seconds when no configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(600000)
it("should use default timeout of 600 seconds when no configuration is set", async () => {
// Mock the timeout function to return default
mockGetApiRequestTimeout.mockReturnValue(600000)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
@ -42,37 +34,105 @@ describe("OllamaHandler timeout configuration", () => {
ollamaBaseUrl: "http://localhost:11434",
}
new OllamaHandler(options)
const handler = new OllamaHandler(options)
expect(getApiRequestTimeout).toHaveBeenCalled()
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
// Create a mock stream for testing
const mockStream = new Readable({
read() {
this.push(
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: { role: "assistant", content: "Test" },
done: true,
}) + "\n",
)
this.push(null)
},
})
mockedAxios.post.mockResolvedValueOnce({
data: mockStream,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
// Trigger a request to verify timeout is used
const stream = handler.createMessage("System", [{ role: "user", content: "Test" }])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify getApiRequestTimeout was called
expect(mockGetApiRequestTimeout).toHaveBeenCalled()
// Verify axios was called with the correct timeout
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
baseURL: "http://localhost:11434/v1",
apiKey: "ollama",
timeout: 600000, // 600 seconds in milliseconds
timeout: 600000,
}),
)
})
it("should use custom timeout when configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(3600000) // 1 hour
it("should use custom timeout when configuration is set", async () => {
// Mock custom timeout
mockGetApiRequestTimeout.mockReturnValue(3600000)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
}
new OllamaHandler(options)
const handler = new OllamaHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
// Create a mock stream for testing
const mockStream = new Readable({
read() {
this.push(
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: { role: "assistant", content: "Test" },
done: true,
}) + "\n",
)
this.push(null)
},
})
mockedAxios.post.mockResolvedValueOnce({
data: mockStream,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
// Trigger a request to verify timeout is used
const stream = handler.createMessage("System", [{ role: "user", content: "Test" }])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify axios was called with the correct timeout
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
timeout: 3600000, // 3600 seconds in milliseconds
timeout: 3600000,
}),
)
})
it("should handle zero timeout (no timeout)", () => {
;(getApiRequestTimeout as any).mockReturnValue(0)
it("should handle zero timeout (no timeout)", async () => {
// Mock zero timeout
mockGetApiRequestTimeout.mockReturnValue(0)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
@ -80,28 +140,97 @@ describe("OllamaHandler timeout configuration", () => {
ollamaBaseUrl: "http://localhost:11434",
}
new OllamaHandler(options)
const handler = new OllamaHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
// Create a mock stream for testing
const mockStream = new Readable({
read() {
this.push(
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: { role: "assistant", content: "Test" },
done: true,
}) + "\n",
)
this.push(null)
},
})
mockedAxios.post.mockResolvedValueOnce({
data: mockStream,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
// Trigger a request to verify timeout is used
const stream = handler.createMessage("System", [{ role: "user", content: "Test" }])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify axios was called with zero timeout
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
timeout: 0, // No timeout
timeout: 0,
}),
)
})
it("should use default base URL when not provided", () => {
;(getApiRequestTimeout as any).mockReturnValue(600000)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
}
const handler = new OllamaHandler(options)
// The base URL should be set to default
expect(handler).toBeInstanceOf(OllamaHandler)
// We can't directly access private baseUrl, but we can verify it works
// by checking that requests go to the default URL
})
it("should use timeout for completePrompt as well", async () => {
// Mock custom timeout
mockGetApiRequestTimeout.mockReturnValue(1800000)
const options: ApiHandlerOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
}
new OllamaHandler(options)
const handler = new OllamaHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
mockedAxios.post.mockResolvedValueOnce({
data: {
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: {
role: "assistant",
content: "Test response",
},
done: true,
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
await handler.completePrompt("Test prompt")
// Verify axios was called with the correct timeout
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
baseURL: "http://localhost:11434/v1",
timeout: 1800000,
}),
)
})

View file

@ -1,69 +1,16 @@
// npx vitest run api/providers/__tests__/ollama.spec.ts
import { vi, describe, it, expect, beforeEach } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import { Readable } from "stream"
import { OllamaHandler } from "../ollama"
import { ApiHandlerOptions } from "../../../shared/api"
const mockCreate = vitest.fn()
vitest.mock("openai", () => {
return {
__esModule: true,
default: vitest.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response" },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
}
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
}),
},
},
})),
}
})
// Mock axios
vi.mock("axios")
const mockedAxios = axios as any
describe("OllamaHandler", () => {
let handler: OllamaHandler
@ -73,10 +20,10 @@ describe("OllamaHandler", () => {
mockOptions = {
apiModelId: "llama2",
ollamaModelId: "llama2",
ollamaBaseUrl: "http://localhost:11434/v1",
ollamaBaseUrl: "http://localhost:11434",
}
handler = new OllamaHandler(mockOptions)
mockCreate.mockClear()
vi.clearAllMocks()
})
describe("constructor", () => {
@ -104,6 +51,47 @@ describe("OllamaHandler", () => {
]
it("should handle streaming responses", async () => {
// Create a mock readable stream
const mockStreamData = [
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: { role: "assistant", content: "Test " },
done: false,
}),
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:01Z",
message: { role: "assistant", content: "response" },
done: false,
}),
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:02Z",
done: true,
prompt_eval_count: 10,
eval_count: 5,
}),
]
const mockStream = new Readable({
read() {
if (mockStreamData.length > 0) {
this.push(mockStreamData.shift() + "\n")
} else {
this.push(null)
}
},
})
mockedAxios.post.mockResolvedValueOnce({
data: mockStream,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
@ -112,12 +100,44 @@ describe("OllamaHandler", () => {
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
expect(textChunks).toHaveLength(2)
expect(textChunks[0].text).toBe("Test ")
expect(textChunks[1].text).toBe("response")
// Check usage information
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].inputTokens).toBe(10)
expect(usageChunks[0].outputTokens).toBe(5)
// Verify the API was called with correct endpoint and data
expect(mockedAxios.post).toHaveBeenCalledWith(
"http://localhost:11434/api/chat",
{
model: "llama2",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Hello!" },
],
stream: true,
options: {
temperature: 0,
},
},
expect.objectContaining({
responseType: "stream",
headers: {
"Content-Type": "application/json",
},
}),
)
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
const error = new Error("API Error")
;(error as any).code = "ECONNREFUSED"
mockedAxios.isAxiosError = vi.fn().mockReturnValue(true)
mockedAxios.post.mockRejectedValueOnce(error)
const stream = handler.createMessage(systemPrompt, messages)
@ -125,31 +145,91 @@ describe("OllamaHandler", () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("API Error")
}).rejects.toThrow("Ollama service is not running")
})
it("should handle model not found errors", async () => {
const error = new Error("Not Found")
;(error as any).response = { status: 404 }
mockedAxios.isAxiosError = vi.fn().mockReturnValue(true)
mockedAxios.post.mockRejectedValueOnce(error)
const stream = handler.createMessage(systemPrompt, messages)
await expect(async () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("Model llama2 not found in Ollama")
})
})
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: {
role: "assistant",
content: "Test response",
},
done: true,
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.ollamaModelId,
messages: [{ role: "user", content: "Test prompt" }],
temperature: 0,
stream: false,
})
expect(mockedAxios.post).toHaveBeenCalledWith(
"http://localhost:11434/api/chat",
{
model: mockOptions.ollamaModelId,
messages: [{ role: "user", content: "Test prompt" }],
stream: false,
options: {
temperature: 0,
},
},
expect.objectContaining({
headers: {
"Content-Type": "application/json",
},
}),
)
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Ollama completion error: API Error")
const error = new Error("API Error")
;(error as any).code = "ECONNREFUSED"
mockedAxios.isAxiosError = vi.fn().mockReturnValue(true)
mockedAxios.post.mockRejectedValueOnce(error)
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
"Ollama service is not running at http://localhost:11434",
)
})
it("should handle empty response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "" } }],
mockedAxios.post.mockResolvedValueOnce({
data: {
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: {
role: "assistant",
content: "",
},
done: true,
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
@ -164,4 +244,68 @@ describe("OllamaHandler", () => {
expect(modelInfo.info.contextWindow).toBe(128_000)
})
})
describe("message format conversion", () => {
it("should handle complex message content", async () => {
const complexMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Here is an image:" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64data",
},
},
],
},
]
const mockStream = new Readable({
read() {
this.push(
JSON.stringify({
model: "llama2",
created_at: "2024-01-01T00:00:00Z",
message: { role: "assistant", content: "I see the image" },
done: true,
}) + "\n",
)
this.push(null)
},
})
mockedAxios.post.mockResolvedValueOnce({
data: mockStream,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
})
const stream = handler.createMessage("System prompt", complexMessages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify the message was properly converted
expect(mockedAxios.post).toHaveBeenCalledWith(
"http://localhost:11434/api/chat",
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: "Here is an image:",
images: ["base64data"],
}),
]),
}),
expect.any(Object),
)
})
})
})

View file

@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import axios from "axios"
import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
@ -7,29 +7,68 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { getApiRequestTimeout } from "./utils/timeout-config"
type CompletionUsage = OpenAI.Chat.Completions.ChatCompletionChunk["usage"]
interface OllamaMessage {
role: "system" | "user" | "assistant"
content: string
images?: string[]
}
interface OllamaChatRequest {
model: string
messages: OllamaMessage[]
stream?: boolean
options?: {
temperature?: number
[key: string]: any
}
}
interface OllamaChatResponse {
model: string
created_at: string
message: {
role: string
content: string
}
done: boolean
total_duration?: number
load_duration?: number
prompt_eval_count?: number
prompt_eval_duration?: number
eval_count?: number
eval_duration?: number
}
interface OllamaStreamResponse {
model: string
created_at: string
message?: {
role: string
content: string
}
done: boolean
total_duration?: number
load_duration?: number
prompt_eval_count?: number
prompt_eval_duration?: number
eval_count?: number
eval_duration?: number
}
export class OllamaHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: OpenAI
private baseUrl: string
constructor(options: ApiHandlerOptions) {
super()
this.options = options
this.client = new OpenAI({
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
apiKey: "ollama",
timeout: getApiRequestTimeout(),
})
this.baseUrl = this.options.ollamaBaseUrl || "http://localhost:11434"
}
override async *createMessage(
@ -39,49 +78,134 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
): ApiStream {
const modelId = this.getModel().id
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...(useR1Format ? convertToR1Format(messages) : convertToOpenAiMessages(messages)),
]
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: this.options.modelTemperature ?? 0,
// Convert Anthropic messages to Ollama format
const ollamaMessages: OllamaMessage[] = [{ role: "system", content: systemPrompt }]
// Convert messages to Ollama format
for (const message of messages) {
if (message.role === "user" || message.role === "assistant") {
let content = ""
let images: string[] = []
if (typeof message.content === "string") {
content = message.content
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
content += block.text
} else if (block.type === "image" && "source" in block) {
// Handle image blocks if present
if (block.source.type === "base64") {
images.push(block.source.data)
}
}
}
}
const ollamaMessage: OllamaMessage = {
role: message.role,
content: content,
}
if (images.length > 0) {
ollamaMessage.images = images
}
ollamaMessages.push(ollamaMessage)
}
}
const requestBody: OllamaChatRequest = {
model: modelId,
messages: ollamaMessages,
stream: true,
stream_options: { include_usage: true },
})
const matcher = new XmlMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let lastUsage: CompletionUsage | undefined
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
options: {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
},
}
if (delta?.content) {
for (const matcherChunk of matcher.update(delta.content)) {
yield matcherChunk
try {
const response = await axios.post(`${this.baseUrl}/api/chat`, requestBody, {
responseType: "stream",
timeout: getApiRequestTimeout(),
headers: {
"Content-Type": "application/json",
},
})
const matcher = new XmlMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let buffer = ""
let totalInputTokens = 0
let totalOutputTokens = 0
for await (const chunk of response.data) {
const lines = chunk
.toString()
.split("\n")
.filter((line: string) => line.trim())
for (const line of lines) {
try {
const parsed: OllamaStreamResponse = JSON.parse(line)
if (parsed.message?.content) {
// Process content through matcher for reasoning detection
for (const matcherChunk of matcher.update(parsed.message.content)) {
yield matcherChunk
}
}
// When streaming is done, extract token usage
if (parsed.done) {
if (parsed.prompt_eval_count) {
totalInputTokens = parsed.prompt_eval_count
}
if (parsed.eval_count) {
totalOutputTokens = parsed.eval_count
}
}
} catch (e) {
// Skip invalid JSON lines
continue
}
}
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
for (const chunk of matcher.final()) {
yield chunk
}
if (lastUsage) {
yield {
type: "usage",
inputTokens: lastUsage?.prompt_tokens || 0,
outputTokens: lastUsage?.completion_tokens || 0,
// Yield any remaining content from the matcher
for (const chunk of matcher.final()) {
yield chunk
}
// Yield usage information if available
if (totalInputTokens > 0 || totalOutputTokens > 0) {
yield {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
}
}
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === "ECONNREFUSED") {
throw new Error(`Ollama service is not running at ${this.baseUrl}. Please start Ollama first.`)
} else if (error.response?.status === 404) {
throw new Error(
`Model ${modelId} not found in Ollama. Please pull the model first with: ollama pull ${modelId}`,
)
} else {
throw new Error(`Ollama API error: ${error.message}`)
}
}
throw error
}
}
@ -96,16 +220,34 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
try {
const modelId = this.getModel().id
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const response = await this.client.chat.completions.create({
model: this.getModel().id,
messages: useR1Format
? convertToR1Format([{ role: "user", content: prompt }])
: [{ role: "user", content: prompt }],
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
const requestBody: OllamaChatRequest = {
model: modelId,
messages: [{ role: "user", content: prompt }],
stream: false,
options: {
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
},
}
const response = await axios.post<OllamaChatResponse>(`${this.baseUrl}/api/chat`, requestBody, {
timeout: getApiRequestTimeout(),
headers: {
"Content-Type": "application/json",
},
})
return response.choices[0]?.message.content || ""
return response.data.message?.content || ""
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === "ECONNREFUSED") {
throw new Error(`Ollama service is not running at ${this.baseUrl}. Please start Ollama first.`)
} else if (error.response?.status === 404) {
throw new Error(`Model ${this.getModel().id} not found in Ollama.`)
} else {
throw new Error(`Ollama completion error: ${error.message}`)
}
}
if (error instanceof Error) {
throw new Error(`Ollama completion error: ${error.message}`)
}