From 0c38a256eebf518d48c6f204123d3db7a4f37bbe Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:58:59 -0700 Subject: [PATCH] fix(proxy): stop labelling management and health rejections as auth failures Eleven management and health route handlers wrapped their body in a blanket `except Exception` and raised `ProxyException(type=auth_error)` whatever actually went wrong, so `/team/info` answered 404 with `type=auth_error` for a team that does not exist and `/user/update` answered 400 for an unparseable `budget_duration`, both on a valid admin key. `auth_error` is not an OpenAI error type at all, so a client branching on `type` retried the credentials instead of fixing the field. Those handlers now read the type off the exception through a shared `proxy_exception_for` helper, keeping the status each branch already answered with. A `ProxyException` raised deliberately mid-request still passes through with the type it named, so the 403 credential-attach authorization check keeps `auth_error`. Every genuine auth site under `litellm/proxy/auth/` and `ui_sso.py` is untouched --- .../common_utils/openai_error_payload.py | 28 +++++- .../health_endpoints/_health_endpoints.py | 20 +---- .../internal_user_endpoints.py | 18 +--- .../key_management_endpoints.py | 21 ++--- .../model_management_endpoints.py | 50 +---------- .../organization_endpoints.py | 18 +--- .../management_endpoints/team_endpoints.py | 18 +--- litellm/proxy/proxy_server.py | 49 +---------- .../common_utils/test_openai_error_payload.py | 86 +++++++++++++++++++ .../health_endpoints/test_health_endpoints.py | 9 +- .../test_internal_user_endpoints.py | 19 ++++ .../test_key_management_endpoints.py | 26 ++++++ .../test_model_management_endpoints.py | 45 ++++++++++ .../test_organization_endpoints.py | 39 +++++++++ .../test_team_endpoints.py | 28 ++++++ tests/test_litellm/proxy/test_proxy_server.py | 45 ++++++++++ 16 files changed, 343 insertions(+), 176 deletions(-) diff --git a/litellm/proxy/common_utils/openai_error_payload.py b/litellm/proxy/common_utils/openai_error_payload.py index 180ec152094..8ee7db86a09 100644 --- a/litellm/proxy/common_utils/openai_error_payload.py +++ b/litellm/proxy/common_utils/openai_error_payload.py @@ -6,7 +6,9 @@ from collections.abc import Mapping from types import MappingProxyType from typing import Final -from fastapi import status +from fastapi import HTTPException, status + +from litellm.proxy._types import ProxyException _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType( { @@ -46,3 +48,27 @@ def openai_error_param(exc: object) -> str | None: serializes as JSON ``null``.""" carried: Final = attribute_of(exc, "param") return carried if isinstance(carried, str) else None + + +def proxy_exception_for(exc: Exception, default_status_code: int) -> ProxyException: + """The answer a route's blanket ``except Exception`` owes its caller. + + Such a handler has no idea what failed, so it reads the type off the exception + instead of asserting one, and keeps the status each branch already answered with: + an ``HTTPException`` carries its own, anything else gets the route's default.""" + if isinstance(exc, ProxyException): + return exc + if isinstance(exc, HTTPException): + carried_status: Final = error_status_code(exc, default_status_code) + return ProxyException( + message=str(attribute_of(exc, "detail", exc)), + type=openai_error_type(exc, carried_status), + param=openai_error_param(exc), + code=carried_status, + ) + return ProxyException( + message=str(exc), + type=openai_error_type(exc, default_status_code), + param=openai_error_param(exc), + code=default_status_code, + ) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 06e6f281048..1862bb7d423 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -30,8 +30,6 @@ from litellm.proxy._types import ( EnterpriseLicenseData, Litellm_EntityType, LitellmUserRoles, - ProxyErrorTypes, - ProxyException, SpecialModelNames, UserAPIKeyAuth, WebhookEvent, @@ -40,7 +38,7 @@ from litellm.proxy.auth.auth_utils import ( _BANNED_REQUEST_BODY_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers from litellm.proxy.health_check import ( @@ -526,21 +524,7 @@ async def health_services_endpoint( except Exception as e: verbose_proxy_logger.error("litellm.proxy.proxy_server.health_services_endpoint(): Exception occured - %s", e) verbose_proxy_logger.debug(traceback.format_exc()) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e def _convert_health_check_to_dict(check) -> dict: diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index dbe45867010..fd12639b141 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -29,7 +29,7 @@ from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import get_team_object, get_user_object from litellm.proxy.auth.password_policy import validate_password_policy from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for from litellm.proxy.common_utils.user_api_key_cache import ( object_permission_cache_key, user_object_permission_id_cache_key, @@ -1646,21 +1646,7 @@ async def user_update( except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_update(): Exception occured - %s", e) verbose_proxy_logger.debug(traceback.format_exc()) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e async def bulk_update_processed_users( diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 668301724f5..dcd5d8f68f5 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -76,7 +76,10 @@ from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, publish_config_change, ) -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import ( + openai_error_param, + proxy_exception_for, +) from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache @@ -3037,21 +3040,7 @@ async def update_key_fn( # update based on remaining passed in values except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_key_fn(): Exception occured - %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e @router.post( diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index f2ca8e1f541..cf0075e8fbf 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -58,7 +58,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, ) -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin from litellm.proxy.management_endpoints.team_endpoints import ( @@ -1697,21 +1697,7 @@ async def delete_model( except Exception as e: verbose_proxy_logger.exception("Failed to delete model. Due to error - %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e async def delete_team_model_alias( @@ -1921,21 +1907,7 @@ async def add_new_model( except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.add_new_model(): Exception occured - %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e #### MODEL MANAGEMENT #### @@ -2084,21 +2056,7 @@ async def update_model( return model_response except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_model(): Exception occured - %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e @router.post( diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 19147885582..03428098880 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -32,7 +32,7 @@ from litellm._uuid import uuid from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import can_user_call_model, get_user_object from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.management_endpoints.budget_management_endpoints import ( new_budget, @@ -1269,21 +1269,7 @@ async def organization_member_add( ) except Exception as e: verbose_proxy_logger.exception("Error adding member to organization: %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e async def find_member_if_email(user_email: str, prisma_client: PrismaClient) -> LiteLLM_UserTable: diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index cf1a20b0177..c09f0ce15b7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -92,7 +92,7 @@ from litellm.proxy.auth.auth_utils import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch -from litellm.proxy.common_utils.openai_error_payload import openai_error_param +from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity_aggregated, @@ -4458,21 +4458,7 @@ async def team_info( e, traceback.format_exc(), ) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e @router.get( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c4942e5df1b..5719d3319f1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -116,6 +116,7 @@ from litellm.proxy.common_utils.openai_error_payload import ( error_status_code, openai_error_param, openai_error_type, + proxy_exception_for, ) from litellm.proxy.common_utils.realtime_utils import _realtime_request_body from litellm.router_utils.add_retry_fallback_headers import ( @@ -15248,21 +15249,7 @@ async def async_queue_request( await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data ) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e @app.get("/fallback/login", tags=["experimental"], include_in_schema=False) @@ -16466,21 +16453,7 @@ async def update_config( except Exception as e: verbose_proxy_logger.error("litellm.proxy.proxy_server.update_config(): Exception occured - %s", e) verbose_proxy_logger.debug(traceback.format_exc()) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e ### CONFIG GENERAL SETTINGS @@ -17465,21 +17438,7 @@ async def get_config( } except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.get_config(): Exception occured - %s", e) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Authentication Error({e})"), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - elif isinstance(e, ProxyException): - raise e - raise ProxyException( - message="Authentication Error, " + str(e), - type=ProxyErrorTypes.auth_error, - param=openai_error_param(e), - code=status.HTTP_400_BAD_REQUEST, - ) + raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e @router.get( diff --git a/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py b/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py index c165d7ffdb1..ca5f7bd0be7 100644 --- a/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py +++ b/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py @@ -8,6 +8,7 @@ from litellm.proxy.common_utils.openai_error_payload import ( error_status_code, openai_error_param, openai_error_type, + proxy_exception_for, ) @@ -115,3 +116,88 @@ def test_a_status_carried_by_an_exception_drives_the_type_it_reports(): exc = HTTPException(status_code=403, detail="blocked by policy") assert openai_error_type(exc, error_status_code(exc, 400)) == "permission_error" + + +@pytest.mark.parametrize( + "raised_status, expected_type", + [ + (400, "invalid_request_error"), + (404, "invalid_request_error"), + (403, "permission_error"), + (422, "invalid_request_error"), + (500, "internal_server_error"), + ], +) +def test_a_blanket_handler_keeps_the_status_its_route_answered_with(raised_status, expected_type): + """The handler wraps whatever the route raised, so the status the caller sees must be the + one the route chose, and the type must follow that status rather than the route's default.""" + wrapped = proxy_exception_for( + HTTPException(status_code=raised_status, detail={"message": "boom"}), + default_status_code=400, + ) + + assert (wrapped.code, wrapped.type) == (str(raised_status), expected_type) + + +@pytest.mark.parametrize("default_status_code, expected_type", [(400, "invalid_request_error"), (500, "internal_server_error")]) +def test_an_exception_carrying_no_status_falls_back_to_the_routes_default(default_status_code, expected_type): + wrapped = proxy_exception_for(ValueError("boom"), default_status_code=default_status_code) + + assert (wrapped.code, wrapped.type) == (str(default_status_code), expected_type) + + +def test_a_proxy_exception_passes_through_with_the_type_it_already_named(): + """Re-wrapping one would overwrite a type the raising code chose deliberately, which is + what the ``elif isinstance(e, ProxyException): raise e`` branch existed to prevent.""" + raised = ProxyException( + message="Budget has been exceeded", + type=ProxyErrorTypes.budget_exceeded.value, + param="max_budget", + code=400, + ) + + assert proxy_exception_for(raised, default_status_code=500) is raised + + +def test_a_rejection_is_never_labelled_an_auth_failure(): + """The bug: every management route's blanket handler asserted auth_error, so a 404 for a + team that does not exist told a client its credentials were the problem.""" + wrapped = proxy_exception_for( + HTTPException(status_code=404, detail={"message": "Team not found, passed team id: no-such-team."}), + default_status_code=400, + ) + + assert wrapped.type != ProxyErrorTypes.auth_error.value + assert "Authentication Error" not in wrapped.message + + +def test_the_message_is_what_the_route_raised_without_an_invented_prefix(): + assert proxy_exception_for(ValueError("boom"), default_status_code=400).message == "boom" + + +def test_an_http_exceptions_detail_survives_the_wrapping(): + wrapped = proxy_exception_for( + HTTPException(status_code=400, detail={"error": "Invalid budget_duration 'not-a-duration'."}), + default_status_code=400, + ) + + assert "Invalid budget_duration" in wrapped.message + + +def test_a_carried_type_survives_the_wrapping(): + """A litellm exception already names its type; deriving one from the status would lose it.""" + + class _Carrier(Exception): + type = "context_window_exceeded" + status_code = 400 + + assert proxy_exception_for(_Carrier("boom"), default_status_code=500).type == "context_window_exceeded" + + +@pytest.mark.parametrize("exc", [HTTPException(status_code=404, detail="nope"), ValueError("boom")]) +def test_the_wrapped_error_serializes_as_an_openai_error_object(exc): + body = json.loads(json.dumps({"error": proxy_exception_for(exc, default_status_code=400).to_dict()})) + + assert body["error"]["param"] is None + assert isinstance(body["error"]["type"], str) + assert body["error"]["type"] != "None" diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 3d14294eff4..83cf8d210fa 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1092,12 +1092,17 @@ async def test_health_services_endpoint_rejects_unknown_service(): @pytest.mark.asyncio async def test_health_services_endpoint_rejection_is_openai_shaped(): """A caller mistake here must serialize as an OpenAI error object: a real - `type` string and a JSON null `param`, never the literal string "None".""" + `type` string and a JSON null `param`, never the literal string "None". + + The type names what actually went wrong, too. This route answered 400 with + `auth_error` on a valid admin key, blaming the caller's credentials for a + service name it mistyped.""" with pytest.raises(ProxyException) as exc_info: await health_services_endpoint(service="totally_unknown_service_xyz") body = json.loads(json.dumps({"error": exc_info.value.to_dict()})) - assert body["error"]["type"] == "auth_error" + assert body["error"]["type"] == "invalid_request_error" + assert body["error"]["code"] == "400" assert body["error"]["param"] is None diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index f231eb66a50..24f2105f7fb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -4305,3 +4305,22 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo written_data = mock_prisma_client.update_data.call_args.kwargs["data"] assert written_data.get("password") is not None assert written_data["password"] != strong_password + + +@pytest.mark.asyncio +async def test_user_update_reports_a_bad_budget_duration_as_a_request_error(_admin_prisma): + """/user/update answered 400 with type=auth_error for an unparseable budget_duration on a + valid admin key, pointing the caller at its credentials instead of the field it sent.""" + from litellm.proxy._types import ProxyErrorTypes + from litellm.proxy.management_endpoints.internal_user_endpoints import user_update + + with pytest.raises(ProxyException) as exc_info: + await user_update( + data=UpdateUserRequest(user_id="target-user", budget_duration="not-a-duration"), + user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value + assert "Invalid budget_duration" in exc_info.value.message diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 11cf2bfe9f1..e47f21090ea 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -17555,3 +17555,29 @@ def test_key_health_failure_body_is_openai_shaped(): assert error["type"] == "internal_server_error" assert error["param"] is None assert error["code"] == "500" + + +@pytest.mark.asyncio +async def test_update_key_reports_a_bad_budget_duration_as_a_request_error(): + """/key/update answered 400 with type=auth_error for an unparseable budget_duration on a + valid admin key, so the client could not tell a rejected field from a rejected key.""" + from fastapi import Request + + from litellm.proxy._types import ProxyErrorTypes + from litellm.proxy.management_endpoints.key_management_endpoints import update_key_fn + + mock_prisma = MagicMock() + + with patch( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself; no parameter to inject + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ): + with pytest.raises(ProxyException) as exc_info: + await update_key_fn( + request=MagicMock(spec=Request), + data=UpdateKeyRequest(key="sk-whatever", budget_duration="not-a-duration"), + user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 4661cc17dbc..04f3304a781 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -4654,3 +4654,48 @@ class TestBlockModelResponseSerialization: assert body["model_id"] == "m-block-1" assert body["blocked"] is blocked assert body["litellm_params"] == {"model": "openai/gpt-4o-mini", "api_key": "encrypted-value"} + + +@pytest.mark.asyncio +async def test_delete_model_reports_an_unknown_model_id_as_a_request_error(): + """/model/delete answered 400 with type=auth_error for a model id that is not in the db, + on a valid admin key, so the type told the client nothing about what it got wrong.""" + from litellm.proxy._types import ModelInfoDelete, ProxyErrorTypes, ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import delete_model + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=None) + + with patch( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself; no parameter to inject + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ): + with pytest.raises(ProxyException) as exc_info: + await delete_model( + model_info=ModelInfoDelete(id="no-such-model"), + user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value + + +def test_a_real_authorization_failure_on_the_model_routes_keeps_its_auth_error_type(): + """The other half of the fix: /model/new's credential check is a genuine authorization + failure, so the handler must hand its ProxyException back untouched rather than relabel it.""" + from fastapi import status + + from litellm.proxy._types import ProxyErrorTypes, ProxyException + from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for + + with pytest.raises(ProxyException) as exc_info: + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="gpt-4o", litellm_credential_name="shared-cred"), + user_api_key_dict=UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER), + ) + + wrapped = proxy_exception_for(exc_info.value, default_status_code=status.HTTP_400_BAD_REQUEST) + + assert wrapped is exc_info.value + assert wrapped.code == "403" + assert wrapped.type == ProxyErrorTypes.auth_error.value diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py index e2d89a660c2..cb92d1993fe 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -1063,3 +1063,42 @@ async def test_find_member_if_email_missing_row_raises_documented_400(): "non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead." ) } + + +@pytest.mark.asyncio +async def test_organization_member_add_reports_an_unknown_org_as_a_request_error(): + """/organization/member_add answered 404 with type=auth_error on a valid admin key. The + 404 the route raised must survive the handler's 500 default, and the type must follow it.""" + from fastapi import Request + + from litellm.proxy._types import ( + LitellmUserRoles, + OrganizationMemberAddRequest, + OrgMember, + ProxyErrorTypes, + ProxyException, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.organization_endpoints import ( + organization_member_add, + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock(return_value=None) + + with patch( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself; no parameter to inject + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ): + with pytest.raises(ProxyException) as exc_info: + await organization_member_add( + data=OrganizationMemberAddRequest( + organization_id="no-such-org", + member=OrgMember(role="internal_user", user_id="someone"), + ), + http_request=MagicMock(spec=Request), + user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "404" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 30b2ab86b9a..1395c435c81 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -13519,3 +13519,31 @@ async def test_team_member_update_skips_invalidation_when_no_budget_fields_sent( assert await real_cache.async_get_cache(key="team-1_member-1") == "still-fresh-membership" assert real_spend_counter_cache.in_memory_cache.get_cache(key="spend:team_member:member-1:team-1") == 1.5 + + +@pytest.mark.asyncio +async def test_team_info_reports_a_missing_team_as_a_request_error_not_an_auth_error(): + """/team/info answered 404 with type=auth_error on a valid admin key, so a client + branching on the type retried the credentials instead of the team id.""" + from fastapi import Request + + from litellm.proxy._types import ProxyErrorTypes, ProxyException + from litellm.proxy.management_endpoints import team_endpoints + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + + with patch( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself; no parameter to inject + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ): + with pytest.raises(ProxyException) as exc_info: + await team_endpoints.team_info( + http_request=MagicMock(spec=Request), + team_id="no-such-team", + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "404" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value + assert "Authentication Error" not in exc_info.value.message diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1972245764f..d0cd0c8ae17 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -12518,3 +12518,48 @@ def test_assistants_error_body_is_openai_shaped(client_no_auth): error = response.json()["error"] assert error["type"] == "internal_server_error" assert error["param"] is None + + +@pytest.mark.asyncio +async def test_update_config_reports_an_unsupported_router_setting_as_a_request_error(): + """/config/update answered 400 with type=auth_error for a router setting it does not + support, on a valid admin key, so the type blamed the credentials for a body mistake.""" + from fastapi import Request + + from litellm.proxy._types import ConfigYAML, ProxyErrorTypes, ProxyException + from litellm.proxy.proxy_server import update_config + + body = {"router_settings": {"not_a_router_setting": True}} + request = MagicMock(spec=Request) + request.json = AsyncMock(return_value=body) + + with pytest.raises(ProxyException) as exc_info: + await update_config( + config_info=ConfigYAML(**body), + request=request, + user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.type != ProxyErrorTypes.auth_error.value + + +@pytest.mark.asyncio +async def test_update_config_reports_a_non_admin_caller_as_a_permission_error(): + """A real authorization failure keeps an auth-family type, and 403 is `permission_error` + in OpenAI's contract, which `auth_error` never was.""" + from fastapi import Request + + from litellm.proxy._types import ConfigYAML, ProxyException + from litellm.proxy.proxy_server import update_config + + with pytest.raises(ProxyException) as exc_info: + await update_config( + config_info=ConfigYAML(), + request=MagicMock(spec=Request), + user_api_key_dict=UserAPIKeyAuth(user_id="viewer-1", user_role=LitellmUserRoles.INTERNAL_USER), + ) + + assert exc_info.value.code == "403" + assert exc_info.value.type == "permission_error"