fix
This commit is contained in:
@@ -83,6 +83,134 @@ async def test_embeddings_endpoint(
|
|||||||
return await probe_embeddings(settings_map)
|
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])
|
@router.get("/llm-logs", response_model=List[LlmLogOut])
|
||||||
async def list_llm_logs(
|
async def list_llm_logs(
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|||||||
@@ -79,7 +79,15 @@ class LlmClient:
|
|||||||
tool_calls: List[Dict[str, Any]] = []
|
tool_calls: List[Dict[str, Any]] = []
|
||||||
usage: Dict[str, int] = {}
|
usage: Dict[str, int] = {}
|
||||||
try:
|
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 = await client.post(url, json=payload, headers=self._headers())
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -88,9 +96,30 @@ class LlmClient:
|
|||||||
text = msg.get("content") or ""
|
text = msg.get("content") or ""
|
||||||
tool_calls = msg.get("tool_calls") or []
|
tool_calls = msg.get("tool_calls") or []
|
||||||
usage = data.get("usage") 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:
|
except Exception as e:
|
||||||
err = f"{type(e).__name__}: {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
|
raise
|
||||||
finally:
|
finally:
|
||||||
latency_ms = int((time.monotonic() - started) * 1000)
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|||||||
@@ -55,21 +55,11 @@ DEFAULT_SETTINGS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Keys whose values come from environment variables (via Settings fields).
|
# NOTE: env-derived defaults (llm.base_url, llm.api_key, llm.model,
|
||||||
# These are re-applied on EVERY startup so .env is the source of truth.
|
# embedding.* etc.) are ONLY applied on the very first run via _seed_settings.
|
||||||
# Admin-panel changes to these keys are runtime overrides that get reset on
|
# After that, the admin panel is the source of truth — restarting the
|
||||||
# restart unless the operator also updates .env.
|
# container will NOT overwrite admin-configured values with .env values.
|
||||||
ENV_DERIVED_SETTING_KEYS = {
|
# To force a re-seed, drop the `settings` table or delete the relevant rows.
|
||||||
"llm.base_url",
|
|
||||||
"llm.api_key",
|
|
||||||
"llm.model",
|
|
||||||
"embedding.provider",
|
|
||||||
"embedding.base_url",
|
|
||||||
"embedding.api_key",
|
|
||||||
"embedding.model",
|
|
||||||
"embedding.dim",
|
|
||||||
"embedding.request_timeout",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
@@ -91,7 +81,6 @@ async def init_db() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _seed_settings(session)
|
await _seed_settings(session)
|
||||||
await _sync_env_derived_settings(session)
|
|
||||||
await _seed_builtin_presets(session)
|
await _seed_builtin_presets(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as e:
|
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}")
|
log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}")
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await _seed_settings(session)
|
await _seed_settings(session)
|
||||||
await _sync_env_derived_settings(session)
|
|
||||||
await _seed_builtin_presets(session)
|
await _seed_builtin_presets(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -167,46 +155,6 @@ async def _seed_settings(session) -> None:
|
|||||||
log.info("settings_already_exist")
|
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:
|
async def _seed_builtin_presets(session) -> None:
|
||||||
"""Insert built-in presets if none exist yet."""
|
"""Insert built-in presets if none exist yet."""
|
||||||
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
|
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ services:
|
|||||||
DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small}
|
DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small}
|
||||||
DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0}
|
DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0}
|
||||||
DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60}
|
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:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user