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 */