mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
Merge e6a0fcc027 into e0d1e64591
This commit is contained in:
commit
f53baf3d95
15 changed files with 398 additions and 84 deletions
|
|
@ -23,6 +23,7 @@ ANTHROPIC_ERROR_TYPE_MAP: Final[dict[int, AnthropicErrorType]] = {
|
|||
403: "permission_error",
|
||||
404: "not_found_error",
|
||||
413: "request_too_large",
|
||||
422: "invalid_request_error",
|
||||
429: "rate_limit_error",
|
||||
500: "api_error",
|
||||
529: "overloaded_error",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
|
||||
import litellm
|
||||
from litellm.anthropic_interface.exceptions import (
|
||||
AnthropicErrorDetail,
|
||||
AnthropicErrorResponse,
|
||||
AnthropicExceptionMapping,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import ModifyResponseException
|
||||
|
|
@ -36,13 +34,14 @@ from litellm.proxy.common_request_processing import (
|
|||
resolve_counted_tool_fields,
|
||||
resolve_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.common_utils.error_body_call_id import error_body_call_id
|
||||
from litellm.proxy.common_utils.anthropic_error_payload import (
|
||||
anthropic_error_json_response,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
|
||||
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
read_request_body,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
LITELLM_CALL_ID_HEADER,
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
|
|
@ -55,44 +54,13 @@ from litellm.router import Router
|
|||
router: Final = APIRouter()
|
||||
|
||||
|
||||
def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDetail) -> AnthropicErrorDetail:
|
||||
if not exc.provider_specific_fields:
|
||||
return detail
|
||||
with_fields: Final[AnthropicErrorDetail] = {**detail, "provider_specific_fields": exc.provider_specific_fields}
|
||||
return with_fields
|
||||
|
||||
|
||||
def _anthropic_error_detail(
|
||||
exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None
|
||||
) -> AnthropicErrorDetail:
|
||||
if call_id is None:
|
||||
return _with_provider_specific_fields(exc, detail)
|
||||
with_call_id: Final[AnthropicErrorDetail] = {
|
||||
**_with_provider_specific_fields(exc, detail),
|
||||
"litellm_call_id": call_id,
|
||||
}
|
||||
return with_call_id
|
||||
|
||||
|
||||
def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
from litellm.proxy.proxy_server import (
|
||||
_close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; error JSONResponses returned by the route must stamp the OTel server span like the global ProxyException handler does
|
||||
general_settings_view,
|
||||
)
|
||||
"""The Anthropic error envelope for a failure raised inside a Messages handler.
|
||||
|
||||
status_code: Final = int(exc.code) if exc.code is not None and exc.code.isdigit() else 500
|
||||
_close_dangling_otel_server_span(request, status_code, exc=exc)
|
||||
envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
|
||||
status_code=status_code,
|
||||
raw_message=exc.message,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
)
|
||||
body_call_id: Final = error_body_call_id(general_settings_view(), exc.headers.get(LITELLM_CALL_ID_HEADER))
|
||||
content: Final[AnthropicErrorResponse] = {
|
||||
**envelope,
|
||||
"error": _anthropic_error_detail(exc, envelope["error"], body_call_id),
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=content, headers=exc.headers)
|
||||
Delegates to the shared renderer so a failure raised here and one raised by the auth
|
||||
layer before the handler (see the central ProxyException handler) answer identically.
|
||||
"""
|
||||
return anthropic_error_json_response(exc, request)
|
||||
|
||||
|
||||
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")
|
||||
|
|
|
|||
110
litellm/proxy/common_utils/anthropic_error_payload.py
Normal file
110
litellm/proxy/common_utils/anthropic_error_payload.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Renders the Anthropic error envelope for the Messages routes, wherever the failure is raised.
|
||||
|
||||
The central ``ProxyException`` handler answers every route with OpenAI's ``{"error": {...}}``
|
||||
body, which an Anthropic SDK cannot read. Auth, key, budget and model-access failures are
|
||||
raised by ``user_api_key_auth`` before the Messages handler runs, so the envelope is picked
|
||||
from the request route rather than inside the handler.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.anthropic_interface.exceptions import (
|
||||
AnthropicErrorDetail,
|
||||
AnthropicErrorResponse,
|
||||
AnthropicExceptionMapping,
|
||||
)
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.common_utils.error_body_call_id import error_body_call_id
|
||||
from litellm.proxy.common_utils.openai_error_payload import LITELLM_CALL_ID_HEADER
|
||||
|
||||
ANTHROPIC_MESSAGES_ROUTES: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"/v1/messages",
|
||||
"/v1/messages/count_tokens",
|
||||
"/claude_code_gateway/v1/messages",
|
||||
"/claude_code_gateway/v1/messages/count_tokens",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_anthropic_messages_route(route: str) -> bool:
|
||||
"""True for the Messages routes, whose callers read the Anthropic error envelope.
|
||||
|
||||
Narrower than ``LiteLLMRoutes.anthropic_routes``: the Skills and marketplace routes there
|
||||
answer their own body shape, and ``/anthropic/v1/messages`` relays the upstream response.
|
||||
"""
|
||||
return route in ANTHROPIC_MESSAGES_ROUTES
|
||||
|
||||
|
||||
def anthropic_error_status_code(exc: ProxyException) -> int:
|
||||
"""The exception's stringified ``code`` as a status, else 500.
|
||||
|
||||
``ProxyException.__init__`` stringifies ``code``, so a code-less exception holds the
|
||||
literal ``"None"``, which must not be parsed as a status.
|
||||
"""
|
||||
code: Final = exc.code
|
||||
if code.isdigit():
|
||||
return int(code)
|
||||
return status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
def _with_provider_specific_fields(exc: ProxyException, detail: AnthropicErrorDetail) -> AnthropicErrorDetail:
|
||||
if not exc.provider_specific_fields:
|
||||
return detail
|
||||
with_fields: Final[AnthropicErrorDetail] = {**detail, "provider_specific_fields": exc.provider_specific_fields}
|
||||
return with_fields
|
||||
|
||||
|
||||
def _anthropic_error_detail(
|
||||
exc: ProxyException, detail: AnthropicErrorDetail, call_id: str | None
|
||||
) -> AnthropicErrorDetail:
|
||||
if call_id is None:
|
||||
return _with_provider_specific_fields(exc, detail)
|
||||
with_call_id: Final[AnthropicErrorDetail] = {
|
||||
**_with_provider_specific_fields(exc, detail),
|
||||
"litellm_call_id": call_id,
|
||||
}
|
||||
return with_call_id
|
||||
|
||||
|
||||
def anthropic_error_content(
|
||||
exc: ProxyException,
|
||||
status_code: int,
|
||||
request_id: str | None,
|
||||
general_settings: Mapping[str, object],
|
||||
) -> AnthropicErrorResponse:
|
||||
"""The Anthropic ``{"type": "error", "error": {...}}`` body for ``exc``, typed by status."""
|
||||
envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error(
|
||||
status_code=status_code,
|
||||
raw_message=exc.message,
|
||||
request_id=request_id,
|
||||
)
|
||||
body_call_id: Final = error_body_call_id(general_settings, exc.headers.get(LITELLM_CALL_ID_HEADER))
|
||||
content: Final[AnthropicErrorResponse] = {
|
||||
**envelope,
|
||||
"error": _anthropic_error_detail(exc, envelope["error"], body_call_id),
|
||||
}
|
||||
return content
|
||||
|
||||
|
||||
def anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse:
|
||||
"""The Anthropic error response for a Messages request, span close included.
|
||||
|
||||
Shared by the Messages handlers and the central handler, so a failure raised before the
|
||||
handler and one raised inside it answer identically.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
_close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; a JSONResponse returned instead of a raised error must stamp the OTel server span like the global ProxyException handler does
|
||||
general_settings_view,
|
||||
)
|
||||
|
||||
status_code: Final = anthropic_error_status_code(exc)
|
||||
_close_dangling_otel_server_span(request, status_code, exc=exc)
|
||||
content: Final = anthropic_error_content(
|
||||
exc, status_code, request.headers.get("x-request-id"), general_settings_view()
|
||||
)
|
||||
return JSONResponse(status_code=status_code, content=content, headers=exc.headers)
|
||||
|
|
@ -402,6 +402,7 @@ from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET
|
|||
from litellm.proxy.auth.auth_utils import (
|
||||
MAX_REQUEST_SIZE_MB_SETTING,
|
||||
check_response_size_is_safe,
|
||||
get_request_route,
|
||||
is_request_body_safe,
|
||||
log_model_access_denial,
|
||||
log_once_if_budget_reservation_disabled,
|
||||
|
|
@ -465,6 +466,10 @@ from litellm.proxy.common_request_processing import ( # noqa: F401, RUF100 # l
|
|||
sse_error_payload,
|
||||
ttft_keepalive_interval,
|
||||
)
|
||||
from litellm.proxy.common_utils.anthropic_error_payload import (
|
||||
anthropic_error_json_response,
|
||||
is_anthropic_messages_route,
|
||||
)
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
|
||||
AuthCacheInvalidationSubscriber,
|
||||
)
|
||||
|
|
@ -2184,6 +2189,8 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
|
|||
# NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions
|
||||
log_model_access_denial(exc)
|
||||
headers: Final = exc.headers
|
||||
if is_anthropic_messages_route(get_request_route(request)):
|
||||
return anthropic_error_json_response(exc, request)
|
||||
error_dict: Final = with_call_id(
|
||||
JSON_OBJECT.validate_python(exc.to_dict()),
|
||||
error_body_call_id(general_settings_view(), headers.get(LITELLM_CALL_ID_HEADER)),
|
||||
|
|
|
|||
|
|
@ -150,6 +150,13 @@ def error_type(response: httpx.Response) -> str:
|
|||
return str(object_value(JSON_OBJECT.validate_json(response.content)["error"])["type"])
|
||||
|
||||
|
||||
def budget_refusal_error_type(route: str) -> str:
|
||||
"""The ``error.type`` a budget refusal carries on a route label from ``ROUTES``: the
|
||||
Anthropic envelope on Messages (its budget refusal is raised in
|
||||
``user_api_key_auth``), the OpenAI ``budget_exceeded`` type on the other routes."""
|
||||
return "invalid_request_error" if route == "messages" else "budget_exceeded"
|
||||
|
||||
|
||||
def settle(
|
||||
send: Callable[[str], httpx.Response], status: int, *, burst: int = GATEWAY_BURST, seconds: float = 60
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from integration.authorization._hidden_alias_budget import (
|
|||
async_anthropic_client,
|
||||
async_openai_client,
|
||||
base_url,
|
||||
budget_refusal_error_type,
|
||||
error_type,
|
||||
fresh_chat,
|
||||
fresh_message,
|
||||
|
|
@ -191,17 +192,18 @@ def _assert_refused(
|
|||
key: str,
|
||||
model: str,
|
||||
prefix: str,
|
||||
route: str = "chat",
|
||||
send: Callable[[Gateway, str, str, str], httpx.Response] = fresh_chat,
|
||||
) -> None:
|
||||
marker: Final = f"{prefix}-" + uuid.uuid4().hex
|
||||
refused: Final = send(candidate, model, key, marker)
|
||||
assert _refusal(refused) == (BUDGET_EXCEEDED, "budget_exceeded"), refused.text
|
||||
assert _refusal(refused) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), refused.text
|
||||
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0
|
||||
|
||||
|
||||
def _assert_refused_on_every_route(rig: ChainRig, candidate: Gateway, key: str, model: str) -> None:
|
||||
for route, send in ROUTES:
|
||||
_assert_refused(rig, candidate, key, model, f"refused-{route}", send)
|
||||
_assert_refused(rig, candidate, key, model, f"refused-{route}", route, send)
|
||||
|
||||
|
||||
def _assert_provider_failure(response: httpx.Response) -> None:
|
||||
|
|
|
|||
|
|
@ -474,15 +474,14 @@ def test_device_authorization_polls_complete_sso_and_mints_a_model_restricted_be
|
|||
)
|
||||
assert model_b_response.status_code == 403, model_b_response.text
|
||||
assert _JSON_OBJECT.validate_json(model_b_response.content) == {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "permission_error",
|
||||
"message": (
|
||||
f"The requested model '{model_b}' is not available for this API key, or the model name is "
|
||||
"invalid. Check the models available to you and try again."
|
||||
),
|
||||
"type": "team_model_access_denied",
|
||||
"param": "model",
|
||||
"code": "403",
|
||||
}
|
||||
},
|
||||
}, model_b_response.text
|
||||
assert upstream.drain() == ()
|
||||
|
||||
|
|
|
|||
|
|
@ -695,9 +695,11 @@ def test_cli_session_token_is_denied_once_its_team_budget_is_exhausted(
|
|||
key=key,
|
||||
)
|
||||
assert refused.status_code == 422, f"{refused.status_code} {refused.text}"
|
||||
error: Final = object_value(JSON_OBJECT.validate_json(refused.content)["error"])
|
||||
assert error["type"] == "budget_exceeded", refused.text
|
||||
assert error["code"] == "422", refused.text
|
||||
body: Final = JSON_OBJECT.validate_json(refused.content)
|
||||
assert body["type"] == "error", refused.text
|
||||
error: Final = object_value(body["error"])
|
||||
assert error["type"] == "invalid_request_error", refused.text
|
||||
assert "param" not in error and "code" not in error, refused.text
|
||||
message: Final = string_value(error["message"])
|
||||
assert "Budget has been exceeded!" in message, refused.text
|
||||
assert f"Team={team}" in message, refused.text
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from integration.authorization._hidden_alias_budget import (
|
|||
async_anthropic_client,
|
||||
async_openai_client,
|
||||
base_url,
|
||||
budget_refusal_error_type,
|
||||
chat_statuses,
|
||||
clear_provider_script,
|
||||
error_type,
|
||||
|
|
@ -599,7 +600,7 @@ def _assert_refused_like_target(rig: AliasRig, candidate: Gateway, key: str, sha
|
|||
target_marker: Final = f"target-{route}-" + uuid.uuid4().hex
|
||||
via_shadow: Final = send(candidate, shadow, key, shadow_marker)
|
||||
by_name: Final = send(candidate, target, key, target_marker)
|
||||
assert _refusal(by_name) == (BUDGET_EXCEEDED, "budget_exceeded"), by_name.text
|
||||
assert _refusal(by_name) == (BUDGET_EXCEEDED, budget_refusal_error_type(route)), by_name.text
|
||||
assert via_shadow.status_code == by_name.status_code, via_shadow.text
|
||||
assert error_type(via_shadow) == error_type(by_name), via_shadow.text
|
||||
observed: Final = upstream_requests(rig.gateway.upstream_url)
|
||||
|
|
@ -621,7 +622,7 @@ def _assert_refused_on_every_route(rig: AliasRig, candidate: Gateway, key: str,
|
|||
marker: Final = f"chain-{route}-" + uuid.uuid4().hex
|
||||
refused: Final = send(candidate, model, key, marker)
|
||||
assert refused.status_code == BUDGET_EXCEEDED, refused.text
|
||||
assert error_type(refused) == "budget_exceeded", refused.text
|
||||
assert error_type(refused) == budget_refusal_error_type(route), refused.text
|
||||
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0
|
||||
|
||||
|
||||
|
|
@ -828,9 +829,9 @@ def _deployment_id(rig: AliasRig, name: str) -> str:
|
|||
return identities[0]
|
||||
|
||||
|
||||
def _assert_budget_refusal(status_code: int, body: str) -> None:
|
||||
def _assert_budget_refusal(status_code: int, body: str, route: str = "chat") -> None:
|
||||
assert status_code == BUDGET_EXCEEDED, body
|
||||
assert "budget_exceeded" in body, body
|
||||
assert budget_refusal_error_type(route) in body, body
|
||||
|
||||
|
||||
def _assert_no_served_row(key: str, marker: str) -> None:
|
||||
|
|
@ -881,7 +882,7 @@ async def test_exhausted_key_is_refused_on_shadowing_alias_through_streamed_anth
|
|||
extra_headers=spend_marker(marker),
|
||||
) as stream:
|
||||
await stream.get_final_message()
|
||||
_assert_budget_refusal(refused.value.status_code, str(refused.value.body))
|
||||
_assert_budget_refusal(refused.value.status_code, str(refused.value.body), route="messages")
|
||||
assert upstream_hits(upstream_requests(rig.gateway.upstream_url), marker) == 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,23 @@ class _ErrorResponse(BaseModel):
|
|||
error: _ProxyError
|
||||
|
||||
|
||||
class _AnthropicErrorDetail(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
type: str
|
||||
message: str
|
||||
|
||||
|
||||
class _AnthropicErrorResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
type: str
|
||||
error: _AnthropicErrorDetail
|
||||
|
||||
|
||||
GENERIC_ERROR: Final = _ProxyError(message=GENERIC_MESSAGE, type="token_not_found_in_db", param="key", code="401")
|
||||
MESSAGES_PATH: Final = "/v1/messages"
|
||||
ANTHROPIC_GENERIC_ERROR: Final = _AnthropicErrorDetail(type="authentication_error", message=GENERIC_MESSAGE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -119,6 +135,23 @@ def _assert_generic_refusal(response: httpx.Response, *secrets: str) -> None:
|
|||
assert secret not in response.text, response.text
|
||||
|
||||
|
||||
def _assert_messages_refusal(response: httpx.Response, *secrets: str) -> None:
|
||||
assert response.status_code == 401, response.text
|
||||
body: Final = _AnthropicErrorResponse.model_validate_json(response.content)
|
||||
assert body.type == "error", response.text
|
||||
assert body.error == ANTHROPIC_GENERIC_ERROR, response.text
|
||||
for secret in secrets:
|
||||
assert secret not in response.text, response.text
|
||||
|
||||
|
||||
def _assert_refusal(path: str, response: httpx.Response, *secrets: str) -> None:
|
||||
"""The Messages routes answer the Anthropic envelope, every other route the OpenAI one."""
|
||||
if path == MESSAGES_PATH:
|
||||
_assert_messages_refusal(response, *secrets)
|
||||
else:
|
||||
_assert_generic_refusal(response, *secrets)
|
||||
|
||||
|
||||
def _side(gateway: Gateway, peer: Gateway, side: Side) -> Gateway:
|
||||
return gateway if side == "gateway" else peer
|
||||
|
||||
|
|
@ -129,7 +162,10 @@ def test_an_unknown_key_gets_the_generic_401_without_reaching_the_provider(
|
|||
gateway: Gateway, peer: Gateway, provider: SharedProvider, route: str, side: Side
|
||||
) -> None:
|
||||
key: Final = _unknown_key()
|
||||
_assert_generic_refusal(_send(_side(gateway, peer, side), ROUTES[route], key), key, key[-4:], _digest(key))
|
||||
request_route: Final = ROUTES[route]
|
||||
_assert_refusal(
|
||||
request_route.path, _send(_side(gateway, peer, side), request_route, key), key, key[-4:], _digest(key)
|
||||
)
|
||||
assert provider.received() == ()
|
||||
|
||||
|
||||
|
|
@ -183,8 +219,9 @@ def test_two_different_unknown_keys_in_both_headers_get_the_generic_401_naming_n
|
|||
bearer: Final = _unknown_key()
|
||||
api_key: Final = _unknown_key()
|
||||
headers: Final = {**_headers("authorization", bearer), **_headers("x-api-key", api_key)}
|
||||
for path, body in (("/v1/chat/completions", CHAT), ("/v1/messages", MESSAGES)):
|
||||
_assert_generic_refusal(
|
||||
for path, body in (("/v1/chat/completions", CHAT), (MESSAGES_PATH, MESSAGES)):
|
||||
_assert_refusal(
|
||||
path,
|
||||
gateway.client.post(path, json=dict(body), headers=headers),
|
||||
bearer,
|
||||
api_key,
|
||||
|
|
@ -359,16 +396,15 @@ async def _send_async(client: httpx.AsyncClient, route: _Route, key: str) -> htt
|
|||
)
|
||||
|
||||
|
||||
async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[httpx.Response, ...]:
|
||||
async def _burst(owned: OwnedProxy, keys: tuple[str, ...]) -> tuple[tuple[_Route, httpx.Response], ...]:
|
||||
"""Each key's response paired with the route it was sent on, so the caller asserts the
|
||||
envelope the route actually answered with."""
|
||||
routes: Final = tuple(ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]] for index in range(len(keys)))
|
||||
async with httpx.AsyncClient(base_url=str(owned.gateway.client.base_url), timeout=60, trust_env=False) as client:
|
||||
return tuple(
|
||||
await asyncio.gather(
|
||||
*(
|
||||
_send_async(client, ROUTES[BURST_ROUTES[index % len(BURST_ROUTES)]], key)
|
||||
for index, key in enumerate(keys)
|
||||
)
|
||||
)
|
||||
responses: Final = await asyncio.gather(
|
||||
*(_send_async(client, route, key) for route, key in zip(routes, keys, strict=True))
|
||||
)
|
||||
return tuple(zip(routes, responses, strict=True))
|
||||
|
||||
|
||||
def _log_after(owned: OwnedProxy, offset: int) -> str:
|
||||
|
|
@ -397,7 +433,7 @@ async def test_a_burst_across_a_worker_kill_answers_every_unknown_key_with_the_g
|
|||
lambda text: all(_console_line(key) in text for key in keys),
|
||||
30,
|
||||
)
|
||||
for response, key in zip((*during, *after), keys, strict=True):
|
||||
_assert_generic_refusal(response, key, _digest(key))
|
||||
for (route, response), key in zip((*during, *after), keys, strict=True):
|
||||
_assert_refusal(route.path, response, key, _digest(key))
|
||||
assert [window.count(_console_line(key)) for key in keys] == [1] * len(keys)
|
||||
assert [key for key in keys if key in window] == []
|
||||
|
|
|
|||
|
|
@ -268,15 +268,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
|
|||
)
|
||||
assert restricted_response.status_code == 403, restricted_response.text
|
||||
assert _JSON_OBJECT.validate_json(restricted_response.content) == {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "permission_error",
|
||||
"message": (
|
||||
f"The requested model '{restricted_model}' is not available for this API key, or the model name "
|
||||
"is invalid. Check the models available to you and try again."
|
||||
),
|
||||
"type": "key_model_access_denied",
|
||||
"param": "model",
|
||||
"code": "403",
|
||||
}
|
||||
},
|
||||
}, restricted_response.text
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
|
@ -285,15 +284,14 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
|
|||
def test_enabled_gateway_refuses_model_outside_virtual_key(enabled_gateway: Gateway) -> None:
|
||||
result: Final = _restricted_model_responses(enabled_gateway)
|
||||
expected_body: Final = {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "permission_error",
|
||||
"message": (
|
||||
f"The requested model '{result[4]}' is not available for this API key, or the model name is invalid. "
|
||||
"Check the models available to you and try again."
|
||||
),
|
||||
"type": "key_model_access_denied",
|
||||
"param": "model",
|
||||
"code": "403",
|
||||
}
|
||||
},
|
||||
}
|
||||
assert result[0] == result[2] == 403, (result[1], result[3])
|
||||
assert _JSON_OBJECT.validate_json(result[1]) == expected_body, result[1]
|
||||
|
|
|
|||
|
|
@ -258,9 +258,20 @@ def _observed(upstream: httpx.Client) -> list[JsonValue]:
|
|||
return requests
|
||||
|
||||
|
||||
def _denial_error(denied: httpx.Response) -> dict[str, JsonValue]:
|
||||
def _denial_error(denied: httpx.Response, route: str) -> dict[str, JsonValue]:
|
||||
assert denied.status_code == 422, denied.text
|
||||
return object_value(object_value(denied.json())["error"])
|
||||
if route == "/v1/messages":
|
||||
body: Final = object_value(denied.json())
|
||||
assert body["type"] == "error", denied.text
|
||||
anthropic_error: Final = object_value(body["error"])
|
||||
assert anthropic_error["type"] == "invalid_request_error", denied.text
|
||||
assert "param" not in anthropic_error and "code" not in anthropic_error, denied.text
|
||||
return anthropic_error
|
||||
openai_error: Final = object_value(object_value(denied.json())["error"])
|
||||
assert openai_error["type"] == "budget_exceeded", denied.text
|
||||
assert openai_error["param"] is None
|
||||
assert openai_error["code"] == "422"
|
||||
return openai_error
|
||||
|
||||
|
||||
def _prove_end_user_budget_lockout(
|
||||
|
|
@ -304,8 +315,7 @@ def _prove_end_user_budget_lockout(
|
|||
upstream.delete("/__observations").raise_for_status()
|
||||
denied: Final = _send(gateway, spelling, deployment, key, end_user_id, f"denied {end_user_id}")
|
||||
assert _observed(upstream) == [], denied.text
|
||||
error: Final = _denial_error(denied)
|
||||
assert error["type"] == "budget_exceeded", denied.text
|
||||
error: Final = _denial_error(denied, spelling.route)
|
||||
matched: Final = re.fullmatch(
|
||||
rf"ExceededBudget: End User={re.escape(end_user_id)} over budget\. "
|
||||
rf"Spend=([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?), Budget=0\.05",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,19 @@ def _assert_budget_refusal(response: httpx.Response, end_user: str) -> None:
|
|||
]
|
||||
|
||||
|
||||
def _assert_messages_budget_refusal(response: httpx.Response, end_user: str) -> None:
|
||||
"""A budget refusal on /v1/messages keeps its 422 but answers the Anthropic envelope."""
|
||||
assert response.status_code == 422, response.text
|
||||
body: Final = _json_object(response.content)
|
||||
assert body["type"] == "error", response.text
|
||||
error: Final = object_value(body["error"])
|
||||
assert error["type"] == "invalid_request_error"
|
||||
assert "param" not in error and "code" not in error, response.text
|
||||
assert str(error["message"]).startswith(f"ExceededBudget: End User={end_user} over budget. Spend="), error[
|
||||
"message"
|
||||
]
|
||||
|
||||
|
||||
def _json_object(raw: bytes) -> dict[str, JsonValue]:
|
||||
return _JSON_OBJECT.validate_json(raw)
|
||||
|
||||
|
|
@ -345,7 +358,7 @@ def test_anthropic_messages_metadata_user_id_is_charged_and_budget_enforced(gate
|
|||
json={**body, "messages": [{"role": "user", "content": f"again {uuid.uuid4().hex}"}]},
|
||||
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
|
||||
)
|
||||
_assert_budget_refusal(denied, end_user)
|
||||
_assert_messages_budget_refusal(denied, end_user)
|
||||
assert _observed(upstream) == []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,10 +63,17 @@ class TestCreateErrorResponse:
|
|||
)
|
||||
assert response["request_id"] == "req_123"
|
||||
|
||||
def test_unknown_status_defaults_to_api_error(self):
|
||||
"""Test unknown status code defaults to api_error."""
|
||||
response = AnthropicExceptionMapping.create_error_response(418, "I'm a teapot")
|
||||
assert response["error"]["type"] == "api_error"
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected_error_type"),
|
||||
[(422, "invalid_request_error"), (418, "api_error"), (502, "api_error"), (599, "api_error")],
|
||||
)
|
||||
def test_a_budget_status_is_a_request_error_and_a_status_the_table_does_not_name_is_api_error(
|
||||
self, status_code: int, expected_error_type: str
|
||||
):
|
||||
"""The proxy's budget status is a caller-fault request error, so the table names 422.
|
||||
A status the table does not name, such as a 418 or a 5xx, keeps api_error."""
|
||||
response = AnthropicExceptionMapping.create_error_response(status_code, "scripted status")
|
||||
assert response["error"]["type"] == expected_error_type
|
||||
|
||||
|
||||
class TestExtractErrorMessage:
|
||||
|
|
|
|||
|
|
@ -168,6 +168,159 @@ async def test_openai_exception_handler_never_fabricates_a_call_id(monkeypatch):
|
|||
assert "litellm_call_id" not in body["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# openai_exception_handler: Anthropic envelope on the Messages routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model_access_denied_exception():
|
||||
"""The 403 can_team_access_model raises: OpenAI type team_model_access_denied, param model."""
|
||||
return ProxyException(
|
||||
message="Team not allowed to access model. Tried to access gpt-5.6",
|
||||
type="team_model_access_denied",
|
||||
param="model",
|
||||
code=403,
|
||||
)
|
||||
|
||||
|
||||
_MESSAGES_ROUTES: Final = (
|
||||
"/v1/messages",
|
||||
"/v1/messages/count_tokens",
|
||||
"/claude_code_gateway/v1/messages",
|
||||
"/claude_code_gateway/v1/messages/count_tokens",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", _MESSAGES_ROUTES)
|
||||
async def test_openai_exception_handler_answers_the_anthropic_envelope_on_the_messages_routes(path):
|
||||
"""An auth failure raised before the Messages handler (user_api_key_auth) must reach an
|
||||
Anthropic SDK as {"type": "error", "error": {...}}, not the OpenAI {"error": {...}} body."""
|
||||
response = await openai_exception_handler(request=_make_request(path=path), exc=_model_access_denied_exception())
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert body == {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "permission_error",
|
||||
"message": "Team not allowed to access model. Tried to access gpt-5.6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status_code,anthropic_type",
|
||||
[
|
||||
(401, "authentication_error"),
|
||||
(403, "permission_error"),
|
||||
(404, "not_found_error"),
|
||||
(429, "rate_limit_error"),
|
||||
],
|
||||
)
|
||||
async def test_openai_exception_handler_maps_messages_failures_to_anthropic_error_types(status_code, anthropic_type):
|
||||
"""The Anthropic error type follows the HTTP status, so a bad key, a denied model, an
|
||||
unknown model and a rate limit each read the way an Anthropic client expects."""
|
||||
exc = ProxyException(message="denied", type="key_model_access_denied", param="model", code=status_code)
|
||||
|
||||
response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc)
|
||||
|
||||
assert response.status_code == status_code
|
||||
assert json.loads(response.body)["error"]["type"] == anthropic_type
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_exception_handler_keeps_the_status_of_a_budget_failure_on_messages():
|
||||
"""A budget failure carries the proxy's own budget status; the Messages route must keep
|
||||
it and answer the Anthropic envelope rather than the OpenAI body."""
|
||||
exc = ProxyException(message="Budget exceeded", type="budget_exceeded", param=None, code=422)
|
||||
|
||||
response = await openai_exception_handler(request=_make_request(path="/v1/messages/count_tokens"), exc=exc)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert body["type"] == "error"
|
||||
assert body["error"]["type"] == "invalid_request_error"
|
||||
assert body["error"]["message"] == "Budget exceeded"
|
||||
assert "param" not in body["error"] and "code" not in body["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_exception_handler_keeps_the_openai_envelope_off_the_messages_routes():
|
||||
"""The envelope is chosen by route, so OpenAI routes are untouched."""
|
||||
response = await openai_exception_handler(
|
||||
request=_make_request(path="/v1/chat/completions"), exc=_model_access_denied_exception()
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert body == {
|
||||
"error": {
|
||||
"message": "Team not allowed to access model. Tried to access gpt-5.6",
|
||||
"type": "team_model_access_denied",
|
||||
"param": "model",
|
||||
"code": "403",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_exception_handler_keeps_the_openai_envelope_on_other_anthropic_routes():
|
||||
"""The Skills and marketplace routes are not Messages routes and answer their own body."""
|
||||
response = await openai_exception_handler(
|
||||
request=_make_request(path="/v1/skills"), exc=_model_access_denied_exception()
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "type" not in body
|
||||
assert body["error"]["type"] == "team_model_access_denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_exception_handler_messages_route_copies_the_call_id_when_opted_in(monkeypatch):
|
||||
"""The Messages envelope carries error.litellm_call_id under the same opt-in as the
|
||||
OpenAI envelope, so an Anthropic SDK's e.body still names the request to look up."""
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True})
|
||||
exc = ProxyException(
|
||||
message="denied",
|
||||
type="team_model_access_denied",
|
||||
param="model",
|
||||
code=403,
|
||||
headers={"x-litellm-call-id": "call-8302"},
|
||||
provider_specific_fields={"guardrail": "keyword-block"},
|
||||
)
|
||||
|
||||
response = await openai_exception_handler(request=_make_request(path="/v1/messages"), exc=exc)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.headers["x-litellm-call-id"] == "call-8302"
|
||||
assert body["error"] == {
|
||||
"type": "permission_error",
|
||||
"message": "denied",
|
||||
"provider_specific_fields": {"guardrail": "keyword-block"},
|
||||
"litellm_call_id": "call-8302",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_messages_request_with_no_key_answers_the_anthropic_authentication_error(client, monkeypatch):
|
||||
"""End to end through the real user_api_key_auth: a /v1/messages request with no
|
||||
credential is rejected before the handler, and the SDK must see Anthropic's
|
||||
authentication_error envelope, not OpenAI's {"error": {...}} body."""
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
|
||||
response = client.post("/v1/messages", json={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]})
|
||||
body = response.json()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert body["type"] == "error"
|
||||
assert body["error"]["type"] == "authentication_error"
|
||||
assert "param" not in body["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _close_dangling_otel_server_span
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue