From 81bbf8aa69d5b90366eecbea2555e8c1cc7edf8b Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:10:17 +0300 Subject: [PATCH] fix --- backend/app/api/admin.py | 128 ++++++++++++++++++++++++++++++ backend/app/core/llm.py | 33 +++++++- backend/app/migrations/init_db.py | 62 ++------------- docker-compose.yml | 7 ++ 4 files changed, 171 insertions(+), 59 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 5099e1e..0c66022 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -83,6 +83,134 @@ async def test_embeddings_endpoint( return await probe_embeddings(settings_map) +@router.post("/llm/test") +async def test_llm_endpoint( + payload: Dict[str, Any] = Body(default={}), + db: AsyncSession = Depends(get_db_dep), + _: User = Depends(require_admin), +): + """Probe the currently configured LLM endpoint from inside the backend container. + + Accepts an optional `overrides` dict with llm.* keys (e.g. to test a new + endpoint before saving). Returns: ok, base_url, model, http_status, + latency_ms, response_preview (or error + error_type). + + This is the diagnostic tool to use when the LLM call fails with + `ConnectError: All connection attempts failed` — it tells you whether + the backend container can actually reach the LLM URL. + """ + import time + import httpx + import socket + + settings_map = await get_all_settings(db) + overrides = (payload or {}).get("overrides") or {} + for k, v in overrides.items(): + if k in EDITABLE_SETTING_KEYS: + settings_map[k] = v + + base_url = str(settings_map.get("llm.base_url", "")).rstrip("/") + model = str(settings_map.get("llm.model", "local-model")) + api_key = str(settings_map.get("llm.api_key", "dummy")) + timeout_s = float(settings_map.get("llm.request_timeout", 30) or 30) + + result: Dict[str, Any] = { + "base_url": base_url, + "model": model, + "ok": False, + } + + # === Stage 1: DNS / TCP connect (without TLS) === + try: + from urllib.parse import urlparse + parsed = urlparse(base_url) + host = parsed.hostname or "" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + if not host: + result["error"] = "invalid_base_url: no host" + result["error_type"] = "ConfigError" + return result + # Try to resolve + connect TCP + addrs = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + result["dns_resolved"] = True + result["resolved_addrs"] = [a[4][0] for a in addrs[:3]] + # Try to actually open a TCP connection + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5.0) + try: + sock.connect((host, port)) + result["tcp_connect_ok"] = True + finally: + sock.close() + except socket.gaierror as e: + result["dns_resolved"] = False + result["error"] = f"DNS resolution failed for {host}: {e}" + result["error_type"] = "DNSError" + return result + except (socket.timeout, ConnectionRefusedError, OSError) as e: + result["tcp_connect_ok"] = False + result["error"] = f"TCP connect to {host}:{port} failed: {type(e).__name__}: {e}" + result["error_type"] = type(e).__name__ + return result + + # === Stage 2: HTTP request to /v1/models (lightweight probe) === + headers = {"Content-Type": "application/json"} + if api_key and api_key != "dummy": + headers["Authorization"] = f"Bearer {api_key}" + + started = time.monotonic() + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=timeout_s, write=10.0, pool=5.0)) as client: + # First try /models (lightweight, exists on every OpenAI-compatible server) + models_url = f"{base_url}/models" + try: + resp = await client.get(models_url, headers=headers) + result["models_endpoint_status"] = resp.status_code + if resp.status_code == 200: + data = resp.json() + model_ids = [] + if isinstance(data, dict) and isinstance(data.get("data"), list): + model_ids = [m.get("id", "?") for m in data["data"][:10]] + result["available_models"] = model_ids + except Exception as e: + result["models_endpoint_error"] = f"{type(e).__name__}: {e}" + + # Now try the actual chat completions endpoint with a minimal payload + chat_url = f"{base_url}/chat/completions" + chat_payload = { + "model": model, + "messages": [{"role": "user", "content": "Reply with the single word: ok"}], + "max_tokens": 10, + "temperature": 0.1, + "stream": False, + } + resp = await client.post(chat_url, json=chat_payload, headers=headers) + result["chat_endpoint_status"] = resp.status_code + result["latency_ms"] = int((time.monotonic() - started) * 1000) + if resp.status_code >= 400: + result["error"] = f"HTTP {resp.status_code}: {resp.text[:500]}" + result["error_type"] = "HTTPError" + return result + data = resp.json() + choice = (data.get("choices") or [{}])[0] + msg = choice.get("message", {}) + result["ok"] = True + result["response_preview"] = (msg.get("content") or "")[:200] + result["usage"] = data.get("usage", {}) + return result + except httpx.ConnectError as e: + cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None) + result["error"] = f"ConnectError: {e}" + if cause: + result["error"] += f" (cause: {cause})" + result["error_type"] = "ConnectError" + return result + except Exception as e: + result["error"] = f"{type(e).__name__}: {e}" + result["error_type"] = type(e).__name__ + return result + + @router.get("/llm-logs", response_model=List[LlmLogOut]) async def list_llm_logs( limit: int = 50, diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 9222730..074ea4b 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -79,7 +79,15 @@ class LlmClient: tool_calls: List[Dict[str, Any]] = [] usage: Dict[str, int] = {} try: - async with httpx.AsyncClient(timeout=self.timeout) as client: + # Use explicit timeout config so connect/read/write/pool timeouts + # are all visible — a bare `timeout=N` hides WHICH stage failed. + timeout = httpx.Timeout( + connect=10.0, # 10s to establish TCP connection + read=float(self.timeout), # full request timeout + write=10.0, + pool=5.0, + ) + async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(url, json=payload, headers=self._headers()) resp.raise_for_status() data = resp.json() @@ -88,9 +96,30 @@ class LlmClient: text = msg.get("content") or "" tool_calls = msg.get("tool_calls") or [] usage = data.get("usage") or {} + except httpx.ConnectError as e: + err = f"ConnectError: {e}" + # Surface the URL + cause so the operator can see WHY (DNS, refused, etc.) + cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None) + log.error( + "llm_connect_failed", + purpose=purpose, + url=url, + base_url=self.base_url, + model=self.model, + error=err, + cause=str(cause) if cause else None, + ) + raise except Exception as e: err = f"{type(e).__name__}: {e}" - log.error("llm_call_failed", purpose=purpose, error=err) + log.error( + "llm_call_failed", + purpose=purpose, + url=url, + base_url=self.base_url, + model=self.model, + error=err, + ) raise finally: latency_ms = int((time.monotonic() - started) * 1000) diff --git a/backend/app/migrations/init_db.py b/backend/app/migrations/init_db.py index 5aa1000..b864316 100644 --- a/backend/app/migrations/init_db.py +++ b/backend/app/migrations/init_db.py @@ -55,21 +55,11 @@ DEFAULT_SETTINGS = [ ] -# Keys whose values come from environment variables (via Settings fields). -# These are re-applied on EVERY startup so .env is the source of truth. -# Admin-panel changes to these keys are runtime overrides that get reset on -# restart unless the operator also updates .env. -ENV_DERIVED_SETTING_KEYS = { - "llm.base_url", - "llm.api_key", - "llm.model", - "embedding.provider", - "embedding.base_url", - "embedding.api_key", - "embedding.model", - "embedding.dim", - "embedding.request_timeout", -} +# NOTE: env-derived defaults (llm.base_url, llm.api_key, llm.model, +# embedding.* etc.) are ONLY applied on the very first run via _seed_settings. +# After that, the admin panel is the source of truth — restarting the +# container will NOT overwrite admin-configured values with .env values. +# To force a re-seed, drop the `settings` table or delete the relevant rows. async def init_db() -> None: @@ -91,7 +81,6 @@ async def init_db() -> None: ) ) await _seed_settings(session) - await _sync_env_derived_settings(session) await _seed_builtin_presets(session) await session.commit() except Exception as e: @@ -99,7 +88,6 @@ async def init_db() -> None: log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}") async with AsyncSessionLocal() as session: await _seed_settings(session) - await _sync_env_derived_settings(session) await _seed_builtin_presets(session) await session.commit() @@ -167,46 +155,6 @@ async def _seed_settings(session) -> None: log.info("settings_already_exist") -async def _sync_env_derived_settings(session) -> None: - """Re-apply env-derived setting values from .env on every startup. - - This makes .env the source of truth for these keys: changing .env and - restarting the container takes effect immediately. Admin-panel edits to - these keys are runtime overrides that are reset on the next restart - (unless the operator also updates .env). - - Only the env-derived keys (see ENV_DERIVED_SETTING_KEYS) are touched; - other settings (temperature, context params, etc.) are preserved as - configured via the admin panel. - """ - env_values = {key: value for key, value, _desc in DEFAULT_SETTINGS if key in ENV_DERIVED_SETTING_KEYS} - updated = 0 - for key, new_value in env_values.items(): - result = await session.execute(select(Setting).where(Setting.key == key)) - row = result.scalars().first() - if row is None: - # Shouldn't happen (seeded above) but handle defensively. - session.add(Setting(key=key, value=new_value, description="Env-derived")) - updated += 1 - else: - if row.value != new_value: - log.info( - "env_setting_resynced", - key=key, - old_value=str(row.value)[:80], - new_value=str(new_value)[:80], - ) - row.value = new_value - updated += 1 - if updated: - try: - await session.commit() - log.info("env_settings_synced", count=updated) - except IntegrityError: - await session.rollback() - log.warning("env_settings_sync_failed_concurrent") - - async def _seed_builtin_presets(session) -> None: """Insert built-in presets if none exist yet.""" result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True))) diff --git a/docker-compose.yml b/docker-compose.yml index 1f24054..71a3887 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,13 @@ services: DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small} DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0} DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60} + # Make `host.docker.internal` resolvable inside the container (Linux). + # On Docker Desktop (Mac/Win) this is added automatically; on Linux it's not, + # so we add it explicitly. Allows pointing DEFAULT_LLM_BASE_URL at + # http://host.docker.internal:1234/v1 to reach an LM Studio / llama.cpp + # running on the host. + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "8000:8000" volumes: