Coverage for src/lilbee/server/chat_completions_api/routes.py: 100%

146 statements  

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

1"""HTTP routes for ``/v1/models`` and ``/v1/chat/completions``.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import json 

7import logging 

8import time 

9import uuid 

10from collections.abc import AsyncGenerator 

11from datetime import datetime 

12 

13from litestar import Request, Router, get, post 

14from litestar.background_tasks import BackgroundTask 

15from litestar.exceptions import NotAuthorizedException, ValidationException 

16from litestar.response import Response, Stream 

17 

18from lilbee.app.services import get_services 

19from lilbee.catalog.types import ModelTask 

20from lilbee.core.config import cfg 

21from lilbee.core.config.enums import ReasoningMode 

22from lilbee.providers.model_ref import default_first, with_configured_remote_chat 

23from lilbee.retrieval.reasoning import effective_reasoning_cap 

24from lilbee.server.auth import auth_checked_in_handler, session_manager 

25from lilbee.server.chat_completions_api.errors import ( 

26 CompletionsErrorCode, 

27 classify_provider_error, 

28 completions_error_body, 

29) 

30from lilbee.server.chat_completions_api.models import ( 

31 CompletionsRequest, 

32 CompletionsResponse, 

33 ModelEntry, 

34 ModelsListResponse, 

35) 

36from lilbee.server.chat_completions_api.streaming import encode_completions_sse 

37from lilbee.server.chat_completions_api.translate import ( 

38 canonical_stream_to_completions_chunks, 

39 canonical_to_completions_response, 

40 completions_to_canonical_request, 

41) 

42from lilbee.server.chat_dispatch.canonical import CanonicalChatRequest 

43from lilbee.server.chat_dispatch.concurrency import ( 

44 ChatBusyError, 

45 ChatSlotGuard, 

46 acquire_chat_slot_or_busy, 

47) 

48from lilbee.server.chat_dispatch.dispatch import ( 

49 dispatch_chat_stream, 

50 preflight_chat_request, 

51) 

52from lilbee.server.chat_dispatch.reasoning_cap import cap_aware_chat, cap_aware_chat_stream 

53from lilbee.server.handlers.sse import SSE_MEDIA_TYPE 

54from lilbee.server.validation_format import format_validation 

55 

56log = logging.getLogger(__name__) 

57 

58_MULTI_CHOICE_MESSAGE = "lilbee serves one choice per request; set n to 1 or omit it." 

59 

60 

61@get("/v1/models") 

62@auth_checked_in_handler 

63async def list_models_endpoint(request: Request) -> Response: 

64 """Return installed chat models in the ``/v1/models`` shape, the configured one leading.""" 

65 auth_error = _auth_failure(request) 

66 if auth_error is not None: 

67 return auth_error 

68 

69 # list_installed walks the model filesystem and served_chat_ctx may probe the 

70 # engine; run both off the event loop like the sibling chat-completions route. 

71 payload = await asyncio.to_thread(_build_models_list_payload) 

72 # exclude_none like the sibling completions responses: context_window is 

73 # deliberately None for every model but the active one, and emitting it as 

74 # null adds a field OpenAI clients do not expect on a model entry. 

75 return Response(payload.model_dump(exclude_none=True), media_type="application/json") 

76 

77 

78def _build_models_list_payload() -> ModelsListResponse: 

79 """Synchronous /v1/models body: blocking registry walk + engine ctx probe.""" 

80 services = get_services() 

81 # The served shape applies to the active chat model; advertise it so a 

82 # client trims history to fit and an agent harness reads the real 

83 # concurrency instead of assuming the configured target. 

84 served_ctx = services.provider.served_chat_ctx() 

85 served_slots = services.provider.served_chat_slots() 

86 installed = {m.ref: m for m in services.registry.list_installed() if m.task == ModelTask.CHAT} 

87 # A remote-configured chat model has no registry entry but is still listed, 

88 # configured model first (the launcher's picker order). 

89 listed = with_configured_remote_chat(sorted(installed), cfg.chat_model) 

90 refs = default_first(listed, cfg.chat_model) 

91 # A ref without a registry entry carries the newest native timestamp so a 

92 # client sorting by created desc does not bury the model lilbee serves. 

93 fallback_created = max((_parse_created(m.downloaded_at) for m in installed.values()), default=0) 

94 return ModelsListResponse( 

95 data=[ 

96 ModelEntry( 

97 id=ref, 

98 created=_parse_created(installed[ref].downloaded_at) 

99 if ref in installed 

100 else fallback_created, 

101 context_window=served_ctx if ref == cfg.chat_model else None, 

102 slots=served_slots if ref == cfg.chat_model else None, 

103 ) 

104 for ref in refs 

105 ] 

106 ) 

107 

108 

109async def _auth_before_request(request: Request) -> Response | None: 

110 """Reject an unauthenticated caller before Litestar parses the body. 

111 

112 The endpoint is marked @auth_checked_in_handler so AuthMiddleware defers to 

113 the handler, which answers a bad token with the OpenAI error envelope 

114 rather than Litestar's 401 shape. But binding the body as a handler 

115 parameter made Litestar parse and pydantic-validate the whole payload 

116 first, so an unauthenticated caller got request_max_body_size worth of 

117 JSON processed on every request, and a malformed body was answered with a 

118 400 naming the failing fields instead of ever reaching the 401. Litestar 

119 runs before_request ahead of kwargs resolution and short-circuits on a 

120 returned value, so this is the one place the check can sit. 

121 """ 

122 return _auth_failure(request) 

123 

124 

125@post("/v1/chat/completions", status_code=200, before_request=_auth_before_request) 

126@auth_checked_in_handler 

127async def chat_completions_endpoint( 

128 request: Request, data: CompletionsRequest 

129) -> Response | Stream: 

130 """``/v1/chat/completions`` (stream + non-stream + tools). 

131 

132 ``stream: true`` switches the 200 response from JSON to an SSE stream of 

133 chat.completion.chunk frames. The request body picks the arm, which OpenAPI 

134 cannot express, so the schema declares the JSON default and this contract 

135 matches OpenAI's own. 

136 """ 

137 rejection = _reject_before_dispatch(request, data) 

138 if rejection is not None: 

139 return rejection 

140 

141 # The request field wins over the completions_reasoning setting, so a 

142 # client that can send extra body fields picks its mode per call. 

143 mode = data.reasoning if data.reasoning is not None else cfg.completions_reasoning 

144 try: 

145 req = completions_to_canonical_request(data, mode=mode) 

146 except ValueError as exc: 

147 # Request shape is wire-valid but carries something we can't translate 

148 # (e.g. image content). Surface as 400 instead of a generic 500. 

149 return _error_response(400, CompletionsErrorCode.INVALID_REQUEST, str(exc)) 

150 

151 preflight = await _preflight_resolved_model(req) 

152 if isinstance(preflight, Response): 

153 return preflight 

154 resolved_model = preflight 

155 

156 try: 

157 await acquire_chat_slot_or_busy(get_services().provider.max_concurrent_chats()) 

158 except ChatBusyError: 

159 return _error_response( 

160 429, 

161 CompletionsErrorCode.RATE_LIMIT_EXCEEDED, 

162 "Backend is busy. Retry in a moment.", 

163 headers={"Retry-After": "1"}, 

164 ) 

165 

166 guard = ChatSlotGuard() 

167 if req.stream: 

168 include_usage = bool(data.stream_options and data.stream_options.include_usage) 

169 # The after-send hook frees the slot when a disconnect lands before the 

170 # generator's first iteration (its finally never runs in that case). 

171 return Stream( 

172 _gated_completions_stream( 

173 req, guard, model=resolved_model, include_usage=include_usage, mode=mode 

174 ), 

175 media_type=SSE_MEDIA_TYPE, 

176 background=BackgroundTask(guard.release), 

177 ) 

178 return await _run_non_stream(req, guard, canonical_model=resolved_model, mode=mode) 

179 

180 

181def _reject_before_dispatch(request: Request, data: CompletionsRequest) -> Response | None: 

182 """Multi-choice and unsupported-param checks before any dispatch work. 

183 

184 Returns a 4xx Response to short-circuit, or None to proceed. Unmapped OpenAI 

185 params (``response_format``, ``logprobs``, and any other unknown field) are 

186 accepted but logged at debug so a client learns they had no effect. Auth is 

187 not checked here: it runs in the before_request hook, ahead of body parsing. 

188 """ 

189 if data.n is not None and data.n > 1: 

190 return _error_response(400, CompletionsErrorCode.INVALID_REQUEST, _MULTI_CHOICE_MESSAGE) 

191 if data.model_extra: 

192 log.debug("chat/completions ignoring unsupported params: %s", sorted(data.model_extra)) 

193 return None 

194 

195 

196async def _preflight_resolved_model(req: CanonicalChatRequest) -> str | Response: 

197 """Validate *req* before any streaming response starts, returning the resolved model. 

198 

199 A 4xx body here is reachable by any OpenAI-compatible client; once a 

200 Stream is returned the headers are flushed at 200 and downstream errors 

201 can only travel via SSE frames which not every client surfaces cleanly. 

202 Runs the preflight in a thread: a lapsed model-discovery TTL makes it do 

203 blocking HTTP probes that must not stall the event loop. Returns the 

204 canonical resolved model string when *req* is fit to dispatch, so the 

205 streaming response echoes the same model the non-streaming path does. 

206 """ 

207 try: 

208 return await asyncio.to_thread(preflight_chat_request, req) 

209 except Exception as exc: 

210 classified = classify_provider_error(exc) 

211 if classified is None: 

212 # Mirror _run_non_stream: an unclassified failure still rides the 

213 # OpenAI error envelope, not a bare framework 500. 

214 return _internal_error_response() 

215 return _error_response(classified.http_status, classified.code, classified.message) 

216 

217 

218_INTERNAL_ERROR_MESSAGE = "Internal server error. Check the server logs for details." 

219 

220 

221def _internal_error_response() -> Response: 

222 """Log and return the generic internal_error 500 envelope.""" 

223 log.exception("chat_completions_endpoint failed") 

224 return _error_response(500, CompletionsErrorCode.INTERNAL_ERROR, _INTERNAL_ERROR_MESSAGE) 

225 

226 

227async def _run_non_stream( 

228 req: CanonicalChatRequest, 

229 guard: ChatSlotGuard, 

230 *, 

231 canonical_model: str, 

232 mode: ReasoningMode = ReasoningMode.SEPARATE, 

233) -> Response: 

234 """Dispatch a non-streaming chat call, translating errors to the wire envelope.""" 

235 try: 

236 # cap_aware_chat blocks for the whole generation; run it off the event loop 

237 # so a slow chat does not stall other admitted requests. The preflight 

238 # already resolved the model, so hand it in to avoid re-running it. 

239 resp = await asyncio.to_thread( 

240 cap_aware_chat, 

241 req, 

242 canonical_model=canonical_model, 

243 cap_chars=effective_reasoning_cap(), 

244 ) 

245 except Exception as exc: 

246 classified = classify_provider_error(exc) 

247 if classified is None: 

248 return _internal_error_response() 

249 return _error_response(classified.http_status, classified.code, classified.message) 

250 finally: 

251 await guard.release() 

252 body: CompletionsResponse = canonical_to_completions_response( 

253 resp, response_id=_response_id(), mode=mode 

254 ) 

255 return Response(body.model_dump(exclude_none=True), media_type="application/json") 

256 

257 

258async def _gated_completions_stream( 

259 req: CanonicalChatRequest, 

260 guard: ChatSlotGuard, 

261 *, 

262 model: str, 

263 include_usage: bool = False, 

264 mode: ReasoningMode = ReasoningMode.SEPARATE, 

265) -> AsyncGenerator[bytes, None]: 

266 """Drive ``cap_aware_chat_stream`` -> translate -> SSE-encode, freeing the slot on exit. 

267 

268 Pre-flight errors (unknown model, tools-against-non-tool-model) are 

269 surfaced as a single SSE ``data:`` frame carrying the error 

270 envelope, then ``[DONE]``. The chat slot is released in ``finally`` so 

271 natural completion, exception, and client disconnect (GeneratorExit) 

272 all unwind cleanly; a disconnect before the first iteration is covered 

273 by the route's after-send release of the same guard. 

