fix
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user