fix(cookbook): default Ollama serve to loopback (#872)
This commit is contained in:
@@ -318,6 +318,38 @@ _SERVE_CMD_ALLOWLIST = {
|
||||
_GGUF_PRELUDE_RE = re.compile(
|
||||
r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*'
|
||||
)
|
||||
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
||||
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
||||
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
||||
|
||||
|
||||
def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]:
|
||||
"""Return the Ollama bind host/port requested by a serve command.
|
||||
|
||||
Plain local `ollama serve` defaults to loopback. Remote callers can pass a
|
||||
wider default host so the resulting API is reachable by Odysseus.
|
||||
"""
|
||||
if not cmd:
|
||||
return default_host, "11434"
|
||||
match = _OLLAMA_HOST_ASSIGNMENT_RE.search(cmd)
|
||||
if not match:
|
||||
return default_host, "11434"
|
||||
value = match.group(1).strip("'\"")
|
||||
bind_match = _OLLAMA_BIND_RE.match(value)
|
||||
if not bind_match:
|
||||
return "127.0.0.1", "11434"
|
||||
bracketed_host = bind_match.group(1)
|
||||
host = bracketed_host or bind_match.group(3) or "127.0.0.1"
|
||||
port = bind_match.group(2) or bind_match.group(4) or "11434"
|
||||
if not _OLLAMA_BIND_HOST_RE.match(host):
|
||||
return "127.0.0.1", "11434"
|
||||
try:
|
||||
port_num = int(port, 10)
|
||||
except ValueError:
|
||||
return "127.0.0.1", "11434"
|
||||
if port_num < 1 or port_num > 65535:
|
||||
return "127.0.0.1", "11434"
|
||||
return f"[{host}]" if bracketed_host else host, port
|
||||
|
||||
|
||||
def _check_serve_binary(seg: str) -> None:
|
||||
|
||||
@@ -37,7 +37,7 @@ from routes.cookbook_helpers import (
|
||||
_validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path,
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _cached_model_scan_script,
|
||||
_append_serve_exit_code_lines, _cached_model_scan_script, _ollama_bind_from_cmd,
|
||||
_pip_install_fallback_chain, ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -999,13 +999,15 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append('fi')
|
||||
elif "ollama" in req.cmd:
|
||||
handled_ollama_serve = True
|
||||
_ollama_port = "11434"
|
||||
_ollama_match = re.search(r"OLLAMA_HOST=[^\s:]+:(\d+)", req.cmd)
|
||||
if _ollama_match:
|
||||
_ollama_port = _ollama_match.group(1)
|
||||
_ollama_default_host = "0.0.0.0" if remote else "127.0.0.1"
|
||||
_ollama_host, _ollama_port = _ollama_bind_from_cmd(
|
||||
req.cmd,
|
||||
default_host=_ollama_default_host,
|
||||
)
|
||||
# Ollama can be a host binary, a system service, or a Docker
|
||||
# container. If the HTTP API is already reachable, the model is
|
||||
# already served and we should not require a host `ollama` CLI.
|
||||
runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}')
|
||||
runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"')
|
||||
runner_lines.append('ODYSSEUS_OLLAMA_URL=""')
|
||||
runner_lines.append('for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do')
|
||||
@@ -1034,8 +1036,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' echo "=== Process exited with code 127 ==="')
|
||||
runner_lines.append(' exec bash -i')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('echo "Starting ollama server on 0.0.0.0:${ODYSSEUS_OLLAMA_PORT}..."')
|
||||
runner_lines.append('OLLAMA_HOST="0.0.0.0:${ODYSSEUS_OLLAMA_PORT}" ollama serve')
|
||||
runner_lines.append('ODYSSEUS_OLLAMA_URL="http://${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}"')
|
||||
if remote and _ollama_host in ("0.0.0.0", "::"):
|
||||
runner_lines.append('echo "[odysseus] WARNING: remote Ollama will bind to ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT} so Odysseus can reach it from this host."')
|
||||
runner_lines.append('echo "[odysseus] Ollama has no built-in authentication; expose this only on a trusted LAN/VPN or provide an explicit OLLAMA_HOST with your own access controls."')
|
||||
runner_lines.append('echo "Starting ollama server on ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}..."')
|
||||
runner_lines.append('OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve')
|
||||
runner_lines.append('_ody_exit=$?')
|
||||
runner_lines.append('echo')
|
||||
runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="')
|
||||
|
||||
@@ -968,7 +968,7 @@ function initEndpointForm() {
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.';
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||||
|
||||
@@ -426,7 +426,8 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
}
|
||||
} else if (backend === 'ollama') {
|
||||
const ollamaPort = f.port || '11434';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=0.0.0.0:${ollamaPort} ` : '';
|
||||
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
|
||||
cmd = `${hostEnv}ollama serve`;
|
||||
} else if (backend === 'diffusers') {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
|
||||
@@ -11,6 +11,7 @@ from routes.cookbook_helpers import (
|
||||
_append_serve_preflight_exit_lines,
|
||||
_local_tooling_path_export,
|
||||
_pip_install_fallback_chain,
|
||||
_ollama_bind_from_cmd,
|
||||
_safe_env_prefix,
|
||||
_validate_gpus,
|
||||
_validate_repo_id,
|
||||
@@ -130,6 +131,40 @@ def test_serve_runner_preserves_command_exit_code():
|
||||
assert 'echo "=== Process exited with code $? ==="' not in script
|
||||
|
||||
|
||||
def test_ollama_serve_defaults_to_loopback_bind():
|
||||
assert _ollama_bind_from_cmd("ollama serve") == ("127.0.0.1", "11434")
|
||||
assert _ollama_bind_from_cmd("ollama run qwen2.5:0.5b") == ("127.0.0.1", "11434")
|
||||
|
||||
|
||||
def test_ollama_serve_accepts_remote_reachable_default_bind():
|
||||
assert (
|
||||
_ollama_bind_from_cmd("ollama serve", default_host="0.0.0.0")
|
||||
== ("0.0.0.0", "11434")
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_serve_preserves_explicit_bind_opt_in():
|
||||
assert (
|
||||
_ollama_bind_from_cmd("OLLAMA_HOST=0.0.0.0:12345 ollama serve")
|
||||
== ("0.0.0.0", "12345")
|
||||
)
|
||||
assert (
|
||||
_ollama_bind_from_cmd("OLLAMA_HOST=[::1]:11435 ollama serve")
|
||||
== ("[::1]", "11435")
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_serve_rejects_unsafe_bind_values():
|
||||
assert (
|
||||
_ollama_bind_from_cmd("OLLAMA_HOST='$HOST:11434' ollama serve")
|
||||
== ("127.0.0.1", "11434")
|
||||
)
|
||||
assert (
|
||||
_ollama_bind_from_cmd("OLLAMA_HOST=127.0.0.1:99999 ollama serve")
|
||||
== ("127.0.0.1", "11434")
|
||||
)
|
||||
|
||||
|
||||
def test_cached_model_scan_reports_plain_dir_gguf(tmp_path):
|
||||
"""Custom download dirs may sit inside the HF hub cache and contain plain
|
||||
per-model folders. They must show up in Serve and keep the GGUF signal."""
|
||||
|
||||
@@ -125,6 +125,18 @@ def test_readme_native_quickstart_uses_loopback():
|
||||
assert "Use `--host 0.0.0.0` only when you intentionally want" in readme
|
||||
|
||||
|
||||
def test_ollama_cookbook_runner_does_not_force_public_bind():
|
||||
route = Path("routes/cookbook_routes.py").read_text(encoding="utf-8")
|
||||
cookbook_js = Path("static/js/cookbook.js").read_text(encoding="utf-8")
|
||||
assert 'OLLAMA_HOST="0.0.0.0:${ODYSSEUS_OLLAMA_PORT}" ollama serve' not in route
|
||||
assert 'OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve' in route
|
||||
assert '_ollama_default_host = "0.0.0.0" if remote else "127.0.0.1"' in route
|
||||
assert "WARNING: remote Ollama will bind" in route
|
||||
assert "OLLAMA_HOST=0.0.0.0:${ollamaPort}" not in cookbook_js
|
||||
assert "const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';" in cookbook_js
|
||||
assert "OLLAMA_HOST=${bindHost}:${ollamaPort}" in cookbook_js
|
||||
|
||||
|
||||
def _import_integrations(tmp_path, monkeypatch):
|
||||
"""Import src.integrations with data + encryption key redirected to tmp."""
|
||||
_import_secret_storage(tmp_path, monkeypatch)
|
||||
|
||||
Reference in New Issue
Block a user