Coverage for src/lilbee/server/app.py: 100%

147 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Litestar application factory: imports routes from modules, creates app with lifespan.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7from collections.abc import AsyncIterator 

8from contextlib import asynccontextmanager 

9from typing import TYPE_CHECKING, Any 

10 

11import anyio.to_thread 

12from litestar import Litestar, MediaType, Request, Response 

13from litestar.config.cors import CORSConfig 

14from litestar.exceptions import HTTPException, ValidationException 

15from litestar.exceptions.responses import create_exception_response 

16from litestar.middleware import ASGIMiddleware 

17from litestar.middleware.base import DefineMiddleware 

18from litestar.openapi import OpenAPIConfig 

19 

20from lilbee.app.config_follow import ConfigFileFollower 

21from lilbee.app.endpoints import MCP_PATH 

22from lilbee.app.services import ( 

23 get_services, 

24 peek_services, 

25 set_config_refresh, 

26 wait_for_opens, 

27) 

28from lilbee.app.version import get_version 

29from lilbee.core.config import cfg 

30from lilbee.runtime.progress import LibraryUpgradeEvent 

31from lilbee.server.anthropic_api.errors import anthropic_error_body, anthropic_error_type 

32from lilbee.server.anthropic_api.routes import anthropic_router 

33from lilbee.server.auth import AuthMiddleware, session_manager 

34from lilbee.server.chat_completions_api.errors import CompletionsErrorCode, completions_error_body 

35from lilbee.server.chat_completions_api.routes import completions_router 

36from lilbee.server.content_disposition import CONTENT_DISPOSITION 

37from lilbee.server.mcp_mount import build_mcp_mount 

38from lilbee.server.routes.agent_config import ( 

39 agent_config_index_route, 

40 agent_config_route, 

41) 

42from lilbee.server.routes.analyze import analyze_router 

43from lilbee.server.routes.crawl import crawl_route 

44from lilbee.server.routes.documents import ( 

45 add_route, 

46 add_upload_route, 

47 documents_list_route, 

48 documents_remove_route, 

49 export_route, 

50 import_route, 

51 sync_route, 

52) 

53from lilbee.server.routes.general import ( 

54 config_defaults_route, 

55 config_reset_route, 

56 config_route, 

57 config_schema_route, 

58 config_sources_route, 

59 config_update_route, 

60 health_route, 

61 shutdown_route, 

62 source_content_route, 

63 status_route, 

64 warm_stream_route, 

65) 

66from lilbee.server.routes.memory import ( 

67 memories_list_route, 

68 memories_remember_route, 

69 memories_remove_route, 

70 memories_update_route, 

71) 

72from lilbee.server.routes.models import ( 

73 models_catalog_route, 

74 models_delete_route, 

75 models_external_route, 

76 models_installed_route, 

77 models_list_route, 

78 models_pull_route, 

79 models_set_chat_route, 

80 models_set_embedding_route, 

81 models_set_reranker_route, 

82 models_set_vision_route, 

83 models_show_route, 

84) 

85from lilbee.server.routes.placement import ( 

86 gpu_stats_stream_route, 

87 gpus_route, 

88 placement_clear_route, 

89 placement_preview_route, 

90 placement_route, 

91 placement_set_route, 

92) 

93from lilbee.server.routes.profiles import profiles_router 

94from lilbee.server.routes.search import ( 

95 ask_route, 

96 ask_stream_route, 

97 chat_route, 

98 chat_stream_route, 

99 search_route, 

100) 

101from lilbee.server.routes.sessions import ( 

102 session_add_message_route, 

103 session_claim_route, 

104 session_create_route, 

105 session_delete_route, 

106 session_fork_route, 

107 session_get_route, 

108 session_markdown_route, 

109 session_rename_route, 

110 session_set_summary_route, 

111 sessions_list_route, 

112) 

113from lilbee.server.routes.setup import ( 

114 setup_crawler_route, 

115 setup_crawler_status_route, 

116) 

117from lilbee.server.validation_format import plain_validation 

118from lilbee.server.wiki import ( 

119 wiki_build_route, 

120 wiki_citations_reverse_route, 

121 wiki_draft_accept_route, 

122 wiki_draft_diff_route, 

123 wiki_draft_reject_route, 

124 wiki_drafts_route, 

125 wiki_generate_route, 

126 wiki_index_route, 

127 wiki_lint_route, 

128 wiki_list_route, 

129 wiki_prune_route, 

130 wiki_read_route, 

131 wiki_status_route, 

132 wiki_stubs_route, 

133 wiki_synthesize_route, 

134 wiki_update_route, 

135 wiki_wipe_route, 

136) 

