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