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..fcb3f810c0 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.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 ce856a5334..d8747571ff 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 @@ 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" + + 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."""