feat: persist and enforce optional team member budget windows (#49091)

This commit is contained in:
ishaan-j-berri 2026-10-31 13:27:09 -07:00
parent 9438a2b638
commit dfecc32456
30 changed files with 904 additions and 75 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN "team_member_budget_limits" JSONB;
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "budget_limits" JSONB;

View file

@ -179,6 +179,7 @@ model LiteLLM_ModelTable {
// Assign prod keys to groups, not individuals
model LiteLLM_TeamTable {
team_member_budget_limits Json?
team_id String @id @default(uuid())
team_alias String?
organization_id String?
@ -799,6 +800,7 @@ model LiteLLM_UserNotifications {
}
model LiteLLM_TeamMembership {
budget_limits Json?
// Use this table to track the Internal User's Spend within a Team + Set Budgets, rpm limits for the user within the team
user_id String
team_id String

View file

@ -6,11 +6,12 @@ budget-window value types and the team-model alias table). Re-exported from
``litellm.proxy._types`` for backwards compatibility.
"""
import enum
import json
from datetime import datetime
from typing import Final, Literal, Optional
from typing import Annotated, Final, Literal, Optional, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.types.llms.base import LiteLLMPydanticObjectBase
@ -50,6 +51,34 @@ class BudgetLimitEntry(LiteLLMPydanticObjectBase):
reset_at: datetime | None = None
class TeamMemberBudgetDuration(str, enum.Enum):
HOURLY = "1h"
DAILY = "24h"
WEEKLY = "7d"
MONTHLY = "30d"
class TeamMemberBudgetWindow(LiteLLMPydanticObjectBase):
model_config = ConfigDict(frozen=True)
budget_duration: TeamMemberBudgetDuration
max_budget: float = Field(ge=0, allow_inf_nan=False)
def _validate_member_budget_windows(
windows: tuple[TeamMemberBudgetWindow, ...],
) -> tuple[TeamMemberBudgetWindow, ...]:
durations: Final = frozenset(window.budget_duration for window in windows)
if len(durations) != len(windows):
raise ValueError("Each member budget duration must be unique")
return windows
TeamMemberBudgetWindows: TypeAlias = Annotated[
tuple[TeamMemberBudgetWindow, ...], AfterValidator(_validate_member_budget_windows)
]
class LiteLLM_ModelTable(LiteLLMPydanticObjectBase):
id: int | None = None
model_aliases: str | dict | None = None
@ -75,6 +104,7 @@ class TeamBase(LiteLLMPydanticObjectBase):
max_budget: float | None = None
soft_budget: float | None = None
budget_duration: str | None = None
team_member_budget_limits: TeamMemberBudgetWindows | None = None
budget_limits: list[BudgetLimitEntry] | None = None
models: list[str] = []
blocked: bool = False
@ -114,6 +144,7 @@ class LiteLLM_TeamTable(TeamBase):
"model_aliases",
"router_settings",
"budget_limits",
"team_member_budget_limits",
]
if isinstance(values, BaseModel):

View file

@ -5,18 +5,29 @@ Canonical definition for ``litellm_teammembership``. Re-exported from
``litellm.proxy._types`` for backwards compatibility.
"""
import json
from pydantic import field_validator
from litellm.models.budget import LiteLLM_BudgetTable, LiteLLM_BudgetTableFull
from litellm.models.team import TeamMemberBudgetWindows
from litellm.types.llms.base import LiteLLMPydanticObjectBase
class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase):
user_id: str
team_id: str
budget_limits: TeamMemberBudgetWindows | None = None
budget_id: str | None = None
spend: float | None = 0.0
total_spend: float | None = 0.0
litellm_budget_table: LiteLLM_BudgetTableFull | LiteLLM_BudgetTable | None = None
@field_validator("budget_limits", mode="before")
@classmethod
def parse_budget_limits(cls, value: object) -> object:
return json.loads(value) if isinstance(value, str) else value
def safe_get_team_member_rpm_limit(self) -> int | None:
if self.litellm_budget_table is not None:
return self.litellm_budget_table.rpm_limit

View file

@ -1881,6 +1881,7 @@ class OrgMember(MemberBase):
from litellm.models.team import TeamBase as TeamBase # noqa: E402
from litellm.models.team import TeamMemberBudgetWindows
RouterSettingsDict = Annotated[
dict[str, object],
@ -1985,6 +1986,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
object_permission: LiteLLM_ObjectPermissionBase | None = None
disable_global_guardrails: bool | None = None
require_trace_id: bool | None = None
team_member_budget_limits: TeamMemberBudgetWindows | None = None
team_member_budget: float | None = None
team_member_budget_duration: str | None = None
team_member_model_max_budget: GenericBudgetConfigType | None = None
@ -4259,6 +4261,7 @@ class TeamMemberAddRequest(MemberAddRequest):
"""
team_id: str = Field(description="The ID of the team to add the member to")
budget_limits: TeamMemberBudgetWindows | None = None
max_budget_in_team: float | None = Field(
default=None,
description="Maximum budget allocated to this user within the team. If not set, user has unlimited budget within team limits",
@ -4278,6 +4281,7 @@ class TeamMemberDeleteRequest(MemberDeleteRequest):
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
budget_limits: TeamMemberBudgetWindows | None = None
max_budget_in_team: float | None = None
role: Literal["admin", "user"] | None = None
tpm_limit: int | None = Field(default=None, description="Tokens per minute limit for this team member")
@ -4317,6 +4321,7 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
class TeamMemberUpdateResponse(MemberUpdateResponse):
budget_limits: TeamMemberBudgetWindows | None = None
team_id: str
max_budget_in_team: float | None = None
tpm_limit: int | None = None

View file

@ -7123,6 +7123,35 @@ async def _check_team_member_budget(
proxy_logging_obj=proxy_logging_obj,
)
loaded_membership = team_membership
from litellm.proxy.common_utils.team_member_budget import MemberBudgetCounter, get_team_member_budget_windows
from litellm.proxy.proxy_server import ensure_window_spend_counter_initialized, get_current_spend
for window in get_team_member_budget_windows(team_object, loaded_membership):
counter = MemberBudgetCounter.for_member(valid_token.user_id, team_object.team_id, window.budget_duration)
await ensure_window_spend_counter_initialized(
counter_key=counter.key,
entity_type="TeamMember",
entity_id=counter.entity_id,
window_duration=counter.duration.value,
window_start=counter.start,
)
spend = await get_current_spend(
counter_key=counter.key,
fallback_spend=0.0,
max_budget=window.max_budget,
window_entity_type="TeamMember",
window_entity_id=counter.entity_id,
window_duration=counter.duration.value,
window_start=counter.start,
)
if spend >= window.max_budget:
raise litellm.BudgetExceededError(
current_cost=spend,
max_budget=window.max_budget,
message=f"User={valid_token.user_id} in Team={team_object.team_id} exceeded {window.budget_duration.value} budget",
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=counter.entity_id,
)
# Per-member override wins; otherwise fall back to the team-level
# default configured via team.metadata["team_member_budget_id"].

View file

@ -0,0 +1,61 @@
"""Concurrent budget windows scoped to one user within one team."""
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Final
from pydantic import TypeAdapter
from litellm.litellm_core_utils.duration_parser import get_budget_window_start, get_next_standardized_reset_time
from litellm.models.team import LiteLLM_TeamTable, TeamMemberBudgetDuration, TeamMemberBudgetWindow
from litellm.models.team_membership import LiteLLM_TeamMembership
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_settings
_MEMBER_ID: Final = TypeAdapter(tuple[str, str])
def member_window_entity_id(user_id: str, team_id: str) -> str:
return _MEMBER_ID.dump_json((user_id, team_id)).decode()
def parse_member_window_entity_id(entity_id: str) -> tuple[str, str]:
return _MEMBER_ID.validate_json(entity_id)
def get_team_member_budget_windows(
team: LiteLLM_TeamTable, membership: LiteLLM_TeamMembership | None
) -> Sequence[TeamMemberBudgetWindow]:
if membership is not None and membership.budget_limits:
return membership.budget_limits
return team.team_member_budget_limits or ()
@dataclass(frozen=True, slots=True)
class MemberBudgetCounter:
entity_id: str
duration: TeamMemberBudgetDuration
start: datetime
reset_at: datetime
@property
def key(self) -> str:
return f"spend:team_member_window:{self.entity_id}:{self.duration.value}:{int(self.start.timestamp())}"
@classmethod
def for_member(
cls, user_id: str, team_id: str, duration: TeamMemberBudgetDuration, now: datetime | None = None
) -> "MemberBudgetCounter":
settings: Final = get_budget_reset_settings()
reset_at: Final = get_next_standardized_reset_time(
duration=duration.value,
current_time=now if now is not None else datetime.now(timezone.utc),
timezone_str=settings.timezone,
reset_time_of_day=settings.reset_time_of_day,
)
return cls(
entity_id=member_window_entity_id(user_id, team_id),
duration=duration,
start=get_budget_window_start(duration.value, reset_at),
reset_at=reset_at,
)

View file

@ -31,7 +31,10 @@ from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import (
)
if TYPE_CHECKING:
from prisma import models as prisma_models
from litellm.proxy.utils import PrismaClient
from litellm.repositories.prisma_protocols import TableActions
_SELECT_EXISTING_ROWS_SQL: Final = (
@ -133,6 +136,9 @@ class _UpsertBatchManager(Protocol):
class _UpsertTransaction(Protocol):
@property
def litellm_budgetwindowspend(self) -> "TableActions[prisma_models.LiteLLM_BudgetWindowSpend]": ...
def batch_(self) -> _UpsertBatchManager: ...
@ -204,6 +210,26 @@ async def spend_logs_seed_totals(
counts everything: that can only over-count once, which enforcement
tolerates, whereas under-counting is a budget bypass.
"""
if entity_type == Litellm_EntityType.TEAM_MEMBER.value:
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
total: Final = await SpendCounterReseed.window_from_spend_logs(
prisma_client=prisma_client, entity_type="TeamMember", entity_id=entity_id, window_start=window_start
)
before: Final = (
total
if batch_started_at is None
else await SpendCounterReseed.window_from_spend_logs(
prisma_client=prisma_client,
entity_type="TeamMember",
entity_id=entity_id,
window_start=window_start,
window_end=_exclusion_upper_bound(batch_started_at),
)
)
if total is None or before is None:
return None
return WindowSeedTotals(total=total, before_batch=before)
if entity_type == Litellm_EntityType.KEY.value:
bounded_sql, unbounded_sql = _SEED_FROM_SPEND_LOGS_KEY_SQL, _SEED_FROM_SPEND_LOGS_KEY_UNBOUNDED_SQL
elif entity_type == Litellm_EntityType.TEAM.value:
@ -342,10 +368,31 @@ async def commit_window_spend_updates(
if not transactions:
return
ordered: Final = tuple(sorted(transactions, key=window_spend_group_key))
existing_primary_keys: Final = await _existing_primary_keys(
prisma_client=prisma_client,
transactions=ordered,
member_transactions: Final = tuple(
sorted(
(t for t in transactions if t["entity_type"] == Litellm_EntityType.TEAM_MEMBER.value),
key=window_spend_group_key,
)
)
member_seed_bases: Final = tuple(
[
await _member_window_seed_base(prisma_client, transaction, spend_logs_aggregate)
for transaction in member_transactions
]
)
ordered: Final = tuple(
sorted(
(t for t in transactions if t["entity_type"] != Litellm_EntityType.TEAM_MEMBER.value),
key=window_spend_group_key,
)
)
existing_primary_keys: Final[frozenset[tuple[str, str, str]]] = (
await _existing_primary_keys(
prisma_client=prisma_client,
transactions=ordered,
)
if ordered
else frozenset()
)
seed_bases: Final = tuple(
[
@ -368,13 +415,71 @@ async def commit_window_spend_updates(
async with (
db_span("commit_window_spend_updates", "LiteLLM_BudgetWindowSpend"),
_upsert_transaction(prisma_client) as db_transaction,
db_transaction.batch_() as batcher,
):
for transaction, seed_base in zip(ordered, seed_bases):
batcher.execute_raw(
_UPSERT_WINDOW_SPEND_SQL,
*_upsert_params(transaction=transaction, seed_base=seed_base, now=now),
)
for transaction, seed_base in zip(member_transactions, member_seed_bases):
await _write_member_window_spend(db_transaction.litellm_budgetwindowspend, transaction, seed_base)
async with db_transaction.batch_() as batcher:
for transaction, seed_base in zip(ordered, seed_bases):
batcher.execute_raw(
_UPSERT_WINDOW_SPEND_SQL,
*_upsert_params(transaction=transaction, seed_base=seed_base, now=now),
)
async def _member_window_seed_base(
prisma_client: "PrismaClient",
transaction: WindowSpendTransaction,
spend_logs_aggregate: WindowSpendLogsAggregate,
) -> float:
from litellm.repositories.table_repositories import BudgetWindowSpendRepository
existing: Final = await BudgetWindowSpendRepository(prisma_client).table.find_unique(
where={
"entity_type_entity_id_window_duration": {
"entity_type": transaction["entity_type"],
"entity_id": transaction["entity_id"],
"window_duration": transaction["window_duration"],
}
}
)
if existing is not None:
return 0.0
totals: Final = await spend_logs_aggregate(
prisma_client=prisma_client,
entity_type=transaction["entity_type"],
entity_id=transaction["entity_id"],
window_start=datetime.fromisoformat(transaction["window_start"]).replace(tzinfo=timezone.utc),
batch_started_at=_transaction_started_at(transaction),
)
if totals is None:
return 0.0
return _seed_base(totals, transaction["spend"])
async def _write_member_window_spend(
table: "TableActions[prisma_models.LiteLLM_BudgetWindowSpend]",
transaction: WindowSpendTransaction,
seed: float,
) -> None:
key: Final = {
"entity_type": transaction["entity_type"],
"entity_id": transaction["entity_id"],
"window_duration": transaction["window_duration"],
}
start: Final = datetime.fromisoformat(transaction["window_start"])
# Keep this row locked until the entire flush commits, including key/team windows.
await table.upsert(
where={"entity_type_entity_id_window_duration": key},
data={"create": {**key, "window_start": start, "spend": seed}, "update": {"spend": {"increment": 0.0}}},
)
await table.update_many(
where={**key, "window_start": {"lt": start}},
data={"window_start": start, "spend": seed},
)
await table.update(
where={"entity_type_entity_id_window_duration": key},
data={"spend": {"increment": transaction["spend"]}},
)
async def roll_window_spend_row(

View file

@ -55,6 +55,7 @@ _WINDOW_SPEND_ENTITY_TYPES: Final[Mapping[str, str]] = MappingProxyType(
{
"Key": Litellm_EntityType.KEY.value,
"Team": Litellm_EntityType.TEAM.value,
"TeamMember": Litellm_EntityType.TEAM_MEMBER.value,
}
)
@ -68,6 +69,18 @@ _WINDOW_SPEND_LOG_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
)
def _window_spend_log_identity(entity_type: str, entity_id: str) -> Mapping[str, str] | None:
if entity_type == "TeamMember":
from litellm.proxy.common_utils.team_member_budget import parse_member_window_entity_id
user_id, team_id = parse_member_window_entity_id(entity_id)
return {"user": user_id, "team_id": team_id}
group_field: Final = _WINDOW_SPEND_LOG_FIELDS.get(entity_type)
if group_field is None:
return None
return {group_field: entity_id}
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
@ -388,21 +401,20 @@ class SpendCounterReseed:
entity_type: str,
entity_id: str,
window_start: datetime,
window_end: datetime | None = None,
) -> float | None:
if prisma_client is None:
return None
group_field: Final = _WINDOW_SPEND_LOG_FIELDS.get(entity_type)
if group_field is None:
identity: Final = _window_spend_log_identity(entity_type, entity_id)
if identity is None:
return None
where: Final = {
group_field: entity_id,
"startTime": {"gte": window_start},
}
group_fields: Final = tuple(identity)
period: Final = {"gte": window_start, **({"lt": window_end} if window_end is not None else {})}
where: Final = {**identity, "startTime": period}
try:
response: Final = await SpendLogsRepository(prisma_client).table.group_by(
by=[group_field],
by=group_fields,
where=where,
sum={"spend": True},
)

View file

@ -1,6 +1,6 @@
import json
import math
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Optional, Union
@ -44,6 +44,8 @@ def validate_budget_duration(budget_duration: str | None, status_code: int = 400
from litellm._logging import verbose_proxy_logger
from litellm.caching import DualCache
from litellm.models.team import TeamMemberBudgetWindow
from litellm.models.team_membership import LiteLLM_TeamMembership
from litellm.proxy._types import ( # re-exported
CommonProxyErrors,
KeyRequestBase,
@ -69,8 +71,11 @@ from litellm.repositories.team_repository import TeamRepository
from litellm.types.utils import BudgetConfig
if TYPE_CHECKING:
from prisma import models as prisma_models
from litellm.proxy._types import NewProjectRequest, UpdateProjectRequest
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.prisma_protocols import TableActions
_user_has_admin_view: Final = user_api_key_has_admin_view
@ -625,6 +630,24 @@ def _has_meaningful_budget_limit(budget_values: Mapping[str, object]) -> bool:
return any(_is_set_budget_value(budget_values.get(field)) for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS)
async def upsert_team_member_budget_windows(
table: "TableActions[prisma_models.LiteLLM_TeamMembership]",
team_id: str,
user_id: str,
windows: Sequence[TeamMemberBudgetWindow],
) -> LiteLLM_TeamMembership:
payload: Final = "[" + ",".join(window.model_dump_json() for window in windows) + "]"
membership: Final = await table.upsert(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
data={
"create": {"user_id": user_id, "team_id": team_id, "budget_limits": payload},
"update": {"budget_limits": payload},
},
include={"litellm_budget_table": True},
)
return LiteLLM_TeamMembership.model_validate(membership.model_dump())
async def upsert_budget_and_membership(
tx,
*,

View file

@ -8074,7 +8074,7 @@ async def block_key(
--header "Authorization: Bearer $LITELLM_MASTER_KEY" \
--header 'Content-Type: application/json' \
--data '{
"key": "sk-Fn8Ej39NxjAXrvpUGKghGw"
"key": "<VIRTUAL_KEY>"
}'
```
@ -8188,7 +8188,7 @@ async def unblock_key(
--header "Authorization: Bearer $LITELLM_MASTER_KEY" \
--header 'Content-Type: application/json' \
--data '{
"key": "sk-Fn8Ej39NxjAXrvpUGKghGw"
"key": "<VIRTUAL_KEY>"
}'
```

View file

@ -48,6 +48,7 @@ SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset(
"metadata",
"team_member_budget",
"team_member_budget_duration",
"team_member_budget_limits",
"team_member_tpm_limit",
"team_member_rpm_limit",
}

View file

@ -171,6 +171,7 @@ from litellm.proxy.management_endpoints.common_utils import ( # noqa: F401 # l
team_member_has_permission,
update_metadata_fields,
upsert_budget_and_membership,
upsert_team_member_budget_windows,
user_api_key_has_admin_view,
validate_budget_duration,
validate_team_model_max_budget,
@ -533,7 +534,14 @@ def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]"
)
def _team_membership_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
def _team_membership_db(
prisma_client: PrismaClient | None,
tx: "MemberWriteTx | Prisma | None" = None,
) -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
if tx is not None:
return cast( # cast-ok: Prisma JSON writes are strings; TableActions widens inputs at the transaction boundary
"TableActions[prisma_models.LiteLLM_TeamMembership]", tx.litellm_teammembership
)
return TeamMembershipRepository(prisma_client).table
@ -1961,10 +1969,7 @@ async def new_team(
)
## ADD TO TEAM TABLE
complete_team_data: Final = LiteLLM_TeamTable(
**data_json,
model_id=_model_id,
)
complete_team_data: Final = LiteLLM_TeamTable.model_validate({**data_json, "model_id": _model_id})
# Set Management Endpoint Metadata Fields
for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
@ -3147,6 +3152,8 @@ async def _validate_team_member_add_permissions(
# budget and model controls stay admin-only. Reject them here so a
# self-joining non-admin cannot set their own cap, reset window, or model
# scope via the bypass.
if data.budget_limits is not None:
raise HTTPException(status_code=403, detail="Only admins may set member budget windows")
if data.max_budget_in_team is not None or data.budget_duration is not None or data.allowed_models is not None:
raise HTTPException(
status_code=403,
@ -3238,6 +3245,13 @@ async def _process_team_members(
status_code=500,
detail={"error": f"Unable to add user - {m}, to team - {data.team_id}, for reason - {e}"},
)
if data.budget_limits is not None:
updated_tm = await upsert_team_member_budget_windows(
table=_team_membership_db(prisma_client, tx),
team_id=data.team_id,
user_id=updated_user.user_id,
windows=data.budget_limits,
)
updated_users.append(updated_user)
if updated_tm is not None:
updated_team_memberships.append(updated_tm)
@ -4233,7 +4247,14 @@ async def team_member_update(
budget_patch=budget_patch,
team_default_budget_id=team_default_budget_id,
)
if budget_patch:
if data.budget_limits is not None:
await upsert_team_member_budget_windows(
table=_team_membership_db(prisma_client, tx),
team_id=data.team_id,
user_id=received_user_id,
windows=data.budget_limits,
)
if budget_patch or data.budget_limits is not None:
await invalidate_team_member_spend_state(
user_id=received_user_id,
team_id=data.team_id,
@ -4257,6 +4278,7 @@ async def team_member_update(
user_id=received_user_id,
user_email=data.user_email,
max_budget_in_team=data.max_budget_in_team,
budget_limits=data.budget_limits,
tpm_limit=data.tpm_limit,
rpm_limit=data.rpm_limit,
budget_duration=data.budget_duration,
@ -5185,6 +5207,7 @@ def _transform_teams_to_deleted_records(
"litellm_model_table",
"object_permission",
"id",
"team_member_budget_limits",
"budget_limits", # not in LiteLLM_DeletedTeamTable schema
"default_team_member_models", # not in LiteLLM_DeletedTeamTable schema
):

View file

@ -3753,15 +3753,63 @@ async def _increment_spend_counters_batched(
scope_user_id: str, scope_team_id: str
) -> tuple[PendingSpendIncrement | BaseException, ...]:
team_member_counter_key: Final = f"spend:team_member:{scope_user_id}:{scope_team_id}"
if team_member_counter_key in reserved_counter_keys:
return ()
return (
await _prepare_spend_counter_increment(
counter_key=team_member_counter_key,
source_cache_key=f"team_membership:{scope_user_id}:{scope_team_id}",
increment=cost,
),
primary_pending: Final = (
()
if team_member_counter_key in reserved_counter_keys
else (
await _prepare_spend_counter_increment(
counter_key=team_member_counter_key,
source_cache_key=f"team_membership:{scope_user_id}:{scope_team_id}",
increment=cost,
),
)
)
if prisma_client is None:
return primary_pending
from litellm.models.team import TeamMemberBudgetWindow
from litellm.proxy.auth.auth_checks import get_team_membership, get_team_object
from litellm.proxy.common_utils.team_member_budget import MemberBudgetCounter, get_team_member_budget_windows
team: Final = await get_team_object(
team_id=scope_team_id, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache
)
membership: Final = await get_team_membership(
user_id=scope_user_id,
team_id=scope_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
async def member_window_increment(window: TeamMemberBudgetWindow) -> PendingSpendIncrement | None:
counter: Final = MemberBudgetCounter.for_member(scope_user_id, scope_team_id, window.budget_duration)
pending: Final = (
await _prepare_window_spend_counter_increment(
counter_key=counter.key,
entity_type="TeamMember",
entity_id=counter.entity_id,
window_duration=counter.duration.value,
window_start=counter.start,
increment=cost,
)
if counter.key not in reserved_counter_keys
else None
)
await _enqueue_window_spend_row_update(
entity_type=Litellm_EntityType.TEAM_MEMBER,
entity_id=counter.entity_id,
reset_at=counter.reset_at,
window_duration=counter.duration.value,
window_start=counter.start,
increment=cost,
request_started_at=request_started_at,
)
return pending
window_pending: Final = await asyncio.gather(
*(member_window_increment(window) for window in get_team_member_budget_windows(team, membership)),
return_exceptions=True,
)
return primary_pending + tuple(item for item in window_pending if item is not None)
async def _user_scope(scope_user_id: str) -> tuple[PendingSpendIncrement | BaseException, ...]:
user_counter_key: Final = f"spend:user:{scope_user_id}"

View file

@ -179,6 +179,7 @@ model LiteLLM_ModelTable {
// Assign prod keys to groups, not individuals
model LiteLLM_TeamTable {
team_member_budget_limits Json?
team_id String @id @default(uuid())
team_alias String?
organization_id String?
@ -799,6 +800,7 @@ model LiteLLM_UserNotifications {
}
model LiteLLM_TeamMembership {
budget_limits Json?
// Use this table to track the Internal User's Spend within a Team + Set Budgets, rpm limits for the user within the team
user_id String
team_id String

View file

@ -582,6 +582,24 @@ async def _get_budget_counters(
)
if team_member_counter is not None:
counters.append(team_member_counter)
if team_object is not None and valid_token.user_id is not None:
from litellm.proxy.common_utils.team_member_budget import MemberBudgetCounter, get_team_member_budget_windows
membership: Final = await _cached_team_membership(valid_token.user_id, team_object.team_id, user_api_key_cache)
for window in get_team_member_budget_windows(team_object, membership):
counter = MemberBudgetCounter.for_member(valid_token.user_id, team_object.team_id, window.budget_duration)
counters.append(
_BudgetCounter(
counter_key=counter.key,
max_budget=window.max_budget,
fallback_spend=0.0,
entity_type="TeamMember",
entity_id=counter.entity_id,
spend_log_entity_id=counter.entity_id,
window_duration=counter.duration.value,
window_start=counter.start,
)
)
org_counter: Final = await _get_org_budget_counter(
valid_token=valid_token,
@ -726,6 +744,21 @@ def _dedupe_tags(tags: list[str]) -> list[str]:
@with_service_target(AUTH_OBJECTS_TARGET)
async def _cached_team_membership(
user_id: str, team_id: str, user_api_key_cache: UserApiKeyCache
) -> LiteLLM_TeamMembership | None:
cached: Final = _CACHED_VALUE.validate_python(
await user_api_key_cache.async_get_cache(
key=team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
)
)
if isinstance(cached, LiteLLM_TeamMembership):
return cached
if isinstance(cached, dict):
return LiteLLM_TeamMembership.model_validate(cached)
return None
async def _get_team_member_budget_counter(
valid_token: UserAPIKeyAuth,
team_object: LiteLLM_TeamTable | None,
@ -738,14 +771,7 @@ async def _get_team_member_budget_counter(
membership_cache_key: Final = team_membership_reservation_cache_key(
user_id=valid_token.user_id, team_id=team_object.team_id
)
cached_team_membership: Final = _CACHED_VALUE.validate_python(
await user_api_key_cache.async_get_cache(key=membership_cache_key)
)
team_membership: LiteLLM_TeamMembership | None = None
if isinstance(cached_team_membership, LiteLLM_TeamMembership):
team_membership = cached_team_membership
elif isinstance(cached_team_membership, dict):
team_membership = LiteLLM_TeamMembership(**cached_team_membership)
team_membership: Final = await _cached_team_membership(valid_token.user_id, team_object.team_id, user_api_key_cache)
member_budget_row: Final = team_membership.litellm_budget_table if team_membership is not None else None
now: Final = datetime.now(timezone.utc)

View file

@ -6331,12 +6331,11 @@ class PrismaClient:
raise e
def jsonify_team_object(self, db_data: Mapping[str, object]) -> dict[str, object]:
db_data = self.jsonify_object(data=db_data)
if db_data.get("members_with_roles", None) is not None and isinstance(db_data["members_with_roles"], list):
db_data["members_with_roles"] = json.dumps(db_data["members_with_roles"])
if db_data.get("budget_limits", None) is not None and isinstance(db_data["budget_limits"], list):
db_data["budget_limits"] = json.dumps(db_data["budget_limits"])
return db_data
serialized: Final = self.jsonify_object(data=db_data)
for column in ("members_with_roles", "budget_limits", "team_member_budget_limits"):
if isinstance(serialized.get(column), (list, tuple)):
serialized[column] = json.dumps(serialized[column])
return serialized
# Define a retrying strategy with exponential backoff
@backoff.on_exception(

View file

@ -82,6 +82,7 @@ _JSON_ENCODED_TEAM_FIELDS: Final = (
"model_max_budget",
"router_settings",
"budget_limits",
"team_member_budget_limits",
"members_with_roles",
)

View file

@ -179,6 +179,7 @@ model LiteLLM_ModelTable {
// Assign prod keys to groups, not individuals
model LiteLLM_TeamTable {
team_member_budget_limits Json?
team_id String @id @default(uuid())
team_alias String?
organization_id String?
@ -799,6 +800,7 @@ model LiteLLM_UserNotifications {
}
model LiteLLM_TeamMembership {
budget_limits Json?
// Use this table to track the Internal User's Spend within a Team + Set Budgets, rpm limits for the user within the team
user_id String
team_id String

View file

@ -0,0 +1,59 @@
"""A retried flush must not charge a member again after a sibling write fails."""
import json
import os
import uuid
from datetime import datetime, timezone
import psycopg
import pytest
from psycopg import sql
from prisma.errors import RawQueryError
from integration._support.prisma_clients import connected_proxy_prisma
from litellm.proxy.db.budget_window_spend_writer import commit_window_spend_updates
from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import build_window_spend_transaction
@pytest.mark.asyncio
async def test_failed_key_window_rolls_back_member_spend_before_retry() -> None:
marker = uuid.uuid4().hex
member_id = json.dumps(["member-" + marker, "team-" + marker], separators=(",", ":"))
key_id = "key-" + marker
constraint = "window_fault_" + marker
start = datetime(2026, 10, 1, tzinfo=timezone.utc)
transactions = [
build_window_spend_transaction("team_member", member_id, "30d", start, 0.06, start),
build_window_spend_transaction("key", key_id, "30d", start, 0.06, start),
]
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as control:
control.execute(
sql.SQL('ALTER TABLE "LiteLLM_BudgetWindowSpend" ADD CONSTRAINT {} CHECK (entity_id <> {})').format(
sql.Identifier(constraint),
sql.Literal(key_id),
)
)
try:
async with connected_proxy_prisma() as client:
with pytest.raises(RawQueryError, match=constraint):
await commit_window_spend_updates(client, transactions)
assert (
await client.db.litellm_budgetwindowspend.find_many(
where={"entity_id": {"in": [member_id, key_id]}}
)
== []
)
finally:
control.execute(
sql.SQL('ALTER TABLE "LiteLLM_BudgetWindowSpend" DROP CONSTRAINT {}').format(sql.Identifier(constraint))
)
try:
async with connected_proxy_prisma() as client:
await commit_window_spend_updates(client, transactions)
rows = await client.db.litellm_budgetwindowspend.find_many(
where={"entity_id": {"in": [member_id, key_id]}}
)
assert len(rows) == 2
assert all(row.spend == pytest.approx(0.06) for row in rows)
finally:
control.execute('DELETE FROM "LiteLLM_BudgetWindowSpend" WHERE entity_id = ANY(%s)', ([member_id, key_id],))

View file

@ -0,0 +1,162 @@
"""Real proxy, database and Redis: concurrent budgets belong to a member/team pair."""
import uuid
from typing import Final
import pytest
from integration._support.client import Gateway, eventually
pytestmark: Final = pytest.mark.shares_worker_state
CALL_COST: Final = 20 * 0.001 + 20 * 0.002
def test_member_windows_persist_enforce_and_restore_inheritance(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
defaults = [{"budget_duration": "24h", "max_budget": 0.05}, {"budget_duration": "30d", "max_budget": 0.5}]
team = scenario.team(models=[model], team_member_budget_limits=defaults)
alice = scenario.user(auto_create_key=False)
bob = scenario.user(auto_create_key=False)
for user in (alice, bob):
gateway.post(
"/team/member_add",
{
"team_id": team,
"member": {"user_id": user, "role": "user"},
**({"max_budget_in_team": 10, "budget_duration": "30d"} if user == alice else {}),
},
)
alice_key = scenario.key(team_id=team, user_id=alice)
bob_key = scenario.key(team_id=team, user_id=bob)
saved = gateway.get("/team/info", {"team_id": team})
assert saved["team_info"]["team_member_budget_limits"] == defaults
def chat(key: str):
return gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"messages": [{"role": "user", "content": uuid.uuid4().hex}],
"max_tokens": 1,
},
key=key,
)
first = chat(alice_key)
assert first.status_code == 200, first.text
assert float(first.headers["x-litellm-response-cost"]) == pytest.approx(CALL_COST)
eventually(
lambda: gateway.rows(
'SELECT spend FROM "LiteLLM_BudgetWindowSpend" WHERE entity_type = %s AND entity_id = %s',
("team_member", f'["{alice}","{team}"]'),
),
lambda rows: len(rows) == 2 and all(row["spend"] == pytest.approx(CALL_COST) for row in rows),
seconds=70,
)
capped = chat(alice_key)
assert capped.status_code == 422 and "24h" in capped.text, capped.text
assert chat(bob_key).status_code == 200
other_team = scenario.team(models=[model], team_member_budget_limits=defaults)
gateway.post("/team/member_add", {"team_id": other_team, "member": {"user_id": alice, "role": "user"}})
other_key = scenario.key(team_id=other_team, user_id=alice)
assert chat(other_key).status_code == 200
custom = [{"budget_duration": "24h", "max_budget": 1}, {"budget_duration": "30d", "max_budget": 0.05}]
gateway.post("/team/member_update", {"team_id": team, "user_id": alice, "budget_limits": custom})
saved_member = gateway.get("/team/info", {"team_id": team})
alice_membership = next(m for m in saved_member["team_memberships"] if m["user_id"] == alice)
assert alice_membership["budget_limits"] == custom
assert alice_membership["litellm_budget_table"]["max_budget"] == 10
assert alice_membership["litellm_budget_table"]["budget_duration"] == "30d"
monthly = chat(alice_key)
assert monthly.status_code == 422 and "30d" in monthly.text, monthly.text
gateway.post("/team/member_update", {"team_id": team, "user_id": alice, "budget_limits": []})
inherited = chat(alice_key)
assert inherited.status_code == 422 and "24h" in inherited.text, inherited.text
gateway.post("/team/update", {"team_id": team, "team_member_budget_limits": []})
assert chat(alice_key).status_code == 200
@pytest.mark.asyncio
async def test_expired_daily_window_resets_without_resetting_monthly_spend(gateway: Gateway) -> None:
import json
from datetime import datetime, timedelta, timezone
from integration._support.prisma_clients import connected_prisma
with gateway.scenario() as scenario:
model = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
defaults = [{"budget_duration": "24h", "max_budget": 0.05}, {"budget_duration": "30d", "max_budget": 0.1}]
team = scenario.team(models=[model], team_member_budget_limits=defaults)
member = scenario.user(auto_create_key=False)
gateway.post("/team/member_add", {"team_id": team, "member": {"user_id": member, "role": "user"}})
key = scenario.key(team_id=team, user_id=member)
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
identity = json.dumps([member, team], separators=(",", ":"))
async with connected_prisma() as db:
await db.litellm_budgetwindowspend.create(
data={
"entity_type": "team_member",
"entity_id": identity,
"window_duration": "24h",
"window_start": today - timedelta(days=1),
"spend": 10.0,
}
)
await db.litellm_budgetwindowspend.create(
data={
"entity_type": "team_member",
"entity_id": identity,
"window_duration": "30d",
"window_start": today.replace(day=1),
"spend": 0.2,
}
)
def chat():
return gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"messages": [{"role": "user", "content": uuid.uuid4().hex}],
"max_tokens": 1,
},
key=key,
)
monthly = chat()
assert monthly.status_code == 422 and "30d" in monthly.text, monthly.text
gateway.post(
"/team/update",
{
"team_id": team,
"team_member_budget_limits": [
defaults[0],
{"budget_duration": "30d", "max_budget": 1},
],
},
)
renewed = chat()
assert renewed.status_code == 200, renewed.text
eventually(
lambda: gateway.rows(
'SELECT window_duration, spend FROM "LiteLLM_BudgetWindowSpend" WHERE entity_type = %s AND entity_id = %s',
("team_member", identity),
),
lambda rows: (
{row["window_duration"]: row["spend"] for row in rows}
== {
"24h": pytest.approx(CALL_COST),
"30d": pytest.approx(0.2 + CALL_COST),
}
),
seconds=70,
)
daily = chat()
assert daily.status_code == 422 and "24h" in daily.text, daily.text

View file

@ -469,21 +469,56 @@ def test_resolve_effective_team_member_budget_preserves_member_and_default_prece
assert resolve_effective_team_member_budget(member_budget, None, now) == 110
assert resolve_effective_team_member_budget(expired_member_budget, None, now) == 100
assert resolve_effective_team_member_budget(
active_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
) == 60
assert resolve_effective_team_member_budget(
expired_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
) == 50
assert resolve_effective_team_member_budget(
LiteLLM_BudgetTable(max_budget=0),
LiteLLM_BudgetTable(max_budget=50),
now,
) == 0
assert (
resolve_effective_team_member_budget(
active_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
)
== 60
)
assert (
resolve_effective_team_member_budget(
expired_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
)
== 50
)
assert (
resolve_effective_team_member_budget(
LiteLLM_BudgetTable(max_budget=0),
LiteLLM_BudgetTable(max_budget=50),
now,
)
== 0
)
assert resolve_effective_team_member_budget(None, LiteLLM_BudgetTable(max_budget=50), now) == 50
assert resolve_effective_team_member_budget(None, LiteLLM_BudgetTable(max_budget=0), now) is None
assert resolve_effective_team_member_budget(None, None, now) is None
@pytest.mark.asyncio
async def test_jwt_member_without_user_row_still_enforces_default_windows() -> None:
from litellm.models.team import TeamMemberBudgetDuration, TeamMemberBudgetWindow
from litellm.proxy.auth.auth_checks import _check_team_member_budget
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import ProxyLogging
cache = UserApiKeyCache()
team = LiteLLM_TeamTable(
team_id="jwt-member-budget-team",
team_member_budget_limits=(
TeamMemberBudgetWindow(budget_duration=TeamMemberBudgetDuration.DAILY, max_budget=0),
),
)
with pytest.raises(litellm.BudgetExceededError, match="24h"):
await _check_team_member_budget(
team_object=team,
user_object=None,
valid_token=UserAPIKeyAuth(user_id="jwt-member-only", team_id=team.team_id),
prisma_client=None,
user_api_key_cache=cache,
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
team_membership_loaded=True,
)

View file

@ -0,0 +1,85 @@
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from litellm.models.team import LiteLLM_TeamTable, TeamMemberBudgetDuration, TeamMemberBudgetWindow
from litellm.models.team_membership import LiteLLM_TeamMembership
from litellm.proxy._types import NewTeamRequest, TeamMemberAddRequest, TeamMemberUpdateRequest, UpdateTeamRequest
from litellm.proxy.common_utils.team_member_budget import (
MemberBudgetCounter,
get_team_member_budget_windows,
member_window_entity_id,
parse_member_window_entity_id,
)
@pytest.mark.parametrize("amount", [-1, float("inf"), float("nan")])
def test_rejects_invalid_caps(amount: float) -> None:
with pytest.raises(ValidationError):
TeamMemberBudgetWindow(budget_duration=TeamMemberBudgetDuration.DAILY, max_budget=amount)
@pytest.mark.parametrize(
"request_type, fields, field",
[
(NewTeamRequest, {}, "team_member_budget_limits"),
(UpdateTeamRequest, {"team_id": "team"}, "team_member_budget_limits"),
(TeamMemberAddRequest, {"team_id": "team", "member": {"user_id": "member", "role": "user"}}, "budget_limits"),
(TeamMemberUpdateRequest, {"team_id": "team", "user_id": "member"}, "budget_limits"),
],
)
def test_rejects_duplicate_windows(request_type: type, fields: dict, field: str) -> None:
duplicate = [{"budget_duration": "24h", "max_budget": 20}] * 2
with pytest.raises(ValidationError, match="unique"):
request_type(**fields, **{field: duplicate})
def test_override_and_clear_preserve_team_inheritance() -> None:
defaults = [TeamMemberBudgetWindow(budget_duration=TeamMemberBudgetDuration.DAILY, max_budget=20)]
custom = [TeamMemberBudgetWindow(budget_duration=TeamMemberBudgetDuration.DAILY, max_budget=0)]
team = LiteLLM_TeamTable(team_id="team", team_member_budget_limits=defaults)
member = LiteLLM_TeamMembership(user_id="member", team_id="team", budget_limits=custom)
assert get_team_member_budget_windows(team, member) == tuple(custom)
member.budget_limits = []
assert get_team_member_budget_windows(team, member) == tuple(defaults)
assert get_team_member_budget_windows(team, None) == tuple(defaults)
assert get_team_member_budget_windows(LiteLLM_TeamTable(team_id="off"), None) == ()
def test_reset_boundaries_are_independent() -> None:
before = datetime(2026, 10, 10, 23, 59, tzinfo=timezone.utc)
after = datetime(2026, 10, 11, 0, 1, tzinfo=timezone.utc)
daily = MemberBudgetCounter.for_member("member", "team", TeamMemberBudgetDuration.DAILY, before)
monthly = MemberBudgetCounter.for_member("member", "team", TeamMemberBudgetDuration.MONTHLY, before)
assert MemberBudgetCounter.for_member("member", "team", daily.duration, after).key != daily.key
assert MemberBudgetCounter.for_member("member", "team", monthly.duration, after).key == monthly.key
assert MemberBudgetCounter.for_member("other", "team", daily.duration, before).key != daily.key
assert MemberBudgetCounter.for_member("member", "other", daily.duration, before).key != daily.key
def test_member_identity_cannot_collide_at_a_separator() -> None:
first = member_window_entity_id("user:other", "team")
second = member_window_entity_id("user", "other:team")
assert first != second
assert parse_member_window_entity_id(first) == ("user:other", "team")
@pytest.mark.parametrize("request_type", [NewTeamRequest, UpdateTeamRequest])
def test_default_windows_round_trip_through_prisma_json(request_type: type) -> None:
import json
from litellm.proxy.utils import PrismaClient
data = request_type(
team_id="team",
team_member_budget_limits=[
{"budget_duration": "24h", "max_budget": 20},
{"budget_duration": "30d", "max_budget": 200},
],
)
client = object.__new__(PrismaClient)
serialized = client.jsonify_team_object(data.model_dump(exclude_none=True))
windows = json.loads(serialized["team_member_budget_limits"])
assert windows == [{"budget_duration": "24h", "max_budget": 20}, {"budget_duration": "30d", "max_budget": 200}]
restored = LiteLLM_TeamTable(team_id="team", team_member_budget_limits=serialized["team_member_budget_limits"])
assert restored.team_member_budget_limits == data.team_member_budget_limits

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import { act, chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import TeamAdminSettingsForm, { type TeamAdminMetadataSettings } from "./TeamAdminSettingsForm";
@ -301,3 +301,24 @@ describe("TeamAdminSettingsForm", () => {
});
});
});
describe("team admin budget windows form", () => {
it("should configure windows with the same optional editor when permitted", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["team_member_budget_limits"]));
const toggle = screen.getByRole("switch", { name: "Enable budget windows" });
expect(toggle).not.toBeChecked();
await user.click(toggle);
act(() =>
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "20" } }),
);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith({ team_member_budget_limits: [{ budget_duration: "24h", max_budget: 20 }] }),
);
});
it("should hide windows when the proxy has not granted permission", () => {
renderForm(new Set(["tpm_limit"]));
expect(screen.queryByRole("switch", { name: "Enable budget windows" })).not.toBeInTheDocument();
});
});

View file

@ -19,6 +19,8 @@ import MetadataKeyValueFields, {
metadataPairsSchema,
} from "../common_components/MetadataKeyValueFields";
import NumericalInput from "../shared/numerical_input";
import { MemberBudgetWindows } from "./MemberBudgetWindows";
import { isMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
import {
isTeamAdminDurationSettingsField,
TEAM_ADMIN_METADATA_FIELD,
@ -42,6 +44,7 @@ const teamAdminSettingsShape = {
soft_budget: numericInputSchema,
team_member_budget: numericInputSchema,
team_member_budget_duration: z.string().nullish(),
team_member_budget_limits: memberBudgetWindowsSchema.optional(),
team_member_tpm_limit: numericInputSchema,
team_member_rpm_limit: numericInputSchema,
};
@ -128,7 +131,16 @@ function TeamAdminSettingsFormFields({
const form = useZodForm(schema, { defaultValues: { ...initialValues, metadata: [...(metadata?.pairs ?? [])] } });
const draft = useWatch({ control: form.control });
const changesFrom = (values: typeof draft): TeamAdminSettingsChanges => ({
...teamAdminSettingsChanges(values, initialValues, editableFields),
...teamAdminSettingsChanges(
{
...values,
team_member_budget_limits: isMemberBudgetWindows(values.team_member_budget_limits)
? values.team_member_budget_limits
: undefined,
},
initialValues,
editableFields,
),
...(editsMetadata ? teamAdminMetadataChange(values.metadata, metadata.context) : {}),
});
const hasChanges = Object.keys(changesFrom(draft)).length > 0;
@ -170,6 +182,15 @@ function TeamAdminSettingsFormFields({
}
</FormField>
))}
{editableFields.has("team_member_budget_limits") && (
<FormField
control={form.control}
name="team_member_budget_limits"
label={teamAdminFieldLabel("team_member_budget_limits")}
>
{({ value, onChange }) => <MemberBudgetWindows value={value ?? []} onChange={onChange} />}
</FormField>
)}
{editsMetadata && (
<Field>
<FieldLabel>{teamAdminFieldLabel(TEAM_ADMIN_METADATA_FIELD)}</FieldLabel>

View file

@ -1334,6 +1334,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
soft_budget: info.soft_budget,
team_member_budget: info.team_member_budget_table?.max_budget,
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
team_member_budget_limits: info.team_member_budget_limits ?? [],
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
}}

View file

@ -24,7 +24,7 @@ export const memberBudgetWindowsSchema = budgetWindowEntriesSchema.superRefine((
});
});
export const isMemberBudgetWindows = (value: unknown): value is BudgetWindowEntry[] =>
export const isMemberBudgetWindows = (value: unknown): value is MemberBudgetWindow[] =>
budgetWindowEntriesSchema.safeParse(value).success;
export type MemberBudgetWindow = z.infer<typeof memberBudgetWindowsSchema>[number];

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { MemberBudgetWindow } from "./memberBudgetWindowSchema";
import {
type TeamAdminMetadataContext,
@ -365,3 +366,21 @@ describe("teamAdminDeclaredFields", () => {
]);
});
});
describe("team admin budget windows", () => {
const windows: MemberBudgetWindow[] = [{ budget_duration: "24h", max_budget: 20 }];
it("should send configured windows only when the proxy permits editing them", () => {
const values = { team_member_budget_limits: windows };
const initial = { team_member_budget_limits: [] };
expect(teamAdminSettingsChanges(values, initial, new Set(["team_member_budget_limits"]))).toEqual(values);
expect(teamAdminSettingsChanges(values, initial, new Set())).toEqual({});
});
it("should clear configured defaults without resending unchanged windows", () => {
const initial = { team_member_budget_limits: windows };
const permitted = new Set(["team_member_budget_limits"]);
expect(teamAdminSettingsChanges({ team_member_budget_limits: [] }, initial, permitted)).toEqual({
team_member_budget_limits: [],
});
expect(teamAdminSettingsChanges(initial, initial, permitted)).toEqual({});
});
});

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import type { MemberBudgetWindow } from "./memberBudgetWindowSchema";
import { numberOrNull } from "@/lib/forms/numberOrNull";
import {
@ -93,6 +94,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
["soft_budget", "Soft Budget (USD)"],
["team_member_budget", "Member Default Budget (USD)"],
["team_member_budget_duration", "Member Default Budget Duration"],
["team_member_budget_limits", "Default member budget windows"],
["team_member_tpm_limit", "Member Default TPM Limit"],
["team_member_rpm_limit", "Member Default RPM Limit"],
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
@ -105,11 +107,11 @@ export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_L
export type TeamAdminSettingsValues = { readonly [F in TeamAdminNumericSettingsField]?: string | number | null } & {
readonly [F in TeamAdminDurationSettingsField]?: string | null;
};
} & { readonly team_member_budget_limits?: MemberBudgetWindow[] };
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminNumericSettingsField]?: number | null } & {
readonly [F in TeamAdminDurationSettingsField]?: string | null;
} & { readonly metadata?: Record<string, unknown> };
} & { readonly metadata?: Record<string, unknown>; readonly team_member_budget_limits?: MemberBudgetWindow[] };
const durationOrNull = (value: string | number | null | undefined): string | null =>
typeof value === "string" && value !== "" ? value : null;
@ -132,13 +134,19 @@ export const teamAdminSettingsChanges = (
values: TeamAdminSettingsValues,
initialValues: TeamAdminSettingsValues,
editableFields: ReadonlySet<string>,
): TeamAdminSettingsChanges =>
Object.fromEntries(
): TeamAdminSettingsChanges => {
const changes = Object.fromEntries(
TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => {
const change = editableFields.has(field) ? changedValue(field, values[field], initialValues[field]) : null;
return change === null ? [] : [change];
}),
);
if (!editableFields.has("team_member_budget_limits")) return changes;
const windows = values.team_member_budget_limits;
if (windows === undefined) return changes;
if (JSON.stringify(windows) === JSON.stringify(initialValues.team_member_budget_limits ?? [])) return changes;
return { ...changes, team_member_budget_limits: windows };
};
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);

View file

@ -8357,7 +8357,7 @@ export interface paths {
* Example:
* ```bash
* curl --location 'http://0.0.0.0:4000/key/block' --header "Authorization: Bearer $LITELLM_MASTER_KEY" --header 'Content-Type: application/json' --data '{
* "key": "sk-Fn8Ej39NxjAXrvpUGKghGw"
* "key": "<VIRTUAL_KEY>"
* }'
* ```
*
@ -8916,7 +8916,7 @@ export interface paths {
* Example:
* ```bash
* curl --location 'http://0.0.0.0:4000/key/unblock' --header "Authorization: Bearer $LITELLM_MASTER_KEY" --header 'Content-Type: application/json' --data '{
* "key": "sk-Fn8Ej39NxjAXrvpUGKghGw"
* "key": "<VIRTUAL_KEY>"
* }'
* ```
*
@ -34680,6 +34680,8 @@ export interface components {
team_alias?: string | null;
/** Team Id */
team_id: string;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Permissions */
team_member_permissions?: string[] | null;
/** Tpd Limit */
@ -36196,6 +36198,8 @@ export interface components {
LiteLLM_TeamMembership: {
/** Budget Id */
budget_id?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Litellm Budget Table */
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTableFull"] | components["schemas"]["LiteLLM_BudgetTable"] | null;
/**
@ -36303,6 +36307,8 @@ export interface components {
team_alias?: string | null;
/** Team Id */
team_id: string;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Permissions */
team_member_permissions?: string[] | null;
/** Tpd Limit */
@ -39677,6 +39683,8 @@ export interface components {
team_member_budget?: number | null;
/** Team Member Budget Duration */
team_member_budget_duration?: string | null;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Key Duration */
team_member_key_duration?: string | null;
/** Team Member Model Max Budget */
@ -40967,6 +40975,8 @@ export interface components {
team_member_budget?: number | null;
/** Team Member Budget Duration */
team_member_budget_duration?: string | null;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Key Duration */
team_member_key_duration?: string | null;
/** Team Member Model Max Budget */
@ -46738,6 +46748,8 @@ export interface components {
team_alias?: string | null;
/** Team Id */
team_id: string;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Permissions */
team_member_permissions?: string[] | null;
/** Tpd Limit */
@ -46898,6 +46910,8 @@ export interface components {
team_alias?: string | null;
/** Team Id */
team_id: string;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Permissions */
team_member_permissions?: string[] | null;
/** Tpd Limit */
@ -46964,6 +46978,8 @@ export interface components {
* @description Duration after which this team member's budget resets (e.g. '1h', '24h', '7d', '30d'). If not set, the budget never resets.
*/
budget_duration?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/**
* Max Budget In Team
* @description Maximum budget allocated to this user within the team. If not set, user has unlimited budget within team limits
@ -47002,6 +47018,11 @@ export interface components {
/** User Id */
user_id?: string | null;
};
/**
* TeamMemberBudgetDuration
* @enum {string}
*/
TeamMemberBudgetDuration: "1h" | "24h" | "7d" | "30d";
/**
* TeamMemberBudgetPatch
* @description One member's per-member limits, merge-patch style: a field left out of the row is
@ -47053,6 +47074,12 @@ export interface components {
/** User Id */
user_id?: string | null;
};
/** TeamMemberBudgetWindow */
TeamMemberBudgetWindow: {
budget_duration: components["schemas"]["TeamMemberBudgetDuration"];
/** Max Budget */
max_budget: number;
};
/** TeamMemberDeleteRequest */
TeamMemberDeleteRequest: {
/** Team Id */
@ -47083,6 +47110,8 @@ export interface components {
TeamMemberInfoResponse: {
/** Budget Id */
budget_id?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Litellm Budget Table */
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTableFull"] | components["schemas"]["LiteLLM_BudgetTable"] | null;
/** Role */
@ -47144,6 +47173,8 @@ export interface components {
* @description Duration after which this team member's budget resets (e.g. '1h', '24h', '7d', '30d'). If not set, the budget never resets.
*/
budget_duration?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Max Budget In Team */
max_budget_in_team?: number | null;
/**
@ -47188,6 +47219,8 @@ export interface components {
allowed_models?: string[] | null;
/** Budget Duration */
budget_duration?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Max Budget In Team */
max_budget_in_team?: number | null;
/** Model Max Budget */
@ -49284,6 +49317,8 @@ export interface components {
team_member_budget?: number | null;
/** Team Member Budget Duration */
team_member_budget_duration?: string | null;
/** Team Member Budget Limits */
team_member_budget_limits?: components["schemas"]["TeamMemberBudgetWindow"][] | null;
/** Team Member Key Duration */
team_member_key_duration?: string | null;
/** Team Member Model Max Budget */