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:
@@ -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 ""
|
||||
Reference in New Issue
Block a user