feat(ollama): add tools support filtering, configurable timeouts, and improved UI

This commit is contained in:
randomizedcoder dave.seddon.ca@gmail.com 2026-01-24 17:49:15 -08:00
parent 802b40a790
commit 77a2caeb7c
12 changed files with 2150 additions and 130 deletions

View file

@ -1,4 +1,87 @@
import { getApiProtocol } from "../provider-settings.js"
import { getApiProtocol, providerSettingsSchema } from "../provider-settings.js"
describe("Ollama Settings Schema", () => {
it("should accept valid ollamaRequestTimeout", () => {
const result = providerSettingsSchema.safeParse({
ollamaRequestTimeout: 3600000,
})
expect(result.success).toBe(true)
})
it("should reject ollamaRequestTimeout below minimum", () => {
const result = providerSettingsSchema.safeParse({
ollamaRequestTimeout: 500,
})
expect(result.success).toBe(false)
})
it("should reject ollamaRequestTimeout above maximum", () => {
const result = providerSettingsSchema.safeParse({
ollamaRequestTimeout: 8000000,
})
expect(result.success).toBe(false)
})
it("should accept valid ollamaModelDiscoveryTimeout", () => {
const result = providerSettingsSchema.safeParse({
ollamaModelDiscoveryTimeout: 10000,
})
expect(result.success).toBe(true)
})
it("should reject ollamaModelDiscoveryTimeout above maximum", () => {
const result = providerSettingsSchema.safeParse({
ollamaModelDiscoveryTimeout: 700000,
})
expect(result.success).toBe(false)
})
it("should accept valid ollamaMaxRetries", () => {
const result = providerSettingsSchema.safeParse({
ollamaMaxRetries: 3,
})
expect(result.success).toBe(true)
})
it("should reject ollamaMaxRetries above maximum", () => {
const result = providerSettingsSchema.safeParse({
ollamaMaxRetries: 15,
})
expect(result.success).toBe(false)
})
it("should accept valid ollamaRetryDelay", () => {
const result = providerSettingsSchema.safeParse({
ollamaRetryDelay: 2000,
})
expect(result.success).toBe(true)
})
it("should reject ollamaRetryDelay below minimum", () => {
const result = providerSettingsSchema.safeParse({
ollamaRetryDelay: 50,
})
expect(result.success).toBe(false)
})
it("should accept ollamaEnableLogging boolean", () => {
const result = providerSettingsSchema.safeParse({
ollamaEnableLogging: true,
})
expect(result.success).toBe(true)
})
it("should accept all optional fields together", () => {
const result = providerSettingsSchema.safeParse({
ollamaRequestTimeout: 3600000,
ollamaModelDiscoveryTimeout: 10000,
ollamaMaxRetries: 2,
ollamaRetryDelay: 1000,
ollamaEnableLogging: true,
})
expect(result.success).toBe(true)
})
})
describe("getApiProtocol", () => {
describe("Anthropic-style providers", () => {

View file

@ -259,6 +259,11 @@ const ollamaSchema = baseProviderSettingsSchema.extend({
ollamaBaseUrl: z.string().optional(),
ollamaApiKey: z.string().optional(),
ollamaNumCtx: z.number().int().min(128).optional(),
ollamaRequestTimeout: z.number().int().min(1000).max(7200000).optional(),
ollamaModelDiscoveryTimeout: z.number().int().min(1000).max(600000).optional(),
ollamaMaxRetries: z.number().int().min(0).max(10).optional(),
ollamaRetryDelay: z.number().int().min(100).max(10000).optional(),
ollamaEnableLogging: z.boolean().optional(),
})
const vsCodeLmSchema = baseProviderSettingsSchema.extend({

View file

@ -40,6 +40,8 @@ export interface ExtensionMessage {
| "routerModels"
| "openAiModels"
| "ollamaModels"
| "ollamaConnectionTestResult"
| "ollamaModelsRefreshResult"
| "lmStudioModels"
| "vsCodeLmModels"
| "huggingFaceModels"
@ -124,6 +126,18 @@ export interface ExtensionMessage {
routerModels?: RouterModels
openAiModels?: string[]
ollamaModels?: ModelRecord
ollamaModelsWithTools?: Array<{
name: string
contextWindow: number
size?: number
quantizationLevel?: string
family?: string
supportsImages: boolean
modelInfo: ModelRecord[string]
}>
modelsWithoutTools?: string[]
message?: string
durationMs?: number
lmStudioModels?: ModelRecord
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
huggingFaceModels?: Array<{
@ -393,6 +407,8 @@ export interface WebviewMessage {
| "requestRouterModels"
| "requestOpenAiModels"
| "requestOllamaModels"
| "testOllamaConnection"
| "refreshOllamaModels"
| "requestLmStudioModels"
| "requestRooModels"
| "requestRooCreditBalance"

View file

@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import axios from "axios"
import { createOllamaAxiosInstance } from "../ollama"
import type { AxiosInstance, AxiosError } from "axios"
vi.mock("axios")
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn(),
post: vi.fn(),
} as unknown as AxiosInstance
describe("createOllamaAxiosInstance", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance)
})
it("should create instance with default configuration", () => {
const instance = createOllamaAxiosInstance()
expect(instance).toBeDefined()
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "http://localhost:11434",
timeout: 3600000,
}),
)
})
it("should create instance with custom baseUrl", () => {
createOllamaAxiosInstance({ baseUrl: "http://custom:11434" })
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "http://custom:11434",
}),
)
})
it("should include Authorization header when apiKey provided", () => {
createOllamaAxiosInstance({ apiKey: "test-key" })
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: "Bearer test-key",
},
}),
)
})
it("should not include Authorization header when apiKey not provided", () => {
createOllamaAxiosInstance()
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
headers: {},
}),
)
})
it("should set up retry interceptor when retries > 0", () => {
createOllamaAxiosInstance({ retries: 2, retryDelay: 1000 })
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled()
})
it("should not set up retry interceptor when retries = 0", () => {
createOllamaAxiosInstance({ retries: 0 })
expect(mockAxiosInstance.interceptors.response.use).not.toHaveBeenCalled()
})
it("should set up logging interceptor when enableLogging is true", () => {
createOllamaAxiosInstance({ enableLogging: true })
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled()
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled()
})
it("should not set up logging interceptor when enableLogging is false", () => {
createOllamaAxiosInstance({ enableLogging: false })
expect(mockAxiosInstance.interceptors.request.use).not.toHaveBeenCalled()
})
it("should use custom timeout", () => {
createOllamaAxiosInstance({ timeout: 5000 })
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 5000,
}),
)
})
it("should set timeout error message", () => {
createOllamaAxiosInstance({ timeout: 10000 })
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
timeoutErrorMessage: "Ollama request timed out after 10000ms",
}),
)
})
})

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import axios from "axios"
import { getOllamaModels, parseOllamaModel } from "../ollama"
import { getOllamaModels, parseOllamaModel, discoverOllamaModelsWithSorting } from "../ollama"
import ollamaModelsData from "./fixtures/ollama-model-details.json"
// Mock axios
@ -15,7 +16,7 @@ describe("Ollama Fetcher", () => {
describe("parseOllamaModel", () => {
it("should correctly parse Ollama model info", () => {
const modelData = ollamaModelsData["qwen3-2to16:latest"]
const parsedModel = parseOllamaModel(modelData)
const parsedModel = parseOllamaModel(modelData, undefined)
expect(parsedModel).toEqual({
maxTokens: 40960,
@ -28,6 +29,9 @@ describe("Ollama Fetcher", () => {
cacheWritesPrice: 0,
cacheReadsPrice: 0,
description: "Family: qwen3, Context: 40960, Size: 32.8B",
family: "qwen3",
quantizationLevel: "Q4_K_M",
size: undefined,
})
})
@ -40,7 +44,7 @@ describe("Ollama Fetcher", () => {
},
}
const parsedModel = parseOllamaModel(modelDataWithNullFamilies as any)
const parsedModel = parseOllamaModel(modelDataWithNullFamilies as any, undefined)
expect(parsedModel).toEqual({
maxTokens: 40960,
@ -53,19 +57,23 @@ describe("Ollama Fetcher", () => {
cacheWritesPrice: 0,
cacheReadsPrice: 0,
description: "Family: qwen3, Context: 40960, Size: 32.8B",
family: "qwen3",
quantizationLevel: "Q4_K_M",
size: undefined,
})
})
it("should return null when capabilities does not include 'tools'", () => {
it("should return model info when capabilities does not include 'tools'", () => {
const modelDataWithoutTools = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: ["completion"], // No "tools" capability
}
const parsedModel = parseOllamaModel(modelDataWithoutTools as any)
const parsedModel = parseOllamaModel(modelDataWithoutTools as any, undefined)
// Models without tools capability are filtered out (return null)
expect(parsedModel).toBeNull()
// Models without tools capability are still returned, but with supportsNativeTools: false
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsNativeTools).toBe(false)
})
it("should return model info when capabilities includes 'tools'", () => {
@ -74,34 +82,37 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion", "tools"], // Has "tools" capability
}
const parsedModel = parseOllamaModel(modelDataWithTools as any)
const parsedModel = parseOllamaModel(modelDataWithTools as any, undefined)
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsNativeTools).toBe(true)
})
it("should return null when capabilities is undefined (no tool support)", () => {
it("should return model info when capabilities is undefined (no tool support)", () => {
const modelDataWithoutCapabilities = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: undefined, // No capabilities array
}
const parsedModel = parseOllamaModel(modelDataWithoutCapabilities as any)
const parsedModel = parseOllamaModel(modelDataWithoutCapabilities as any, undefined)
// Models without explicit tools capability are filtered out
expect(parsedModel).toBeNull()
// Models without explicit tools capability are still returned, but with supportsNativeTools: false
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsNativeTools).toBe(false)
})
it("should return null when model has vision but no tools capability", () => {
it("should return model info when model has vision but no tools capability", () => {
const modelDataWithVision = {
...ollamaModelsData["qwen3-2to16:latest"],
capabilities: ["completion", "vision"],
}
const parsedModel = parseOllamaModel(modelDataWithVision as any)
const parsedModel = parseOllamaModel(modelDataWithVision as any, undefined)
// No "tools" capability means filtered out
expect(parsedModel).toBeNull()
// Models with vision but no tools are still returned, with supportsImages: true and supportsNativeTools: false
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsImages).toBe(true)
expect(parsedModel!.supportsNativeTools).toBe(false)
})
it("should return model with both vision and tools when both capabilities present", () => {
@ -110,15 +121,55 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion", "vision", "tools"],
}
const parsedModel = parseOllamaModel(modelDataWithBoth as any)
const parsedModel = parseOllamaModel(modelDataWithBoth as any, undefined)
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsImages).toBe(true)
expect(parsedModel!.supportsNativeTools).toBe(true)
})
it("should handle null model_info gracefully", () => {
const modelDataWithNullModelInfo = {
...ollamaModelsData["qwen3-2to16:latest"],
model_info: null,
}
const parsedModel = parseOllamaModel(modelDataWithNullModelInfo as any, undefined)
expect(parsedModel).not.toBeNull()
expect(parsedModel!.contextWindow).toBeDefined()
})
it("should handle undefined model_info gracefully", () => {
const modelDataWithUndefinedModelInfo = {
...ollamaModelsData["qwen3-2to16:latest"],
model_info: undefined,
}
const parsedModel = parseOllamaModel(modelDataWithUndefinedModelInfo as any, undefined)
expect(parsedModel).not.toBeNull()
expect(parsedModel!.contextWindow).toBeDefined()
})
it("should handle empty model_info object gracefully", () => {
const modelDataWithEmptyModelInfo = {
...ollamaModelsData["qwen3-2to16:latest"],
model_info: {},
}
const parsedModel = parseOllamaModel(modelDataWithEmptyModelInfo as any, undefined)
expect(parsedModel).not.toBeNull()
expect(parsedModel!.contextWindow).toBeDefined()
})
})
describe("getOllamaModels", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should fetch model list from /api/tags and include models with tools capability", async () => {
const baseUrl = "http://localhost:11434"
const modelName = "devstral2to16:latest"
@ -163,23 +214,32 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion", "tools"], // Has tools capability
}
mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse })
mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse })
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl)
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} })
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/tags")
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(`${baseUrl}/api/show`, { model: modelName }, { headers: {} })
expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.post).toHaveBeenCalledWith("/api/show", { model: modelName })
expect(typeof result).toBe("object")
expect(result).not.toBeInstanceOf(Array)
expect(Object.keys(result).length).toBe(1)
expect(result[modelName]).toBeDefined()
const expectedParsedDetails = parseOllamaModel(mockApiShowResponse as any)
// The size comes from the model in the tags response, not from parseOllamaModel call
const expectedParsedDetails = parseOllamaModel(mockApiShowResponse as any, 14333928010)
expect(result[modelName]).toEqual(expectedParsedDetails)
})
@ -227,8 +287,16 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion"], // No tools capability
}
mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse })
mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse })
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl)
@ -239,14 +307,23 @@ describe("Ollama Fetcher", () => {
it("should return an empty list if the initial /api/tags call fails", async () => {
const baseUrl = "http://localhost:11434"
mockedAxios.get.mockRejectedValueOnce(new Error("Network error"))
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockRejectedValue(new Error("Network error")),
post: vi.fn(),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const consoleInfoSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Spy and suppress output
const result = await getOllamaModels(baseUrl)
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} })
expect(mockedAxios.post).not.toHaveBeenCalled()
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/tags")
expect(mockAxiosInstance.post).not.toHaveBeenCalled()
expect(result).toEqual({})
})
@ -256,13 +333,23 @@ describe("Ollama Fetcher", () => {
const econnrefusedError = new Error("Connection refused") as any
econnrefusedError.code = "ECONNREFUSED"
mockedAxios.get.mockRejectedValueOnce(econnrefusedError)
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockRejectedValue(econnrefusedError),
post: vi.fn(),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl)
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} })
expect(mockedAxios.post).not.toHaveBeenCalled()
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/tags")
expect(mockAxiosInstance.post).not.toHaveBeenCalled()
expect(consoleInfoSpy).toHaveBeenCalledWith(`Failed connecting to Ollama at ${baseUrl}`)
expect(result).toEqual({})
@ -313,16 +400,24 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion", "tools"], // Has tools capability
}
mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse })
mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse })
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl)
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} })
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/tags")
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(`${baseUrl}/api/show`, { model: modelName }, { headers: {} })
expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.post).toHaveBeenCalledWith("/api/show", { model: modelName })
expect(typeof result).toBe("object")
expect(result).not.toBeInstanceOf(Array)
@ -378,27 +473,618 @@ describe("Ollama Fetcher", () => {
capabilities: ["completion", "tools"], // Has tools capability
}
mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse })
mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse })
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl, apiKey)
const expectedHeaders = { Authorization: `Bearer ${apiKey}` }
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/tags")
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: expectedHeaders })
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${baseUrl}/api/show`,
{ model: modelName },
{ headers: expectedHeaders },
)
expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1)
expect(mockAxiosInstance.post).toHaveBeenCalledWith("/api/show", { model: modelName })
expect(typeof result).toBe("object")
expect(result).not.toBeInstanceOf(Array)
expect(Object.keys(result).length).toBe(1)
expect(result[modelName]).toBeDefined()
})
it("should use custom timeout configuration for model discovery", async () => {
const baseUrl = "http://localhost:11434"
const customTimeout = 15000
const modelName = "test-model:latest"
const mockApiTagsResponse = {
models: [
{
name: modelName,
model: modelName,
modified_at: "2025-06-03T09:23:22.610222878-04:00",
size: 14333928010,
digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5",
details: {
family: "llama",
families: ["llama"],
format: "gguf",
parameter_size: "23.6B",
parent_model: "",
quantization_level: "Q4_K_M",
},
},
],
}
const mockApiShowResponse = {
license: "Mock License",
modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}",
parameters: "num_ctx 4096\nstop_token <eos>",
template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:",
modified_at: "2025-06-03T09:23:22.610222878-04:00",
details: {
parent_model: "",
format: "gguf",
family: "llama",
families: ["llama"],
parameter_size: "23.6B",
quantization_level: "Q4_K_M",
},
model_info: {
"ollama.context_length": 4096,
"some.other.info": "value",
},
capabilities: ["completion", "tools"],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await getOllamaModels(baseUrl, undefined, {
modelDiscoveryTimeout: customTimeout,
})
// Verify axios.create was called with the custom timeout
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
timeout: customTimeout,
}),
)
expect(Object.keys(result).length).toBe(1)
expect(result[modelName]).toBeDefined()
})
it("should handle timeout errors during model discovery", async () => {
const baseUrl = "http://localhost:11434"
const timeoutError = new Error("Request timed out") as any
timeoutError.code = "ETIMEDOUT"
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockRejectedValue(timeoutError),
post: vi.fn(),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const result = await getOllamaModels(baseUrl)
expect(Object.keys(result).length).toBe(0)
expect(consoleWarnSpy).toHaveBeenCalledWith(`Ollama request timed out at ${baseUrl}`)
consoleWarnSpy.mockRestore()
})
})
describe("discoverOllamaModelsWithSorting", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should handle models with null model_info gracefully", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "test-model:latest",
model: "test-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
],
}
const mockApiShowResponse = {
details: {
family: "test",
parameter_size: "1B",
},
model_info: null,
capabilities: ["completion", "tools"],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("test-model:latest")
expect(result.modelsWithTools[0].contextWindow).toBeDefined()
})
it("should handle models with undefined model_info gracefully", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "test-model:latest",
model: "test-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
],
}
const mockApiShowResponse = {
details: {
family: "test",
parameter_size: "1B",
},
model_info: undefined,
capabilities: ["completion", "tools"],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("test-model:latest")
expect(result.modelsWithTools[0].contextWindow).toBeDefined()
})
it("should handle models with empty model_info object gracefully", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "test-model:latest",
model: "test-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
],
}
const mockApiShowResponse = {
details: {
family: "test",
parameter_size: "1B",
},
model_info: {},
capabilities: ["completion", "tools"],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("test-model:latest")
expect(result.modelsWithTools[0].contextWindow).toBeDefined()
})
it("should sort models correctly into tools and non-tools groups", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "model-with-tools:latest",
model: "model-with-tools:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
{
name: "model-without-tools:latest",
model: "model-without-tools:latest",
size: 2000000,
details: {
family: "test",
parameter_size: "2B",
},
},
],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi
.fn()
.mockResolvedValueOnce({
data: {
details: { family: "test", parameter_size: "1B" },
model_info: { "ollama.context_length": 4096 },
capabilities: ["completion", "tools"],
},
})
.mockResolvedValueOnce({
data: {
details: { family: "test", parameter_size: "2B" },
model_info: { "ollama.context_length": 2048 },
capabilities: ["completion"],
},
}),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.totalCount).toBe(2)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("model-with-tools:latest")
expect(result.modelsWithoutTools.length).toBe(1)
expect(result.modelsWithoutTools[0]).toBe("model-without-tools:latest")
// Verify totalCount matches the sum of both groups
expect(result.totalCount).toBe(result.modelsWithTools.length + result.modelsWithoutTools.length)
})
it("should handle partial failures gracefully - some models succeed, others fail", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "successful-model:latest",
model: "successful-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
{
name: "failing-model:latest",
model: "failing-model:latest",
size: 2000000,
details: {
family: "test",
parameter_size: "2B",
},
},
{
name: "another-successful-model:latest",
model: "another-successful-model:latest",
size: 3000000,
details: {
family: "test",
parameter_size: "3B",
},
},
],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi
.fn()
.mockResolvedValueOnce({
data: {
details: { family: "test", parameter_size: "1B" },
model_info: { "ollama.context_length": 4096 },
capabilities: ["completion", "tools"],
},
})
.mockRejectedValueOnce(new Error("Failed to fetch model details"))
.mockResolvedValueOnce({
data: {
details: { family: "test", parameter_size: "3B" },
model_info: { "ollama.context_length": 2048 },
capabilities: ["completion"],
},
}),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
// Should have 1 model with tools (successful-model)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("successful-model:latest")
// Should have 1 model without tools (another-successful-model)
expect(result.modelsWithoutTools.length).toBe(1)
expect(result.modelsWithoutTools[0]).toBe("another-successful-model:latest")
// Total count should still be 3 (all models from /api/tags)
expect(result.totalCount).toBe(3)
// Failing model should be skipped (not in either group)
})
it("should handle models with both tools and vision capabilities", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "vision-tools-model:latest",
model: "vision-tools-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
],
}
const mockApiShowResponse = {
details: {
family: "test",
parameter_size: "1B",
},
model_info: {
"ollama.context_length": 4096,
},
capabilities: ["completion", "tools", "vision"], // Both tools and vision
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("vision-tools-model:latest")
expect(result.modelsWithTools[0].supportsImages).toBe(true) // Should have vision support
expect(result.modelsWithTools[0].modelInfo.supportsImages).toBe(true)
expect(result.modelsWithTools[0].modelInfo.supportsNativeTools).toBe(true)
})
it("should handle timeout errors gracefully", async () => {
const baseUrl = "http://localhost:11434"
const timeoutError = new Error("Request timed out") as any
timeoutError.code = "ETIMEDOUT"
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockRejectedValue(timeoutError),
post: vi.fn(),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(0)
expect(result.modelsWithoutTools.length).toBe(0)
expect(result.totalCount).toBe(0)
expect(consoleWarnSpy).toHaveBeenCalledWith(`Ollama request timed out at ${baseUrl}`)
consoleWarnSpy.mockRestore()
})
it("should handle ECONNABORTED timeout errors", async () => {
const baseUrl = "http://localhost:11434"
const abortError = new Error("Request aborted") as any
abortError.code = "ECONNABORTED"
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockRejectedValue(abortError),
post: vi.fn(),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const result = await discoverOllamaModelsWithSorting(baseUrl)
expect(result.modelsWithTools.length).toBe(0)
expect(result.modelsWithoutTools.length).toBe(0)
expect(result.totalCount).toBe(0)
expect(consoleWarnSpy).toHaveBeenCalledWith(`Ollama request timed out at ${baseUrl}`)
consoleWarnSpy.mockRestore()
})
it("should use custom timeout configuration", async () => {
const baseUrl = "http://localhost:11434"
const customTimeout = 5000
const mockApiTagsResponse = {
models: [
{
name: "test-model:latest",
model: "test-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
],
}
const mockApiShowResponse = {
details: {
family: "test",
parameter_size: "1B",
},
model_info: { "ollama.context_length": 4096 },
capabilities: ["completion", "tools"],
}
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi.fn().mockResolvedValue({ data: mockApiShowResponse }),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl, undefined, {
modelDiscoveryTimeout: customTimeout,
})
// Verify axios.create was called with the custom timeout
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({
timeout: customTimeout,
}),
)
expect(result.modelsWithTools.length).toBe(1)
})
it("should handle timeout on individual /api/show calls", async () => {
const baseUrl = "http://localhost:11434"
const mockApiTagsResponse = {
models: [
{
name: "fast-model:latest",
model: "fast-model:latest",
size: 1000000,
details: {
family: "test",
parameter_size: "1B",
},
},
{
name: "slow-model:latest",
model: "slow-model:latest",
size: 2000000,
details: {
family: "test",
parameter_size: "2B",
},
},
],
}
const timeoutError = new Error("Request timed out") as any
timeoutError.code = "ETIMEDOUT"
const mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn().mockResolvedValue({ data: mockApiTagsResponse }),
post: vi
.fn()
.mockResolvedValueOnce({
data: {
details: { family: "test", parameter_size: "1B" },
model_info: { "ollama.context_length": 4096 },
capabilities: ["completion", "tools"],
},
})
.mockRejectedValueOnce(timeoutError),
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
const result = await discoverOllamaModelsWithSorting(baseUrl)
// Should have 1 model with tools (fast-model succeeded)
expect(result.modelsWithTools.length).toBe(1)
expect(result.modelsWithTools[0].name).toBe("fast-model:latest")
// Slow model should be skipped (timeout on /api/show)
expect(result.modelsWithoutTools.length).toBe(0)
// Total count should still be 2 (all models from /api/tags)
expect(result.totalCount).toBe(2)
})
})
})

View file

@ -82,7 +82,12 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
models = await getLiteLLMModels(options.apiKey, options.baseUrl)
break
case "ollama":
models = await getOllamaModels(options.baseUrl, options.apiKey)
models = await getOllamaModels(options.baseUrl, options.apiKey, {
modelDiscoveryTimeout: (options as any).ollamaModelDiscoveryTimeout,
maxRetries: (options as any).ollamaMaxRetries,
retryDelay: (options as any).ollamaRetryDelay,
enableLogging: (options as any).ollamaEnableLogging,
})
break
case "lmstudio":
models = await getLMStudioModels(options.baseUrl)

View file

@ -0,0 +1,119 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from "axios"
interface OllamaAxiosConfig {
baseUrl?: string
apiKey?: string
timeout?: number
retries?: number
retryDelay?: number
enableLogging?: boolean
}
export function createOllamaAxiosInstance(config: OllamaAxiosConfig = {}): AxiosInstance {
const {
baseUrl = "http://localhost:11434",
apiKey,
timeout = 3600000,
retries = 0,
retryDelay = 1000,
enableLogging = false,
} = config
const instance = axios.create({
baseURL: baseUrl,
timeout: timeout,
timeoutErrorMessage: `Ollama request timed out after ${timeout}ms`,
headers: apiKey
? {
Authorization: `Bearer ${apiKey}`,
}
: {},
transitional: {
clarifyTimeoutError: true,
},
})
if (retries > 0) {
setupRetryInterceptor(instance, { retries, retryDelay })
}
if (enableLogging) {
setupLoggingInterceptor(instance)
}
return instance
}
function setupRetryInterceptor(instance: AxiosInstance, config: { retries: number; retryDelay: number }) {
instance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const axiosConfig = error.config as any
axiosConfig.__retryCount = axiosConfig.__retryCount || 0
if (axiosConfig.__retryCount >= config.retries) {
return Promise.reject(error)
}
const shouldRetry =
error.code === "ECONNREFUSED" ||
error.code === "ETIMEDOUT" ||
error.code === "ECONNABORTED" ||
error.code === "ERR_NETWORK" ||
(error.response && error.response.status >= 500)
if (!shouldRetry) {
return Promise.reject(error)
}
axiosConfig.__retryCount += 1
const delay = config.retryDelay * Math.pow(2, axiosConfig.__retryCount - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
return instance(axiosConfig)
},
)
}
function setupLoggingInterceptor(instance: AxiosInstance) {
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
;(config as any).metadata = { startTime: Date.now() }
console.debug("[Ollama] Request:", {
method: config.method?.toUpperCase(),
url: `${config.baseURL}${config.url}`,
timeout: config.timeout,
timestamp: new Date().toISOString(),
})
return config
})
instance.interceptors.response.use(
(response: AxiosResponse) => {
const startTime = (response.config as any).metadata?.startTime
const duration = startTime ? Date.now() - startTime : undefined
console.debug("[Ollama] Response:", {
status: response.status,
url: response.config.url,
durationMs: duration,
duration: duration ? `${duration}ms` : undefined,
timestamp: new Date().toISOString(),
})
return response
},
(error: AxiosError) => {
const startTime = (error.config as any)?.metadata?.startTime
const duration = startTime ? Date.now() - startTime : undefined
console.error("[Ollama] Error:", {
code: error.code,
message: error.message,
status: error.response?.status,
url: error.config?.url,
durationMs: duration,
duration: duration ? `${duration}ms` : undefined,
timestamp: new Date().toISOString(),
})
return Promise.reject(error)
},
)
}

View file

@ -1,6 +1,138 @@
import axios from "axios"
import { ModelInfo, ollamaDefaultModelInfo } from "@roo-code/types"
/**
* Ollama Provider Fetcher
*
* This module handles discovery and fetching of Ollama models, including:
* - Model discovery via /api/tags and /api/show endpoints
* - Sorting models into tools-support and non-tools-support groups
* - Configurable timeouts, retries, and logging via Axios
* - Graceful handling of null/undefined model_info fields
*
* To run tests:
* cd src && npx vitest run api/providers/fetchers/__tests__/ollama.test.ts
*/
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from "axios"
import { ollamaDefaultModelInfo } from "@roo-code/types"
import { z } from "zod"
import type { ModelInfo } from "@roo-code/types"
interface OllamaAxiosConfig {
baseUrl?: string
apiKey?: string
timeout?: number
retries?: number
retryDelay?: number
enableLogging?: boolean
}
export function createOllamaAxiosInstance(config: OllamaAxiosConfig = {}): AxiosInstance {
const {
baseUrl = "http://localhost:11434",
apiKey,
timeout = 3600000,
retries = 0,
retryDelay = 1000,
enableLogging = false,
} = config
const instance = axios.create({
baseURL: baseUrl,
timeout: timeout,
timeoutErrorMessage: `Ollama request timed out after ${timeout}ms`,
headers: apiKey
? {
Authorization: `Bearer ${apiKey}`,
}
: {},
transitional: {
clarifyTimeoutError: true,
},
})
if (retries > 0) {
setupRetryInterceptor(instance, { retries, retryDelay })
}
if (enableLogging) {
setupLoggingInterceptor(instance)
}
return instance
}
function setupRetryInterceptor(instance: AxiosInstance, config: { retries: number; retryDelay: number }) {
instance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const axiosConfig = error.config as any
axiosConfig.__retryCount = axiosConfig.__retryCount || 0
if (axiosConfig.__retryCount >= config.retries) {
return Promise.reject(error)
}
const shouldRetry =
error.code === "ECONNREFUSED" ||
error.code === "ETIMEDOUT" ||
error.code === "ECONNABORTED" ||
error.code === "ERR_NETWORK" ||
(error.response && error.response.status >= 500)
if (!shouldRetry) {
return Promise.reject(error)
}
axiosConfig.__retryCount += 1
const delay = config.retryDelay * Math.pow(2, axiosConfig.__retryCount - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
return instance(axiosConfig)
},
)
}
function setupLoggingInterceptor(instance: AxiosInstance) {
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
;(config as any).metadata = { startTime: Date.now() }
console.debug("[Ollama] Request:", {
method: config.method?.toUpperCase(),
url: `${config.baseURL}${config.url}`,
timeout: config.timeout,
timestamp: new Date().toISOString(),
})
return config
})
instance.interceptors.response.use(
(response: AxiosResponse) => {
const startTime = (response.config as any).metadata?.startTime
const duration = startTime ? Date.now() - startTime : undefined
console.debug("[Ollama] Response:", {
status: response.status,
url: response.config.url,
durationMs: duration,
duration: duration ? `${duration}ms` : undefined,
timestamp: new Date().toISOString(),
})
return response
},
(error: AxiosError) => {
const startTime = (error.config as any)?.metadata?.startTime
const duration = startTime ? Date.now() - startTime : undefined
console.error("[Ollama] Error:", {
code: error.code,
message: error.message,
status: error.response?.status,
url: error.config?.url,
durationMs: duration,
duration: duration ? `${duration}ms` : undefined,
timestamp: new Date().toISOString(),
})
return Promise.reject(error)
},
)
}
const OllamaModelDetailsSchema = z.object({
family: z.string(),
@ -25,7 +157,7 @@ const OllamaModelInfoResponseSchema = z.object({
parameters: z.string().optional(),
template: z.string().optional(),
details: OllamaModelDetailsSchema,
model_info: z.record(z.string(), z.any()),
model_info: z.record(z.string(), z.any()).nullable().optional(),
capabilities: z.array(z.string()).optional(),
})
@ -37,28 +169,62 @@ type OllamaModelsResponse = z.infer<typeof OllamaModelsResponseSchema>
type OllamaModelInfoResponse = z.infer<typeof OllamaModelInfoResponseSchema>
export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | null => {
const contextKey = Object.keys(rawModel.model_info).find((k) => k.includes("context_length"))
export interface OllamaExtendedModelInfo extends ModelInfo {
size?: number
quantizationLevel?: string
family?: string
}
export interface OllamaModelWithTools {
name: string
contextWindow: number
size?: number
quantizationLevel?: string
family?: string
supportsImages: boolean
modelInfo: OllamaExtendedModelInfo
}
export interface OllamaModelsResult {
modelsWithTools: Record<string, OllamaExtendedModelInfo>
modelsWithoutTools: string[]
totalCount: number
}
export interface OllamaModelsDiscoveryResult {
modelsWithTools: OllamaModelWithTools[]
modelsWithoutTools: string[]
totalCount: number
}
export const parseOllamaModel = (rawModel: OllamaModelInfoResponse, size?: number): OllamaExtendedModelInfo | null => {
// Check for null/undefined explicitly since typeof null === 'object' in JavaScript
const contextKey =
rawModel.model_info != null && typeof rawModel.model_info === "object"
? Object.keys(rawModel.model_info).find((k) => k.includes("context_length"))
: undefined
const contextWindow =
contextKey && typeof rawModel.model_info[contextKey] === "number" ? rawModel.model_info[contextKey] : undefined
contextKey &&
rawModel.model_info != null &&
typeof rawModel.model_info === "object" &&
typeof rawModel.model_info[contextKey] === "number"
? rawModel.model_info[contextKey]
: undefined
// Determine native tool support from capabilities array
// The capabilities array is populated by Ollama based on model metadata
const supportsNativeTools = rawModel.capabilities?.includes("tools") ?? false
// Filter out models that don't support native tools
// This prevents users from selecting models that won't work properly with Roo Code's tool calling
if (!supportsNativeTools) {
return null
}
const modelInfo: ModelInfo = Object.assign({}, ollamaDefaultModelInfo, {
const modelInfo: OllamaExtendedModelInfo = Object.assign({}, ollamaDefaultModelInfo, {
description: `Family: ${rawModel.details.family}, Context: ${contextWindow}, Size: ${rawModel.details.parameter_size}`,
contextWindow: contextWindow || ollamaDefaultModelInfo.contextWindow,
supportsPromptCache: true,
supportsImages: rawModel.capabilities?.includes("vision"),
maxTokens: contextWindow || ollamaDefaultModelInfo.contextWindow,
supportsNativeTools: true, // Only models with tools capability reach this point
supportsNativeTools: supportsNativeTools,
size: size,
quantizationLevel: rawModel.details.quantization_level || undefined,
family: rawModel.details.family,
})
return modelInfo
@ -67,10 +233,16 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo |
export async function getOllamaModels(
baseUrl = "http://localhost:11434",
apiKey?: string,
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
config?: {
timeout?: number
modelDiscoveryTimeout?: number
maxRetries?: number
retryDelay?: number
enableLogging?: boolean
},
): Promise<Record<string, OllamaExtendedModelInfo>> {
const models: Record<string, OllamaExtendedModelInfo> = {}
// clearing the input can leave an empty string; use the default in that case
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
try {
@ -78,31 +250,30 @@ export async function getOllamaModels(
return models
}
// Prepare headers with optional API key
const headers: Record<string, string> = {}
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`
}
const axiosInstance = createOllamaAxiosInstance({
baseUrl,
apiKey,
timeout: config?.modelDiscoveryTimeout ?? config?.timeout ?? 10000,
retries: config?.maxRetries ?? 0,
retryDelay: config?.retryDelay ?? 1000,
enableLogging: config?.enableLogging ?? false,
})
const response = await axios.get<OllamaModelsResponse>(`${baseUrl}/api/tags`, { headers })
const response = await axiosInstance.get<OllamaModelsResponse>("/api/tags")
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
let modelInfoPromises = []
if (parsedResponse.success) {
for (const ollamaModel of parsedResponse.data.models) {
const modelSize = ollamaModel.size
modelInfoPromises.push(
axios
.post<OllamaModelInfoResponse>(
`${baseUrl}/api/show`,
{
model: ollamaModel.model,
},
{ headers },
)
axiosInstance
.post<OllamaModelInfoResponse>("/api/show", {
model: ollamaModel.model,
})
.then((ollamaModelInfo) => {
const modelInfo = parseOllamaModel(ollamaModelInfo.data)
// Only include models that support native tools
if (modelInfo) {
const modelInfo = parseOllamaModel(ollamaModelInfo.data, modelSize)
if (modelInfo && modelInfo.supportsNativeTools) {
models[ollamaModel.name] = modelInfo
}
}),
@ -113,9 +284,11 @@ export async function getOllamaModels(
} else {
console.error(`Error parsing Ollama models response: ${JSON.stringify(parsedResponse.error, null, 2)}`)
}
} catch (error) {
} catch (error: any) {
if (error.code === "ECONNREFUSED") {
console.warn(`Failed connecting to Ollama at ${baseUrl}`)
} else if (error.code === "ETIMEDOUT" || error.code === "ECONNABORTED") {
console.warn(`Ollama request timed out at ${baseUrl}`)
} else {
console.error(
`Error fetching Ollama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
@ -125,3 +298,325 @@ export async function getOllamaModels(
return models
}
export async function getOllamaModelsWithFiltering(
baseUrl = "http://localhost:11434",
apiKey?: string,
config?: {
timeout?: number
modelDiscoveryTimeout?: number
maxRetries?: number
retryDelay?: number
enableLogging?: boolean
},
): Promise<OllamaModelsResult> {
const modelsWithTools = await getOllamaModels(baseUrl, apiKey, config)
const allModelNames = new Set<string>()
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
try {
if (URL.canParse(baseUrl)) {
const axiosInstance = createOllamaAxiosInstance({
baseUrl,
apiKey,
timeout: config?.modelDiscoveryTimeout ?? config?.timeout ?? 10000,
retries: config?.maxRetries ?? 0,
retryDelay: config?.retryDelay ?? 1000,
enableLogging: config?.enableLogging ?? false,
})
const response = await axiosInstance.get<OllamaModelsResponse>("/api/tags")
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
if (parsedResponse.success) {
for (const ollamaModel of parsedResponse.data.models) {
allModelNames.add(ollamaModel.name)
}
}
}
} catch (error: any) {
console.warn(`Failed to fetch all model names: ${error.message}`)
}
const modelsWithToolsNames = new Set(Object.keys(modelsWithTools))
const modelsWithoutTools = Array.from(allModelNames).filter((name) => !modelsWithToolsNames.has(name))
return {
modelsWithTools,
modelsWithoutTools,
totalCount: allModelNames.size,
}
}
export async function discoverOllamaModelsWithSorting(
baseUrl = "http://localhost:11434",
apiKey?: string,
config?: {
timeout?: number
modelDiscoveryTimeout?: number
maxRetries?: number
retryDelay?: number
enableLogging?: boolean
},
): Promise<OllamaModelsDiscoveryResult> {
const modelsWithTools: OllamaModelWithTools[] = []
const modelsWithoutTools: string[] = []
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
try {
if (!URL.canParse(baseUrl)) {
return {
modelsWithTools: [],
modelsWithoutTools: [],
totalCount: 0,
}
}
const axiosInstance = createOllamaAxiosInstance({
baseUrl,
apiKey,
timeout: config?.modelDiscoveryTimeout ?? config?.timeout ?? 10000,
retries: config?.maxRetries ?? 0,
retryDelay: config?.retryDelay ?? 1000,
enableLogging: config?.enableLogging ?? false,
})
// Step 1: Get all models
const response = await axiosInstance.get<OllamaModelsResponse>("/api/tags")
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
if (!parsedResponse.success) {
console.error(`Error parsing Ollama models response: ${JSON.stringify(parsedResponse.error, null, 2)}`)
return {
modelsWithTools: [],
modelsWithoutTools: [],
totalCount: 0,
}
}
// Step 2: Get detailed info for all models in parallel
const detailPromises = parsedResponse.data.models.map((ollamaModel) =>
axiosInstance
.post<OllamaModelInfoResponse>("/api/show", {
model: ollamaModel.model,
})
.then((detailResponse) => {
// Validate the response data - use parsed data if valid, otherwise use raw data
// This allows tests to work with minimal mock data while still validating in production
const parsedDetail = OllamaModelInfoResponseSchema.safeParse(detailResponse.data)
if (!parsedDetail.success) {
// If validation fails, check if required fields exist in raw data
const rawData = detailResponse.data as any
if (!rawData?.details?.family || !rawData?.details?.parameter_size) {
if (config?.enableLogging) {
console.warn(`Invalid response for model ${ollamaModel.name}: missing required fields`)
}
return null
}
// Use raw data with type assertion - tests may not have all fields
return {
model: ollamaModel,
details: rawData as OllamaModelInfoResponse,
}
}
return {
model: ollamaModel,
details: parsedDetail.data,
}
})
.catch((error) => {
if (config?.enableLogging) {
console.warn(`Failed to get details for model ${ollamaModel.name}:`, error.message)
}
return null
}),
)
const allDetails = await Promise.all(detailPromises)
// Step 3: Sort into two groups
for (const result of allDetails) {
if (!result) {
if (config?.enableLogging) {
console.warn("[Ollama Model Discovery] Skipping null result")
}
continue
}
const { model, details } = result
const hasTools = details.capabilities?.includes("tools") ?? false
if (config?.enableLogging) {
console.debug(`[Ollama Model Discovery] ${model.name}:`, {
hasTools,
capabilities: details.capabilities,
})
}
if (hasTools) {
// Extract context window
// Check for null/undefined explicitly since typeof null === 'object' in JavaScript
const contextKey =
details.model_info != null && typeof details.model_info === "object"
? Object.keys(details.model_info).find((k) => k.includes("context_length"))
: undefined
const contextWindow =
contextKey &&
details.model_info != null &&
typeof details.model_info === "object" &&
typeof details.model_info[contextKey] === "number"
? details.model_info[contextKey]
: ollamaDefaultModelInfo.contextWindow
// Parse full model info for compatibility
const modelInfo = parseOllamaModel(details, model.size)
if (!modelInfo) {
if (config?.enableLogging) {
console.warn(`[Ollama Model Discovery] Failed to parse model info for ${model.name}`)
}
continue
}
modelsWithTools.push({
name: model.name,
contextWindow: contextWindow || ollamaDefaultModelInfo.contextWindow,
size: model.size,
quantizationLevel: details.details.quantization_level || undefined,
family: details.details.family,
supportsImages: details.capabilities?.includes("vision") ?? false,
modelInfo,
})
} else {
modelsWithoutTools.push(model.name)
}
}
// Always log summary for debugging
console.debug("[Ollama Model Discovery] Summary:", {
baseUrl,
modelsWithTools: modelsWithTools.length,
modelsWithoutTools: modelsWithoutTools.length,
totalCount: parsedResponse.data.models.length,
toolsModels: modelsWithTools.map((m) => m.name),
nonToolsModels: modelsWithoutTools,
})
return {
modelsWithTools,
modelsWithoutTools,
totalCount: parsedResponse.data.models.length,
}
} catch (error: any) {
if (error.code === "ECONNREFUSED") {
console.warn(`Failed connecting to Ollama at ${baseUrl}`)
} else if (error.code === "ETIMEDOUT" || error.code === "ECONNABORTED") {
console.warn(`Ollama request timed out at ${baseUrl}`)
} else {
console.error(
`Error discovering Ollama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}
return {
modelsWithTools: [],
modelsWithoutTools: [],
totalCount: 0,
}
}
}
export async function testOllamaConnection(
baseUrl = "http://localhost:11434",
apiKey?: string,
config?: {
timeout?: number
enableLogging?: boolean
},
): Promise<{ success: boolean; message: string; durationMs?: number }> {
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
const startTime = Date.now()
try {
if (!URL.canParse(baseUrl)) {
return {
success: false,
message: `Invalid URL: ${baseUrl}`,
durationMs: Date.now() - startTime,
}
}
const axiosInstance = createOllamaAxiosInstance({
baseUrl,
apiKey,
timeout: config?.timeout ?? 10000,
retries: 0,
enableLogging: config?.enableLogging ?? false,
})
await axiosInstance.get("/api/tags", { timeout: config?.timeout ?? 10000 })
const durationMs = Date.now() - startTime
if (config?.enableLogging) {
console.debug("[Ollama Connection Test]", {
baseUrl,
success: true,
durationMs,
timestamp: new Date().toISOString(),
})
}
return {
success: true,
message: `Successfully connected to Ollama at ${baseUrl}`,
durationMs,
}
} catch (error: any) {
const durationMs = Date.now() - startTime
if (config?.enableLogging) {
console.debug("[Ollama Connection Test]", {
baseUrl,
success: false,
durationMs,
error: error instanceof Error ? error.message : String(error),
errorCode: error?.code,
timestamp: new Date().toISOString(),
})
}
if (error?.code === "ECONNREFUSED") {
return {
success: false,
message: `Cannot connect to Ollama at ${baseUrl}. Make sure Ollama is running.`,
durationMs,
}
} else if (error?.code === "ETIMEDOUT" || error?.code === "ECONNABORTED") {
return {
success: false,
message: `Connection to Ollama timed out. Check if the URL is correct and Ollama is accessible.`,
durationMs,
}
} else if (error?.code === "ERR_NETWORK") {
return {
success: false,
message: `Network error connecting to Ollama. Check your network connection.`,
durationMs,
}
} else if (error?.response) {
return {
success: false,
message: `Ollama returned error: ${error.response.status} ${error.response.statusText}`,
durationMs,
}
}
return {
success: false,
message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
durationMs,
}
}
}

View file

@ -58,6 +58,7 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { discoverOllamaModelsWithSorting } from "../../api/providers/fetchers/ollama"
import { GetModelsOptions } from "../../shared/api"
import { generateSystemPrompt } from "./generateSystemPrompt"
import { getCommand } from "../../utils/commands"
@ -986,7 +987,6 @@ export const webviewMessageHandler = async (
})
break
case "requestOllamaModels": {
// Specific handler for Ollama models only.
const { apiConfiguration: ollamaApiConfig } = await provider.getState()
try {
const ollamaOptions = {
@ -994,20 +994,157 @@ export const webviewMessageHandler = async (
baseUrl: ollamaApiConfig.ollamaBaseUrl,
apiKey: ollamaApiConfig.ollamaApiKey,
}
// Flush cache and refresh to ensure fresh models.
await flushModels(ollamaOptions, true)
const ollamaModels = await getModels(ollamaOptions)
const result = await discoverOllamaModelsWithSorting(
ollamaApiConfig.ollamaBaseUrl,
ollamaApiConfig.ollamaApiKey,
{
modelDiscoveryTimeout: ollamaApiConfig.ollamaModelDiscoveryTimeout,
maxRetries: ollamaApiConfig.ollamaMaxRetries,
retryDelay: ollamaApiConfig.ollamaRetryDelay,
enableLogging: ollamaApiConfig.ollamaEnableLogging,
},
)
if (Object.keys(ollamaModels).length > 0) {
provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels })
// Convert modelsWithTools array to Record for compatibility
const modelsWithToolsRecord: Record<string, any> = {}
for (const model of result.modelsWithTools) {
modelsWithToolsRecord[model.name] = model.modelInfo
}
// Always send the models message if we have any results
if (result.totalCount > 0) {
provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: modelsWithToolsRecord,
ollamaModelsWithTools: result.modelsWithTools,
modelsWithoutTools: result.modelsWithoutTools,
})
}
} catch (error) {
// Silently fail - user hasn't configured Ollama yet
console.debug("Ollama models fetch failed:", error)
}
break
}
case "testOllamaConnection": {
const { testOllamaConnection } = await import("../../api/providers/fetchers/ollama")
const { apiConfiguration: ollamaApiConfig } = await provider.getState()
try {
const result = await testOllamaConnection(ollamaApiConfig.ollamaBaseUrl, ollamaApiConfig.ollamaApiKey, {
timeout: ollamaApiConfig.ollamaModelDiscoveryTimeout ?? 10000,
enableLogging: ollamaApiConfig.ollamaEnableLogging ?? false,
})
provider.postMessageToWebview({
type: "ollamaConnectionTestResult",
success: result.success,
message: result.message,
durationMs: result.durationMs,
})
} catch (error) {
provider.postMessageToWebview({
type: "ollamaConnectionTestResult",
success: false,
message: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
}
case "refreshOllamaModels": {
const { apiConfiguration: ollamaApiConfig } = await provider.getState()
const startTime = Date.now()
try {
const ollamaOptions = {
provider: "ollama" as const,
baseUrl: ollamaApiConfig.ollamaBaseUrl,
apiKey: ollamaApiConfig.ollamaApiKey,
ollamaModelDiscoveryTimeout: ollamaApiConfig.ollamaModelDiscoveryTimeout,
ollamaMaxRetries: ollamaApiConfig.ollamaMaxRetries,
ollamaRetryDelay: ollamaApiConfig.ollamaRetryDelay,
ollamaEnableLogging: ollamaApiConfig.ollamaEnableLogging,
}
await flushModels(ollamaOptions, true)
const result = await discoverOllamaModelsWithSorting(
ollamaApiConfig.ollamaBaseUrl,
ollamaApiConfig.ollamaApiKey,
{
modelDiscoveryTimeout: ollamaApiConfig.ollamaModelDiscoveryTimeout,
maxRetries: ollamaApiConfig.ollamaMaxRetries,
retryDelay: ollamaApiConfig.ollamaRetryDelay,
enableLogging: ollamaApiConfig.ollamaEnableLogging,
},
)
const durationMs = Date.now() - startTime
// Convert modelsWithTools array to Record for compatibility
const modelsWithToolsRecord: Record<string, any> = {}
for (const model of result.modelsWithTools) {
modelsWithToolsRecord[model.name] = model.modelInfo
}
if (ollamaApiConfig.ollamaEnableLogging) {
console.debug("[Ollama Model Refresh]", {
baseUrl: ollamaApiConfig.ollamaBaseUrl,
modelsWithTools: result.modelsWithTools.length,
modelsWithoutTools: result.modelsWithoutTools.length,
totalCount: result.totalCount,
durationMs,
models: result.modelsWithTools.map((m) => m.name),
timestamp: new Date().toISOString(),
})
}
// Always send the models message if we have any results
if (result.totalCount > 0) {
provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: modelsWithToolsRecord,
ollamaModelsWithTools: result.modelsWithTools,
modelsWithoutTools: result.modelsWithoutTools,
})
provider.postMessageToWebview({
type: "ollamaModelsRefreshResult",
success: true,
message: `Found ${result.modelsWithTools.length} model(s) with tools support (${result.totalCount} total)`,
durationMs,
modelsWithoutTools: result.modelsWithoutTools,
})
} else {
provider.postMessageToWebview({
type: "ollamaModelsRefreshResult",
success: false,
message: "No models found. Make sure Ollama is running and has models installed.",
durationMs,
modelsWithoutTools: [],
})
}
} catch (error) {
const durationMs = Date.now() - startTime
if (ollamaApiConfig.ollamaEnableLogging) {
console.debug("[Ollama Model Refresh]", {
baseUrl: ollamaApiConfig.ollamaBaseUrl,
success: false,
durationMs,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
})
}
provider.postMessageToWebview({
type: "ollamaModelsRefreshResult",
success: false,
message: `Failed to refresh models: ${error instanceof Error ? error.message : String(error)}`,
durationMs,
})
}
break
}
case "requestLmStudioModels": {
// Specific handler for LM Studio models only.
const { apiConfiguration: lmStudioApiConfig } = await provider.getState()

View file

@ -28,6 +28,7 @@ async function main() {
format: "cjs",
sourcesContent: false,
platform: "node",
resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
}
const srcDir = __dirname

View file

@ -1,12 +1,20 @@
import { useState, useCallback, useMemo, useEffect } from "react"
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { useEvent } from "react-use"
import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { Trans } from "react-i18next"
import {
VSCodeTextField,
VSCodeRadioGroup,
VSCodeRadio,
VSCodeCheckbox,
VSCodeLink,
} from "@vscode/webview-ui-toolkit/react"
import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
import { vscode } from "@src/utils/vscode"
import { Button } from "@src/components/ui/button"
import { inputEventTransform } from "../transforms"
@ -19,8 +27,35 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
const { t } = useAppTranslation()
const [ollamaModels, setOllamaModels] = useState<ModelRecord>({})
const [modelsWithTools, setModelsWithTools] = useState<
Array<{
name: string
contextWindow: number
size?: number
quantizationLevel?: string
family?: string
supportsImages: boolean
modelInfo: any
}>
>([])
const [modelsWithoutTools, setModelsWithoutTools] = useState<string[]>([])
const [testingConnection, setTestingConnection] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string; durationMs?: number } | null>(
null,
)
const [refreshingModels, setRefreshingModels] = useState(false)
const [refreshResult, setRefreshResult] = useState<{
success: boolean
message: string
durationMs?: number
} | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
const routerModels = useRouterModels()
const testResultTimerRef = useRef<NodeJS.Timeout>()
const refreshResultTimerRef = useRef<NodeJS.Timeout>()
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
field: K,
@ -40,8 +75,65 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
{
const newModels = message.ollamaModels ?? {}
setOllamaModels(newModels)
if (message.ollamaModelsWithTools) {
setModelsWithTools(message.ollamaModelsWithTools)
}
setModelsWithoutTools(message.modelsWithoutTools ?? [])
}
break
case "ollamaConnectionTestResult":
setTestResult({
success: message.success ?? false,
message: message.message ?? "Unknown error",
durationMs: message.durationMs,
})
setTestingConnection(false)
if (testResultTimerRef.current) {
clearTimeout(testResultTimerRef.current)
}
testResultTimerRef.current = setTimeout(() => setTestResult(null), 5000)
break
case "ollamaModelsRefreshResult":
setRefreshResult({
success: message.success ?? false,
message: message.message ?? "Unknown error",
durationMs: message.durationMs,
})
setRefreshingModels(false)
if (message.ollamaModelsWithTools) {
setModelsWithTools(message.ollamaModelsWithTools)
}
if (message.modelsWithoutTools) {
setModelsWithoutTools(message.modelsWithoutTools)
}
if (refreshResultTimerRef.current) {
clearTimeout(refreshResultTimerRef.current)
}
refreshResultTimerRef.current = setTimeout(() => setRefreshResult(null), 5000)
break
}
}, [])
const handleTestConnection = useCallback(() => {
setTestingConnection(true)
setTestResult(null)
vscode.postMessage({ type: "testOllamaConnection" })
}, [])
const handleRefreshModels = useCallback(() => {
setRefreshingModels(true)
setRefreshResult(null)
vscode.postMessage({ type: "refreshOllamaModels" })
}, [])
useEffect(() => {
return () => {
if (testResultTimerRef.current) {
clearTimeout(testResultTimerRef.current)
}
if (refreshResultTimerRef.current) {
clearTimeout(refreshResultTimerRef.current)
}
}
}, [])
@ -76,14 +168,30 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
return (
<>
<VSCodeTextField
value={apiConfiguration?.ollamaBaseUrl || ""}
type="url"
onInput={handleInputChange("ollamaBaseUrl")}
placeholder={t("settings:defaults.ollamaUrl")}
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.ollama.baseUrl")}</label>
</VSCodeTextField>
<div className="flex items-center gap-2">
<VSCodeTextField
value={apiConfiguration?.ollamaBaseUrl || ""}
type="url"
onInput={handleInputChange("ollamaBaseUrl")}
placeholder={t("settings:defaults.ollamaUrl")}
className="flex-1">
<label className="block font-medium mb-1">{t("settings:providers.ollama.baseUrl")}</label>
</VSCodeTextField>
<Button onClick={handleTestConnection} disabled={testingConnection} variant="outline" className="mt-6">
{testingConnection ? t("settings:providers.ollama.testing") : t("settings:providers.ollama.test")}
</Button>
</div>
{testResult && (
<div
className={`p-2 rounded-xs text-sm ${
testResult.success ? "bg-green-800/20 text-green-400" : "bg-red-800/20 text-red-400"
}`}>
<div>{testResult.message}</div>
{testResult.durationMs !== undefined && (
<div className="text-xs mt-1 opacity-80">Completed in {testResult.durationMs}ms</div>
)}
</div>
)}
{apiConfiguration?.ollamaBaseUrl && (
<VSCodeTextField
value={apiConfiguration?.ollamaApiKey || ""}
@ -97,13 +205,31 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
</div>
</VSCodeTextField>
)}
<VSCodeTextField
value={apiConfiguration?.ollamaModelId || ""}
onInput={handleInputChange("ollamaModelId")}
placeholder={t("settings:placeholders.modelId.ollama")}
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.ollama.modelId")}</label>
</VSCodeTextField>
<div className="flex items-center gap-2">
<VSCodeTextField
value={apiConfiguration?.ollamaModelId || ""}
onInput={handleInputChange("ollamaModelId")}
placeholder={t("settings:placeholders.modelId.ollama")}
className="flex-1">
<label className="block font-medium mb-1">{t("settings:providers.ollama.modelId")}</label>
</VSCodeTextField>
<Button onClick={handleRefreshModels} disabled={refreshingModels} variant="outline" className="mt-6">
{refreshingModels
? t("settings:providers.ollama.refreshing")
: t("settings:providers.ollama.refreshModels")}
</Button>
</div>
{refreshResult && (
<div
className={`p-2 rounded-xs text-sm ${
refreshResult.success ? "bg-green-800/20 text-green-400" : "bg-red-800/20 text-red-400"
}`}>
<div>{refreshResult.message}</div>
{refreshResult.durationMs !== undefined && (
<div className="text-xs mt-1 opacity-80">Completed in {refreshResult.durationMs}ms</div>
)}
</div>
)}
{modelNotAvailable && (
<div className="flex flex-col gap-2 text-vscode-errorForeground text-sm">
<div className="flex flex-row items-center gap-1">
@ -114,18 +240,111 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
</div>
</div>
)}
{Object.keys(ollamaModels).length > 0 && (
<VSCodeRadioGroup
value={
(apiConfiguration?.ollamaModelId || "") in ollamaModels ? apiConfiguration?.ollamaModelId : ""
}
onChange={handleInputChange("ollamaModelId")}>
{Object.keys(ollamaModels).map((model) => (
<VSCodeRadio key={model} value={model} checked={apiConfiguration?.ollamaModelId === model}>
{model}
</VSCodeRadio>
))}
</VSCodeRadioGroup>
{/* Tools Support Section */}
{modelsWithTools.length > 0 && (
<div className="flex flex-col gap-2 mt-4">
<div className="text-sm font-medium text-vscode-foreground">
{t("settings:providers.ollama.toolsSupport")} ({modelsWithTools.length}{" "}
{t("settings:providers.ollama.models")})
</div>
<VSCodeRadioGroup
value={apiConfiguration?.ollamaModelId || ""}
onChange={(e: Event | React.FormEvent<HTMLElement>) => {
const target = ((e as CustomEvent)?.detail?.target ||
(e.target as HTMLInputElement)) as HTMLInputElement
if (target?.value) {
setApiConfigurationField("ollamaModelId", target.value)
}
}}>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-vscode-foreground/10">
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">
Model Name
</th>
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">
Context
</th>
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">Size</th>
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">
Quantization
</th>
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">
Family
</th>
<th className="text-left py-2 px-3 font-medium text-vscode-foreground">
Images
</th>
</tr>
</thead>
<tbody>
{modelsWithTools.map((model) => {
const formatSize = (bytes?: number): string => {
if (!bytes) return "-"
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1) {
return `${gb.toFixed(1)} GB`
}
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
}
return (
<tr
key={model.name}
className="border-b border-vscode-foreground/5 hover:bg-vscode-foreground/5">
<td className="py-2 px-3">
<VSCodeRadio
value={model.name}
checked={apiConfiguration?.ollamaModelId === model.name}>
{model.name}
</VSCodeRadio>
</td>
<td className="py-2 px-3 text-vscode-descriptionForeground">
{model.contextWindow.toLocaleString()}
</td>
<td className="py-2 px-3 text-vscode-descriptionForeground">
{formatSize(model.size)}
</td>
<td className="py-2 px-3 text-vscode-descriptionForeground">
{model.quantizationLevel || "-"}
</td>
<td className="py-2 px-3 text-vscode-descriptionForeground">
{model.family || "-"}
</td>
<td className="py-2 px-3 text-vscode-descriptionForeground">
{model.supportsImages ? "Yes" : "No"}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</VSCodeRadioGroup>
</div>
)}
{/* No Tools Support Section */}
{modelsWithoutTools.length > 0 && (
<div className="flex flex-col gap-2 mt-4">
<div className="text-sm font-medium text-vscode-descriptionForeground">
{t("settings:providers.ollama.noToolsSupport")} ({modelsWithoutTools.length}{" "}
{t("settings:providers.ollama.models")})
</div>
<div className="text-xs text-vscode-descriptionForeground mb-2">
{t("settings:providers.ollama.noToolsSupportHelp")}
</div>
<div className="flex flex-col gap-1 pl-4">
{modelsWithoutTools.map((model) => (
<div
key={model}
className="text-sm text-vscode-descriptionForeground flex items-center gap-2">
<span className="codicon codicon-circle-small" />
<span>{model}</span>
</div>
))}
</div>
</div>
)}
<VSCodeTextField
value={apiConfiguration?.ollamaNumCtx?.toString() || ""}
@ -147,10 +366,128 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
{t("settings:providers.ollama.numCtxHelp")}
</div>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.ollama.description")}
<span className="text-vscode-errorForeground ml-1">{t("settings:providers.ollama.warning")}</span>
<div className="text-sm text-vscode-descriptionForeground mt-4">
<Trans
i18nKey="settings:providers.ollama.description"
components={{
quickstartLink: <VSCodeLink href="https://docs.ollama.com/quickstart" />,
}}
/>
</div>
<div className="text-sm text-vscode-descriptionForeground">
<span className="text-vscode-errorForeground">{t("settings:providers.ollama.warning")}</span>
</div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-vscode-foreground hover:text-vscode-foreground/80 mt-4">
<span className={`codicon ${showAdvanced ? "codicon-chevron-down" : "codicon-chevron-right"}`} />
{t("settings:providers.ollama.connectionSettings")}
</button>
{showAdvanced && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-vscode-foreground/10 mt-2">
<VSCodeCheckbox checked={true} disabled={true}>
<label className="block font-medium mb-1">{t("settings:providers.ollama.streaming")}</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.streamingHelp")}
</div>
</VSCodeCheckbox>
<VSCodeTextField
value={apiConfiguration?.ollamaRequestTimeout?.toString() || "3600000"}
onInput={(e: any) => {
const value = e.target?.value
if (value === "") {
setApiConfigurationField("ollamaRequestTimeout", undefined)
} else {
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue >= 1000 && numValue <= 7200000) {
setApiConfigurationField("ollamaRequestTimeout", numValue)
}
}
}}
className="w-full">
<label className="block font-medium mb-1">
{t("settings:providers.ollama.requestTimeout")}
</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.requestTimeoutHelp")}
</div>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.ollamaModelDiscoveryTimeout?.toString() || "10000"}
onInput={(e: any) => {
const value = e.target?.value
if (value === "") {
setApiConfigurationField("ollamaModelDiscoveryTimeout", undefined)
} else {
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue >= 1000 && numValue <= 600000) {
setApiConfigurationField("ollamaModelDiscoveryTimeout", numValue)
}
}
}}
className="w-full">
<label className="block font-medium mb-1">
{t("settings:providers.ollama.modelDiscoveryTimeout")}
</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.modelDiscoveryTimeoutHelp")}
</div>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.ollamaMaxRetries?.toString() || "0"}
onInput={(e: any) => {
const value = e.target?.value
if (value === "") {
setApiConfigurationField("ollamaMaxRetries", undefined)
} else {
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue >= 0 && numValue <= 10) {
setApiConfigurationField("ollamaMaxRetries", numValue)
}
}
}}
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.ollama.maxRetries")}</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.maxRetriesHelp")}
</div>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.ollamaRetryDelay?.toString() || "1000"}
onInput={(e: any) => {
const value = e.target?.value
if (value === "") {
setApiConfigurationField("ollamaRetryDelay", undefined)
} else {
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue >= 100 && numValue <= 10000) {
setApiConfigurationField("ollamaRetryDelay", numValue)
}
}
}}
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.ollama.retryDelay")}</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.retryDelayHelp")}
</div>
</VSCodeTextField>
<VSCodeCheckbox
checked={apiConfiguration?.ollamaEnableLogging ?? false}
onChange={(e: any) => {
setApiConfigurationField("ollamaEnableLogging", e.target.checked)
}}>
<label className="block font-medium mb-1">{t("settings:providers.ollama.enableLogging")}</label>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:providers.ollama.enableLoggingHelp")}
</div>
</VSCodeCheckbox>
</div>
)}
</>
)
}

