From c292f8c80297ba0442060cd5f06ca039ce683221 Mon Sep 17 00:00:00 2001 From: Parthiban Sivakumar Date: Thu, 27 Aug 2026 18:01:26 +0530 Subject: [PATCH 1/2] 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 --- .../providers/openai_compatible/completion.py | 33 ++++++++++++++----- .../test_openai_compatible.py | 21 +++++++++++- 2 files changed, 45 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 da4cfd03db..a4169042a7 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 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 @@ class 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 + 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 ce856a5334..5d38b6d429 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,26 @@ def test_strips_trailing_slash(self): 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" class TestOpenAICompatibleCompletion: """Tests for OpenAICompatibleCompletion class.""" From b9d534d4c9b011eff4a9926affd675f565c35537 Mon Sep 17 00:00:00 2001 From: Parthiban Sivakumar Date: Wed, 2 Sep 2026 16:51:01 +0530 Subject: [PATCH 2/2] 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 --- .../crewai/llms/providers/openai_compatible/completion.py | 6 +++--- .../llms/openai_compatible/test_openai_compatible.py | 8 ++++++++ 2 files changed, 11 insertions(+), 3 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 a4169042a7..fcb3f810c0 100644 --- a/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai_compatible/completion.py @@ -111,16 +111,16 @@ def _normalize_ollama_base_url(base_url: str) -> str: Returns: A fully-qualified base URL ending in /v1. """ - base_url = base_url.rstrip("/") - 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 + path = parts.path.rstrip("/") if not path.endswith("/v1"): path = f"{path}/v1" 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 5d38b6d429..d8747571ff 100644 --- a/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py +++ b/lib/crewai/tests/llms/openai_compatible/test_openai_compatible.py @@ -135,6 +135,14 @@ 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."""