"""Admin panel routes: settings, LLM logs, users.""" from __future__ import annotations from typing import Any, Dict, List from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Body, Depends, HTTPException from app.core.settings_service import EDITABLE_SETTING_KEYS, get_all_settings, update_settings from app.db import get_db_dep from app.deps import require_admin from app.models import LlmCallLog, Setting, User from app.schemas import LlmLogOut, SettingsOut, SettingsUpdate router = APIRouter(prefix="/api/admin", tags=["admin"]) def _mask_secrets(values: Dict[str, Any]) -> Dict[str, Any]: """Mask sensitive api_key fields in outbound responses.""" for k in ("llm.api_key", "embedding.api_key"): v = values.get(k) if isinstance(v, str) and v: values[k] = v[:4] + "***" + v[-4:] if len(v) > 8 else "***" # Never expose admin setup token via this endpoint values.pop("admin.setup_token", None) return values @router.get("/settings", response_model=SettingsOut) async def get_settings_endpoint( db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): values = await get_all_settings(db) values = _mask_secrets(values) return SettingsOut(values=values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys())) @router.put("/settings", response_model=SettingsOut) async def update_settings_endpoint( payload: SettingsUpdate, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): # Strip masked api_key fields unless the user typed a new value cleaned: Dict[str, Any] = {} for k, v in (payload.values or {}).items(): if k in ("llm.api_key", "embedding.api_key") and isinstance(v, str) and "***" in v: continue cleaned[k] = v new_values = await update_settings(db, cleaned) # If embedding settings changed, drop the cached RAG client so the next # get_rag() call rebuilds it (and reconfigures Qdrant collections if dim changed). if any(k.startswith("embedding.") for k in cleaned): from app.core.rag import reset_rag await reset_rag() new_values = _mask_secrets(new_values) return SettingsOut(values=new_values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys())) @router.post("/embeddings/test") async def test_embeddings_endpoint( payload: Dict[str, Any] = Body(default={}), db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): """Probe the currently configured embeddings endpoint. Accepts an optional `overrides` dict with embedding.* keys (e.g. to test a new endpoint before saving). Returns: ok, provider, base_url, model, dim, sample_norm (or error). """ from app.core.rag import probe_embeddings settings_map = await get_all_settings(db) # Apply ad-hoc overrides (without saving) so the admin can try before save overrides = (payload or {}).get("overrides") or {} for k, v in overrides.items(): if k in EDITABLE_SETTING_KEYS: settings_map[k] = v 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, offset: int = 0, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): result = await db.execute( select(LlmCallLog).order_by(LlmCallLog.created_at.desc()).limit(min(limit, 200)).offset(offset) ) return result.scalars().all() @router.get("/llm-logs/{log_id}") async def get_llm_log( log_id: str, db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): from uuid import UUID result = await db.execute(select(LlmCallLog).where(LlmCallLog.id == UUID(log_id))) log = result.scalars().first() if not log: raise HTTPException(status_code=404, detail="log_not_found") return { "id": str(log.id), "purpose": log.purpose, "model": log.model, "base_url": log.base_url, "prompt_messages": log.prompt_messages, "tools": log.tools, "response_text": log.response_text, "tool_calls": log.tool_calls, "prompt_tokens": log.prompt_tokens, "completion_tokens": log.completion_tokens, "total_tokens": log.total_tokens, "latency_ms": log.latency_ms, "error": log.error, "created_at": log.created_at.isoformat() if log.created_at else None, } @router.get("/users") async def list_users( db: AsyncSession = Depends(get_db_dep), _: User = Depends(require_admin), ): result = await db.execute(select(User).order_by(User.created_at.desc())) users = result.scalars().all() return [ { "id": str(u.id), "email": u.email, "username": u.username, "is_admin": u.is_admin, "is_active": u.is_active, "created_at": u.created_at.isoformat() if u.created_at else None, } for u in users ] @router.post("/users/{user_id}/set-active") async def set_user_active( user_id: UUID, payload: Dict[str, Any] = Body(default={}), db: AsyncSession = Depends(get_db_dep), admin: User = Depends(require_admin), ): """Activate or ban a user. Banned users cannot log in (see auth.login). Body: `{"is_active": true|false}`. Admins cannot ban themselves. """ is_active = bool(payload.get("is_active")) result = await db.execute(select(User).where(User.id == user_id)) user = result.scalars().first() if not user: raise HTTPException(status_code=404, detail="user_not_found") if user.id == admin.id and not is_active: raise HTTPException(status_code=400, detail="cannot_ban_self") user.is_active = is_active await db.commit() return { "id": str(user.id), "email": user.email, "username": user.username, "is_admin": user.is_admin, "is_active": user.is_active, }