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
This commit is contained in:
mateo-berri 2026-09-03 04:58:59 -07:00
parent f9051a16bb
commit 0c38a256ee
16 changed files with 343 additions and 176 deletions

View file

@ -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,
)

View file

@ -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:

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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:

View file

@ -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(

View file

@ -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(

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"