Coverage for src/lilbee/server/routes/general.py: 100%
64 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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.services import request_server_exit
23from lilbee.app.settings import config_write_failure_message
24from lilbee.server import handlers
25from lilbee.server.content_disposition import CONTENT_DISPOSITION, attachment_disposition
26from lilbee.server.handlers.sse import SSE_MEDIA_TYPE
27from lilbee.server.models import (
28 ConfigResponse,
29 ConfigSchemaResponse,
30 ConfigUpdateResponse,
31 HealthResponse,
32 ShutdownResponse,
33 SourceContentResponse,
34 StatusResponse,
35)
38@get("/api/health")
39async def health_route() -> HealthResponse:
40 """Service health check returning server version and uptime status."""
41 return await handlers.health()
44@get("/api/warm/stream", media_type=SSE_MEDIA_TYPE)
45async def warm_stream_route() -> Stream:
46 """Stream chat-model cold-load progress as SSE for a launcher's warm indicator."""
47 return Stream(handlers.warm_stream(), media_type=SSE_MEDIA_TYPE)
50@get("/api/status")
51async def status_route() -> StatusResponse:
52 """Current configuration, indexed document sources, and chunk counts."""
53 return await handlers.status()
56async def _stop_server() -> None:
57 """Stop the serving loop when one runs; otherwise raise SIGTERM."""
58 if not request_server_exit():
59 signal.raise_signal(signal.SIGTERM)
62@post("/api/shutdown", status_code=HTTP_202_ACCEPTED)
63async def shutdown_route() -> Response[ShutdownResponse]:
64 """Gracefully stop the server through its serving loop.
66 The stop rides a background task so it runs after the response has
67 been handed to the transport, rather than after a guessed delay that a
68 slow flush could lose.
69 """
70 return Response(
71 await handlers.shutdown(),
72 status_code=HTTP_202_ACCEPTED,
73 background=BackgroundTask(_stop_server),
74 )
77@get("/api/config")
78async def config_route() -> ConfigResponse:
79 """Return all user-facing configuration values."""
80 return await handlers.get_config()
83@get("/api/config/defaults")
84async def config_defaults_route() -> ConfigResponse:
85 """Return canonical defaults for every writable, public configuration field."""
86 return await handlers.get_config_defaults()
89@get("/api/config/schema")
90async def config_schema_route() -> ConfigSchemaResponse:
91 """Return type, choices, writability and reindex metadata for every public field."""
92 return await handlers.get_config_schema()
95@patch("/api/config")
96async def config_update_route(data: dict[str, Any]) -> ConfigUpdateResponse:
97 """Partial update of writable configuration fields."""
98 try:
99 return await handlers.update_config(data)
100 except (ValueError, ValidationError) as exc:
101 raise ValidationException(str(exc)) from exc
102 except OSError as exc:
103 raise HTTPException(
104 status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=config_write_failure_message(exc)
105 ) from exc
108@get("/api/source")
109async def source_content_route(
110 source: FromQuery[str], raw: FromQuery[bool] = False
111) -> SourceContentResponse | Response[bytes]:
112 """Return stored source file as JSON (``raw=0``) or raw bytes (``raw=1``)."""
113 try:
114 result = await handlers.get_source_content(source, raw=raw)
115 except FileNotFoundError as exc:
116 raise NotFoundException(f"source not found: {source}") from exc
117 except ValueError as exc:
118 raise ValidationException(str(exc)) from exc
120 # ``raw=True`` returns ``(bytes, content_type)``; narrow via ``isinstance``
121 # so mypy sees the tuple branch without leaning on ``type: ignore``.
122 if isinstance(result, tuple):
123 body, content_type = result
124 # nosniff blocks browser MIME-sniffing fallbacks; attachment forces a
125 # download for any type the handler degraded to octet-stream so
126 # attacker-named files don't render inline anywhere.
127 headers = {"X-Content-Type-Options": "nosniff"}
128 if content_type == "application/octet-stream":
129 headers[CONTENT_DISPOSITION] = attachment_disposition(Path(source).name)
130 return Response(content=body, media_type=content_type, status_code=200, headers=headers)
131 return result