mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* fix(proxy): preserve discovery after budget exhaustion (#48809) * refactor(proxy): decide budget exemption once (#48809) * refactor(proxy): centralize request budget policy (#48809) * fix(proxy): keep custom pass-through budget checks (#48809)
This commit is contained in:
parent
6eddc4a979
commit
941f52f26f
4 changed files with 213 additions and 39 deletions
|
|
@ -1086,13 +1086,31 @@ def model_max_budget_of(holder: ModelMaxBudgetHolder) -> Mapping[str, object] |
|
|||
|
||||
|
||||
def route_skips_budget_checks(route: str) -> bool:
|
||||
return route not in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES and (
|
||||
route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route)
|
||||
if route in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES:
|
||||
return False
|
||||
if route in MODEL_DISCOVERY_ROUTES:
|
||||
return True
|
||||
if RouteChecks.is_llm_api_route(route=route):
|
||||
return False
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( # noqa: PLC0415 # pass-through endpoints import the auth stack
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
return not InitPassThroughEndpointHelpers.is_registered_pass_through_route(route=route)
|
||||
|
||||
def request_skips_budget_checks(route: str, model: str | list[str] | None, llm_router: Router | None) -> bool:
|
||||
return route_skips_budget_checks(route=route) or is_model_cost_zero(model=model, llm_router=llm_router)
|
||||
|
||||
def request_skips_budget_checks(
|
||||
route: str,
|
||||
model: str | list[str] | None,
|
||||
llm_router: Router | None,
|
||||
*,
|
||||
request_body: Mapping[str, object] | None = None,
|
||||
) -> bool:
|
||||
if route_skips_budget_checks(route=route):
|
||||
return True
|
||||
if request_body is not None and is_mcp_discovery_request(route=route, request_body=request_body):
|
||||
return True
|
||||
return is_model_cost_zero(model=model, llm_router=llm_router)
|
||||
|
||||
|
||||
async def common_checks(
|
||||
|
|
@ -1142,10 +1160,11 @@ async def common_checks(
|
|||
team_id=valid_token.team_id if valid_token is not None else None,
|
||||
)
|
||||
|
||||
skip_all_budget_checks: Final = (
|
||||
skip_budget_checks
|
||||
or route_skips_budget_checks(route=route)
|
||||
or is_mcp_discovery_request(route=route, request_body=_typed_request_body(request_body))
|
||||
skip_all_budget_checks: Final = skip_budget_checks or request_skips_budget_checks(
|
||||
route=route,
|
||||
model=_model,
|
||||
llm_router=llm_router,
|
||||
request_body=_typed_request_body(request_body),
|
||||
)
|
||||
|
||||
membership_user_id: Final = (
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
|
|||
get_team_object,
|
||||
get_user_object,
|
||||
get_user_role,
|
||||
is_mcp_discovery_request,
|
||||
is_model_cost_zero,
|
||||
is_user_proxy_admin,
|
||||
is_valid_fallback_model,
|
||||
|
|
@ -88,6 +87,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
|
|||
key_access_group_grants_model,
|
||||
key_model_aliases_for_auth_check,
|
||||
model_max_budget_of,
|
||||
request_skips_budget_checks,
|
||||
resolve_and_validate_end_user_id,
|
||||
resolve_default_end_user_budget,
|
||||
route_skips_budget_checks,
|
||||
|
|
@ -2497,15 +2497,9 @@ async def user_api_key_auth_builder(
|
|||
llm_router=llm_router,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data)
|
||||
if not skip_budget_checks and model is not None and llm_router is not None:
|
||||
from litellm.proxy.auth.auth_checks import is_model_cost_zero
|
||||
|
||||
skip_budget_checks = ( # rebind-ok: pre-existing rebinding on a rename-only line
|
||||
is_model_cost_zero(model=model, llm_router=llm_router)
|
||||
)
|
||||
if skip_budget_checks:
|
||||
verbose_proxy_logger.info("Skipping all budget checks for zero-cost model: %s", model)
|
||||
skip_budget_checks = request_skips_budget_checks(
|
||||
route=route, model=model, llm_router=llm_router, request_body=request_data
|
||||
)
|
||||
|
||||
# Fetch project object for JWT path if project_id is set
|
||||
_jwt_project_obj = None
|
||||
|
|
@ -3019,15 +3013,9 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
|
|||
llm_router=llm_router,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data)
|
||||
if not skip_budget_checks and model is not None and llm_router is not None:
|
||||
from litellm.proxy.auth.auth_checks import is_model_cost_zero
|
||||
|
||||
skip_budget_checks = is_model_cost_zero( # rebind-ok: pre-existing rebinding on a rename-only line
|
||||
model=model, llm_router=llm_router
|
||||
)
|
||||
if skip_budget_checks:
|
||||
verbose_proxy_logger.info("Skipping all budget checks for zero-cost model: %s", model)
|
||||
skip_budget_checks: Final = request_skips_budget_checks(
|
||||
route=route, model=model, llm_router=llm_router, request_body=request_data
|
||||
)
|
||||
|
||||
# Check 3. Check if user is in their team budget
|
||||
if not skip_budget_checks and valid_token.team_member_spend is not None:
|
||||
|
|
@ -4088,11 +4076,7 @@ def _should_skip_budget_checks(
|
|||
llm_router=llm_router,
|
||||
team_id=team_id,
|
||||
)
|
||||
if is_mcp_discovery_request(route=route, request_body=request_data):
|
||||
return True
|
||||
if model is not None and llm_router is not None:
|
||||
return is_model_cost_zero(model=model, llm_router=llm_router)
|
||||
return False
|
||||
return request_skips_budget_checks(route=route, model=model, llm_router=llm_router, request_body=request_data)
|
||||
|
||||
|
||||
def _resolve_request_principal(request: Request, valid_token: UserAPIKeyAuth) -> Principal:
|
||||
|
|
|
|||
|
|
@ -11202,9 +11202,47 @@ def test_route_skips_budget_checks_marks_only_spend_free_routes() -> None:
|
|||
assert route_skips_budget_checks(route="/v1/chat/completions") is False
|
||||
|
||||
|
||||
def test_request_skips_budget_checks_extends_route_rule_with_zero_cost_models() -> None:
|
||||
assert request_skips_budget_checks(route="/v1/models", model=None, llm_router=None) is True
|
||||
assert request_skips_budget_checks(route="/v1/chat/completions", model=None, llm_router=None) is False
|
||||
@pytest.mark.parametrize(
|
||||
"route, request_body, model, expected",
|
||||
[
|
||||
("/v1/models", None, None, True),
|
||||
("/v1/models", None, "paid", True),
|
||||
("/v1/chat/completions", None, None, False),
|
||||
("/v1/chat/completions", None, "free", True),
|
||||
("/v1/chat/completions", None, "paid", False),
|
||||
("/mcp", {"method": "tools/list"}, "paid", True),
|
||||
("/mcp", {"method": "tools/call"}, "paid", False),
|
||||
("/mcp", None, "paid", False),
|
||||
("/mcp/tools/call", {"method": "initialize"}, "paid", False),
|
||||
("/health/services", None, "paid", False),
|
||||
],
|
||||
)
|
||||
def test_request_skips_budget_checks_for_non_spending_requests(
|
||||
route: str, request_body: Mapping[str, object] | None, model: str | None, expected: bool
|
||||
) -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "free",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"input_cost_per_token": 0,
|
||||
"output_cost_per_token": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "paid",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"input_cost_per_token": 1,
|
||||
"output_cost_per_token": 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert (
|
||||
request_skips_budget_checks(route=route, model=model, llm_router=router, request_body=request_body) is expected
|
||||
)
|
||||
|
||||
|
||||
def _agent_model_ceiling_resolver(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import os
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
|
|
@ -17,6 +17,7 @@ from typing import Final
|
|||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
import fastapi
|
||||
import orjson
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
|
@ -24,6 +25,8 @@ from starlette.datastructures import UploadFile
|
|||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server
|
||||
from litellm.proxy import utils as proxy_utils
|
||||
from litellm.proxy.common_utils import user_api_key_cache as key_cache_utils
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
|
|
@ -85,6 +88,7 @@ from litellm.proxy.auth.user_api_key_auth import (
|
|||
user_api_key_auth,
|
||||
user_api_key_auth_websocket_for_model,
|
||||
)
|
||||
from litellm.proxy.pass_through_endpoints import pass_through_endpoints as pass_through
|
||||
from litellm.proxy.spend_tracking.carried_budget_state import carried_budget_metadata
|
||||
from tests.unit.proxy.db.fake_prisma_engine import engine_call
|
||||
from tests._master_key import MASTER_KEY
|
||||
|
|
@ -11173,6 +11177,30 @@ async def test_temp_budget_increase_applied_for_cached_key():
|
|||
assert cached_after.max_budget == 2.0
|
||||
|
||||
|
||||
BUDGETED_PASS_THROUGH_ROUTE: Final = "/custom-provider/infer"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_budgeted_route() -> Iterator[None]:
|
||||
pass_through.InitPassThroughEndpointHelpers.add_exact_path_route(
|
||||
app=fastapi.FastAPI(),
|
||||
path=BUDGETED_PASS_THROUGH_ROUTE,
|
||||
target="https://provider.example/infer",
|
||||
custom_headers=None,
|
||||
forward_headers=None,
|
||||
merge_query_params=None,
|
||||
dependencies=None,
|
||||
cost_per_request=1.0,
|
||||
endpoint_id="budget-regression",
|
||||
auth=True,
|
||||
methods=["POST"],
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
pass_through.InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id="budget-regression")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"team_member_spend, expect_blocked",
|
||||
|
|
@ -11182,7 +11210,11 @@ async def test_temp_budget_increase_applied_for_cached_key():
|
|||
(2.39, False),
|
||||
],
|
||||
)
|
||||
async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spend, expect_blocked):
|
||||
@pytest.mark.usefixtures("registered_budgeted_route")
|
||||
@pytest.mark.parametrize(
|
||||
"route", ["/v1/messages", "/health/services", "/health/test_connection", BUDGETED_PASS_THROUGH_ROUTE]
|
||||
)
|
||||
async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spend, expect_blocked, route):
|
||||
"""A team member counter sitting exactly at the cap (where a resized reservation
|
||||
lands it) must be rejected by the cached-key auth path like every other budget check."""
|
||||
from litellm.proxy._types import LiteLLM_TeamMembership, LiteLLM_TeamTableCachedObj
|
||||
|
|
@ -11227,8 +11259,8 @@ async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spe
|
|||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/messages"
|
||||
mock_request.method = "POST"
|
||||
mock_request.url.path = route
|
||||
mock_request.method = "POST" if route == "/v1/messages" else "GET"
|
||||
mock_request.headers = {"authorization": f"Bearer {api_key}"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = SimpleNamespace()
|
||||
|
|
@ -11276,6 +11308,107 @@ async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spe
|
|||
assert f"TeamMember={user_id}:{team_id}" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("key_max_budget, member_max_budget", [(None, 1.0), (1.0, None)])
|
||||
@pytest.mark.parametrize(
|
||||
"route, expect_blocked",
|
||||
[
|
||||
("/models", False),
|
||||
("/v1/models", False),
|
||||
("/utils/model_info", False),
|
||||
("/model/info", False),
|
||||
("/key/info", False),
|
||||
("/user/info", False),
|
||||
("/v1/chat/completions", True),
|
||||
("/v1/messages", True),
|
||||
],
|
||||
)
|
||||
async def test_cached_key_budget_preserves_non_spending_access(
|
||||
key_max_budget: float | None,
|
||||
member_max_budget: float | None,
|
||||
route: str,
|
||||
expect_blocked: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api_key: Final = "sk-budget-route-fixture"
|
||||
hashed_token: Final = proxy_utils.hash_token(api_key)
|
||||
team_id: Final = "budget-route-team"
|
||||
user_id: Final = "budget-route-user"
|
||||
cache: Final = DualCache()
|
||||
await cache_key_object(
|
||||
hashed_token=hashed_token,
|
||||
user_api_key_obj=UserAPIKeyAuth(
|
||||
token=hashed_token,
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
team_member_spend=2.0,
|
||||
spend=2.0,
|
||||
max_budget=key_max_budget,
|
||||
),
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
await cache.async_set_cache(key=f"team_id:{team_id}", value=LiteLLM_TeamTableCachedObj(team_id=team_id))
|
||||
await cache.async_set_cache(
|
||||
key=user_id,
|
||||
value=LiteLLM_UserTable(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
)
|
||||
await cache.async_set_cache(
|
||||
key=key_cache_utils.team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
|
||||
value=LiteLLM_TeamMembership(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
spend=2.0,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=member_max_budget),
|
||||
),
|
||||
)
|
||||
await cache.async_set_cache(key=f"spend:team_member:{user_id}:{team_id}", value=2.0)
|
||||
await cache.async_set_cache(key=f"spend:key:{hashed_token}", value=2.0)
|
||||
request: Final = Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"path": route,
|
||||
"method": ("POST" if route in ("/v1/chat/completions", "/v1/messages") else "GET"),
|
||||
"headers": [(b"authorization", f"Bearer {api_key}".encode())],
|
||||
"query_string": b"",
|
||||
}
|
||||
)
|
||||
proxy_logging: Final = MagicMock()
|
||||
proxy_logging.budget_alerts = AsyncMock()
|
||||
proxy_logging.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
litellm.proxy.proxy_server,
|
||||
"general_settings",
|
||||
{"disable_budget_reservation": True},
|
||||
)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "spend_counter_cache", cache)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_logging_obj", proxy_logging)
|
||||
|
||||
async def authenticate() -> UserAPIKeyAuth:
|
||||
return await user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=f"Bearer {api_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
if expect_blocked:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await authenticate()
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
return
|
||||
|
||||
result: Final = await authenticate()
|
||||
assert result.user_id == user_id
|
||||
assert result.team_id == team_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"expiry_offset, expect_blocked",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue