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
« 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."""
3from __future__ import annotations
5import logging
6from dataclasses import dataclass
7from enum import StrEnum
8from typing import Any
10from lilbee.providers.base import ProviderError, ProviderErrorKind
11from lilbee.server.chat_dispatch.dispatch import (
12 ModelDoesNotSupportToolsError,
13 ModelNotFoundError,
14)
16log = logging.getLogger(__name__)
19class CompletionsErrorCode(StrEnum):
20 """Stable error-code vocabulary for the chat-completions surface."""
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"
31_FALLBACK_ERROR_TYPE = "invalid_request_error"
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}
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 }
57@dataclass(frozen=True)
58class ClassifiedError:
59 """A typed provider/dispatch failure mapped to its client-facing shape."""
61 http_status: int
62 code: CompletionsErrorCode
63 message: str
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}
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)
94_BACKEND_FAILURE_MESSAGE = "The model backend is unavailable. Check the server logs for details."
97def classify_provider_error(exc: BaseException) -> ClassifiedError | None:
98 """Map a typed dispatch/provider failure to ``(status, code, message)``, or None.
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