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

42 statements  

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

1"""Error envelope and code/type taxonomy for the chat-completions surface.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from typing import Any 

9 

10from lilbee.providers.base import ProviderError, ProviderErrorKind 

11from lilbee.server.chat_dispatch.dispatch import ( 

12 ModelDoesNotSupportToolsError, 

13 ModelNotFoundError, 

14) 

15 

16log = logging.getLogger(__name__) 

17 

18 

19class CompletionsErrorCode(StrEnum): 

20 """Stable error-code vocabulary for the chat-completions surface.""" 

21 

22 INVALID_REQUEST = "invalid_request" 

23 MODEL_NOT_FOUND = "model_not_found" 

24 MODEL_DOES_NOT_SUPPORT_TOOLS = "model_does_not_support_tools" 

25 CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded" 

26 INVALID_API_KEY = "invalid_api_key" 

27 RATE_LIMIT_EXCEEDED = "rate_limit_exceeded" 

28 INTERNAL_ERROR = "internal_error" 

29 

30 

31_FALLBACK_ERROR_TYPE = "invalid_request_error" 

32 

33COMPLETIONS_ERROR_TYPES: dict[CompletionsErrorCode, str] = { 

34 CompletionsErrorCode.INVALID_REQUEST: "invalid_request_error", 

35 CompletionsErrorCode.MODEL_NOT_FOUND: "invalid_request_error", 

36 CompletionsErrorCode.MODEL_DOES_NOT_SUPPORT_TOOLS: "invalid_request_error", 

37 CompletionsErrorCode.CONTEXT_LENGTH_EXCEEDED: "invalid_request_error", 

38 CompletionsErrorCode.INVALID_API_KEY: "authentication_error", 

39 CompletionsErrorCode.RATE_LIMIT_EXCEEDED: "rate_limit_error", 

40 CompletionsErrorCode.INTERNAL_ERROR: "api_error", 

41} 

42 

43 

44def completions_error_body(code: CompletionsErrorCode, message: str) -> dict[str, Any]: 

45 """Build the JSON body for an error response.""" 

46 return { 

47 "error": { 

48 "message": message, 

49 # .get, not a subscript: this map is hand-maintained alongside the 

50 # enum, and a missing entry would turn a handled 4xx into a 500. 

51 "type": COMPLETIONS_ERROR_TYPES.get(code, _FALLBACK_ERROR_TYPE), 

52 "code": str(code), 

53 } 

54 } 

55 

56 

57@dataclass(frozen=True) 

58class ClassifiedError: 

59 """A typed provider/dispatch failure mapped to its client-facing shape.""" 

60 

61 http_status: int 

62 code: CompletionsErrorCode 

63 message: str 

64 

65 

66# UNKNOWN stays unmapped on purpose: an unclassified failure keeps the callers' 

67# generic internal_error fallback instead of guessing a status. 

68_PROVIDER_KIND_CLASSIFICATIONS: dict[ProviderErrorKind, tuple[int, CompletionsErrorCode]] = { 

69 ProviderErrorKind.CONTEXT_OVERFLOW: (400, CompletionsErrorCode.CONTEXT_LENGTH_EXCEEDED), 

70 ProviderErrorKind.NOT_FOUND: (404, CompletionsErrorCode.MODEL_NOT_FOUND), 

71 ProviderErrorKind.BAD_REQUEST: (400, CompletionsErrorCode.INVALID_REQUEST), 

72 ProviderErrorKind.AUTH: (401, CompletionsErrorCode.INVALID_API_KEY), 

73 ProviderErrorKind.RATE_LIMIT: (429, CompletionsErrorCode.RATE_LIMIT_EXCEEDED), 

74 ProviderErrorKind.CONNECTION: (503, CompletionsErrorCode.INTERNAL_ERROR), 

75 ProviderErrorKind.SERVER: (502, CompletionsErrorCode.INTERNAL_ERROR), 

76 ProviderErrorKind.CAPACITY: (503, CompletionsErrorCode.INTERNAL_ERROR), 

77 ProviderErrorKind.PORT_CONFLICT: (503, CompletionsErrorCode.INTERNAL_ERROR), 

78} 

79 

80 

81# Kinds describing the backend, not the caller's request. Their text is built 

82# at the fleet boundary and carries up to 600 bytes of upstream body plus the 

83# dead server's stderr (loopback ports, engine paths), so it is logged rather 

84# than returned. The client-input kinds stay pass-through. 

85_INFRASTRUCTURE_KINDS = frozenset( 

86 { 

87 ProviderErrorKind.CONNECTION, 

88 ProviderErrorKind.SERVER, 

89 ProviderErrorKind.CAPACITY, 

90 ProviderErrorKind.PORT_CONFLICT, 

91 } 

92) 

93 

94_BACKEND_FAILURE_MESSAGE = "The model backend is unavailable. Check the server logs for details." 

95 

96 

97def classify_provider_error(exc: BaseException) -> ClassifiedError | None: 

98 """Map a typed dispatch/provider failure to ``(status, code, message)``, or None. 

99 

100 Returns None for any exception that isn't a recognized typed dispatch error 

101 or a ProviderError with a client-mappable kind; callers apply their own 

102 generic fallback (internal_error 500 / service-unavailable 503). 

103 """ 

104 if isinstance(exc, ModelNotFoundError): 

105 return ClassifiedError(404, CompletionsErrorCode.MODEL_NOT_FOUND, str(exc)) 

106 if isinstance(exc, ModelDoesNotSupportToolsError): 

107 return ClassifiedError(400, CompletionsErrorCode.MODEL_DOES_NOT_SUPPORT_TOOLS, str(exc)) 

108 if isinstance(exc, ProviderError): 

109 mapped = _PROVIDER_KIND_CLASSIFICATIONS.get(exc.kind) 

110 if mapped is not None: 

111 status, code = mapped 

112 if exc.kind in _INFRASTRUCTURE_KINDS: 

113 log.warning("Chat backend failure (%s)", exc.kind, exc_info=exc) 

114 return ClassifiedError(status, code, _BACKEND_FAILURE_MESSAGE) 

115 return ClassifiedError(status, code, str(exc)) 

116 return None