From c90337ba5a1f031de7c1aecf2df7ee5435d058dd Mon Sep 17 00:00:00 2001 From: Parthiban Sivakumar <228711334+parthiban-sivakumar@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:05:56 +0530 Subject: [PATCH] fix(llms): normalize scheme and port in Ollama base URL (#7206) * fix(llms): normalize scheme and port in Ollama base URL OLLAMA_HOST follows Ollama's own convention and may be a bare host ("0.0.0.0") or a host:port pair ("127.0.0.1:11434") rather than a full URL. _normalize_ollama_base_url only appended "/v1", so those values produced invalid base URLs such as "0.0.0.0/v1", and every request failed with the misleading error "Failed to connect to OpenAI API: Connection error." - confusing, since no OpenAI model was requested. Fill in the missing parts the way Ollama's own client does: prepend http:// when no scheme is present, append the default port 11434 when none is present and the scheme is http (https implies 443), then append the /v1 suffix the OpenAI-compatible endpoint requires. Six of nine realistic OLLAMA_HOST forms were affected, including 127.0.0.1:11434, which is Ollama's documented default. Co-Authored-By: Claude Opus 5 * fix(llms): strip only the parsed path when normalizing Ollama base URL Stripping trailing slashes from the whole URL before parsing corrupted inputs that carry a query or fragment. "http://ollama/?tenant=acme" kept a "/" path and produced a doubled "//v1", and a query or fragment ending in "/" silently lost that character. Parse first, then rstrip only parts.path. Adds regression tests for a root path alongside a query and for a query value ending in "/". Reported by CodeRabbit on #7206. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> --- .../providers/openai_compatible/completion.py | 35 ++++++++++++++----- .../test_openai_compatible.py | 27 ++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py b/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py index da4cfd03d..fcb3f810c 100644 --- a/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py @@ -15,6 +15,7 @@ from __future__ import annotations from dataclasses import dataclass, field import os from typing import Any +from urllib.parse import urlsplit, urlunsplit from pydantic import model_validator @@ -91,23 +92,39 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderConfig] = { ), } +_OLLAMA_DEFAULT_PORT = 11434 + def _normalize_ollama_base_url(base_url: str) -> str: - """Normalize Ollama base URL to ensure it ends with /v1. + """Normalize an Ollama base URL into a full OpenAI-compatible endpoint. - Ollama uses OLLAMA_HOST which may not include the /v1 suffix, - but the OpenAI-compatible endpoint requires it. + ``OLLAMA_HOST`` follows Ollama's own convention and may be a bare host + (``0.0.0.0``), a ``host:port`` pair (``127.0.0.1:11434``), or a full URL. + Whichever parts are missing are filled in: ``http://`` when no scheme is + given, the default Ollama port when none is given and the scheme is + ``http`` (``https`` implies 443), and the ``/v1`` suffix that the + OpenAI-compatible endpoint requires. Args: - base_url: The base URL, potentially without /v1 suffix. + base_url: The base URL, potentially missing scheme, port or /v1. Returns: - The base URL with /v1 suffix if needed. + A fully-qualified base URL ending in /v1. """ - base_url = base_url.rstrip("/") - if not base_url.endswith("/v1"): - return f"{base_url}/v1" - return base_url + if "://" not in base_url: + base_url = f"http://{base_url}" + + parts = urlsplit(base_url) + + netloc = parts.netloc + if parts.scheme == "http" and parts.port is None: + netloc = f"{netloc}:{_OLLAMA_DEFAULT_PORT}" + + path = parts.path.rstrip("/") + if not path.endswith("/v1"): + path = f"{path}/v1" + + return urlunsplit((parts.scheme, netloc, path, parts.query, parts.fragment)) class OpenAICompatibleCompletion(OpenAICompletion): diff --git a/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py b/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py index ce856a533..d8747571f 100644 --- a/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py +++ b/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py @@ -114,7 +114,34 @@ class TestNormalizeOllamaBaseUrl: def test_handles_v1_with_trailing_slash(self): """Test /v1/ is normalized.""" assert _normalize_ollama_base_url("http://localhost:11434/v1/") == "http://localhost:11434/v1" + + def test_bare_host_gets_scheme_and_port(self): + """Bare host from OLLAMA_HOST gets http:// and the default port.""" + assert _normalize_ollama_base_url("0.0.0.0") == "http://0.0.0.0:11434/v1" + def test_bare_localhost_gets_scheme_and_port(self): + """Bare localhost gets http:// and the default port.""" + assert _normalize_ollama_base_url("localhost") == "http://localhost:11434/v1" + + def test_host_port_without_scheme_gets_scheme(self): + """host:port without a scheme gets http:// prepended.""" + assert _normalize_ollama_base_url("127.0.0.1:11434") == "http://127.0.0.1:11434/v1" + + def test_lan_host_port_without_scheme(self): + """A LAN host:port without a scheme gets http:// prepended.""" + assert _normalize_ollama_base_url("192.168.1.5:11434") == "http://192.168.1.5:11434/v1" + + def test_https_url_keeps_scheme_and_gets_no_default_port(self): + """An explicit https:// URL keeps its scheme and gets no default port.""" + assert _normalize_ollama_base_url("https://ollama.example.com") == "https://ollama.example.com/v1" + + def test_root_path_with_query_does_not_double_slash(self): + """A root path alongside a query yields /v1, not //v1.""" + assert _normalize_ollama_base_url("http://ollama/?tenant=acme") == "http://ollama:11434/v1?tenant=acme" + + def test_trailing_slash_in_query_is_preserved(self): + """Only the path is stripped, so a query ending in / keeps that character.""" + assert _normalize_ollama_base_url("http://ollama:11434/?x=a/") == "http://ollama:11434/v1?x=a/" class TestOpenAICompatibleCompletion: """Tests for OpenAICompatibleCompletion class."""