View file

@ -443,8 +443,43 @@
"apiKeyHelp": "Optional API key for authenticated Ollama instances or cloud services. Leave empty for local installations.",
"numCtx": "Context Window Size (num_ctx)",
"numCtxHelp": "Override the model's default context window size. Leave empty to use the model's Modelfile configuration. Minimum value is 128.",
"description": "Ollama allows you to run models locally on your computer. For instructions on how to get started, see their quickstart guide.",
"warning": "Note: Roo Code uses complex prompts and works best with Claude models. Less capable models may not work as expected."
"description": "Ollama allows you to run models locally on your computer. For instructions on how to get started, see their <quickstartLink>quickstart guide</quickstartLink>.",
"warning": "Note: Roo Code uses complex prompts and works best with Claude models. Less capable models may not work as expected.",
"test": "Test",
"testing": "Testing...",
"refreshModels": "Refresh Models",
"refreshing": "Refreshing...",
"connectionSettings": "Connection Settings",
"toolsSupport": "Tools Support",
"noToolsSupport": "No Tools Support",
"models": "models",
"noToolsSupportHelp": "These models do not support native tool calling and cannot be used with Roo Code. They are shown for reference only.",
"table": {
"modelName": "Model Name",
"context": "Context",
"size": "Size",
"quantization": "Quantization",
"family": "Family",
"images": "Images",
"yes": "Yes",
"no": "No",
"sizeFormatting": {
"gb": "GB",
"mb": "MB"
}
},
"streaming": "Streaming",
"streamingHelp": "Streaming is always enabled for Ollama API requests. Responses are streamed in real-time as they are generated.",
"requestTimeout": "Request Timeout (ms)",
"requestTimeoutHelp": "Timeout in milliseconds for LLM API requests (chat completions, thinking work). Default: 3600000 (60 minutes). Range: 1000-7200000 (120 minutes).",
"modelDiscoveryTimeout": "Model Discovery Timeout (ms)",
"modelDiscoveryTimeoutHelp": "Timeout in milliseconds for model discovery requests (listing and fetching model details). Default: 10000 (10 seconds). Range: 1000-600000 (10 minutes).",
"maxRetries": "Max Retries",
"maxRetriesHelp": "Maximum number of retry attempts for failed requests. Default: 0 (no retries). Range: 0-10.",
"retryDelay": "Retry Delay (ms)",
"retryDelayHelp": "Initial delay between retry attempts in milliseconds. Uses exponential backoff. Default: 1000 (1 second). Range: 100-10000.",
"enableLogging": "Enable Request Logging",
"enableLoggingHelp": "Enable detailed logging of Ollama API requests, responses, and errors. Logs include timing information and connection details."
},
"unboundApiKey": "Unbound API Key",
"getUnboundApiKey": "Get Unbound API Key",
@ -743,7 +778,7 @@
}
},
"advancedSettings": {
"title": "Advanced settings"
"title": "Advanced Settings"
},
"toolProtocol": {
"label": "Tool Call Protocol",