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

98 statements  

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

1"""HTTP route for the Anthropic-compatible ``/v1/messages``.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7import uuid 

8from collections.abc import AsyncGenerator 

9 

10from litestar import Request, Router, post 

11from litestar.background_tasks import BackgroundTask 

12from litestar.exceptions import NotAuthorizedException, ValidationException 

13from litestar.response import Response, Stream 

14 

15from lilbee.app.services import get_services 

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

17from lilbee.server.anthropic_api.models import ( 

18 AnthropicEventType, 

19 MessagesRequest, 

20 MessagesResponse, 

21) 

22from lilbee.server.anthropic_api.streaming import encode_anthropic_event, encode_anthropic_sse 

23from lilbee.server.anthropic_api.translate import ( 

24 canonical_stream_to_anthropic_events, 

25 canonical_to_messages_response, 

26 messages_to_canonical_request, 

27) 

28from lilbee.server.auth import auth_checked_in_handler, session_manager 

29from lilbee.server.chat_completions_api.errors import ( 

30 CompletionsErrorCode, 

31 classify_provider_error, 

32) 

33from lilbee.server.chat_dispatch.canonical import CanonicalChatRequest 

34from lilbee.server.chat_dispatch.concurrency import ( 

35 ChatBusyError, 

36 ChatSlotGuard, 

37 acquire_chat_slot_or_busy, 

38) 

39from lilbee.server.chat_dispatch.dispatch import ( 

40 dispatch_chat, 

41 dispatch_chat_stream, 

42 preflight_chat_request, 

43) 

44from lilbee.server.handlers.sse import SSE_MEDIA_TYPE 

45from lilbee.server.validation_format import format_validation 

46 

47log = logging.getLogger(__name__) 

48 

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

50 

51 

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

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

54 

55 Same rationale as the completions surface: the auth answer must ride this 

56 surface's own error envelope, and it must win over body validation. 

57 """ 

58 return _auth_failure(request) 

59 

60 

61@post("/v1/messages", status_code=200, before_request=_auth_before_request) 

62@auth_checked_in_handler 

63async def messages_endpoint(request: Request, data: MessagesRequest) -> Response | Stream: 

64 """``/v1/messages`` (stream + non-stream + tools), Anthropic wire format. 

65 

66 ``stream: true`` switches the 200 response from JSON to the Anthropic SSE 

67 event stream; the request body picks the arm, matching Anthropic's own 

68 contract. 

69 """ 

70 try: 

71 req = messages_to_canonical_request(data) 

72 except ValueError as exc: 

73 # Wire-valid but untranslatable (image content, bare tool choice). 

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

75 

76 preflight = await _preflight_resolved_model(req) 

77 if isinstance(preflight, Response): 

78 return preflight 

79 resolved_model = preflight 

80 

81 try: 

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

83 except ChatBusyError: 

84 return _error_response( 

85 429, 

86 CompletionsErrorCode.RATE_LIMIT_EXCEEDED, 

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

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

89 ) 

90 

91 guard = ChatSlotGuard() 

92 if req.stream: 

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

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

95 return Stream( 

96 _gated_messages_stream(req, guard, model=resolved_model), 

97 media_type=SSE_MEDIA_TYPE, 

98 background=BackgroundTask(guard.release), 

99 ) 

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

101 

102 

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

104 """Validate *req* before any streaming response starts (see completions).""" 

105 try: 

106 return await asyncio.to_thread(preflight_chat_request, req) 

107 except Exception as exc: 

108 classified = classify_provider_error(exc) 

109 if classified is None: 

110 return _internal_error_response() 

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

112 

113 

114async def _run_non_stream( 

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

116) -> Response: 

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

118 try: 

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

120 except Exception as exc: 

121 classified = classify_provider_error(exc) 

122 if classified is None: 

123 return _internal_error_response() 

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

125 finally: 

126 await guard.release() 

127 body: MessagesResponse = canonical_to_messages_response(resp, response_id=_response_id()) 

128 return Response(body.model_dump(), media_type="application/json") 

129 

130 

131async def _gated_messages_stream( 

132 req: CanonicalChatRequest, 

133 guard: ChatSlotGuard, 

134 *, 

135 model: str, 

136) -> AsyncGenerator[bytes, None]: 

137 """Drive dispatch -> translate -> SSE-encode, freeing the slot on exit. 

138 

139 A mid-stream failure surfaces as Anthropic's ``event: error`` frame; the 

140 headers are already flushed at 200 by then, so the frame is the only 

141 channel left. 

142 """ 

143 response_id = _response_id() 

144 try: 

145 try: 

146 events = dispatch_chat_stream(req, canonical_model=model) 

147 pairs = canonical_stream_to_anthropic_events( 

148 events, model=model, response_id=response_id 

149 ) 

150 async for frame in encode_anthropic_sse(pairs): 

151 yield frame 

152 except Exception as exc: 

153 classified = classify_provider_error(exc) 

154 if classified is None: 

155 log.exception("anthropic messages stream failed") 

156 body = anthropic_error_body("api_error", _INTERNAL_ERROR_MESSAGE) 

157 else: 

158 body = anthropic_error_body( 

159 anthropic_error_type(classified.code), classified.message 

160 ) 

161 yield encode_anthropic_event(AnthropicEventType.ERROR, body) 

162 finally: 

163 await guard.release() 

164 

165 

166def _internal_error_response() -> Response: 

167 """Log and return the generic api_error 500 envelope.""" 

168 log.exception("messages_endpoint failed") 

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

170 

171 

172def _error_response( 

173 status: int, 

174 code: CompletionsErrorCode, 

175 message: str, 

176 *, 

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

178) -> Response: 

179 return Response( 

180 anthropic_error_body(anthropic_error_type(code), message), 

181 status_code=status, 

182 headers=headers or {}, 

183 media_type="application/json", 

184 ) 

185 

186 

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

188 """Return a 401 envelope if the bearer token is missing/wrong, else None. 

189 

190 Claude Code sends ``ANTHROPIC_AUTH_TOKEN`` as a bearer Authorization 

191 header, which is exactly the session token check every /v1 route runs. 

192 """ 

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

194 try: 

195 authorized = session_manager.validate(auth_header) 

196 except NotAuthorizedException: 

197 authorized = False 

198 if authorized: 

199 return None 

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

201 

202 

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

204 """Wrap Litestar's body-parse failures in the Anthropic error envelope.""" 

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

206 

207 

208def _response_id() -> str: 

209 """Anthropic-style ``msg_*`` id.""" 

210 return f"msg_{uuid.uuid4().hex[:24]}" 

211 

212 

213anthropic_router = Router( 

214 path="/", 

215 route_handlers=[messages_endpoint], 

216 exception_handlers={ValidationException: _validation_exception_handler}, 

217)