diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 0ad4258fb04..5b85cde5e77 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5259,10 +5259,24 @@ async def generate_key_helper_fn( if isinstance(saved_token["metadata"], str): saved_token["metadata"] = json.loads(saved_token["metadata"]) if isinstance(saved_token["permissions"], str): - if "get_spend_routes" in saved_token["permissions"] and premium_user is not True: - raise ValueError("get_spend_routes permission is only available for LiteLLM Enterprise users") - - saved_token["permissions"] = json.loads(saved_token["permissions"]) + parsed_permissions = json.loads(saved_token["permissions"]) + # Refuse only when the permission is requested (truthy). An explicit + # false must not trip a substring check on the serialized JSON. + if ( + isinstance(parsed_permissions, dict) + and parsed_permissions.get("get_spend_routes") + and premium_user is not True + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "get_spend_routes permission is only available for " + f"LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}" + ) + }, + ) + saved_token["permissions"] = parsed_permissions if isinstance(saved_token["model_max_budget"], str): saved_token["model_max_budget"] = json.loads(saved_token["model_max_budget"]) router_settings = cast(dict | None, saved_token.get("router_settings")) diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index d6ee9b1d29e..537fcb623cf 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -951,6 +951,90 @@ async def test_generate_key_helper_fn_with_access_group_ids(monkeypatch): assert captured_key_data.get("access_group_ids") == ["ag-1", "ag-2"] +@pytest.mark.asyncio +async def test_generate_key_helper_fn_get_spend_routes_non_premium_returns_403( + monkeypatch, +): + """Without Enterprise, requesting get_spend_routes must be a 403 that + names the licence — not a bare 500 Internal Server Error. (#47011)""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id=None) + ) + mock_prisma_client.db.query_raw = AsyncMock(return_value=[]) + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock( + token="hashed_token_spend", + litellm_budget_table=None, + object_permission=None, + created_at=None, + updated_at=None, + ) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + with pytest.raises(HTTPException) as exc_info: + await generate_key_helper_fn( + request_type="key", + table_name="key", + user_id="test-user", + permissions={"get_spend_routes": True}, + ) + assert exc_info.value.status_code == 403 + detail = str(exc_info.value.detail) + assert "get_spend_routes" in detail + assert "Enterprise" in detail + + +@pytest.mark.asyncio +async def test_generate_key_helper_fn_get_spend_routes_false_allowed_non_premium( + monkeypatch, +): + """Explicit get_spend_routes=false must not be refused on OSS. (#47011)""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id=None) + ) + mock_prisma_client.db.query_raw = AsyncMock(return_value=[]) + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock( + token="hashed_token_spend_false", + litellm_budget_table=None, + object_permission=None, + created_at=None, + updated_at=None, + ) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + result = await generate_key_helper_fn( + request_type="key", + table_name="key", + user_id="test-user", + permissions={"get_spend_routes": False}, + ) + assert result is not None + assert result.get("token") or result.get("key") or result.get("token_id") + + @pytest.mark.asyncio async def test_generate_key_helper_fn_with_budget_fallbacks(monkeypatch): """Regression: /key/generate must accept `budget_fallbacks` end-to-end.