137 

138if TYPE_CHECKING: 

139 from litestar.types import ASGIApp, Receive, Scope, Send 

140 

141 from lilbee.retrieval.embedder import Embedder 

142 

143log = logging.getLogger(__name__) 

144 

145 

146# Below this soft open-file limit a large agent fleet meets the limit as 

147# connection failures before it saturates the machine. 

148_FD_SOFT_LIMIT_NUDGE = 4096 

149 

150 

151def _warn_if_few_file_descriptors() -> None: 

152 """Log an advisory when the open-file limit is low enough to cap the agent fleet.""" 

153 try: 

154 import resource 

155 except ImportError: # pragma: no cover - Windows has no resource module 

156 return 

157 soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) 

158 if soft == resource.RLIM_INFINITY or soft >= _FD_SOFT_LIMIT_NUDGE: 

159 return 

160 log.info( 

161 "Open-file limit is %d. Each connected agent holds a socket, so a large " 

162 "fleet will hit this before it saturates the machine; raise it with " 

163 "'ulimit -n %d' before starting the server (this shell allows up to %s).", 

164 soft, 

165 _FD_SOFT_LIMIT_NUDGE, 

166 "unlimited" if hard == resource.RLIM_INFINITY else hard, 

167 ) 

168 

169 

170def _raise_thread_pool_ceiling() -> None: 

171 """Set anyio's shared thread-pool size to ``mcp_tool_threads``. 

172 

173 Resizes anyio's own default limiter, not a private one, so every offload in 

174 the process is lifted (Litestar and MCP sync handlers included), not only ours. 

175 Must run on the server event loop, where the default limiter lives. 

176 """ 

177 limiter = anyio.to_thread.current_default_thread_limiter() 

178 if limiter.total_tokens != cfg.mcp_tool_threads: 

179 limiter.total_tokens = cfg.mcp_tool_threads 

180 

181 

182class _ServerLoop: 

183 """Holds the running server's event loop so a config change off the loop can reach it. 

184 

185 A settings write from a worker thread (the MCP handler) needs the loop to 

186 resize its thread pool there; nothing is held when no server runs (CLI/TUI). 

187 """ 

188 

189 def __init__(self) -> None: 

190 self._loop: asyncio.AbstractEventLoop | None = None 

191 

192 def set(self, loop: asyncio.AbstractEventLoop | None) -> None: 

193 self._loop = loop 

194 

195 def running(self) -> asyncio.AbstractEventLoop | None: 

196 loop = self._loop 

197 return loop if loop is not None and not loop.is_closed() else None 

198 

199 

200_server_loop = _ServerLoop() 

201 

202 

203def reapply_thread_pool_ceiling() -> None: 

204 """Resize the running server's thread pool after ``mcp_tool_threads`` changes. 

205 

206 Marshalled onto the server loop, so it is safe from the HTTP handler (on the 

207 loop) and the MCP handler (a worker thread) alike. With no server running the 

208 new value simply takes effect at the next start. 

209 """ 

210 loop = _server_loop.running() 

211 if loop is not None: 

212 loop.call_soon_threadsafe(_raise_thread_pool_ceiling) 

213 

214 

215def _log_embedding_model_state(embedder: Embedder) -> None: 

216 """Report whether embeddings will work, without letting that check stop startup. 

217 

218 A model that reports itself unavailable and one whose check raises both leave 

219 the server usable for everything that does not embed, so neither is fatal. 

220 """ 

221 try: 

222 validated = embedder.validate_model() 

223 except Exception: 

224 log.warning("Failed to validate embedding model", exc_info=True) 

225 return 

226 if validated: 

227 log.info("Embedding model validated") 

228 elif cfg.embedding_model: 

229 log.warning( 

230 "Embedding model %s is unavailable; search and chat will run without embeddings", 

231 cfg.embedding_model, 

232 ) 

233 # Unconfigured: validate_model already logged the one INFO line. 

234 

235 

236_UPGRADE_WAIT_SECONDS = 10.0 

237# How long a stopping server waits for the library open it started, beside the provider 

238# shutdown; an open still running then ends the process at once (exit_if_opens_remain). 

239_OPEN_EXIT_WAIT_SECONDS = 5.0 

240_UPGRADE_RETRY_AFTER_SECONDS = 10 

241_UPGRADING_MESSAGE = "lilbee is upgrading the library. Try again in a few seconds." 

242 

243 

244def _upgrading_response(path: str, upgrade: LibraryUpgradeEvent | None) -> Response: 

245 """The 503 for a request that waited out the limit, in the error shape of its API. 

246 

247 Every shape also carries the upgrade's progress, as health reports it. 

248 """ 

