From 1fda906407063f10bb3417a9b8b65c160186b4f9 Mon Sep 17 00:00:00 2001 From: ghreprimand Date: Tue, 2 Jun 2026 10:09:48 -0500 Subject: [PATCH] Fix Cookbook container-local model endpoints (#1223) Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com> --- routes/model_routes.py | 33 +++++++--- static/js/cookbookRunning.js | 64 +++++++++++++------- tests/test_cookbook_endpoint_registration.py | 30 +++++++++ tests/test_endpoint_probing.py | 33 ++++++++++ 4 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 tests/test_cookbook_endpoint_registration.py diff --git a/routes/model_routes.py b/routes/model_routes.py index a7e364d..f66cdd6 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -124,7 +124,8 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int: # Loopback hosts a user might type for a local model server (LM Studio, # llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the # host the server actually runs on. -_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"} +_ANY_BIND_HOSTS = {"0.0.0.0", "::"} +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", *_ANY_BIND_HOSTS} def _docker_host_gateway_reachable() -> bool: @@ -148,19 +149,33 @@ def _docker_host_gateway_reachable() -> bool: return False -def _rewrite_loopback_for_docker(base_url: str) -> str: +def _rewrite_loopback_for_docker(base_url: str, *, container_local: bool = False) -> str: """Rewrite a loopback model-endpoint URL to ``host.docker.internal`` when running in Docker. A URL like ``http://localhost:1234/v1`` (the LM Studio default) otherwise targets the Odysseus container itself, so the probe gets a connection error and the endpoint is rejected with a misleading "No - models found for that provider/key". The Ollama paths already handle this; - this extends the same fix to OpenAI-compatible local servers.""" + models found for that provider/key". + + Cookbook local serves are the opposite case: Odysseus started the model + server inside the same container/process environment, so the saved endpoint + must remain container-local. In that mode, normalize a bind address such as + 0.0.0.0 to a connectable loopback host, but do not jump to the Docker host. + """ try: parsed = urlparse(base_url) except Exception: return base_url - if (parsed.hostname or "").lower() not in _LOOPBACK_HOSTS: + host = (parsed.hostname or "").lower() + if host not in _LOOPBACK_HOSTS: return base_url + if container_local: + if host in _ANY_BIND_HOSTS: + netloc = "127.0.0.1" + (f":{parsed.port}" if parsed.port else "") + return urlunparse(parsed._replace(netloc=netloc)) + return base_url + if host in _ANY_BIND_HOSTS and not _docker_host_gateway_reachable(): + netloc = "127.0.0.1" + (f":{parsed.port}" if parsed.port else "") + return urlunparse(parsed._replace(netloc=netloc)) if not _docker_host_gateway_reachable(): return base_url netloc = "host.docker.internal" + (f":{parsed.port}" if parsed.port else "") @@ -1115,6 +1130,7 @@ def setup_model_routes(model_discovery): require_models: str = Form("false"), model_type: str = Form("llm"), supports_tools: str = Form(""), # "true"/"false"/"" (unknown) + container_local: str = Form("false"), # Default `shared=true` → endpoints are visible to all users (the # app's historical behaviour). Admins can pass `shared=false` to # scope a new endpoint to their own account only. @@ -1127,9 +1143,10 @@ def setup_model_routes(model_discovery): # Resolve hostname via Tailscale if DNS fails from src.endpoint_resolver import resolve_url base_url = resolve_url(base_url) - # In Docker, rewrite a loopback URL to host.docker.internal so the probe - # — and the saved URL used for chat — reach the host, not the container. - base_url = _rewrite_loopback_for_docker(base_url) + # In Docker, manually added loopback URLs usually point at a host-local + # server. Cookbook local serves are launched inside Odysseus itself, so + # keep those container-local when the frontend marks them as such. + base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local)) # Auto-generate name from URL if not provided if not name.strip(): diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index ac27335..35f366f 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -375,6 +375,36 @@ function _refreshModelsAfterEndpointChange() { }, 1500); } +function _appendCookbookEndpointScope(fd, remoteHost) { + const host = String(remoteHost || '').trim(); + if (!host || host === 'local' || host === 'localhost' || host === '127.0.0.1') { + fd.append('container_local', 'true'); + } +} + +function _connectHostFromRemote(remoteHost, fallback = 'localhost') { + const host = String(remoteHost || '').trim(); + if (!host || host === 'local') return fallback; + return host.includes('@') ? host.split('@').pop() : host; +} + +function _isAnyBindHost(host) { + const h = String(host || '').trim().toLowerCase(); + return h === '0.0.0.0' || h === '::' || h === '[::]'; +} + +function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434') { + try { + const u = new URL(rawUrl); + const host = _isAnyBindHost(u.hostname) ? currentHost : (u.hostname || currentHost); + const port = u.port || fallbackPort; + const bracketedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; + return { host, port, baseUrl: `${u.protocol}//${bracketedHost}${port ? `:${port}` : ''}/v1` }; + } catch { + return null; + } +} + // ── Download queue — runs one at a time per server ── function _processQueue() { @@ -754,8 +784,7 @@ function _endpointUrlForTask(task, outputText = '') { if (_taskLooksOllama(task, outputText)) { return _ollamaBaseUrlForTask(task, outputText) + '/v1'; } - const rawHost = task.remoteHost || 'localhost'; - const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost; + const host = _connectHostFromRemote(task.remoteHost); const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); const port = portMatch ? portMatch[1] : '8000'; return `http://${host}:${port}/v1`; @@ -1953,8 +1982,7 @@ export function _renderRunningTab() { // serve to the model-endpoints list regardless of prior flag state. if (task.type === 'serve' && task.payload?._cmd) { items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => { - const rawHost = task.remoteHost || 'localhost'; - const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost; + const host = _connectHostFromRemote(task.remoteHost); const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); const port = portMatch ? portMatch[1] : '8000'; const baseUrl = `http://${host}:${port}/v1`; @@ -1977,6 +2005,7 @@ export function _renderRunningTab() { fd.append('base_url', baseUrl); fd.append('name', task.name); fd.append('skip_probe', 'true'); + _appendCookbookEndpointScope(fd, task.remoteHost || ''); if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image'); const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd }); if (res.ok) { @@ -2635,8 +2664,7 @@ async function _reconnectTask(el, task) { // first one's dedup check can observe the newly-added row. if (task.type === 'serve' && !task._endpointAdded && !task._endpointAddInFlight && task._serveReady) { task._endpointAddInFlight = true; - const rawHost = task.remoteHost || 'localhost'; - let host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost; + let host = _connectHostFromRemote(task.remoteHost); const portMatch = task.payload?._cmd?.match(/--port[=\s]+(\d+)/) || task.payload?._cmd?.match(/(?:^|\s)-p[=\s]+(\d+)/) || snapshot.match(/Uvicorn running on\D*?:(\d+)/i) @@ -2647,12 +2675,8 @@ async function _reconnectTask(el, task) { let baseUrl = `http://${host}:${port}/v1`; const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i); if (ollamaUrlMatch) { - try { - const u = new URL(ollamaUrlMatch[1]); - host = u.hostname || host; - port = u.port || '11434'; - baseUrl = `${u.origin}/v1`; - } catch {} + const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434'); + if (endpoint) ({ host, port, baseUrl } = endpoint); } fetch('/api/model-endpoints', { credentials: 'same-origin' }) .then(r => r.json()) @@ -2680,6 +2704,7 @@ async function _reconnectTask(el, task) { fd.append('base_url', baseUrl); fd.append('name', task.name); fd.append('skip_probe', 'true'); + _appendCookbookEndpointScope(fd, task.remoteHost || ''); if (_isDiffusion) fd.append('model_type', 'image'); return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd }); }) @@ -2783,8 +2808,7 @@ async function _checkServeReachability() { ]); } catch { return; } for (const task of serveTasks) { - const rawHost = task.remoteHost || 'localhost'; - const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost; + const host = _connectHostFromRemote(task.remoteHost); const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); const port = portMatch ? portMatch[1] : '8000'; const baseUrl = `http://${host}:${port}/v1`; @@ -3037,8 +3061,7 @@ async function _pollBackgroundStatus() { const localTask = localTasks.find(lt => lt.sessionId === t.session_id); if (localTask && localTask._endpointAdded) continue; - const rawHost = localTask?.remoteHost || t.remote || 'localhost'; - let host = rawHost.includes('@') ? rawHost.split('@').pop() : (rawHost === 'local' ? 'localhost' : rawHost); + let host = _connectHostFromRemote(localTask?.remoteHost || t.remote); const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/) || localTask?.payload?._cmd?.match(/OLLAMA_HOST=[^\s:]+:(\d+)/); let port = portMatch ? portMatch[1] : '8000'; @@ -3046,12 +3069,8 @@ async function _pollBackgroundStatus() { const snapshot = t.output || localTask?.output || ''; const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i); if (ollamaUrlMatch) { - try { - const u = new URL(ollamaUrlMatch[1]); - host = u.hostname || host; - port = u.port || '11434'; - baseUrl = `${u.origin}/v1`; - } catch {} + const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434'); + if (endpoint) ({ host, port, baseUrl } = endpoint); } const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server'); @@ -3082,6 +3101,7 @@ async function _pollBackgroundStatus() { fd.append('base_url', baseUrl); fd.append('name', t.model); fd.append('skip_probe', 'true'); + _appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || ''); if (_isDiffusion) fd.append('model_type', 'image'); if (_supportsTools) fd.append('supports_tools', 'true'); return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd }); diff --git a/tests/test_cookbook_endpoint_registration.py b/tests/test_cookbook_endpoint_registration.py new file mode 100644 index 0000000..8e3a9b9 --- /dev/null +++ b/tests/test_cookbook_endpoint_registration.py @@ -0,0 +1,30 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +COOKBOOK_RUNNING = ROOT / "static" / "js" / "cookbookRunning.js" + + +def _source() -> str: + return COOKBOOK_RUNNING.read_text(encoding="utf-8") + + +def test_cookbook_marks_local_endpoint_registration_as_container_local(): + src = _source() + assert "function _appendCookbookEndpointScope" in src + assert "fd.append('container_local', 'true')" in src + assert src.count("_appendCookbookEndpointScope(fd,") >= 3 + + +def test_cookbook_does_not_use_local_as_endpoint_hostname(): + src = _source() + assert "function _connectHostFromRemote" in src + assert "if (!host || host === 'local') return fallback;" in src + assert "const rawHost = task.remoteHost || 'localhost';" not in src + + +def test_cookbook_advertised_bind_urls_keep_connectable_host(): + src = _source() + assert "function _endpointFromAdvertisedUrl" in src + assert "_isAnyBindHost(u.hostname) ? currentHost" in src + assert "host = u.hostname || host;" not in src diff --git a/tests/test_endpoint_probing.py b/tests/test_endpoint_probing.py index c6fed7b..aab4c52 100644 --- a/tests/test_endpoint_probing.py +++ b/tests/test_endpoint_probing.py @@ -49,6 +49,7 @@ from routes.model_routes import ( _ping_endpoint, _probe_single_model, _classify_endpoint, + _rewrite_loopback_for_docker, _PROVIDER_CURATED, ) @@ -192,6 +193,38 @@ class TestPingEndpoint: } +# ── Docker loopback rewrite ── + +class TestDockerLoopbackRewrite: + def test_manual_loopback_rewrites_to_docker_host_when_available(self, monkeypatch): + monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True) + assert ( + _rewrite_loopback_for_docker("http://localhost:8000/v1") + == "http://host.docker.internal:8000/v1" + ) + + def test_cookbook_container_local_loopback_stays_inside_container(self, monkeypatch): + monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True) + assert ( + _rewrite_loopback_for_docker("http://localhost:8000/v1", container_local=True) + == "http://localhost:8000/v1" + ) + + def test_bind_address_becomes_connectable_loopback_for_container_local(self, monkeypatch): + monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True) + assert ( + _rewrite_loopback_for_docker("http://0.0.0.0:8000/v1", container_local=True) + == "http://127.0.0.1:8000/v1" + ) + + def test_bind_address_becomes_connectable_loopback_on_native_install(self, monkeypatch): + monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: False) + assert ( + _rewrite_loopback_for_docker("http://0.0.0.0:8000/v1") + == "http://127.0.0.1:8000/v1" + ) + + # ── _probe_single_model: completion probe ── class TestProbeSingleModel: