diff --git a/app/ai_analyzer.py b/app/ai_analyzer.py
index 867300ef..5bbe835c 100644
--- a/app/ai_analyzer.py
+++ b/app/ai_analyzer.py
@@ -87,6 +87,22 @@ def rewrite_ai_url_for_runtime(url: str) -> str:
return rewritten
+def openai_compat_base_url(url: str, endpoint: str = "/chat/completions") -> str:
+ """Return the OpenAI-compatible *base* URL (scheme+host+optional /v1).
+
+ Users often paste the full chat URL (…/v1/chat/completions) into the
+ Settings base-URL field. Built-in providers then append ``endpoint``
+ again and Ollama returns 404. Strip a trailing copy of ``endpoint``.
+ """
+ if not url:
+ return url
+ base = url.rstrip("/")
+ ep = "/" + (endpoint or "").lstrip("/")
+ if ep not in ("", "/") and base.lower().endswith(ep.lower()):
+ base = base[: -len(ep)].rstrip("/")
+ return base
+
+
def dummy_key_for_local(api_key: Optional[str]) -> str:
"""Ollama accepts any Bearer token; the proxy still sends Authorization."""
key = (api_key or "").strip()
@@ -2541,7 +2557,8 @@ def call_ai_provider(provider: str,
base = api_url.rstrip('/')
full_url = f"{base}/models/{model}:generateContent"
else:
- full_url = api_url.rstrip('/') + '/' + endpoint.lstrip('/')
+ base = openai_compat_base_url(api_url, endpoint)
+ full_url = base.rstrip('/') + '/' + endpoint.lstrip('/')
return _execute_ai_request(full_url, headers, ai_request_payload)
diff --git a/app/app_base.py b/app/app_base.py
index 46536dac..8a007b04 100644
--- a/app/app_base.py
+++ b/app/app_base.py
@@ -1343,7 +1343,8 @@ def ai_api_call():
else:
full_url = f"{api_url.rstrip('/')}/models/{model}:generateContent"
else:
- full_url = api_url.rstrip('/') + '/' + endpoint.lstrip('/')
+ base = ai_analyzer.openai_compat_base_url(api_url, endpoint)
+ full_url = base.rstrip('/') + '/' + endpoint.lstrip('/')
logger.debug("Full URL: %s", full_url)
# Prepare headers
diff --git a/app/assets/js/settings.js b/app/assets/js/settings.js
index 4eae3417..109ef27e 100644
--- a/app/assets/js/settings.js
+++ b/app/assets/js/settings.js
@@ -121,7 +121,7 @@ const AI_PROVIDERS = [
defaultUrl: 'http://localhost:11434/v1',
apiKeyOptional: true,
allowCustomModel: true,
- helpHtml: 'Talks to Ollama, LM Studio, vLLM, or llama.cpp on your laptop (not inside Docker). From Docker Desktop, localhost is rewritten to host.docker.internal. Ollama default port is 11434; LM Studio is usually 1234.',
+ helpHtml: 'Use the base URL only — http://localhost:11434/v1. Do not add /chat/completions (the app appends it). From Docker Desktop, localhost is rewritten to host.docker.internal. Ollama port 11434; LM Studio is usually 1234.',
models: [
{ id: 'llama3.2', name: 'llama3.2' },
{ id: 'llama3.1', name: 'llama3.1' },
diff --git a/tests/python/test_local_openai.py b/tests/python/test_local_openai.py
index c5e56b42..02f524ae 100644
--- a/tests/python/test_local_openai.py
+++ b/tests/python/test_local_openai.py
@@ -2,6 +2,7 @@
from ai_analyzer import (
dummy_key_for_local,
is_local_openai_compat,
+ openai_compat_base_url,
rewrite_ai_url_for_runtime,
running_in_docker,
)
@@ -40,6 +41,18 @@ def test_rewrite_localhost_inside_docker(monkeypatch):
)
+def test_openai_compat_base_url_strips_chat_completions():
+ assert openai_compat_base_url("http://localhost:11434/v1") == "http://localhost:11434/v1"
+ assert (
+ openai_compat_base_url("http://host.docker.internal:11434/v1/chat/completions")
+ == "http://host.docker.internal:11434/v1"
+ )
+ assert (
+ openai_compat_base_url("http://localhost:11434/v1/chat/completions/")
+ == "http://localhost:11434/v1"
+ )
+
+
def test_running_in_docker_env(monkeypatch):
monkeypatch.setenv("CBQA_IN_DOCKER", "true")
assert running_in_docker() is True