diff --git a/routes/model_routes.py b/routes/model_routes.py index 47e527c..de3a59a 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -1,14 +1,16 @@ # routes/model_routes.py """Routes for model and provider management.""" +import os import re import uuid import json +import socket import time as _time import logging import httpx from datetime import datetime 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 pydantic import BaseModel from fastapi.responses import StreamingResponse @@ -27,6 +29,52 @@ from src.auth_helpers import _auth_disabled, owner_filter 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 ── # 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. @@ -959,6 +1007,9 @@ 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) # Auto-generate name from URL if not provided if not name.strip(): @@ -1067,6 +1118,7 @@ def setup_model_routes(model_discovery): raise HTTPException(400, "Base URL is required") from src.endpoint_resolver import resolve_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 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) diff --git a/tests/test_model_routes.py b/tests/test_model_routes.py index fd8de0b..d9ca5e3 100644 --- a/tests/test_model_routes.py +++ b/tests/test_model_routes.py @@ -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." + + +# ── _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