mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
refactor: migrate NativeOllamaHandler to AI SDK (#11355)
This commit is contained in:
parent
4272a69726
commit
cdd1a4dabf
4 changed files with 402 additions and 574 deletions
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue