Working chat: auth, connections, streaming, folders

LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit dd9e0e9440
59 changed files with 6273 additions and 12 deletions
+245
View File
@@ -0,0 +1,245 @@
"""Client for OpenAI-compatible chat endpoints.
Deliberately plain httpx rather than the official SDK. The target is not just
api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
thin client passes request parameters through untouched and is tolerant about
what comes back, which is exactly what talking to all of them requires.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import httpx
from lembas.config import settings
from lembas.db.models import Connection
from lembas.services.crypto import decrypt
log = logging.getLogger(__name__)
class LLMError(Exception):
"""An upstream failure with a message fit to show a user.
Every failure path in this module raises this rather than letting an httpx
or JSON exception escape, so callers have exactly one thing to catch and
the chat UI always has something intelligible to display.
"""
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
@dataclass(frozen=True)
class Endpoint:
"""Everything needed to call a connection, with the key already decrypted.
A frozen snapshot rather than the ORM object because streaming outlives the
request that started it, and a detached SQLAlchemy instance is a trap.
"""
base_url: str
api_key: str
extra_headers: dict[str, str]
name: str = ""
@classmethod
def from_connection(cls, connection: Connection) -> Endpoint:
return cls(
base_url=connection.base_url.rstrip("/"),
api_key=decrypt(connection.api_key_encrypted),
extra_headers=dict(connection.extra_headers_json or {}),
name=connection.name,
)
def url(self, path: str) -> str:
# Accept both "http://host:1234" and "http://host:1234/v1" so users do
# not have to guess which form this expects.
base = self.base_url
if not base.endswith("/v1") and "/v1/" not in base:
base = f"{base}/v1"
return f"{base}/{path.lstrip('/')}"
def headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", **self.extra_headers}
# Local endpoints frequently need no key at all; sending an empty
# bearer token makes some of them reject the request outright.
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def _describe_http_error(exc: httpx.HTTPStatusError) -> str:
"""Turn an upstream error response into something worth reading.
Providers put the useful part in wildly different places, so try the common
shapes before falling back to the raw body.
"""
status = exc.response.status_code
detail = ""
try:
payload = exc.response.json()
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
detail = error.get("message", "")
elif isinstance(error, str):
detail = error
detail = detail or payload.get("message", "") or payload.get("detail", "")
except (ValueError, json.JSONDecodeError):
detail = exc.response.text[:300]
friendly = {
401: "The API key was rejected.",
403: "The API key is not permitted to use this model.",
404: "The endpoint or model was not found.",
429: "Rate limited by the provider.",
}.get(status)
if friendly and detail:
return f"{friendly} {detail}"
return friendly or detail or f"The endpoint returned HTTP {status}."
def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
if isinstance(exc, httpx.ConnectError):
return LLMError(
f"Could not reach {endpoint.base_url}. Is the endpoint running and "
f"the URL correct?"
)
if isinstance(exc, httpx.TimeoutException):
return LLMError(
f"{endpoint.base_url} did not respond within "
f"{settings.request_timeout:.0f}s."
)
return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
"""Fetch the models a connection advertises via GET /v1/models."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(endpoint.url("models"), headers=endpoint.headers())
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise _wrap_transport_error(exc, endpoint) from exc
except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc
# The spec says {"data": [...]}, but some servers return a bare list.
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
if not isinstance(entries, list):
raise LLMError("The endpoint's model list was not in the expected format.")
models = []
for entry in entries:
if isinstance(entry, dict) and entry.get("id"):
models.append(entry)
elif isinstance(entry, str):
models.append({"id": entry})
return models
async def stream_chat(
endpoint: Endpoint,
payload: dict[str, Any],
) -> AsyncIterator[dict[str, Any]]:
"""Stream a chat completion, yielding each parsed SSE data object.
Yields the raw upstream chunks; interpreting them is the caller's job. The
terminating "[DONE]" sentinel is consumed here and not yielded.
"""
body = {**payload, "stream": True}
try:
async with (
httpx.AsyncClient(timeout=settings.request_timeout) as client,
client.stream(
"POST",
endpoint.url("chat/completions"),
headers=endpoint.headers(),
json=body,
) as response,
):
if response.status_code >= 400:
# The body has not been read yet on a streaming response, and
# the error detail is in it.
await response.aread()
response.raise_for_status()
async for line in response.aiter_lines():
line = line.strip()
if not line or line.startswith(":"):
continue # keep-alive comment
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
# A malformed frame is not worth killing a reply over.
log.warning("skipping unparseable SSE frame: %.120s", data)
continue
except httpx.HTTPStatusError as exc:
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise _wrap_transport_error(exc, endpoint) from exc
async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
"""Non-streaming completion. Used for short internal calls like auto-titling."""
body = {**payload, "stream": False}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
except httpx.RequestError as exc:
raise _wrap_transport_error(exc, endpoint) from exc
except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc
try:
return data["choices"][0]["message"]["content"] or ""
except (KeyError, IndexError, TypeError) as exc:
raise LLMError("The endpoint returned no completion.") from exc
def delta_text(chunk: dict[str, Any]) -> str:
"""Pull the text out of one streamed chunk, tolerating provider variation."""
try:
choices = chunk.get("choices") or []
if not choices:
return ""
delta = choices[0].get("delta") or {}
content = delta.get("content")
if isinstance(content, str):
return content
# Some providers send content as a list of typed parts even in deltas.
if isinstance(content, list):
return "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return ""
except (AttributeError, TypeError):
return ""