249 code = CompletionsErrorCode.INTERNAL_ERROR 

250 body: dict[str, Any] 

251 if path.startswith("/v1/messages"): 

252 body = anthropic_error_body(anthropic_error_type(code), _UPGRADING_MESSAGE) 

253 elif path.startswith("/v1/"): 

254 body = completions_error_body(code, _UPGRADING_MESSAGE) 

255 else: 

256 body = {"status_code": 503, "detail": _UPGRADING_MESSAGE} 

257 body["upgrade"] = None if upgrade is None else upgrade.model_dump(mode="json") 

258 return Response( 

259 body, 

260 status_code=503, 

261 headers={"Retry-After": str(_UPGRADE_RETRY_AFTER_SECONDS)}, 

262 media_type=MediaType.JSON, 

263 ) 

264 

265 

266class _LibraryOpenWait(ASGIMiddleware): 

267 """Hold a request up to 10 s for the library to open, then answer 503 with Retry-After. 

268 

269 The wait runs off the loop, so an upgrade never blocks it. The limit sits under the 

270 15 s timeout of the Obsidian plugin, so the plugin shows the reason. Health, the warm 

271 stream and shutdown pass at once: health reports the upgrade. MCP tool calls wait 

272 for the library where they use it. A process with no services has no open to wait 

273 for, and the request passes without building them. 

274 """ 

275 

276 exclude_path_pattern = ( 

277 "^/api/health$", 

278 "^/api/warm/stream$", 

279 "^/api/shutdown$", 

280 f"^{MCP_PATH}(/.*)?$", 

281 ) 

282 

283 async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: 

284 services = peek_services() 

285 store = None if services is None else services.store 

286 if ( 

287 store is not None 

288 and not store.is_open 

289 and not await asyncio.to_thread(store.wait_for_open, _UPGRADE_WAIT_SECONDS) 

290 ): 

291 response = _upgrading_response(scope["path"], store.upgrade_progress()) 

292 await response.to_asgi_response(None, Request(scope, receive, send))( 

293 scope, receive, send 

294 ) 

295 return 

296 await next_app(scope, receive, send) 

297 

298 

299class _ConfigFileReload(ASGIMiddleware): 

300 """Take what another process changed in config.toml before each request runs. 

301 

302 The MCP mount is served by this middleware too, so its server does not follow the 

303 file itself. 

304 """ 

305 

306 def __init__(self) -> None: 

307 self._follower = ConfigFileFollower() 

308 set_config_refresh(self._follower.take_now) 

309 

310 async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: 

311 await self._follower.take() 

312 await next_app(scope, receive, send) 

313 

314 

315@asynccontextmanager 

316async def _lifespan(app: Litestar) -> AsyncIterator[None]: 

317 """Pre-load LLM provider and embedding model on server startup.""" 

318 _raise_thread_pool_ceiling() 

319 _server_loop.set(asyncio.get_running_loop()) 

320 _warn_if_few_file_descriptors() 

321 session_manager.load_or_generate() 

322 

323 try: 

324 services = get_services() # pre-load all services (provider, embedder, etc.) 

325 log.info("LLM provider pre-loaded") 

326 except Exception: 

327 log.warning("Failed to pre-load LLM provider", exc_info=True) 

328 else: 

329 _log_embedding_model_state(services.embedder) 

330 services.store.open_in_background() 

331 try: 

332 yield 

333 finally: 

334 _server_loop.set(None) 

335 session_manager.cleanup() 

336 # Terminate the provider's worker/fleet subprocesses so they don't 

337 # outlive the server (e.g. on parent-death shutdown in managed mode). 

338 # The open wait runs beside it, so its bound counts from the same start. 

339 svc = peek_services() 

340 stops = [_wait_for_the_opens()] 

341 if svc is not None: 

342 stops.append(asyncio.to_thread(svc.provider.shutdown)) 

343 # A failed shutdown must not leave the open wait unawaited. 

344 for outcome in await asyncio.gather(*stops, return_exceptions=True): 

345 if isinstance(outcome, BaseException): 

346 log.warning("A server shutdown step failed", exc_info=outcome) 

347 

348 

349async def _wait_for_the_opens() -> None: 

350 """Wait, within one bound, for every open; an open in LanceDB at exit aborts 3.12 on Linux.""" 

351 if not await asyncio.to_thread(wait_for_opens, _OPEN_EXIT_WAIT_SECONDS): 

352 log.warning( 

353 "The library is still opening after %.0f s; the server exits anyway.", 

354 _OPEN_EXIT_WAIT_SECONDS, 

355 ) 

