Coverage for src/lilbee/server/routes/placement.py: 100%
80 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Placement routes: inspect, preview, and (when enabled) apply GPU placement.
3Every route requires auth: even the reads run resolve_placement_plan, which
4spawns subprocess device probes. Every route needs the token. Applying or
5clearing placement restarts the shared fleet's moved roles, which is unsafe
6across concurrent HTTP clients, so PUT/DELETE are refused by default. They are gated on the
7``allow_http_placement`` flag (LILBEE_ALLOW_HTTP_PLACEMENT), which an operator
8turns on for a single-client / owned deployment to get the same apply/clear
9capability the CLI and TUI have.
10"""
12from __future__ import annotations
14import asyncio
15import json
16import logging
18from litestar import delete, get, post, put
19from litestar.exceptions import HTTPException
20from litestar.response import Stream
22from lilbee.core.config import cfg
23from lilbee.providers.base import ProviderError
24from lilbee.providers.fleet.placement_spec import PlacementError
25from lilbee.server import handlers
26from lilbee.server.handlers.sse import SSE_MEDIA_TYPE
27from lilbee.server.models import GpusResponse, PlacementResponse, PlacementSpecBody
29log = logging.getLogger(__name__)
31_HTTP_UNPROCESSABLE = 422
32_HTTP_CONFLICT = 409
33_HTTP_UNAVAILABLE = 503
34# OSError is deliberately absent: on this path it means the device probe could
35# not run (no nvidia-smi on PATH, no permission on the device node, a failed
36# spawn), which is a host fault and not something the caller's spec can fix.
37_INPUT_ERRORS = (PlacementError, ValueError)
38_PROBE_FAILED_DETAIL = (
39 "Could not probe the GPUs. Check that the vendor tool (nvidia-smi or "
40 "rocm-smi) is installed and this process may read the device."
41)
42_MISSING_SPEC_DETAIL = "spec is required to apply placement; send {} to DELETE for auto."
45def _spec_json(body: PlacementSpecBody) -> str | None:
46 """Serialize the optional spec dict to JSON, or return None when absent."""
47 return json.dumps(body.spec) if body.spec is not None else None
50def _refused() -> HTTPException:
51 from lilbee.app.placement import placement_refused_message
53 return HTTPException(status_code=_HTTP_CONFLICT, detail=placement_refused_message())
56@get("/api/placement")
57async def placement_route() -> PlacementResponse:
58 """Current effective placement."""
59 try:
60 return await handlers.placement()
61 except ProviderError as exc:
62 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
65@post("/api/placement/preview", status_code=200)
66async def placement_preview_route(data: PlacementSpecBody) -> PlacementResponse:
67 """Preview a candidate spec (or auto when no spec). Requires auth: runs subprocess probes."""
68 try:
69 return await handlers.placement_preview(_spec_json(data))
70 except ProviderError as exc:
71 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
72 except OSError as exc:
73 log.exception("GPU probe failed")
74 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=_PROBE_FAILED_DETAIL) from exc
75 except _INPUT_ERRORS as exc:
76 raise HTTPException(status_code=_HTTP_UNPROCESSABLE, detail=str(exc)) from exc
79@put("/api/placement")
80async def placement_set_route(data: PlacementSpecBody) -> PlacementResponse:
81 """Apply a manual spec. Refused unless allow_http_placement is enabled."""
82 if not cfg.allow_http_placement:
83 raise _refused()
84 spec_json = _spec_json(data)
85 if spec_json is None:
86 raise HTTPException(status_code=_HTTP_UNPROCESSABLE, detail=_MISSING_SPEC_DETAIL)
87 try:
88 return await handlers.placement_set(spec_json)
89 except ProviderError as exc:
90 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
91 except OSError as exc:
92 log.exception("GPU probe failed")
93 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=_PROBE_FAILED_DETAIL) from exc
94 except _INPUT_ERRORS as exc:
95 raise HTTPException(status_code=_HTTP_UNPROCESSABLE, detail=str(exc)) from exc
98@delete("/api/placement", status_code=200)
99async def placement_clear_route() -> PlacementResponse:
100 """Clear placement (back to auto). Refused unless allow_http_placement is enabled."""
101 if not cfg.allow_http_placement:
102 raise _refused()
103 try:
104 return await handlers.placement_clear()
105 except ProviderError as exc:
106 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
109@get("/api/gpus")
110async def gpus_route() -> GpusResponse:
111 """Detected GPUs with free/total VRAM, plus the host-level Intel util notice."""
112 try:
113 return await handlers.gpus()
114 except ProviderError as exc:
115 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
118@get("/api/gpus/stream", media_type=SSE_MEDIA_TYPE)
119async def gpu_stats_stream_route() -> Stream:
120 """Live per-GPU utilization + free memory as SSE for the placement view."""
121 from lilbee.app.placement import get_placement
123 try:
124 # get_placement runs resolve_placement_plan, which spawns subprocess
125 # device probes that can wedge for the probe timeout on a broken driver;
126 # offload so the probe never blocks the event loop.
127 view = await asyncio.to_thread(get_placement)
128 except ProviderError as exc:
129 raise HTTPException(status_code=_HTTP_UNAVAILABLE, detail=str(exc)) from exc
130 return Stream(handlers.gpu_stats_stream(view.gpus), media_type=SSE_MEDIA_TYPE)