diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 8e1e3f7f072..7ef3ad81319 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -1,6 +1,7 @@ import axios from "axios" import { ModelInfo, ollamaDefaultModelInfo } from "@roo-code/types" import { z } from "zod" +import { joinUrlPath } from "../../../utils/url-normalization" const OllamaModelDetailsSchema = z.object({ family: z.string(), @@ -65,7 +66,7 @@ export async function getOllamaModels(baseUrl = "http://localhost:11434"): Promi return models } - const response = await axios.get(`${baseUrl}/api/tags`) + const response = await axios.get(joinUrlPath(baseUrl, "/api/tags")) const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data) let modelInfoPromises = [] @@ -73,7 +74,7 @@ export async function getOllamaModels(baseUrl = "http://localhost:11434"): Promi for (const ollamaModel of parsedResponse.data.models) { modelInfoPromises.push( axios - .post(`${baseUrl}/api/show`, { + .post(joinUrlPath(baseUrl, "/api/show"), { model: ollamaModel.model, }) .then((ollamaModelInfo) => { diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index a7713ba4214..da8066949e6 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -6,6 +6,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERAT import type { ApiHandlerOptions } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" +import { joinUrlPath } from "../../utils/url-normalization" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" @@ -23,8 +24,9 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl constructor(options: ApiHandlerOptions) { super() this.options = options + const baseUrl = this.options.ollamaBaseUrl || "http://localhost:11434" this.client = new OpenAI({ - baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1", + baseURL: joinUrlPath(baseUrl, "/v1"), apiKey: "ollama", }) } diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index c160d39490e..4c90464bd51 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -6,6 +6,7 @@ import { t } from "../../../i18n" import { withValidationErrorHandling, sanitizeErrorMessage } from "../shared/validation-helpers" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import { joinUrlPath } from "../../../utils/url-normalization" // Timeout constants for Ollama API requests const OLLAMA_EMBEDDING_TIMEOUT_MS = 60000 // 60 seconds for embedding requests @@ -32,7 +33,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { */ async createEmbeddings(texts: string[], model?: string): Promise { const modelToUse = model || this.defaultModelId - const url = `${this.baseUrl}/api/embed` // Endpoint as specified + const url = joinUrlPath(this.baseUrl, "/api/embed") // Endpoint as specified // Apply model-specific query prefix if required const queryPrefix = getModelQueryPrefix("ollama", modelToUse) @@ -140,7 +141,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { return withValidationErrorHandling( async () => { // First check if Ollama service is running by trying to list models - const modelsUrl = `${this.baseUrl}/api/tags` + const modelsUrl = joinUrlPath(this.baseUrl, "/api/tags") // Add timeout to prevent indefinite hanging const controller = new AbortController() @@ -197,7 +198,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { } // Try a test embedding to ensure the model works for embeddings - const testUrl = `${this.baseUrl}/api/embed` + const testUrl = joinUrlPath(this.baseUrl, "/api/embed") // Add timeout for test request too const testController = new AbortController() diff --git a/src/utils/__tests__/url-normalization.test.ts b/src/utils/__tests__/url-normalization.test.ts new file mode 100644 index 00000000000..c00e82ec990 --- /dev/null +++ b/src/utils/__tests__/url-normalization.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest" +import { normalizeBaseUrl, joinUrlPath } from "../url-normalization" + +describe("url-normalization", () => { + describe("normalizeBaseUrl", () => { + it("should remove a single trailing slash", () => { + expect(normalizeBaseUrl("http://localhost:11434/")).toBe("http://localhost:11434") + }) + + it("should remove multiple trailing slashes", () => { + expect(normalizeBaseUrl("http://localhost:11434//")).toBe("http://localhost:11434") + expect(normalizeBaseUrl("http://localhost:11434///")).toBe("http://localhost:11434") + }) + + it("should not modify URLs without trailing slashes", () => { + expect(normalizeBaseUrl("http://localhost:11434")).toBe("http://localhost:11434") + }) + + it("should handle URLs with paths", () => { + expect(normalizeBaseUrl("http://localhost:11434/api/")).toBe("http://localhost:11434/api") + expect(normalizeBaseUrl("http://localhost:11434/api/v1/")).toBe("http://localhost:11434/api/v1") + }) + + it("should handle URLs with query parameters", () => { + expect(normalizeBaseUrl("http://localhost:11434/?key=value")).toBe("http://localhost:11434/?key=value") + expect(normalizeBaseUrl("http://localhost:11434/api/?key=value")).toBe( + "http://localhost:11434/api/?key=value", + ) + }) + + it("should handle empty strings", () => { + expect(normalizeBaseUrl("")).toBe("") + }) + + it("should handle URLs with ports", () => { + expect(normalizeBaseUrl("http://localhost:8080/")).toBe("http://localhost:8080") + }) + + it("should handle HTTPS URLs", () => { + expect(normalizeBaseUrl("https://api.example.com/")).toBe("https://api.example.com") + }) + }) + + describe("joinUrlPath", () => { + it("should join base URL with path correctly", () => { + expect(joinUrlPath("http://localhost:11434", "/api/tags")).toBe("http://localhost:11434/api/tags") + }) + + it("should handle base URL with trailing slash", () => { + expect(joinUrlPath("http://localhost:11434/", "/api/tags")).toBe("http://localhost:11434/api/tags") + }) + + it("should handle base URL with multiple trailing slashes", () => { + expect(joinUrlPath("http://localhost:11434//", "/api/tags")).toBe("http://localhost:11434/api/tags") + }) + + it("should handle path without leading slash", () => { + expect(joinUrlPath("http://localhost:11434", "api/tags")).toBe("http://localhost:11434/api/tags") + }) + + it("should handle complex paths", () => { + expect(joinUrlPath("http://localhost:11434/", "/v1/api/embed")).toBe("http://localhost:11434/v1/api/embed") + }) + + it("should handle base URL with existing path", () => { + expect(joinUrlPath("http://localhost:11434/ollama", "/api/tags")).toBe( + "http://localhost:11434/ollama/api/tags", + ) + expect(joinUrlPath("http://localhost:11434/ollama/", "/api/tags")).toBe( + "http://localhost:11434/ollama/api/tags", + ) + }) + + it("should handle empty path", () => { + expect(joinUrlPath("http://localhost:11434", "")).toBe("http://localhost:11434/") + }) + }) +}) diff --git a/src/utils/url-normalization.ts b/src/utils/url-normalization.ts new file mode 100644 index 00000000000..0cfa5f1ef84 --- /dev/null +++ b/src/utils/url-normalization.ts @@ -0,0 +1,34 @@ +/** + * Normalizes a base URL by removing trailing slashes. + * This prevents double slashes when concatenating paths. + * + * @param url - The URL to normalize + * @returns The normalized URL without trailing slashes + * + * @example + * normalizeBaseUrl("http://localhost:11434/") // returns "http://localhost:11434" + * normalizeBaseUrl("http://localhost:11434") // returns "http://localhost:11434" + * normalizeBaseUrl("http://localhost:11434//") // returns "http://localhost:11434" + */ +export function normalizeBaseUrl(url: string): string { + // Remove all trailing slashes + return url.replace(/\/+$/, "") +} + +/** + * Joins a base URL with a path, ensuring no double slashes. + * + * @param baseUrl - The base URL (will be normalized) + * @param path - The path to append (should start with /) + * @returns The joined URL + * + * @example + * joinUrlPath("http://localhost:11434/", "/api/tags") // returns "http://localhost:11434/api/tags" + * joinUrlPath("http://localhost:11434", "/api/tags") // returns "http://localhost:11434/api/tags" + */ +export function joinUrlPath(baseUrl: string, path: string): string { + const normalizedBase = normalizeBaseUrl(baseUrl) + // Ensure path starts with a single slash + const normalizedPath = path.startsWith("/") ? path : `/${path}` + return `${normalizedBase}${normalizedPath}` +}