Skip to content

Commit f3864ff

Browse files
authored
fix: use native Ollama API instead of OpenAI compatibility layer (RooCodeInc#7137)
1 parent 45ac7ee commit f3864ff

File tree

5 files changed

+466
-3
lines changed

5 files changed

+466
-3
lines changed

pnpm-lock.yaml

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
VertexHandler,
1414
AnthropicVertexHandler,
1515
OpenAiHandler,
16-
OllamaHandler,
1716
LmStudioHandler,
1817
GeminiHandler,
1918
OpenAiNativeHandler,
@@ -37,6 +36,7 @@ import {
3736
ZAiHandler,
3837
FireworksHandler,
3938
} from "./providers"
39+
import { NativeOllamaHandler } from "./providers/native-ollama"
4040

4141
export interface SingleCompletionHandler {
4242
completePrompt(prompt: string): Promise<string>
@@ -95,7 +95,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
9595
case "openai":
9696
return new OpenAiHandler(options)
9797
case "ollama":
98-
return new OllamaHandler(options)
98+
return new NativeOllamaHandler(options)
9999
case "lmstudio":
100100
return new LmStudioHandler(options)
101101
case "gemini":
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// npx vitest run api/providers/__tests__/native-ollama.spec.ts
2+
3+
import { NativeOllamaHandler } from "../native-ollama"
4+
import { ApiHandlerOptions } from "../../../shared/api"
5+
6+
// Mock the ollama package
7+
const mockChat = vitest.fn()
8+
vitest.mock("ollama", () => {
9+
return {
10+
Ollama: vitest.fn().mockImplementation(() => ({
11+
chat: mockChat,
12+
})),
13+
Message: vitest.fn(),
14+
}
15+
})
16+
17+
// Mock the getOllamaModels function
18+
vitest.mock("../fetchers/ollama", () => ({
19+
getOllamaModels: vitest.fn().mockResolvedValue({
20+
llama2: {
21+
contextWindow: 4096,
22+
maxTokens: 4096,
23+
supportsImages: false,
24+
supportsPromptCache: false,
25+
},
26+
}),
27+
}))
28+
29+
describe("NativeOllamaHandler", () => {
30+
let handler: NativeOllamaHandler
31+
32+
beforeEach(() => {
33+
vitest.clearAllMocks()
34+
35+
const options: ApiHandlerOptions = {
36+
apiModelId: "llama2",
37+
ollamaModelId: "llama2",
38+
ollamaBaseUrl: "http://localhost:11434",
39+
}
40+
41+
handler = new NativeOllamaHandler(options)
42+
})
43+
44+
describe("createMessage", () => {
45+
it("should stream messages from Ollama", async () => {
46+
// Mock the chat response as an async generator
47+
mockChat.mockImplementation(async function* () {
48+
yield {
49+
message: { content: "Hello" },
50+
eval_count: undefined,
51+
prompt_eval_count: undefined,
52+
}
53+
yield {
54+
message: { content: " world" },
55+
eval_count: 2,
56+
prompt_eval_count: 10,
57+
}
58+
})
59+
60+
const systemPrompt = "You are a helpful assistant"
61+
const messages = [{ role: "user" as const, content: "Hi there" }]
62+
63+
const stream = handler.createMessage(systemPrompt, messages)
64+
const results = []
65+
66+
for await (const chunk of stream) {
67+
results.push(chunk)
68+
}
69+
70+
expect(results).toHaveLength(3)
71+
expect(results[0]).toEqual({ type: "text", text: "Hello" })
72+
expect(results[1]).toEqual({ type: "text", text: " world" })
73+
expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 })
74+
})
75+
76+
it("should handle DeepSeek R1 models with reasoning detection", async () => {
77+
const options: ApiHandlerOptions = {
78+
apiModelId: "deepseek-r1",
79+
ollamaModelId: "deepseek-r1",
80+
ollamaBaseUrl: "http://localhost:11434",
81+
}
82+
83+
handler = new NativeOllamaHandler(options)
84+
85+
// Mock response with thinking tags
86+
mockChat.mockImplementation(async function* () {
87+
yield { message: { content: "<think>Let me think" } }
88+
yield { message: { content: " about this</think>" } }
89+
yield { message: { content: "The answer is 42" } }
90+
})
91+
92+
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Question?" }])
93+
const results = []
94+
95+
for await (const chunk of stream) {
96+
results.push(chunk)
97+
}
98+
99+
// Should detect reasoning vs regular text
100+
expect(results.some((r) => r.type === "reasoning")).toBe(true)
101+
expect(results.some((r) => r.type === "text")).toBe(true)
102+
})
103+
})
104+
105+
describe("completePrompt", () => {
106+
it("should complete a prompt without streaming", async () => {
107+
mockChat.mockResolvedValue({
108+
message: { content: "This is the response" },
109+
})
110+
111+
const result = await handler.completePrompt("Tell me a joke")
112+
113+
expect(mockChat).toHaveBeenCalledWith({
114+
model: "llama2",
115+
messages: [{ role: "user", content: "Tell me a joke" }],
116+
stream: false,
117+
options: {
118+
temperature: 0,
119+
},
120+
})
121+
expect(result).toBe("This is the response")
122+
})
123+
})
124+
125+
describe("error handling", () => {
126+
it("should handle connection refused errors", async () => {
127+
const error = new Error("ECONNREFUSED") as any
128+
error.code = "ECONNREFUSED"
129+
mockChat.mockRejectedValue(error)
130+
131+
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
132+
133+
await expect(async () => {
134+
for await (const _ of stream) {
135+
// consume stream
136+
}
137+
}).rejects.toThrow("Ollama service is not running")
138+
})
139+
140+
it("should handle model not found errors", async () => {
141+
const error = new Error("Not found") as any
142+
error.status = 404
143+
mockChat.mockRejectedValue(error)
144+
145+
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
146+
147+
await expect(async () => {
148+
for await (const _ of stream) {
149+
// consume stream
150+
}
151+
}).rejects.toThrow("Model llama2 not found in Ollama")
152+
})
153+
})
154+
155+
describe("getModel", () => {
156+
it("should return the configured model", () => {
157+
const model = handler.getModel()
158+
expect(model.id).toBe("llama2")
159+
expect(model.info).toBeDefined()
160+
})
161+
})
162+
})

0 commit comments

Comments
 (0)