diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index cedf9a3e2f..07f4510c0f 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -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", () => { diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 457252e7fe..77d171035c 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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({ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 86d8b2ddbb..510aa2e403 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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" diff --git a/src/api/providers/fetchers/__tests__/ollama-axios-config.spec.ts b/src/api/providers/fetchers/__tests__/ollama-axios-config.spec.ts new file mode 100644 index 0000000000..159a7ce0ef --- /dev/null +++ b/src/api/providers/fetchers/__tests__/ollama-axios-config.spec.ts @@ -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", + }), + ) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index fd4e2e80b8..7528270935 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -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 ", + 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) + }) }) }) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 51ca19e2bc..5b2df8dba5 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -82,7 +82,12 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise 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) + }, + ) +} diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 4ea0e396fe..4f16d00675 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -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 type OllamaModelInfoResponse = z.infer -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 + 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> { - const models: Record = {} + config?: { + timeout?: number + modelDiscoveryTimeout?: number + maxRetries?: number + retryDelay?: number + enableLogging?: boolean + }, +): Promise> { + const models: Record = {} - // 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 = {} - 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(`${baseUrl}/api/tags`, { headers }) + const response = await axiosInstance.get("/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( - `${baseUrl}/api/show`, - { - model: ollamaModel.model, - }, - { headers }, - ) + axiosInstance + .post("/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 { + const modelsWithTools = await getOllamaModels(baseUrl, apiKey, config) + const allModelNames = new Set() + + 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("/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 { + 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("/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("/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, + } + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dbeb380d16..1f2f1d1cab 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 = {} + 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 = {} + 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() diff --git a/src/esbuild.mjs b/src/esbuild.mjs index aabacfcee9..7a55868a11 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -28,6 +28,7 @@ async function main() { format: "cjs", sourcesContent: false, platform: "node", + resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"], } const srcDir = __dirname diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index d05c3a6d8e..2f5c4feb1c 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -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({}) + const [modelsWithTools, setModelsWithTools] = useState< + Array<{ + name: string + contextWindow: number + size?: number + quantizationLevel?: string + family?: string + supportsImages: boolean + modelInfo: any + }> + >([]) + const [modelsWithoutTools, setModelsWithoutTools] = useState([]) + 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() + const refreshResultTimerRef = useRef() + const handleInputChange = useCallback( ( 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 ( <> - - - +
+ + + + +
+ {testResult && ( +
+
{testResult.message}
+ {testResult.durationMs !== undefined && ( +
Completed in {testResult.durationMs}ms
+ )} +
+ )} {apiConfiguration?.ollamaBaseUrl && ( )} - - - +
+ + + + +
+ {refreshResult && ( +
+
{refreshResult.message}
+ {refreshResult.durationMs !== undefined && ( +
Completed in {refreshResult.durationMs}ms
+ )} +
+ )} {modelNotAvailable && (
@@ -114,18 +240,111 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
)} - {Object.keys(ollamaModels).length > 0 && ( - - {Object.keys(ollamaModels).map((model) => ( - - {model} - - ))} - + {/* Tools Support Section */} + {modelsWithTools.length > 0 && ( +
+
+ {t("settings:providers.ollama.toolsSupport")} ({modelsWithTools.length}{" "} + {t("settings:providers.ollama.models")}) +
+ ) => { + const target = ((e as CustomEvent)?.detail?.target || + (e.target as HTMLInputElement)) as HTMLInputElement + if (target?.value) { + setApiConfigurationField("ollamaModelId", target.value) + } + }}> +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ) + })} + +
+ Model Name + + Context + Size + Quantization + + Family + + Images +
+ + {model.name} + + + {model.contextWindow.toLocaleString()} + + {formatSize(model.size)} + + {model.quantizationLevel || "-"} + + {model.family || "-"} + + {model.supportsImages ? "Yes" : "No"} +
+
+
+
+ )} + {/* No Tools Support Section */} + {modelsWithoutTools.length > 0 && ( +
+
+ {t("settings:providers.ollama.noToolsSupport")} ({modelsWithoutTools.length}{" "} + {t("settings:providers.ollama.models")}) +
+
+ {t("settings:providers.ollama.noToolsSupportHelp")} +
+
+ {modelsWithoutTools.map((model) => ( +
+ + {model} +
+ ))} +
+
)} -
- {t("settings:providers.ollama.description")} - {t("settings:providers.ollama.warning")} +
+ , + }} + />
+
+ {t("settings:providers.ollama.warning")} +
+ + {showAdvanced && ( +
+ + +
+ {t("settings:providers.ollama.streamingHelp")} +
+
+ + { + 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"> + +
+ {t("settings:providers.ollama.requestTimeoutHelp")} +
+
+ + { + 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"> + +
+ {t("settings:providers.ollama.modelDiscoveryTimeoutHelp")} +
+
+ + { + 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"> + +
+ {t("settings:providers.ollama.maxRetriesHelp")} +
+
+ + { + 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"> + +
+ {t("settings:providers.ollama.retryDelayHelp")} +
+
+ + { + setApiConfigurationField("ollamaEnableLogging", e.target.checked) + }}> + +
+ {t("settings:providers.ollama.enableLoggingHelp")} +
+
+
+ )} ) } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index fc64ad1851..ce19f239ed 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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 quickstart guide.", + "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",