274 """ 

275 # One id for the whole stream, error frame included: the frame is shaped as a 

276 # real chunk so SDK clients parse it, and those accumulate by chunk id, so a 

277 # fresh id made the error look like part of a different completion. 

278 response_id = _response_id() 

279 try: 

280 try: 

281 events = cap_aware_chat_stream( 

282 dispatch_chat_stream(req, canonical_model=model), 

283 req, 

284 canonical_model=model, 

285 cap_chars=effective_reasoning_cap(), 

286 ) 

287 chunks = canonical_stream_to_completions_chunks( 

288 events, 

289 model=model, 

290 response_id=response_id, 

291 include_usage=include_usage, 

292 mode=mode, 

293 ) 

294 async for frame in encode_completions_sse(chunks): 

295 yield frame 

296 except Exception as exc: 

297 classified = classify_provider_error(exc) 

298 if classified is None: 

299 log.exception("chat_completions stream failed") 

300 yield _sse_error_frame( 

301 CompletionsErrorCode.INTERNAL_ERROR, 

302 _INTERNAL_ERROR_MESSAGE, 

303 model=model, 

304 response_id=response_id, 

305 ) 

306 else: 

307 yield _sse_error_frame( 

308 classified.code, classified.message, model=model, response_id=response_id 

309 ) 

310 finally: 

311 await guard.release() 

312 

313 

314def _sse_error_frame( 

315 code: CompletionsErrorCode, 

316 message: str, 

317 *, 

318 model: str = "", 

319 response_id: str | None = None, 

320) -> bytes: 

321 """SSE frame carrying a mid-stream error in OpenAI's chunk-shaped wire format. 

322 

323 OpenAI-SDK clients only parse ``chat.completion.chunk``-shaped frames, so the 

324 error rides a real chunk (empty delta, inline ``error`` field) followed by 

325 ``[DONE]`` rather than a bare error frame. ``finish_reason`` stays null, as 

326 in OpenAI's non-final chunks: a concrete reason like ``"length"`` would tell 

327 clients the answer was merely truncated, and some auto-continue on it. 

328 """ 

329 body = completions_error_body(code, message) 

330 chunk: dict[str, object] = { 

331 "id": response_id or _response_id(), 

332 "object": "chat.completion.chunk", 

333 "created": int(time.time()), 

334 "model": model, 

335 "choices": [{"index": 0, "delta": {}, "finish_reason": None}], 

336 "error": body["error"], 

337 } 

338 payload = json.dumps(chunk, separators=(",", ":")) 

339 return f"data: {payload}\n\ndata: [DONE]\n\n".encode() 

340 

341 

342def _error_response( 

343 status: int, 

344 code: CompletionsErrorCode, 

345 message: str, 

346 *, 

347 headers: dict[str, str] | None = None, 

348) -> Response: 

349 return Response( 

350 completions_error_body(code, message), 

351 status_code=status, 

352 headers=headers or {}, 

353 media_type="application/json", 

354 ) 

355 

356 

357def _auth_failure(request: Request) -> Response | None: 

358 """Return a 401 error response if the bearer token is missing/wrong, else None. 

359 

360 validate() fails closed by raising when auth is uninitialized; surface that 

361 as the same OpenAI 401 envelope rather than letting it escape as a 500. 

362 """ 

363 auth_header = request.headers.get("authorization", "") 

364 try: 

365 authorized = session_manager.validate(auth_header) 

366 except NotAuthorizedException: 

367 authorized = False 

368 if authorized: 

369 return None 

370 return _error_response(401, CompletionsErrorCode.INVALID_API_KEY, "Missing or invalid API key.") 

371 

372 

373def _validation_exception_handler(_: Request, exc: ValidationException) -> Response: 

374 """Wrap Litestar's body-parse failures in the OpenAI error envelope.""" 

375 return _error_response(400, CompletionsErrorCode.INVALID_REQUEST, format_validation(exc)) 

376 

377 

378def _parse_created(downloaded_at: str | None) -> int: 

379 """Best-effort ISO-8601 to Unix-timestamp conversion; zero on failure.""" 

380 if not downloaded_at: 

381 return 0 

382 try: 

383 return int(datetime.fromisoformat(downloaded_at).timestamp()) 

384 except ValueError: 

385 return 0 

386 

387 

388def _response_id() -> str: 

389 """OpenAI-style ``chatcmpl-*`` id.""" 

390 return f"chatcmpl-{uuid.uuid4().hex[:24]}" 

391 

392 

393completions_router = Router( 

394 path="/", 

395 route_handlers=[list_models_endpoint, chat_completions_endpoint], 

396 exception_handlers={ValidationException: _validation_exception_handler}, 

397)