Coverage for src/lilbee/server/chat_completions_api/errors.py: 100%
42 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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 # Passes through: the text names the model and the fix, and holds no engine path.
79 ProviderErrorKind.MODEL_FILE_BROKEN: (503, CompletionsErrorCode.INTERNAL_ERROR),
80}
83# Kinds describing the backend, not the caller's request. Their text is built
84# at the fleet boundary and carries up to 600 bytes of upstream body plus the
85# dead server's stderr (loopback ports, engine paths), so it is logged rather
86# than returned. The client-input kinds stay pass-through.
87_INFRASTRUCTURE_KINDS = frozenset(
88 {
89 ProviderErrorKind.CONNECTION,
90 ProviderErrorKind.SERVER,
91 ProviderErrorKind.CAPACITY,
92 ProviderErrorKind.PORT_CONFLICT,
93 }
94)
96_BACKEND_FAILURE_MESSAGE = "The model backend is unavailable. Check the server logs for details."
99def classify_provider_error(exc: BaseException) -> ClassifiedError | None:
100 """Map a typed dispatch/provider failure to ``(status, code, message)``, or None.
102 Returns None for any exception that isn't a recognized typed dispatch error
103 or a ProviderError with a client-mappable kind; callers apply their own
104 generic fallback (internal_error 500 / service-unavailable 503).
105 """
106 if isinstance(exc, ModelNotFoundError):
107 return ClassifiedError(404, CompletionsErrorCode.MODEL_NOT_FOUND, str(exc))
108 if isinstance(exc, ModelDoesNotSupportToolsError):
109 return ClassifiedError(400, CompletionsErrorCode.MODEL_DOES_NOT_SUPPORT_TOOLS, str(exc))
110 if isinstance(exc, ProviderError):
111 mapped = _PROVIDER_KIND_CLASSIFICATIONS.get(exc.kind)
112 if mapped is not None:
113 status, code = mapped
114 if exc.kind in _INFRASTRUCTURE_KINDS:
115 log.warning("Chat backend failure (%s)", exc.kind, exc_info=exc)
116 return ClassifiedError(status, code, _BACKEND_FAILURE_MESSAGE)
117 return ClassifiedError(status, code, str(exc))
118 return None