Coverage for src/lilbee/server/anthropic_api/routes.py: 100%
108 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 21:55 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 21:55 +0000
1"""HTTP route for the Anthropic-compatible ``/v1/messages``."""
3from __future__ import annotations
5import asyncio
6import logging
7import uuid
8from collections.abc import AsyncGenerator
10from litestar import Request, Router, post
11from litestar.background_tasks import BackgroundTask
12from litestar.exceptions import NotAuthorizedException, ValidationException
13from litestar.response import Response, Stream
15from lilbee.app.services import get_services
16from lilbee.core.config import cfg
17from lilbee.core.config.enums import ReasoningMode
18from lilbee.retrieval.reasoning import effective_reasoning_cap
19from lilbee.server.anthropic_api.errors import anthropic_error_body, anthropic_error_type
20from lilbee.server.anthropic_api.models import (
21 _THINKING_DISABLED,
22 AnthropicEventType,
23 MessagesRequest,
24 MessagesResponse,
25)
26from lilbee.server.anthropic_api.streaming import encode_anthropic_event, encode_anthropic_sse
27from lilbee.server.anthropic_api.translate import (
28 canonical_stream_to_anthropic_events,
29 canonical_to_messages_response,
30 messages_to_canonical_request,
31 resolve_reasoning_mode,
32)
33from lilbee.server.auth import auth_checked_in_handler, session_manager
34from lilbee.server.chat_completions_api.errors import (
35 CompletionsErrorCode,
36 classify_provider_error,
37)
38from lilbee.server.chat_dispatch.canonical import CanonicalChatRequest
39from lilbee.server.chat_dispatch.concurrency import (
40 ChatBusyError,
41 ChatSlotGuard,
42 acquire_chat_slot_or_busy,
43)
44from lilbee.server.chat_dispatch.dispatch import (
45 dispatch_chat_stream,
46 preflight_chat_request,
47)
48from lilbee.server.chat_dispatch.reasoning_cap import (
49 budget_capped_chars,
50 cap_aware_chat,
51 cap_aware_chat_stream,
52)
53from lilbee.server.handlers.sse import SSE_MEDIA_TYPE
54from lilbee.server.validation_format import format_validation
56log = logging.getLogger(__name__)
58_INTERNAL_ERROR_MESSAGE = "Internal server error. Check the server logs for details."
61async def _auth_before_request(request: Request) -> Response | None:
62 """Reject an unauthenticated caller before Litestar parses the body.
64 Same rationale as the completions surface: the auth answer must ride this
65 surface's own error envelope, and it must win over body validation.
66 """
67 return _auth_failure(request)
70@post("/v1/messages", status_code=200, before_request=_auth_before_request)
71@auth_checked_in_handler
72async def messages_endpoint(request: Request, data: MessagesRequest) -> Response | Stream:
73 """``/v1/messages`` (stream + non-stream + tools), Anthropic wire format.
75 ``stream: true`` switches the 200 response from JSON to the Anthropic SSE
76 event stream; the request body picks the arm, matching Anthropic's own
77 contract.
78 """
79 # Thinking is opt-in per request; the setting only presents it.
80 mode = resolve_reasoning_mode(data.thinking, default=cfg.messages_reasoning)
81 cap_chars = budget_capped_chars(effective_reasoning_cap(), _budget_tokens(data))
82 try:
83 req = messages_to_canonical_request(data, mode=mode)
84 except ValueError as exc:
85 # Wire-valid but untranslatable (image content, bare tool choice).
86 return _error_response(400, CompletionsErrorCode.INVALID_REQUEST, str(exc))
88 preflight = await _preflight_resolved_model(req)
89 if isinstance(preflight, Response):
90 return preflight
91 resolved_model = preflight
93 try:
94 await acquire_chat_slot_or_busy(get_services().provider.max_concurrent_chats())
95 except ChatBusyError:
96 return _error_response(
97 429,
98 CompletionsErrorCode.RATE_LIMIT_EXCEEDED,
99 "Backend is busy. Retry in a moment.",
100 headers={"Retry-After": "1"},
101 )
103 guard = ChatSlotGuard()
104 if req.stream:
105 # The after-send hook frees the slot when a disconnect lands before the
106 # generator's first iteration (its finally never runs in that case).
107 return Stream(
108 _gated_messages_stream(
109 req, guard, model=resolved_model, mode=mode, cap_chars=cap_chars
110 ),
111 media_type=SSE_MEDIA_TYPE,
112 background=BackgroundTask(guard.release),
113 )
114 return await _run_non_stream(
115 req, guard, canonical_model=resolved_model, mode=mode, cap_chars=cap_chars
116 )
119def _budget_tokens(data: MessagesRequest) -> int | None:
120 """The thinking budget this request asks for; ``disabled`` carries none."""
121 if data.thinking is None or data.thinking.type == _THINKING_DISABLED:
122 return None
123 return data.thinking.budget_tokens
126async def _preflight_resolved_model(req: CanonicalChatRequest) -> str | Response:
127 """Validate *req* before any streaming response starts (see completions)."""
128 try:
129 return await asyncio.to_thread(preflight_chat_request, req)
130 except Exception as exc:
131 classified = classify_provider_error(exc)
132 if classified is None:
133 return _internal_error_response()
134 return _error_response(classified.http_status, classified.code, classified.message)
137async def _run_non_stream(
138 req: CanonicalChatRequest,
139 guard: ChatSlotGuard,
140 *,
141 canonical_model: str,
142 mode: ReasoningMode = ReasoningMode.SEPARATE,
143 cap_chars: int = 0,
144) -> Response:
145 """Dispatch a non-streaming chat call, translating errors to the envelope."""
146 try:
147 resp = await asyncio.to_thread(
148 cap_aware_chat, req, canonical_model=canonical_model, cap_chars=cap_chars
149 )
150 except Exception as exc:
151 classified = classify_provider_error(exc)
152 if classified is None:
153 return _internal_error_response()
154 return _error_response(classified.http_status, classified.code, classified.message)
155 finally:
156 await guard.release()
157 body: MessagesResponse = canonical_to_messages_response(
158 resp, response_id=_response_id(), mode=mode
159 )
160 return Response(body.model_dump(), media_type="application/json")
163async def _gated_messages_stream(
164 req: CanonicalChatRequest,
165 guard: ChatSlotGuard,
166 *,
167 model: str,
168 mode: ReasoningMode = ReasoningMode.SEPARATE,
169 cap_chars: int = 0,
170) -> AsyncGenerator[bytes, None]:
171 """Drive dispatch -> translate -> SSE-encode, freeing the slot on exit.
173 A mid-stream failure surfaces as Anthropic's ``event: error`` frame; the
174 headers are already flushed at 200 by then, so the frame is the only
175 channel left.
176 """
177 response_id = _response_id()
178 try:
179 try:
180 events = cap_aware_chat_stream(
181 dispatch_chat_stream(req, canonical_model=model),
182 req,
183 canonical_model=model,
184 cap_chars=cap_chars,
185 )
186 pairs = canonical_stream_to_anthropic_events(
187 events, model=model, response_id=response_id, mode=mode
188 )
189 async for frame in encode_anthropic_sse(pairs):
190 yield frame
191 except Exception as exc:
192 classified = classify_provider_error(exc)
193 if classified is None:
194 log.exception("anthropic messages stream failed")
195 body = anthropic_error_body("api_error", _INTERNAL_ERROR_MESSAGE)
196 else:
197 body = anthropic_error_body(
198 anthropic_error_type(classified.code), classified.message
199 )
200 yield encode_anthropic_event(AnthropicEventType.ERROR, body)
201 finally:
202 await guard.release()
205def _internal_error_response() -> Response:
206 """Log and return the generic api_error 500 envelope."""
207 log.exception("messages_endpoint failed")
208 return _error_response(500, CompletionsErrorCode.INTERNAL_ERROR, _INTERNAL_ERROR_MESSAGE)
211def _error_response(
212 status: int,
213 code: CompletionsErrorCode,
214 message: str,
215 *,
216 headers: dict[str, str] | None = None,
217) -> Response:
218 return Response(
219 anthropic_error_body(anthropic_error_type(code), message),
220 status_code=status,
221 headers=headers or {},
222 media_type="application/json",
223 )
226def _auth_failure(request: Request) -> Response | None:
227 """Return a 401 envelope if the bearer token is missing/wrong, else None.
229 Claude Code sends ``ANTHROPIC_AUTH_TOKEN`` as a bearer Authorization
230 header, which is exactly the session token check every /v1 route runs.
231 """
232 auth_header = request.headers.get("authorization", "")
233 try:
234 authorized = session_manager.validate(auth_header)
235 except NotAuthorizedException:
236 authorized = False
237 if authorized:
238 return None
239 return _error_response(401, CompletionsErrorCode.INVALID_API_KEY, "Missing or invalid API key.")
242def _validation_exception_handler(_: Request, exc: ValidationException) -> Response:
243 """Wrap Litestar's body-parse failures in the Anthropic error envelope."""
244 return _error_response(400, CompletionsErrorCode.INVALID_REQUEST, format_validation(exc))
247def _response_id() -> str:
248 """Anthropic-style ``msg_*`` id."""
249 return f"msg_{uuid.uuid4().hex[:24]}"
252anthropic_router = Router(
253 path="/",
254 route_handlers=[messages_endpoint],
255 exception_handlers={ValidationException: _validation_exception_handler},
256)