mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
Models: rewrite Docker loopback endpoints to host gateway
In Docker, a model-endpoint URL pointing at loopback (e.g. the LM Studio default http://localhost:1234/v1) targets the Odysseus container itself, not the host running the server, so the probe gets a connection error and the endpoint is rejected with a misleading 'No models found for that provider/key'. Rewrite loopback to host.docker.internal (which compose already maps to host-gateway) for the probe and the saved URL, mirroring the existing Ollama handling. Gated on actually being in a container with the gateway reachable, so native installs and gateway-less deploys are untouched. Fixes #25 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+53
-1
@@ -1,14 +1,16 @@
|
|||||||
# routes/model_routes.py
|
# routes/model_routes.py
|
||||||
"""Routes for model and provider management."""
|
"""Routes for model and provider management."""
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
from fastapi import APIRouter, HTTPException, Form, Query, Body, Request
|
from fastapi import APIRouter, HTTPException, Form, Query, Body, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -27,6 +29,52 @@ from src.auth_helpers import _auth_disabled, owner_filter
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 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"}
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_host_gateway_reachable() -> bool:
|
||||||
|
"""True when we run inside a container whose host is reachable via
|
||||||
|
``host.docker.internal`` (compose maps it to ``host-gateway``). Returns
|
||||||
|
False on native installs and on container setups without the mapping, so
|
||||||
|
the loopback rewrite below stays a no-op there."""
|
||||||
|
in_container = os.path.exists("/.dockerenv")
|
||||||
|
if not in_container:
|
||||||
|
try:
|
||||||
|
with open("/proc/1/cgroup", encoding="utf-8") as fh:
|
||||||
|
in_container = any(t in fh.read() for t in ("docker", "containerd", "kubepods"))
|
||||||
|
except OSError:
|
||||||
|
in_container = False
|
||||||
|
if not in_container:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo("host.docker.internal", None)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_loopback_for_docker(base_url: str) -> 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."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
except Exception:
|
||||||
|
return base_url
|
||||||
|
if (parsed.hostname or "").lower() not in _LOOPBACK_HOSTS:
|
||||||
|
return base_url
|
||||||
|
if not _docker_host_gateway_reachable():
|
||||||
|
return base_url
|
||||||
|
netloc = "host.docker.internal" + (f":{parsed.port}" if parsed.port else "")
|
||||||
|
return urlunparse(parsed._replace(netloc=netloc))
|
||||||
|
|
||||||
|
|
||||||
# ── Curated model lists per provider ──
|
# ── Curated model lists per provider ──
|
||||||
# For cloud providers that return 100+ models, only show these by default.
|
# For cloud providers that return 100+ models, only show these by default.
|
||||||
# A model ID matches if it starts with or equals a curated entry.
|
# A model ID matches if it starts with or equals a curated entry.
|
||||||
@@ -959,6 +1007,9 @@ 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
|
||||||
|
# — and the saved URL used for chat — reach the host, not the container.
|
||||||
|
base_url = _rewrite_loopback_for_docker(base_url)
|
||||||
|
|
||||||
# Auto-generate name from URL if not provided
|
# Auto-generate name from URL if not provided
|
||||||
if not name.strip():
|
if not name.strip():
|
||||||
@@ -1067,6 +1118,7 @@ def setup_model_routes(model_discovery):
|
|||||||
raise HTTPException(400, "Base URL is required")
|
raise HTTPException(400, "Base URL is required")
|
||||||
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)
|
||||||
|
base_url = _rewrite_loopback_for_docker(base_url)
|
||||||
probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 2
|
probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 2
|
||||||
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||||
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||||
|
|||||||
@@ -316,3 +316,57 @@ def test_generic_endpoint_error_message_preserves_probe_error():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert msg == "No models found for that provider/key. Last probe error: HTTP 401."
|
assert msg == "No models found for that provider/key. Last probe error: HTTP 401."
|
||||||
|
|
||||||
|
|
||||||
|
# ── _rewrite_loopback_for_docker (issue #25: LM Studio on host loopback) ──
|
||||||
|
|
||||||
|
class TestDockerLoopbackRewrite:
|
||||||
|
def test_rewrites_loopback_when_in_docker(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
|
||||||
|
assert (model_routes._rewrite_loopback_for_docker("http://localhost:1234/v1")
|
||||||
|
== "http://host.docker.internal:1234/v1")
|
||||||
|
assert (model_routes._rewrite_loopback_for_docker("http://127.0.0.1:1234/v1")
|
||||||
|
== "http://host.docker.internal:1234/v1")
|
||||||
|
|
||||||
|
def test_no_rewrite_when_not_in_docker(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: False)
|
||||||
|
assert (model_routes._rewrite_loopback_for_docker("http://localhost:1234/v1")
|
||||||
|
== "http://localhost:1234/v1")
|
||||||
|
|
||||||
|
def test_non_loopback_untouched_even_in_docker(self, monkeypatch):
|
||||||
|
# Cloud and LAN hosts must never be rewritten or they would break.
|
||||||
|
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
|
||||||
|
assert (model_routes._rewrite_loopback_for_docker("https://api.openai.com/v1")
|
||||||
|
== "https://api.openai.com/v1")
|
||||||
|
assert (model_routes._rewrite_loopback_for_docker("http://192.168.1.50:1234/v1")
|
||||||
|
== "http://192.168.1.50:1234/v1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerHostGatewayReachable:
|
||||||
|
def test_native_host_is_false_and_skips_dns(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_routes.os.path, "exists", lambda p: False)
|
||||||
|
|
||||||
|
def _no_cgroup(*a, **k):
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
|
monkeypatch.setattr("builtins.open", _no_cgroup)
|
||||||
|
|
||||||
|
def _must_not_run(*a, **k):
|
||||||
|
raise AssertionError("getaddrinfo must not run on native hosts")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes.socket, "getaddrinfo", _must_not_run)
|
||||||
|
assert model_routes._docker_host_gateway_reachable() is False
|
||||||
|
|
||||||
|
def test_container_with_host_gateway_is_true(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_routes.os.path, "exists", lambda p: p == "/.dockerenv")
|
||||||
|
monkeypatch.setattr(model_routes.socket, "getaddrinfo", lambda *a, **k: [("ok",)])
|
||||||
|
assert model_routes._docker_host_gateway_reachable() is True
|
||||||
|
|
||||||
|
def test_container_without_host_gateway_is_false(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_routes.os.path, "exists", lambda p: p == "/.dockerenv")
|
||||||
|
|
||||||
|
def _fail(*a, **k):
|
||||||
|
raise OSError("name or service not known")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes.socket, "getaddrinfo", _fail)
|
||||||
|
assert model_routes._docker_host_gateway_reachable() is False
|
||||||
|
|||||||
Reference in New Issue
Block a user