Fix Cookbook container-local model endpoints (#1223)
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
# 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
|
# llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the
|
||||||
# host the server actually runs on.
|
# 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:
|
def _docker_host_gateway_reachable() -> bool:
|
||||||
@@ -148,19 +149,33 @@ def _docker_host_gateway_reachable() -> bool:
|
|||||||
return False
|
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
|
"""Rewrite a loopback model-endpoint URL to ``host.docker.internal`` when
|
||||||
running in Docker. A URL like ``http://localhost:1234/v1`` (the LM Studio
|
running in Docker. A URL like ``http://localhost:1234/v1`` (the LM Studio
|
||||||
default) otherwise targets the Odysseus container itself, so the probe gets
|
default) otherwise targets the Odysseus container itself, so the probe gets
|
||||||
a connection error and the endpoint is rejected with a misleading "No
|
a connection error and the endpoint is rejected with a misleading "No
|
||||||
models found for that provider/key". The Ollama paths already handle this;
|
models found for that provider/key".
|
||||||
this extends the same fix to OpenAI-compatible local servers."""
|
|
||||||
|
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:
|
try:
|
||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
except Exception:
|
except Exception:
|
||||||
return base_url
|
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
|
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():
|
if not _docker_host_gateway_reachable():
|
||||||
return base_url
|
return base_url
|
||||||
netloc = "host.docker.internal" + (f":{parsed.port}" if parsed.port else "")
|
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"),
|
require_models: str = Form("false"),
|
||||||
model_type: str = Form("llm"),
|
model_type: str = Form("llm"),
|
||||||
supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
|
supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
|
||||||
|
container_local: str = Form("false"),
|
||||||
# Default `shared=true` → endpoints are visible to all users (the
|
# Default `shared=true` → endpoints are visible to all users (the
|
||||||
# app's historical behaviour). Admins can pass `shared=false` to
|
# app's historical behaviour). Admins can pass `shared=false` to
|
||||||
# scope a new endpoint to their own account only.
|
# 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
|
# Resolve hostname via Tailscale if DNS fails
|
||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
base_url = resolve_url(base_url)
|
base_url = resolve_url(base_url)
|
||||||
# In Docker, rewrite a loopback URL to host.docker.internal so the probe
|
# In Docker, manually added loopback URLs usually point at a host-local
|
||||||
# — and the saved URL used for chat — reach the host, not the container.
|
# server. Cookbook local serves are launched inside Odysseus itself, so
|
||||||
base_url = _rewrite_loopback_for_docker(base_url)
|
# 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
|
# Auto-generate name from URL if not provided
|
||||||
if not name.strip():
|
if not name.strip():
|
||||||
|
|||||||
@@ -375,6 +375,36 @@ function _refreshModelsAfterEndpointChange() {
|
|||||||
}, 1500);
|
}, 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 ──
|
// ── Download queue — runs one at a time per server ──
|
||||||
|
|
||||||
function _processQueue() {
|
function _processQueue() {
|
||||||
@@ -754,8 +784,7 @@ function _endpointUrlForTask(task, outputText = '') {
|
|||||||
if (_taskLooksOllama(task, outputText)) {
|
if (_taskLooksOllama(task, outputText)) {
|
||||||
return _ollamaBaseUrlForTask(task, outputText) + '/v1';
|
return _ollamaBaseUrlForTask(task, outputText) + '/v1';
|
||||||
}
|
}
|
||||||
const rawHost = task.remoteHost || 'localhost';
|
const host = _connectHostFromRemote(task.remoteHost);
|
||||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
|
||||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||||
const port = portMatch ? portMatch[1] : '8000';
|
const port = portMatch ? portMatch[1] : '8000';
|
||||||
return `http://${host}:${port}/v1`;
|
return `http://${host}:${port}/v1`;
|
||||||
@@ -1953,8 +1982,7 @@ export function _renderRunningTab() {
|
|||||||
// serve to the model-endpoints list regardless of prior flag state.
|
// serve to the model-endpoints list regardless of prior flag state.
|
||||||
if (task.type === 'serve' && task.payload?._cmd) {
|
if (task.type === 'serve' && task.payload?._cmd) {
|
||||||
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
|
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
|
||||||
const rawHost = task.remoteHost || 'localhost';
|
const host = _connectHostFromRemote(task.remoteHost);
|
||||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
|
||||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||||
const port = portMatch ? portMatch[1] : '8000';
|
const port = portMatch ? portMatch[1] : '8000';
|
||||||
const baseUrl = `http://${host}:${port}/v1`;
|
const baseUrl = `http://${host}:${port}/v1`;
|
||||||
@@ -1977,6 +2005,7 @@ export function _renderRunningTab() {
|
|||||||
fd.append('base_url', baseUrl);
|
fd.append('base_url', baseUrl);
|
||||||
fd.append('name', task.name);
|
fd.append('name', task.name);
|
||||||
fd.append('skip_probe', 'true');
|
fd.append('skip_probe', 'true');
|
||||||
|
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||||
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
|
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 });
|
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -2635,8 +2664,7 @@ async function _reconnectTask(el, task) {
|
|||||||
// first one's dedup check can observe the newly-added row.
|
// first one's dedup check can observe the newly-added row.
|
||||||
if (task.type === 'serve' && !task._endpointAdded && !task._endpointAddInFlight && task._serveReady) {
|
if (task.type === 'serve' && !task._endpointAdded && !task._endpointAddInFlight && task._serveReady) {
|
||||||
task._endpointAddInFlight = true;
|
task._endpointAddInFlight = true;
|
||||||
const rawHost = task.remoteHost || 'localhost';
|
let host = _connectHostFromRemote(task.remoteHost);
|
||||||
let host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
|
||||||
const portMatch = task.payload?._cmd?.match(/--port[=\s]+(\d+)/)
|
const portMatch = task.payload?._cmd?.match(/--port[=\s]+(\d+)/)
|
||||||
|| task.payload?._cmd?.match(/(?:^|\s)-p[=\s]+(\d+)/)
|
|| task.payload?._cmd?.match(/(?:^|\s)-p[=\s]+(\d+)/)
|
||||||
|| snapshot.match(/Uvicorn running on\D*?:(\d+)/i)
|
|| snapshot.match(/Uvicorn running on\D*?:(\d+)/i)
|
||||||
@@ -2647,12 +2675,8 @@ async function _reconnectTask(el, task) {
|
|||||||
let baseUrl = `http://${host}:${port}/v1`;
|
let baseUrl = `http://${host}:${port}/v1`;
|
||||||
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||||
if (ollamaUrlMatch) {
|
if (ollamaUrlMatch) {
|
||||||
try {
|
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||||
const u = new URL(ollamaUrlMatch[1]);
|
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||||
host = u.hostname || host;
|
|
||||||
port = u.port || '11434';
|
|
||||||
baseUrl = `${u.origin}/v1`;
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -2680,6 +2704,7 @@ async function _reconnectTask(el, task) {
|
|||||||
fd.append('base_url', baseUrl);
|
fd.append('base_url', baseUrl);
|
||||||
fd.append('name', task.name);
|
fd.append('name', task.name);
|
||||||
fd.append('skip_probe', 'true');
|
fd.append('skip_probe', 'true');
|
||||||
|
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||||
if (_isDiffusion) fd.append('model_type', 'image');
|
if (_isDiffusion) fd.append('model_type', 'image');
|
||||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||||
})
|
})
|
||||||
@@ -2783,8 +2808,7 @@ async function _checkServeReachability() {
|
|||||||
]);
|
]);
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
for (const task of serveTasks) {
|
for (const task of serveTasks) {
|
||||||
const rawHost = task.remoteHost || 'localhost';
|
const host = _connectHostFromRemote(task.remoteHost);
|
||||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
|
||||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||||
const port = portMatch ? portMatch[1] : '8000';
|
const port = portMatch ? portMatch[1] : '8000';
|
||||||
const baseUrl = `http://${host}:${port}/v1`;
|
const baseUrl = `http://${host}:${port}/v1`;
|
||||||
@@ -3037,8 +3061,7 @@ async function _pollBackgroundStatus() {
|
|||||||
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
|
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
|
||||||
if (localTask && localTask._endpointAdded) continue;
|
if (localTask && localTask._endpointAdded) continue;
|
||||||
|
|
||||||
const rawHost = localTask?.remoteHost || t.remote || 'localhost';
|
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
|
||||||
let host = rawHost.includes('@') ? rawHost.split('@').pop() : (rawHost === 'local' ? 'localhost' : rawHost);
|
|
||||||
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|
||||||
|| localTask?.payload?._cmd?.match(/OLLAMA_HOST=[^\s:]+:(\d+)/);
|
|| localTask?.payload?._cmd?.match(/OLLAMA_HOST=[^\s:]+:(\d+)/);
|
||||||
let port = portMatch ? portMatch[1] : '8000';
|
let port = portMatch ? portMatch[1] : '8000';
|
||||||
@@ -3046,12 +3069,8 @@ async function _pollBackgroundStatus() {
|
|||||||
const snapshot = t.output || localTask?.output || '';
|
const snapshot = t.output || localTask?.output || '';
|
||||||
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||||
if (ollamaUrlMatch) {
|
if (ollamaUrlMatch) {
|
||||||
try {
|
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||||
const u = new URL(ollamaUrlMatch[1]);
|
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||||
host = u.hostname || host;
|
|
||||||
port = u.port || '11434';
|
|
||||||
baseUrl = `${u.origin}/v1`;
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
|
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
|
||||||
|
|
||||||
@@ -3082,6 +3101,7 @@ async function _pollBackgroundStatus() {
|
|||||||
fd.append('base_url', baseUrl);
|
fd.append('base_url', baseUrl);
|
||||||
fd.append('name', t.model);
|
fd.append('name', t.model);
|
||||||
fd.append('skip_probe', 'true');
|
fd.append('skip_probe', 'true');
|
||||||
|
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
|
||||||
if (_isDiffusion) fd.append('model_type', 'image');
|
if (_isDiffusion) fd.append('model_type', 'image');
|
||||||
if (_supportsTools) fd.append('supports_tools', 'true');
|
if (_supportsTools) fd.append('supports_tools', 'true');
|
||||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||||
|
|||||||
30
tests/test_cookbook_endpoint_registration.py
Normal file
30
tests/test_cookbook_endpoint_registration.py
Normal file
@@ -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
|
||||||
@@ -49,6 +49,7 @@ from routes.model_routes import (
|
|||||||
_ping_endpoint,
|
_ping_endpoint,
|
||||||
_probe_single_model,
|
_probe_single_model,
|
||||||
_classify_endpoint,
|
_classify_endpoint,
|
||||||
|
_rewrite_loopback_for_docker,
|
||||||
_PROVIDER_CURATED,
|
_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 ──
|
# ── _probe_single_model: completion probe ──
|
||||||
|
|
||||||
class TestProbeSingleModel:
|
class TestProbeSingleModel:
|
||||||
|
|||||||
Reference in New Issue
Block a user