356 

357 

358def _json_error_response(request: Request, exc: Exception) -> Response: 

359 """Answer errors as JSON even on a route whose success media type is SSE. 

360 

361 Litestar builds an error response with the route handler's media type, so a 

362 streaming route would label a JSON error body text/event-stream. 

363 """ 

364 response = create_exception_response(request, exc) 

365 response.media_type = MediaType.JSON 

366 return response 

367 

368 

369def _validation_error_response(request: Request, exc: ValidationException) -> Response: 

370 """Answer a body that fails validation as the JSON 400, with no internal class name.""" 

371 return _json_error_response(request, plain_validation(exc)) 

372 

373 

374def _unhandled_error_response(request: Request, exc: Exception) -> Response: 

375 """Log an unmapped failure, then answer it as the same JSON 500. 

376 

377 Litestar's own handler logs nothing outside debug mode. 

378 """ 

379 log.error("Unhandled error serving %s %s", request.method, request.url.path, exc_info=exc) 

380 return _json_error_response(request, exc) 

381 

382 

383def create_app() -> Litestar: 

384 """Create the Litestar application instance.""" 

385 cors = CORSConfig( 

386 allow_origins=cfg.cors_origins, 

387 allow_origin_regex=cfg.cors_origin_regex, 

388 allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"], 

389 allow_headers=["Content-Type", "Authorization"], 

390 expose_headers=[CONTENT_DISPOSITION], 

391 ) 

392 mcp_route, mcp_session_lifespan = build_mcp_mount() 

393 return Litestar( 

394 lifespan=[_lifespan, mcp_session_lifespan], 

395 logging_config=None, 

396 middleware=[DefineMiddleware(AuthMiddleware), _LibraryOpenWait(), _ConfigFileReload()], 

397 exception_handlers={ 

398 HTTPException: _json_error_response, 

399 ValidationException: _validation_error_response, 

400 Exception: _unhandled_error_response, 

401 }, 

402 route_handlers=[ 

403 mcp_route, 

404 health_route, 

405 warm_stream_route, 

406 status_route, 

407 shutdown_route, 

408 config_route, 

409 config_defaults_route, 

410 config_reset_route, 

411 config_schema_route, 

412 config_sources_route, 

413 config_update_route, 

414 source_content_route, 

415 search_route, 

416 ask_route, 

417 ask_stream_route, 

418 chat_route, 

419 chat_stream_route, 

420 completions_router, 

421 anthropic_router, 

422 sync_route, 

423 add_route, 

424 add_upload_route, 

425 models_list_route, 

426 models_external_route, 

427 models_set_chat_route, 

428 models_set_embedding_route, 

429 models_set_vision_route, 

430 models_set_reranker_route, 

431 models_catalog_route, 

432 models_installed_route, 

433 models_pull_route, 

434 models_show_route, 

435 models_delete_route, 

436 documents_list_route, 

437 documents_remove_route, 

438 memories_list_route, 

439 memories_remember_route, 

440 memories_update_route, 

441 memories_remove_route, 

442 sessions_list_route, 

443 session_get_route, 

444 session_markdown_route, 

445 session_create_route, 

446 session_add_message_route, 

447 session_fork_route, 

448 session_claim_route, 

449 session_set_summary_route, 

450 session_rename_route, 

451 session_delete_route, 

452 export_route, 

453 import_route, 

454 profiles_router, 

455 analyze_router, 

456 placement_route, 

457 placement_preview_route, 

458 placement_set_route, 

459 placement_clear_route, 

460 gpus_route, 

461 gpu_stats_stream_route, 

462 crawl_route, 

463 agent_config_index_route, 

464 agent_config_route, 

465 setup_crawler_route, 

466 setup_crawler_status_route, 

467 wiki_list_route, 

468 wiki_read_route, 

469 wiki_drafts_route, 

470 wiki_draft_diff_route, 

471 wiki_draft_accept_route, 

472 wiki_draft_reject_route, 

473 wiki_citations_reverse_route, 

474 wiki_lint_route, 

475 wiki_prune_route, 

476 wiki_index_route, 

477 wiki_stubs_route, 

478 wiki_generate_route, 

479 wiki_wipe_route, 

480 wiki_build_route, 

481 wiki_update_route, 

482 wiki_status_route, 

483 wiki_synthesize_route, 

484 ], 

485 request_max_body_size=10 * 1024 * 1024, 

486 cors_config=cors, 

487 openapi_config=OpenAPIConfig( 

488 title="lilbee", 

489 description="Local knowledge base REST API", 

490 version=get_version(), 

491 path="/schema", 

492 ), 

493 )