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

141 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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.providers.model_ref import default_first, with_configured_remote_chat 

22from lilbee.server.auth import auth_checked_in_handler, session_manager 

23from lilbee.server.chat_completions_api.errors import ( 

24 CompletionsErrorCode, 

25 classify_provider_error, 

26 completions_error_body, 

27) 

28from lilbee.server.chat_completions_api.models import ( 

29 CompletionsRequest, 

30 CompletionsResponse, 

31 ModelEntry, 

32 ModelsListResponse, 

33) 

34from lilbee.server.chat_completions_api.streaming import encode_completions_sse 

35from lilbee.server.chat_completions_api.translate import ( 

36 canonical_stream_to_completions_chunks, 

37 canonical_to_completions_response, 

38 completions_to_canonical_request, 

39) 

40from lilbee.server.chat_dispatch.canonical import CanonicalChatRequest 

41from lilbee.server.chat_dispatch.concurrency import ( 

42 ChatBusyError, 

43 ChatSlotGuard, 

44 acquire_chat_slot_or_busy, 

45) 

46from lilbee.server.chat_dispatch.dispatch import ( 

47 dispatch_chat, 

48 dispatch_chat_stream, 

49 preflight_chat_request, 

50) 

51from lilbee.server.handlers.sse import SSE_MEDIA_TYPE 

52from lilbee.server.validation_format import format_validation 

53 

54log = logging.getLogger(__name__) 

55 

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

57 

58 

59@get("/v1/models") 

60@auth_checked_in_handler 

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

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

63 auth_error = _auth_failure(request) 

64 if auth_error is not None: 

65 return auth_error 

66 

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

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

69 payload = await asyncio.to_thread(_build_models_list_payload) 

70 # exclude_none like the sibling completions responses: context_window is 

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

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

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

74 

75 

76def _build_models_list_payload() -> ModelsListResponse: 

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

78 services = get_services() 

79 # The served window applies to the active chat model; advertise it so a 

80 # client trims history to fit instead of overflowing on a long session. 

81 served_ctx = services.provider.served_chat_ctx() 

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

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

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

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

86 refs = default_first(listed, cfg.chat_model) 

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

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

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

90 return ModelsListResponse( 

91 data=[ 

92 ModelEntry( 

93 id=ref, 

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

95 if ref in installed 

96 else fallback_created, 

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

98 ) 

99 for ref in refs 

100 ] 

101 ) 

102 

103 

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

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

106 

107 The endpoint is marked @auth_checked_in_handler so AuthMiddleware defers to 

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

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

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

111 first, so an unauthenticated caller got request_max_body_size worth of 

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

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

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

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

116 """ 

117 return _auth_failure(request) 

118 

119 

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

121@auth_checked_in_handler 

122async def chat_completions_endpoint( 

123 request: Request, data: CompletionsRequest 

124) -> Response | Stream: 

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

126 

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

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

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

130 matches OpenAI's own. 

131 """ 

132 rejection = _reject_before_dispatch(request, data) 

133 if rejection is not None: 

134 return rejection 

135 

136 try: 

137 req = completions_to_canonical_request(data) 

138 except ValueError as exc: 

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

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

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

142 

143 preflight = await _preflight_resolved_model(req) 

144 if isinstance(preflight, Response): 

145 return preflight 

146 resolved_model = preflight 

147 

148 try: 

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

150 except ChatBusyError: 

151 return _error_response( 

152 429, 

153 CompletionsErrorCode.RATE_LIMIT_EXCEEDED, 

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

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

156 ) 

157 

158 guard = ChatSlotGuard() 

159 if req.stream: 

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

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

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

163 return Stream( 

164 _gated_completions_stream( 

165 req, guard, model=resolved_model, include_usage=include_usage 

166 ), 

167 media_type=SSE_MEDIA_TYPE, 

168 background=BackgroundTask(guard.release), 

169 ) 

170 return await _run_non_stream(req, guard, canonical_model=resolved_model) 

171 

172 

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

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

175 

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

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

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

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

