Skip to content

fix: normalize Ollama URLs to handle trailing slashes #6081

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/api/providers/fetchers/ollama.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -65,15 +66,15 @@ export async function getOllamaModels(baseUrl = "http://localhost:11434"): Promi
return models
}

const response = await axios.get<OllamaModelsResponse>(`${baseUrl}/api/tags`)
const response = await axios.get<OllamaModelsResponse>(joinUrlPath(baseUrl, "/api/tags"))
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
let modelInfoPromises = []

if (parsedResponse.success) {
for (const ollamaModel of parsedResponse.data.models) {
modelInfoPromises.push(
axios
.post<OllamaModelInfoResponse>(`${baseUrl}/api/show`, {
.post<OllamaModelInfoResponse>(joinUrlPath(baseUrl, "/api/show"), {
model: ollamaModel.model,
})
.then((ollamaModelInfo) => {
Expand Down
4 changes: 3 additions & 1 deletion src/api/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
})
}
Expand Down
7 changes: 4 additions & 3 deletions src/services/code-index/embedders/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +33,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
*/
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
78 changes: 78 additions & 0 deletions src/utils/__tests__/url-normalization.test.ts
Original file line number Diff line number Diff line change
@@ -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/")
})
})
})
34 changes: 34 additions & 0 deletions src/utils/url-normalization.ts
Original file line number Diff line number Diff line change
@@ -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(/\/+$/, "")

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
}

/**
* 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}`
}