mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
feat(team): allow member budgets to overflow into team budget
This commit is contained in:
parent
4aa3ff47fe
commit
3a98fcae1a
11 changed files with 514 additions and 8 deletions
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2162,6 +2162,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
|
|||
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
|
||||
|
||||
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(<TeamInfoView {...props} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import TeamMembersComponent from "./TeamMemberTab";
|
|||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
|
||||
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = 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<TeamInfoProps> = ({
|
|||
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<TeamInfoProps> = ({
|
|||
|
||||
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<TeamInfoProps> = ({
|
|||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_team_member_budget_overflow"
|
||||
label="Allow member budget overflow"
|
||||
description="Members with a positive budget can keep spending from the remaining total team budget. Requires a positive team Max Budget. Other limits still apply; unused member allowances are not reserved."
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<Switch id={id} checked={value === true} onCheckedChange={onChange} />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_member_key_duration"
|
||||
|
|
@ -2147,6 +2172,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
</SimpleTooltip>
|
||||
</p>
|
||||
<div>Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}</div>
|
||||
<div>
|
||||
Member Budget Overflow:{" "}
|
||||
{info.metadata?.allow_team_member_budget_overflow === true ? "Allowed" : "Off"}
|
||||
</div>
|
||||
<div>Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}</div>
|
||||
<div>Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}</div>
|
||||
<div>TPM Limit: {info.team_member_budget_table?.tpm_limit ?? "No Limit"}</div>
|
||||
|
|
|
|||
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue