Coverage for src/lilbee/server/routes/general.py: 100%
75 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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_200_OK, 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 ConfigResetRequest,
29 ConfigResponse,
30 ConfigSchemaResponse,
31 ConfigSourcesResponse,
32 ConfigUpdateResponse,
33 HealthResponse,
34 ShutdownResponse,
35 SourceContentResponse,
36 StatusResponse,
37)
40@get("/api/health")
41async def health_route() -> HealthResponse:
42 """Service health check returning server version and uptime status."""
43 return await handlers.health()
46@get("/api/warm/stream", media_type=SSE_MEDIA_TYPE)
47async def warm_stream_route() -> Stream:
48 """Stream chat-model cold-load progress as SSE for a launcher's warm indicator."""
49 return Stream(handlers.warm_stream(), media_type=SSE_MEDIA_TYPE)
52@get("/api/status")
53async def status_route() -> StatusResponse:
54 """Current configuration, indexed document sources, and chunk counts."""
55 return await handlers.status()
58async def _stop_server() -> None:
59 """Stop the serving loop when one runs; otherwise raise SIGTERM."""
60 if not request_server_exit():
61 signal.raise_signal(signal.SIGTERM)
64@post("/api/shutdown", status_code=HTTP_202_ACCEPTED)
65async def shutdown_route() -> Response[ShutdownResponse]:
66 """Gracefully stop the server through its serving loop.
68 The stop rides a background task so it runs after the response has
69 been handed to the transport, rather than after a guessed delay that a
70 slow flush could lose.
71 """
72 return Response(
73 await handlers.shutdown(),
74 status_code=HTTP_202_ACCEPTED,
75 background=BackgroundTask(_stop_server),
76 )
79@get("/api/config")
80async def config_route() -> ConfigResponse:
81 """Return all user-facing configuration values."""
82 return await handlers.get_config()
85@get("/api/config/defaults")
86async def config_defaults_route() -> ConfigResponse:
87 """Return canonical defaults for every writable, public configuration field."""
88 return await handlers.get_config_defaults()
91@post("/api/config/reset", status_code=HTTP_200_OK)
92async def config_reset_route(data: ConfigResetRequest) -> ConfigUpdateResponse:
93 """Remove the listed keys from config.toml; each falls back to the next source."""
94 try:
95 return await handlers.reset_config(data.keys)
96 except ValueError as exc:
97 raise ValidationException(str(exc)) from exc
98 except OSError as exc:
99 raise HTTPException(
100 status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=config_write_failure_message(exc)
101 ) from exc
104@get("/api/config/schema")
105async def config_schema_route() -> ConfigSchemaResponse:
106 """Return type, choices, writability and reindex metadata for every public field."""
107 return await handlers.get_config_schema()
110@get("/api/config/sources")
111async def config_sources_route() -> ConfigSourcesResponse:
112 """Return the layer that supplies each configuration value."""
113 return await handlers.get_config_sources()
116@patch("/api/config")
117async def config_update_route(data: dict[str, Any]) -> ConfigUpdateResponse:
118 """Partial update of writable configuration fields."""
119 try:
120 return await handlers.update_config(data)
121 except (ValueError, ValidationError) as exc:
122 raise ValidationException(str(exc)) from exc
123 except OSError as exc:
124 raise HTTPException(
125 status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=config_write_failure_message(exc)
126 ) from exc
129@get("/api/source")
130async def source_content_route(
131 source: FromQuery[str], raw: FromQuery[bool] = False
132) -> SourceContentResponse | Response[bytes]:
133 """Return stored source file as JSON (``raw=0``) or raw bytes (``raw=1``)."""
134 try:
135 result = await handlers.get_source_content(source, raw=raw)
136 except FileNotFoundError as exc:
137 raise NotFoundException(f"source not found: {source}") from exc
138 except ValueError as exc:
139 raise ValidationException(str(exc)) from exc
141 # ``raw=True`` returns ``(bytes, content_type)``; narrow via ``isinstance``
142 # so mypy sees the tuple branch without leaning on ``type: ignore``.
143 if isinstance(result, tuple):
144 body, content_type = result
145 # nosniff blocks browser MIME-sniffing fallbacks; attachment forces a
146 # download for any type the handler degraded to octet-stream so
147 # attacker-named files don't render inline anywhere.
148 headers = {"X-Content-Type-Options": "nosniff"}
149 if content_type == "application/octet-stream":
150 headers[CONTENT_DISPOSITION] = attachment_disposition(Path(source).name)
151 return Response(content=body, media_type=content_type, status_code=200, headers=headers)
152 return result