180 """ 

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

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

183 if data.model_extra: 

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

185 return None 

186 

187 

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

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

190 

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

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

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

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

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

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

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

198 """ 

199 try: 

200 return await asyncio.to_thread(preflight_chat_request, req) 

201 except Exception as exc: 

202 classified = classify_provider_error(exc) 

203 if classified is None: 

204 # Mirror _run_non_stream: an unclassified failure still rides the 

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

206 return _internal_error_response() 

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

208 

209 

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

211 

212 

213def _internal_error_response() -> Response: 

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

215 log.exception("chat_completions_endpoint failed") 

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

217 

218 

219async def _run_non_stream( 

220 req: CanonicalChatRequest, guard: ChatSlotGuard, *, canonical_model: str 

221) -> Response: 

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

223 try: 

224 # dispatch_chat blocks for the whole generation; run it off the event loop 

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

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

227 resp = await asyncio.to_thread(dispatch_chat, req, canonical_model=canonical_model) 

228 except Exception as exc: 

229 classified = classify_provider_error(exc) 

230 if classified is None: 

231 return _internal_error_response() 

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

233 finally: 

234 await guard.release() 

235 body: CompletionsResponse = canonical_to_completions_response(resp, response_id=_response_id()) 

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

237 

238 

239async def _gated_completions_stream( 

240 req: CanonicalChatRequest, 

241 guard: ChatSlotGuard, 

242 *, 

243 model: str, 

244 include_usage: bool = False, 

245) -> AsyncGenerator[bytes, None]: 

246 """Drive ``dispatch_chat_stream`` -> translate -> SSE-encode, freeing the slot on exit. 

247 

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

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

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

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

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

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

254 """ 

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

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

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

258 response_id = _response_id() 

259 try: 

260 try: 

261 events = dispatch_chat_stream(req, canonical_model=model) 

262 chunks = canonical_stream_to_completions_chunks( 

263 events, model=model, response_id=response_id, include_usage=include_usage 

264 ) 

265 async for frame in encode_completions_sse(chunks): 

266 yield frame 

267 except Exception as exc: 

268 classified = classify_provider_error(exc) 

269 if classified is None: 

270 log.exception("chat_completions stream failed") 

271 yield _sse_error_frame( 

272 CompletionsErrorCode.INTERNAL_ERROR, 

273 _INTERNAL_ERROR_MESSAGE, 

274 model=model, 

275 response_id=response_id, 

276 ) 

277 else: 

278 yield _sse_error_frame( 

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

280 ) 

281 finally: 

282 await guard.release() 

283 

284 

285def _sse_error_frame( 

286 code: CompletionsErrorCode, 

287 message: str, 

288 *, 

289 model: str = "", 

290 response_id: str | None = None, 

291) -> bytes: 

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

293 

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

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

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

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

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

299 """ 

300 body = completions_error_body(code, message) 

301 chunk: dict[str, object] = { 

302 "id": response_id or _response_id(), 

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

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

305 "model": model, 

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

307 "error": body["error"], 

308 } 

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

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

311 

312 

313def _error_response( 

314 status: int, 

315 code: CompletionsErrorCode, 

316 message: str, 

317 *, 

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

319) -> Response: 

320 return Response( 

321 completions_error_body(code, message), 

322 status_code=status, 

323 headers=headers or {}, 

324 media_type="application/json", 

325 ) 

326 

327 

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

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

330 

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

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

333 """ 

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

335 try: 

336 authorized = session_manager.validate(auth_header) 

337 except NotAuthorizedException: 

338 authorized = False 

339 if authorized: 

340 return None 

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

342 

343 

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

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

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

347 

348 

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

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

351 if not downloaded_at: 

352 return 0 

353 try: 

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

355 except ValueError: 

356 return 0 

357 

358 

359def _response_id() -> str: 

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

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

362 

363 

364completions_router = Router( 

365 path="/", 

366 route_handlers=[list_models_endpoint, chat_completions_endpoint], 

367 exception_handlers={ValidationException: _validation_exception_handler}, 

368)