fix: normalize ollama openai compat base url

This commit is contained in:
rookiestar28
2026-04-09 14:49:20 +08:00
parent 5c0d168e31
commit fed9fc44c5
9 changed files with 136 additions and 6 deletions
+5 -2
View File
@@ -13,6 +13,7 @@ from .providers.catalog import (
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_PROVIDER,
get_provider_info,
normalize_provider_base_url,
)
from .providers.keys import get_api_key_for_provider
from .runtime_config import get_effective_config
@@ -41,11 +42,13 @@ def get_effective_llm_base_url(provider: str) -> str:
current_provider = str(effective.get("provider") or "").lower()
configured_base_url = str(effective.get("base_url") or "").strip()
if configured_base_url and str(provider).lower() == current_provider:
return configured_base_url
# IMPORTANT: persisted Ollama configs may still store the historical root URL
# without `/v1`; normalize at the shared facade so all consumers stay aligned.
return normalize_provider_base_url(provider, configured_base_url)
info = get_provider_info(provider)
if info:
return info.base_url
return normalize_provider_base_url(provider, info.base_url)
raise ValueError(f"Unknown provider: {provider}")
+7
View File
@@ -35,6 +35,7 @@ from .providers.catalog import (
DEFAULT_PROVIDER,
ProviderType,
get_provider_info,
normalize_provider_base_url,
)
from .providers.keys import get_api_key_for_provider, mask_api_key, requires_api_key
from .structured_logging import (
@@ -121,6 +122,12 @@ class LLMClient:
info = get_provider_info(self.provider)
if info:
self.base_url = info.base_url
# IMPORTANT: test-connection and normal OpenAI-compatible completions must
# share the same Ollama `/v1` normalization path as model discovery. Do not
# bypass this with provider-specific ad hoc request URL assembly.
self.base_url = normalize_provider_base_url(
self.provider, str(self.base_url or "")
)
# R57: Strict Precedence (Arg > Config > Default)
# CRITICAL: Only inherit config['model'] if the effective provider matches config['provider'].
+5 -1
View File
@@ -159,7 +159,11 @@ def resolve_model_list_target(
if info.api_type != catalog.ProviderType.OPENAI_COMPAT:
raise TypeError("Model list is only supported for OpenAI-compatible providers.")
base_url = runtime_base_url if runtime_base_url else info.base_url
raw_base_url = runtime_base_url if runtime_base_url else info.base_url
# IMPORTANT: model discovery is one of the primary Ollama debug hotspots.
# Keep provider-aware base URL normalization here so legacy root URLs still
# resolve to the OpenAI-compatible `/v1/models` endpoint instead of 404.
base_url = catalog.normalize_provider_base_url(provider, raw_base_url)
if not base_url:
raise ValueError(f"No base URL configured for provider '{provider}'.")
+29 -2
View File
@@ -7,7 +7,7 @@ R73: Provider drift governance — alias/deprecation metadata and resolution tra
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
class ProviderType(Enum):
@@ -81,7 +81,7 @@ PROVIDER_CATALOG: Dict[str, ProviderInfo] = {
),
"ollama": ProviderInfo(
name="Ollama (Local)",
base_url="http://127.0.0.1:11434",
base_url="http://127.0.0.1:11434/v1",
api_type=ProviderType.OPENAI_COMPAT,
supports_vision=True,
env_key_name=None, # Local, no key needed
@@ -302,6 +302,33 @@ def normalize_provider_id(provider: str) -> str:
return PROVIDER_ALIASES.get(p, p)
def normalize_provider_base_url(provider: str, base_url: str) -> str:
"""
Normalize provider-specific base URL compatibility seams.
Currently used to keep Ollama's OpenAI-compatible endpoint path aligned to
`/v1` even when older persisted configs still store the historical root URL.
"""
value = str(base_url or "").strip()
if not value:
return ""
if normalize_provider_id(str(provider or "")) != "ollama":
return value
try:
parsed = urlparse(value)
except Exception:
return value
# IMPORTANT: old Ollama configs may still store the root OpenAI-compat host.
# Normalize only the empty-path form to `/v1`; do not rewrite custom subpaths.
if parsed.path not in ("", "/"):
return value
return urlunparse(parsed._replace(path="/v1"))
def normalize_model_id(model: str) -> str:
"""Normalize model ID (resolve aliases)."""
m = model.lower().strip()
+18
View File
@@ -48,6 +48,24 @@ class EffectiveConfigFacadeTests(unittest.TestCase):
"https://custom-openai.example/v1",
)
@patch("services.effective_config.get_effective_config")
def test_ollama_root_base_url_is_normalized_from_effective_config(
self, mock_get_effective
):
mock_get_effective.return_value = (
{
"provider": "ollama",
"model": "llama3.2",
"base_url": "http://127.0.0.1:11434",
},
{},
)
self.assertEqual(
effective_config.get_effective_llm_base_url("ollama"),
"http://127.0.0.1:11434/v1",
)
@patch("config.get_effective_llm_api_key", return_value="sk-effective")
def test_config_get_api_key_uses_effective_facade(self, mock_get_key):
self.assertEqual(config_module.get_api_key(), "sk-effective")
+36
View File
@@ -157,6 +157,42 @@ class TestLLMClientPluginIntegration(unittest.TestCase):
# Should succeed without plugins
self.assertEqual(result["text"], "test")
@patch("services.llm_client.PLUGINS_AVAILABLE", False)
@patch("services.runtime_config.get_effective_config")
@patch("services.effective_config.get_effective_config")
@patch("services.llm_client.requires_api_key", return_value=False)
@patch("services.llm_client.get_api_key_for_provider", return_value=None)
@patch("services.llm_client.openai_compat.make_request")
def test_ollama_root_base_url_is_normalized_before_openai_compat_request(
self,
mock_request,
_mock_key,
_mock_requires_key,
mock_effective_config_facade,
mock_runtime_config,
):
config_payload = (
{
"provider": "ollama",
"model": "llama3.2",
"base_url": "http://127.0.0.1:11434",
"timeout_sec": 120,
"max_retries": 3,
},
None,
)
mock_runtime_config.return_value = config_payload
mock_effective_config_facade.return_value = config_payload
mock_request.return_value = {"text": "test", "raw": {}}
client = LLMClient()
client.complete(system="test", user_message="test")
self.assertEqual(
mock_request.call_args.kwargs["base_url"],
"http://127.0.0.1:11434/v1",
)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -25,6 +25,24 @@ class LlmModelListServiceTests(unittest.TestCase):
("tenant-a", "custom", "https://custom.example/v1"),
)
@patch("services.providers.keys.requires_api_key", return_value=False)
@patch("services.providers.keys.get_api_key_for_provider", return_value=None)
def test_resolve_target_normalizes_legacy_ollama_root_url(
self, _mock_key, _mock_requires_key
):
target = resolve_model_list_target(
provider_override="ollama",
effective={"provider": "ollama", "base_url": "http://127.0.0.1:11434"},
tenant_id="default",
)
self.assertEqual(target.provider, "ollama")
self.assertEqual(target.base_url, "http://127.0.0.1:11434/v1")
self.assertEqual(
target.cache_key,
("ollama", "http://127.0.0.1:11434/v1"),
)
@patch("services.safe_io.safe_request_json")
def test_fetch_remote_model_list_builds_auth_header(self, mock_safe_request):
mock_safe_request.return_value = {"data": [{"id": "gpt-4o-mini"}]}
+17
View File
@@ -21,6 +21,7 @@ from services.providers.catalog import (
is_local_provider,
is_loopback_host,
list_providers,
normalize_provider_base_url,
)
from services.providers.keys import (
get_all_configured_keys,
@@ -95,6 +96,22 @@ class TestProviderCatalog(unittest.TestCase):
self.assertTrue(is_local_provider("lmstudio"))
self.assertFalse(is_local_provider("openai"))
def test_ollama_default_base_url_uses_v1_openai_compat_prefix(self):
self.assertEqual(
PROVIDER_CATALOG["ollama"].base_url,
"http://127.0.0.1:11434/v1",
)
def test_ollama_root_base_url_normalizes_to_v1(self):
self.assertEqual(
normalize_provider_base_url("ollama", "http://127.0.0.1:11434"),
"http://127.0.0.1:11434/v1",
)
self.assertEqual(
normalize_provider_base_url("ollama", "http://127.0.0.1:11434/v1"),
"http://127.0.0.1:11434/v1",
)
def test_loopback_helpers(self):
self.assertTrue(is_loopback_host("localhost"))
self.assertTrue(is_loopback_host("127.0.0.1"))
@@ -56,7 +56,7 @@ class TestR123RealBackendModelListLane(AioHTTPTestCase):
class ModelsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ("/models", "/v1/models"):
if self.path == "/v1/models":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload_bytes)))