Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""What it means when an endpoint does not answer.
|
||||
|
||||
Separate from `client.py` because eight modules catch this and none of them
|
||||
talk to an endpoint: routers, modes and the authoring code only need to know
|
||||
what went wrong and how to say it. The client itself stays the one place that
|
||||
CALLS an endpoint.
|
||||
|
||||
Nothing here ever carries content — not the prompt, not the reply, not the
|
||||
original exception's message. A failure is described by class name, status
|
||||
code and duration, which is everything a log line may hold (rule 12).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
# Exception class names that mean "nothing answered at the other end" versus
|
||||
# "the other end is there but not ready for us". The SDK wraps both, so the
|
||||
# class name is all we have: APITimeoutError subclasses APIConnectionError,
|
||||
# which is why timeouts are matched first.
|
||||
_TIMEOUT_CAUSES = frozenset(
|
||||
{"APITimeoutError", "ReadTimeout", "PoolTimeout", "TimeoutError"}
|
||||
)
|
||||
_CONNECTION_CAUSES = frozenset(
|
||||
{"APIConnectionError", "ConnectError", "ConnectTimeout", "RemoteProtocolError"}
|
||||
)
|
||||
# Server said "come back later" (rate limit, no free slot, model still loading).
|
||||
_BUSY_STATUS = frozenset({408, 429, 503, 504})
|
||||
# Server said "not with these credentials / not this model".
|
||||
_SETUP_STATUS = frozenset({401, 403, 404})
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Sanitized LLM failure: structured metadata for debugging —
|
||||
never content, never original exception messages.
|
||||
|
||||
Fields: role, kind, status ("error" | "invalid"), cause_type (original
|
||||
exception CLASS NAME only), status_code (HTTP, if any), duration_ms,
|
||||
attempt (chat_json: 1 or 2).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
role: str,
|
||||
kind: str,
|
||||
status: str,
|
||||
cause_type: str | None = None,
|
||||
status_code: int | None = None,
|
||||
duration_ms: int | None = None,
|
||||
attempt: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.role = role
|
||||
self.kind = kind
|
||||
self.status = status
|
||||
self.cause_type = cause_type
|
||||
self.status_code = status_code
|
||||
self.duration_ms = duration_ms
|
||||
self.attempt = attempt
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
"""The API error code for this failure — the ONE place an endpoint
|
||||
failure is classified, so every caller reports the same reason and the
|
||||
frontend can phrase it (`docs/api-protocol.md`).
|
||||
|
||||
`llm_busy` and `llm_unreachable` are worth telling apart: the first is
|
||||
worth retrying in a moment, the second needs someone to start the
|
||||
endpoint.
|
||||
"""
|
||||
if self.status_code in _BUSY_STATUS:
|
||||
return "llm_busy"
|
||||
if self.status_code in _SETUP_STATUS:
|
||||
return "llm_misconfigured"
|
||||
if self.cause_type in _TIMEOUT_CAUSES:
|
||||
return "llm_busy"
|
||||
if self.cause_type in _CONNECTION_CAUSES:
|
||||
return "llm_unreachable"
|
||||
return "llm_failed"
|
||||
|
||||
|
||||
def llm_error(
|
||||
kind: str,
|
||||
role: str,
|
||||
exc: Exception,
|
||||
started: float,
|
||||
*,
|
||||
attempt: int | None = None,
|
||||
) -> LLMError:
|
||||
"""Wrap whatever the SDK raised, keeping only what may be logged."""
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
return LLMError(
|
||||
f"{kind} failed (role={role}): {type(exc).__name__}",
|
||||
role=role,
|
||||
kind=kind,
|
||||
status="error",
|
||||
cause_type=type(exc).__name__,
|
||||
status_code=status_code if isinstance(status_code, int) else None,
|
||||
duration_ms=round((time.monotonic() - started) * 1000),
|
||||
attempt=attempt,
|
||||
)
|
||||
Reference in New Issue
Block a user