mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
f9051a16bb
commit
0c38a256ee
16 changed files with 343 additions and 176 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue