Coverage for src/lilbee/server/routes/general.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
1"""General routes: health, status, config, source, warm.
3Every route needs the token, ``/api/health`` included: it reports the chat
4engine's last error, which carries model paths and loader failures. A local
5probe reads the token from server.json like every other local client.
6"""
8from __future__ import annotations
10import signal
11from pathlib import Path
12from typing import Any
14from litestar import Response, get, patch, post
15from litestar.background_tasks import BackgroundTask
16from litestar.exceptions import HTTPException, NotFoundException, ValidationException
17from litestar.params import FromQuery
18from litestar.response import Stream
19from litestar.status_codes import HTTP_202_ACCEPTED, HTTP_503_SERVICE_UNAVAILABLE
20from pydantic import ValidationError
22from lilbee.app.settings import config_write_failure_message
23from lilbee.server import handlers
24from lilbee.server.handlers.sse import SSE_MEDIA_TYPE
25from lilbee.server.models import (
26 ConfigResponse,
27 ConfigUpdateResponse,
28 HealthResponse,
29 ShutdownResponse,
30 SourceContentResponse,
31 StatusResponse,
32)
35@get("/api/health")
36async def health_route() -> HealthResponse:
37 """Service health check returning server version and uptime status."""
38 return await handlers.health()
41@get("/api/warm/stream", media_type=SSE_MEDIA_TYPE)
42async def warm_stream_route() -> Stream:
43 """Stream chat-model cold-load progress as SSE for a launcher's warm indicator."""
44 return Stream(handlers.warm_stream(), media_type=SSE_MEDIA_TYPE)
47@get("/api/status")
48async def status_route() -> StatusResponse:
49 """Current configuration, indexed document sources, and chunk counts."""
50 return await handlers.status()
53@post("/api/shutdown", status_code=HTTP_202_ACCEPTED)
54async def shutdown_route() -> Response[ShutdownResponse]:
55 """Gracefully stop the server, exactly as an external SIGTERM would.
57 The signal rides a background task so it is raised after the response has
58 been handed to the transport, rather than after a guessed delay that a
59 slow flush could lose.
60 """
61 return Response(
62 await handlers.shutdown(),
63 status_code=HTTP_202_ACCEPTED,
64 background=BackgroundTask(signal.raise_signal, signal.SIGTERM),
65 )
68@get("/api/config")
69async def config_route() -> ConfigResponse:
70 """Return all user-facing configuration values."""
71 return await handlers.get_config()
74@get("/api/config/defaults")
75async def config_defaults_route() -> ConfigResponse:
76 """Return canonical defaults for every writable, public configuration field."""
77 return await handlers.get_config_defaults()
80@patch("/api/config")
81async def config_update_route(data: dict[str, Any]) -> ConfigUpdateResponse:
82 """Partial update of writable configuration fields."""
83 try:
84 return await handlers.update_config(data)
85 except (ValueError, ValidationError) as exc:
86 raise ValidationException(str(exc)) from exc
87 except OSError as exc:
88 raise HTTPException(
89 status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=config_write_failure_message(exc)
90 ) from exc
93@get("/api/source")
94async def source_content_route(
95 source: FromQuery[str], raw: FromQuery[bool] = False
96) -> SourceContentResponse | Response[bytes]:
97 """Return stored source file as JSON (``raw=0``) or raw bytes (``raw=1``)."""
98 try:
99 result = await handlers.get_source_content(source, raw=raw)
100 except FileNotFoundError as exc:
101 raise NotFoundException(f"source not found: {source}") from exc
102 except ValueError as exc:
103 raise ValidationException(str(exc)) from exc
105 # ``raw=True`` returns ``(bytes, content_type)``; narrow via ``isinstance``
106 # so mypy sees the tuple branch without leaning on ``type: ignore``.
107 if isinstance(result, tuple):
108 body, content_type = result
109 # nosniff blocks browser MIME-sniffing fallbacks; attachment forces a
110 # download for any type the handler degraded to octet-stream so
111 # attacker-named files don't render inline anywhere.
112 headers = {"X-Content-Type-Options": "nosniff"}
113 if content_type == "application/octet-stream":
114 headers["Content-Disposition"] = f'attachment; filename="{Path(source).name}"'
115 return Response(content=body, media_type=content_type, status_code=200, headers=headers)
116 return result