From 3a98fcae1a858c55ec9e8e971087a50753f66229 Mon Sep 17 00:00:00 2001
From: moe-berri
Date: Thu, 24 Sep 2026 15:50:35 -0700
Subject: [PATCH] feat(team): allow member budgets to overflow into team budget
---
litellm/models/team.py | 13 ++
litellm/proxy/_types.py | 10 ++
litellm/proxy/auth/auth_checks.py | 4 +-
.../management_endpoints/team_endpoints.py | 54 +++++++-
.../spend_tracking/budget_reservation.py | 8 +-
.../proxy/auth/test_auth_checks.py | 92 +++++++++++++
.../test_team_endpoints.py | 102 ++++++++++++++-
.../spend_tracking/test_budget_reservation.py | 123 ++++++++++++++++++
.../src/components/team/TeamInfo.test.tsx | 72 ++++++++++
.../src/components/team/TeamInfo.tsx | 29 +++++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 15 +++
11 files changed, 514 insertions(+), 8 deletions(-)
diff --git a/litellm/models/team.py b/litellm/models/team.py
index 8edf10703b1..46385daba56 100644
--- a/litellm/models/team.py
+++ b/litellm/models/team.py
@@ -7,6 +7,7 @@ budget-window value types and the team-model alias table). Re-exported from
"""
import json
+import math
from datetime import datetime
from typing import Final, Literal, Optional
@@ -137,6 +138,18 @@ class LiteLLM_TeamTableCachedObj(LiteLLM_TeamTable):
last_refreshed_at: float | None = None
+def team_member_budget_allows_overflow(team: LiteLLM_TeamTable, member_max_budget: float | None) -> bool:
+ return (
+ team.metadata is not None
+ and team.metadata.get("allow_team_member_budget_overflow") is True
+ and team.max_budget is not None
+ and math.isfinite(team.max_budget)
+ and team.max_budget > 0
+ and member_max_budget is not None
+ and member_max_budget > 0
+ )
+
+
class LiteLLM_DeletedTeamTable(LiteLLM_TeamTable):
"""Audit record for deleted teams; mirrors the team plus deletion metadata."""
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index b6de36f8423..a70ff849997 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -2095,6 +2095,11 @@ RouterSettingsDict = Annotated[
class NewTeamRequest(TeamBase):
+ allow_team_member_budget_overflow: bool | None = Field(
+ default=None,
+ strict=True,
+ description="Allow positive member budgets to use remaining total team budget. Defaults to false; requires a finite positive team max_budget.",
+ )
router_settings: RouterSettingsDict | None = None
model_aliases: dict | None = None
model_max_budget: GenericBudgetConfigType | None = Field(
@@ -2167,6 +2172,11 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
policies: Optional[List[str]] = None
"""
+ allow_team_member_budget_overflow: bool | None = Field(
+ default=None,
+ strict=True,
+ description="Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.",
+ )
team_id: str # required
team_alias: str | None = None
organization_id: str | None = None
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 67950e603c0..96f597371e3 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -5650,7 +5650,9 @@ async def _check_team_member_budget(
member_budget_row.active_temp_budget_increase(now=now) if member_budget_row is not None else 0.0
)
- if team_member_budget is not None:
+ from litellm.models.team import team_member_budget_allows_overflow
+
+ if team_member_budget is not None and not team_member_budget_allows_overflow(team_object, team_member_budget):
team_member_spend = (loaded_membership.spend if loaded_membership is not None else 0.0) or 0.0
# Read from cross-pod counter (Redis-first) if available
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 8a6cd1218ee..47a72dd01b7 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -557,6 +557,44 @@ def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[s
assert_never(role)
+def _team_member_budget_overflow_metadata(
+ data: NewTeamRequest | UpdateTeamRequest,
+ existing_team: LiteLLM_TeamTable | None = None,
+) -> Mapping[str, JsonValue] | None:
+ supplied_metadata: Final = (
+ data.metadata if "metadata" in data.model_fields_set or existing_team is None else existing_team.metadata
+ )
+ metadata: Final = (
+ TypeAdapter(dict[str, JsonValue]).validate_python(supplied_metadata) if supplied_metadata is not None else None
+ )
+ resulting_metadata: Final = (
+ TypeAdapter(dict[str, JsonValue]).validate_python(
+ MappingProxyType(
+ {
+ **(metadata if metadata is not None else MappingProxyType({})),
+ "allow_team_member_budget_overflow": data.allow_team_member_budget_overflow,
+ }
+ )
+ )
+ if data.allow_team_member_budget_overflow is not None
+ else metadata
+ )
+ enabled: Final = (
+ resulting_metadata.get("allow_team_member_budget_overflow", False) if resulting_metadata is not None else False
+ )
+ if enabled is not None and not isinstance(enabled, bool):
+ raise HTTPException(status_code=400, detail="allow_team_member_budget_overflow must be a boolean")
+ max_budget: Final = (
+ data.max_budget if "max_budget" in data.model_fields_set or existing_team is None else existing_team.max_budget
+ )
+ if enabled is True and (max_budget is None or not math.isfinite(max_budget) or max_budget <= 0):
+ raise HTTPException(
+ status_code=400,
+ detail="allow_team_member_budget_overflow requires a finite positive team max_budget",
+ )
+ return resulting_metadata
+
+
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@@ -1662,9 +1700,10 @@ async def new_team(
if isinstance(data.metadata, dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
+ overflow_metadata: Final = _team_member_budget_overflow_metadata(data)
await validate_team_metadata_if_configured(
operation="create",
- metadata=data.metadata,
+ metadata=overflow_metadata,
existing_metadata=None,
team_id=data.team_id,
team_alias=data.team_alias,
@@ -1690,7 +1729,9 @@ async def new_team(
_model_id = model_dict.id
- data_json = data.json()
+ data_json = data.model_copy(update=MappingProxyType({"metadata": overflow_metadata})).json(
+ exclude=frozenset(("allow_team_member_budget_overflow",))
+ )
## Handle Object Permission - MCP, Vector Stores etc.
await enforce_all_proxy_mcp_servers_grant_is_admin_only(
@@ -2446,7 +2487,10 @@ async def update_team(
existing_model_max_budget=existing_team_row.model_max_budget,
)
- updated_kv = data.json(exclude_unset=True)
+ overflow_metadata: Final = _team_member_budget_overflow_metadata(data, existing_team)
+ updated_kv = data.json(exclude_unset=True, exclude=frozenset(("allow_team_member_budget_overflow",)))
+ if data.allow_team_member_budget_overflow is not None:
+ updated_kv["metadata"] = overflow_metadata
if "model_max_budget" in updated_kv and updated_kv["model_max_budget"] is None:
updated_kv["model_max_budget"] = {}
@@ -2498,9 +2542,9 @@ async def update_team(
if isinstance(existing_team_row.metadata, dict):
if "metadata" not in updated_kv and (_team_member_fields_in_request or _writes_metadata_backed_field):
updated_kv["metadata"] = copy.deepcopy(existing_team_row.metadata)
- elif isinstance(updated_kv.get("metadata"), dict):
+ elif isinstance(update_metadata := updated_kv.get("metadata"), dict):
updated_kv["metadata"] = {
- **updated_kv["metadata"],
+ **update_metadata,
**{
key: existing_team_row.metadata[key]
for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS
diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py
index e28fa2c06a4..155946b50c2 100644
--- a/litellm/proxy/spend_tracking/budget_reservation.py
+++ b/litellm/proxy/spend_tracking/budget_reservation.py
@@ -778,7 +778,13 @@ async def _get_team_member_budget_counter(
member_budget_row.active_temp_budget_increase(now=now) if member_budget_row is not None else 0.0
)
- if team_member_budget is None or team_member_budget <= 0:
+ from litellm.models.team import team_member_budget_allows_overflow
+
+ if (
+ team_member_budget is None
+ or team_member_budget <= 0
+ or team_member_budget_allows_overflow(team_object, team_member_budget)
+ ):
return None
team_member_spend = cast(LiteLLM_TeamMembership, team_membership).spend if team_membership is not None else 0.0
diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py
index 30f5abdbb98..ce00ecce9d0 100644
--- a/tests/test_litellm/proxy/auth/test_auth_checks.py
+++ b/tests/test_litellm/proxy/auth/test_auth_checks.py
@@ -9456,3 +9456,95 @@ def test_can_object_call_model_allows_listed_model_for_key():
)
assert result is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "enabled,team_cap,member_cap,spend,denied",
+ [
+ (None, 10.0, 1.0, 1.0, True),
+ (False, 10.0, 1.0, 2.0, True),
+ (True, 10.0, 1.0, 1.0, False),
+ (True, 10.0, 1.0, 2.0, False),
+ (True, 10.0, 0.0, 0.0, True),
+ (True, 10.0, None, 2.0, False),
+ (True, None, 1.0, 2.0, True),
+ (True, 0.0, 1.0, 2.0, True),
+ (True, float("inf"), 1.0, 2.0, True),
+ (True, float("nan"), 1.0, 2.0, True),
+ ("true", 10.0, 1.0, 2.0, True),
+ ],
+)
+async def test_team_member_overflow_preserves_explicit_zero_and_requires_bounded_team(
+ monkeypatch: pytest.MonkeyPatch,
+ enabled: bool | str | None,
+ team_cap: float | None,
+ member_cap: float | None,
+ spend: float,
+ denied: bool,
+) -> None:
+ from litellm.caching import DualCache
+ from litellm.models.team_membership import LiteLLM_TeamMembership
+ from litellm.proxy import proxy_server
+ from litellm.proxy.utils import ProxyLogging
+
+ cache: Final = UserApiKeyCache()
+ monkeypatch.setattr(proxy_server, "spend_counter_cache", DualCache())
+ membership: Final = LiteLLM_TeamMembership(
+ user_id="overflow-member", team_id="overflow-team", spend=spend,
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=member_cap),
+ )
+ check: Final = _check_team_member_budget(
+ team_object=LiteLLM_TeamTable(
+ team_id="overflow-team", max_budget=team_cap,
+ metadata={"allow_team_member_budget_overflow": enabled},
+ ),
+ user_object=LiteLLM_UserTable(user_id="overflow-member"),
+ valid_token=UserAPIKeyAuth(user_id="overflow-member", team_id="overflow-team"),
+ prisma_client=None, user_api_key_cache=cache,
+ proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
+ team_membership=membership, team_membership_loaded=True,
+ )
+ if denied:
+ with pytest.raises(litellm.BudgetExceededError) as error:
+ await check
+ assert error.value.max_budget == member_cap
+ assert error.value.current_cost == spend
+ else:
+ assert await check is None
+ assert membership.spend == spend
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("enabled", [False, True])
+async def test_team_member_overflow_uses_live_default_after_temporary_grant_expiry(enabled: bool) -> None:
+ from litellm.models.team_membership import LiteLLM_TeamMembership
+ from litellm.proxy.utils import ProxyLogging
+
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(
+ key="team_member_default_budget:overflow-default",
+ value=LiteLLM_BudgetTable(budget_id="overflow-default", max_budget=1.0),
+ )
+ check: Final = _check_team_member_budget(
+ team_object=LiteLLM_TeamTable(
+ team_id="overflow-default-team", max_budget=10.0,
+ metadata={"team_member_budget_id": "overflow-default", "allow_team_member_budget_overflow": enabled},
+ ),
+ user_object=None, valid_token=UserAPIKeyAuth(user_id="overflow-default-member"),
+ prisma_client=MagicMock(), user_api_key_cache=cache,
+ proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
+ team_membership=LiteLLM_TeamMembership(
+ user_id="overflow-default-member", team_id="overflow-default-team", spend=2.0,
+ litellm_budget_table=LiteLLM_BudgetTable(
+ temp_budget_increase=5.0, temp_budget_expiry=datetime.now(timezone.utc) - timedelta(days=1),
+ ),
+ ),
+ team_membership_loaded=True,
+ )
+ if enabled:
+ assert await check is None
+ else:
+ with pytest.raises(litellm.BudgetExceededError) as error:
+ await check
+ assert error.value.max_budget == 1.0
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 6d902cb7fec..e28f3e0b6fc 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
import httpx
import pytest
-from fastapi import HTTPException
+from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from pydantic import ValidationError
@@ -17228,3 +17228,103 @@ def test_team_export_csv_escapes_formula_aliases_and_keeps_dash_placeholder():
assert record["Key Alias"] == "'@cmd"
assert record["User ID"] == "-"
assert record["User Email"] == "-"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["post", "patch"])
+@pytest.mark.parametrize("cap", [None, 0.0, -1.0, float("inf"), float("nan")])
+async def test_team_overflow_rejects_removing_or_invalidating_enabled_hard_cap(kind: str, cap: float | None) -> None:
+ with pytest.raises(ProxyException) as error:
+ await _drive_team_write(
+ kind, existing_metadata={"allow_team_member_budget_overflow": True},
+ existing_kwargs={"max_budget": 10.0}, payload={"max_budget": cap},
+ )
+ assert str(error.value.code) == "400"
+ assert "max_budget" in str(error.value.message)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["post", "patch"])
+@pytest.mark.parametrize("enabled", [False, True])
+async def test_team_overflow_update_preserves_metadata_and_toggles_policy(kind: str, enabled: bool) -> None:
+ recorded: Final = []
+ stored: Final = {"cost_center": "budget-test", "team_member_budget_id": "member-budget"}
+ with _configured_team_metadata_validator(_recording_validator(recorded)):
+ _, write = await _drive_team_write(
+ kind, existing_metadata=stored, existing_kwargs={"max_budget": 10.0},
+ payload={"allow_team_member_budget_overflow": enabled},
+ )
+ assert write.call_args.kwargs["data"]["metadata"] == {**stored, "allow_team_member_budget_overflow": enabled}
+ assert "allow_team_member_budget_overflow" not in write.call_args.kwargs["data"]
+ assert recorded[0].metadata == {"cost_center": "budget-test", "allow_team_member_budget_overflow": enabled}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["post", "patch"])
+async def test_team_overflow_can_disable_while_removing_hard_cap(kind: str) -> None:
+ _, write = await _drive_team_write(
+ kind, existing_metadata={"allow_team_member_budget_overflow": True},
+ existing_kwargs={"max_budget": 10.0},
+ payload={"max_budget": None, "allow_team_member_budget_overflow": False},
+ )
+ assert write.call_args.kwargs["data"]["max_budget"] is None
+ assert write.call_args.kwargs["data"]["metadata"] == {"allow_team_member_budget_overflow": False}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["post", "patch"])
+@pytest.mark.parametrize("payload", [
+ {"allow_team_member_budget_overflow": True},
+ {"metadata": {"allow_team_member_budget_overflow": True}},
+])
+async def test_team_overflow_cannot_be_enabled_without_hard_cap(kind: str, payload: dict[str, object]) -> None:
+ with pytest.raises(ProxyException) as error:
+ await _drive_team_write(kind, payload=payload)
+ assert str(error.value.code) == "400"
+ assert "finite positive team max_budget" in str(error.value.message)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("payload", [
+ {"allow_team_member_budget_overflow": True},
+ {"metadata": {"allow_team_member_budget_overflow": True}},
+])
+async def test_team_admin_cannot_enable_overflow_through_either_input(payload: dict[str, object]) -> None:
+ with _team_admin_may_edit("max_budget"):
+ with pytest.raises(ProxyException) as error:
+ await _drive_team_write(
+ "post", existing_kwargs={"max_budget": 10.0, "members_with_roles": [{"user_id": "member", "role": "admin"}]},
+ payload=payload,
+ user=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
+ )
+ assert str(error.value.code) == "403"
+ assert "permission" in str(error.value.message)
+
+
+@pytest.mark.asyncio
+async def test_team_overflow_still_runs_custom_metadata_validator() -> None:
+ recorded: Final = []
+ with _configured_team_metadata_validator(_recording_validator(recorded, valid=False, error_message="cost center required")):
+ with pytest.raises(ProxyException) as error:
+ await _drive_team_write(
+ "post", existing_kwargs={"max_budget": 10.0}, payload={"allow_team_member_budget_overflow": True},
+ )
+ assert str(error.value.code) == "400"
+ assert "cost center required" in str(error.value.message)
+ assert recorded[0].metadata == {"allow_team_member_budget_overflow": True}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cap", [None, 0.0, -1.0, float("inf"), float("nan")])
+async def test_new_team_overflow_requires_finite_positive_hard_cap(mock_db_client, mock_admin_auth, cap: float | None) -> None:
+ from litellm.proxy._types import NewTeamRequest
+ from litellm.proxy.management_endpoints.team_endpoints import new_team
+
+ mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0)
+ with pytest.raises(ProxyException) as error:
+ await new_team(
+ data=NewTeamRequest(max_budget=cap, allow_team_member_budget_overflow=True),
+ http_request=MagicMock(spec=Request), user_api_key_dict=mock_admin_auth,
+ )
+ assert str(error.value.code) == "400"
+ assert "max_budget" in str(error.value.message)
diff --git a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py
index 9df8e6f4d67..9eee926a81c 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py
@@ -342,3 +342,126 @@ async def test_release_unbound_budget_reservation_leaves_a_bound_one_to_its_call
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(reservation["reserved_cost"])
assert reservation["finalized"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("enabled", [False, True])
+async def test_overflow_reserves_crossing_request_only_against_team_cap(
+ spend_counter_cache: DualCache, enabled: bool,
+) -> None:
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(
+ key=team_membership_reservation_cache_key(user_id="overflow-member", team_id="overflow-team"),
+ value=LiteLLM_TeamMembership(
+ user_id="overflow-member", team_id="overflow-team", spend=0.0,
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=1e-9),
+ ),
+ )
+ reserve: Final = reserve_budget_for_request(
+ request_body={"model": "gpt-6-astra", "input": "hello", "max_output_tokens": 10},
+ route="/v1/responses", llm_router=None,
+ valid_token=UserAPIKeyAuth(token="overflow-key", user_id="overflow-member", team_id="overflow-team"),
+ team_object=LiteLLM_TeamTable(
+ team_id="overflow-team", max_budget=10.0,
+ metadata={"allow_team_member_budget_overflow": enabled},
+ ),
+ user_object=LiteLLM_UserTable(user_id="overflow-member"), prisma_client=None,
+ user_api_key_cache=cache, proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
+ fail_closed_budget_enforcement=True,
+ )
+ if not enabled:
+ with pytest.raises(litellm.BudgetExceededError, match="TeamMember"):
+ await reserve
+ return
+ reservation: Final = await reserve
+ assert reservation is not None
+ assert reservation["reserved_cost"] > 1e-9
+ assert [entry["counter_key"] for entry in reservation["entries"]] == ["spend:team:overflow-team"]
+ assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:overflow-team") == reservation["reserved_cost"]
+ await release_unbound_budget_reservation(reservation)
+
+
+@pytest.mark.asyncio
+async def test_overflow_keeps_every_other_budget_reservation_guard() -> None:
+ from litellm.models.project import LiteLLM_ProjectTable
+ from litellm.proxy._types import LiteLLM_OrganizationTable
+ from litellm.proxy.common_utils.user_api_key_cache import project_cache_key
+ from litellm.proxy.spend_tracking.budget_reservation import _get_budget_counters
+
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(
+ key=team_membership_reservation_cache_key(user_id="overflow-member", team_id="overflow-team"),
+ value=LiteLLM_TeamMembership(
+ user_id="overflow-member", team_id="overflow-team", spend=1.0,
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0),
+ ),
+ )
+ await cache.async_set_cache(
+ key="org_id:overflow-org:with_budget",
+ value=LiteLLM_OrganizationTable(
+ organization_id="overflow-org", budget_id="org-budget", created_by="admin", updated_by="admin",
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=20.0),
+ ),
+ )
+ await cache.async_set_cache(
+ key=project_cache_key("overflow-project"),
+ value=LiteLLM_ProjectTable(project_id="overflow-project", team_id="overflow-team", litellm_budget_table=LiteLLM_BudgetTable(max_budget=5.0)),
+ )
+ counters: Final = await _get_budget_counters(
+ request_body={},
+ valid_token=UserAPIKeyAuth(
+ token="overflow-key", user_id="overflow-member", team_id="overflow-team",
+ project_id="overflow-project", max_budget=2.0,
+ ),
+ team_object=LiteLLM_TeamTable(
+ team_id="overflow-team", organization_id="overflow-org", max_budget=10.0,
+ budget_limits=[{"budget_duration": "1d", "max_budget": 3.0}],
+ metadata={"allow_team_member_budget_overflow": True},
+ ),
+ user_object=LiteLLM_UserTable(user_id="overflow-member", max_budget=4.0),
+ prisma_client=None, user_api_key_cache=cache,
+ proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), apply_user_budget_to_team_keys=True,
+ )
+ assert [(counter.entity_type, counter.max_budget) for counter in counters] == [
+ ("Key", 2.0), ("Team", 10.0), ("Team", 3.0), ("User", 4.0), ("Organization", 20.0), ("Project", 5.0),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_overflow_requests_compete_for_remaining_team_reservation_capacity(spend_counter_cache: DualCache) -> None:
+ import asyncio
+
+ body: Final = {"model": "gpt-6-astra", "input": "hello", "max_output_tokens": 10}
+ estimated: Final = estimate_request_max_cost(request_body=body, route="/v1/responses", llm_router=None)
+ assert estimated > 0
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(
+ key=team_membership_reservation_cache_key(user_id="concurrent-member", team_id="concurrent-team"),
+ value=LiteLLM_TeamMembership(
+ user_id="concurrent-member", team_id="concurrent-team", spend=1.0,
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0),
+ ),
+ )
+
+ async def reserve() -> object:
+ return await reserve_budget_for_request(
+ request_body=body, route="/v1/responses", llm_router=None,
+ valid_token=UserAPIKeyAuth(token="concurrent-key", user_id="concurrent-member", team_id="concurrent-team"),
+ team_object=LiteLLM_TeamTable(
+ team_id="concurrent-team", max_budget=estimated * 1.5,
+ metadata={"allow_team_member_budget_overflow": True},
+ ),
+ user_object=LiteLLM_UserTable(user_id="concurrent-member"), prisma_client=None,
+ user_api_key_cache=cache, proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
+ fail_closed_budget_enforcement=True,
+ )
+
+ results: Final = await asyncio.gather(reserve(), reserve(), return_exceptions=True)
+ admitted: Final = tuple(result for result in results if isinstance(result, dict))
+ rejected: Final = tuple(result for result in results if isinstance(result, litellm.BudgetExceededError))
+ assert len(admitted) == 1
+ assert len(rejected) == 1
+ assert "Team=concurrent-team" in str(rejected[0])
+ assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:concurrent-team") == pytest.approx(estimated)
+ await release_unbound_budget_reservation(admitted[0])
+ assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:concurrent-team") == pytest.approx(0.0)
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 18b1505e342..d48a5d59bb5 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -2162,6 +2162,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
const wireBody = (payload: Record) => JSON.parse(JSON.stringify(payload)) as Record;
const alwaysSent = {
+ allow_team_member_budget_overflow: false,
team_id: "123",
team_alias: "Test Team",
models: ["gpt-4"],
@@ -3005,3 +3006,74 @@ describe("TeamInfoView - disable_global_guardrails switch gating", () => {
expect(await screen.findByRole("switch", { name: /Disable all global guardrails/i })).toBeInTheDocument();
});
});
+
+describe("TeamInfoView member budget overflow", () => {
+ const props = {
+ teamId: "123",
+ onUpdate: vi.fn(),
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ is_team_admin: true,
+ is_proxy_admin: true,
+ userModels: ["gpt-6-astra"],
+ editTeam: false,
+ };
+ beforeEach(seedDefaultMocks);
+ afterEach(() => vi.clearAllMocks());
+
+ const openOverflowEditor = async (enabled: boolean) => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ max_budget: 100,
+ metadata: { allow_team_member_budget_overflow: enabled, cost_center: "keep-me" },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({
+ data: { ...createMockTeamData().team_info, organization_id: "", keys: [] },
+ team_id: "123",
+ });
+ renderWithProviders();
+ await screen.findByRole("tab", { name: "Settings" });
+ fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
+ await screen.findByText(`Member Budget Overflow: ${enabled ? "Allowed" : "Off"}`);
+ fireEvent.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ it.each([false, true])("saves the overflow switch from %s while retaining unrelated metadata", async (enabled) => {
+ await openOverflowEditor(enabled);
+ fireEvent.click(screen.getByText("Team Member Settings"));
+ const toggle = await screen.findByRole("switch", { name: "Allow member budget overflow" });
+ if (enabled) expect(toggle).toBeChecked();
+ else expect(toggle).not.toBeChecked();
+ fireEvent.click(toggle);
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
+ expect(payload.allow_team_member_budget_overflow).toBe(!enabled);
+ expect(payload.metadata.cost_center).toBe("keep-me");
+ expect(payload.metadata).not.toHaveProperty("allow_team_member_budget_overflow");
+ });
+
+ it("preserves enabled overflow when saving with member settings closed", async () => {
+ await openOverflowEditor(true);
+ fireEvent.change(screen.getByLabelText("Team Name"), { target: { value: "Renamed team" } });
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toMatchObject({
+ team_alias: "Renamed team",
+ allow_team_member_budget_overflow: true,
+ metadata: { cost_center: "keep-me" },
+ });
+ });
+
+ it("prevents removing the team cap while overflow is enabled", async () => {
+ await openOverflowEditor(true);
+ fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "" } });
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+ expect(
+ await screen.findByText("Set a positive team Max Budget to allow member budget overflow"),
+ ).toBeInTheDocument();
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index d9e308e9d6f..4f882bde0db 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -121,6 +121,7 @@ import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
const UI_MANAGED_METADATA_KEYS: ReadonlySet = new Set([
+ "allow_team_member_budget_overflow",
"logging",
"secret_manager_settings",
"soft_budget_alerting_emails",
@@ -347,6 +348,7 @@ const teamUpdateFieldsSchema = z.object({
soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
default_team_member_models: z.array(z.string()).optional(),
team_member_budget: numericInputSchema,
+ allow_team_member_budget_overflow: z.boolean().optional(),
team_member_budget_duration: z.string().nullish(),
team_member_key_duration: z.string().optional(),
team_member_tpm_limit: numericInputSchema,
@@ -429,6 +431,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
soft_budget_alerting_emails: "",
default_team_member_models: [],
team_member_budget: undefined,
+ allow_team_member_budget_overflow: false,
team_member_budget_duration: undefined,
team_member_key_duration: undefined,
team_member_tpm_limit: undefined,
@@ -479,6 +482,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
: "",
default_team_member_models: info.default_team_member_models || [],
team_member_budget: info.team_member_budget_table?.max_budget,
+ allow_team_member_budget_overflow: info.metadata?.allow_team_member_budget_overflow === true,
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
@@ -553,6 +557,16 @@ const TeamInfoView: React.FC = ({
const teamUpdateSchema = useMemo(
() =>
teamUpdateFieldsSchema.superRefine((values, ctx) => {
+ if (
+ values.allow_team_member_budget_overflow &&
+ (!Number.isFinite(Number(values.max_budget)) || Number(values.max_budget) <= 0)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Set a positive team Max Budget to allow member budget overflow",
+ path: ["max_budget"],
+ });
+ }
if (!isParsableJson(values.secret_manager_settings)) {
ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] });
}
@@ -968,6 +982,7 @@ const TeamInfoView: React.FC = ({
const updateData: any = {
team_id: teamId,
+ allow_team_member_budget_overflow: values.allow_team_member_budget_overflow === true,
team_alias: values.team_alias,
models: normalizeTeamModelSelection(values.models),
tpm_limit: sanitizeNumeric(values.tpm_limit),
@@ -1539,6 +1554,16 @@ const TeamInfoView: React.FC = ({
/>
)}
+
+ {({ id, value, onChange }) => (
+
+ )}
+
= ({
Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}
+
+ Member Budget Overflow:{" "}
+ {info.metadata?.allow_team_member_budget_overflow === true ? "Allowed" : "Off"}
+
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
TPM Limit: {info.team_member_budget_table?.tpm_limit ?? "No Limit"}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 5d0bb56936a..86c1fcd8041 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -36406,6 +36406,11 @@ export interface components {
* @default []
*/
admins: string[];
+ /**
+ * Allow Team Member Budget Overflow
+ * @description Allow positive member budgets to use remaining total team budget. Defaults to false; requires a finite positive team max_budget.
+ */
+ allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */
@@ -37450,6 +37455,11 @@ export interface components {
PatchTeamRequest: {
/** Access Group Ids */
access_group_ids?: string[] | null;
+ /**
+ * Allow Team Member Budget Overflow
+ * @description Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.
+ */
+ allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */
@@ -44830,6 +44840,11 @@ export interface components {
UpdateTeamRequest: {
/** Access Group Ids */
access_group_ids?: string[] | null;
+ /**
+ * Allow Team Member Budget Overflow
+ * @description Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.
+ */
+ allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */