Add team member budget columns and fix Next Budget Reset display (#24942)

* Add team member budget columns and fix Next Budget Reset display

- Add Spend (Current Period), Total Spend, Team Member Budget, Next Budget
  Reset, and Rate Limits columns to the team members table
- Add total_spend field to LiteLLM_TeamMembership (accumulates across
  resets; never zeroed by the reset job)
- Add budget_reset_at to LiteLLM_BudgetTable Python type and schema
- Fix Next Budget Reset showing proxy start time: run ResetBudgetJob once
  immediately at startup via asyncio.create_task so expired budgets are
  advanced before the first UI request
- Fix update_budget to only recalculate budget_reset_at when duration
  genuinely changes (avoid clobbering a valid future reset on idempotent
  startup calls)
- Fix _reset_budget_reset_at_date to anchor null-reset budgets to
  created_at instead of current_time
- Add Budget Reset Period selector to Edit Member modal; wire
  budget_duration through TeamMemberUpdateRequest and
  _upsert_budget_and_membership so individual member budgets get a
  budget_reset_at on creation
- Wire members with budget_id=null to the team template budget on settings
  update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address PR review: fix test patch target, migration, multi-period reset, wire-up scope

- Fix test_upsert_team_member_budget_table: patch target was
  team_endpoints.prisma_client (module attr) but production code does a
  lazy import from proxy_server; correct target is
  litellm.proxy.proxy_server.prisma_client
- Split test into two: create path wires up null-budget members (expected),
  update path does NOT (new assertion for backwards-compat fix)
- Move null-budget wire-up to create-only path in upsert_team_member_budget_table
  to avoid silently constraining members who were intentionally unlimited
  on every settings save
- Add migration 20260401000000_add_total_spend_to_team_membership with
  ALTER TABLE ... ADD COLUMN IF NOT EXISTS "total_spend" for existing deployments
- Fix _reset_budget_reset_at_date else branch: use while loop (matching the
  None path) so proxies down for multiple periods advance the timestamp all
  the way to the next future date in one scheduler tick
- Add test_reset_budget_reset_at_date_multi_period_catchup to cover this

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: restore mock_new_budget.called assertion dropped in previous fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update existing budget row instead of orphaning it in _upsert_budget_and_membership

When existing_budget_id is provided (member already has a budget), update
the existing LiteLLM_BudgetTable row in-place rather than always creating
a new one. The previous code accepted existing_budget_id but never used it,
silently orphaning one budget row per team_member_update call that set any
budget field.

Adds two unit tests covering the update-existing and create-new paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: allow clearing budget_duration via UI; sync schema copies

Frontend: remove the != null guard in teamMemberUpdateCall so that
explicitly selecting 'No reset (unlimited)' sends budget_duration: null
to the backend (rather than omitting the field entirely).

Backend: add clear_budget_duration flag to _upsert_budget_and_membership.
The team_member_update endpoint sets this flag when budget_duration is
present in model_fields_set but null, allowing the update path to
explicitly null out budget_duration and budget_reset_at on the existing
budget row.

Also sync litellm/proxy/schema.prisma and litellm-proxy-extras schema
to root (missing total_spend column + alignment drift).

* Add total_spend to LiteLLM_TeamMembership in all schema.prisma copies

The migration already added the column to the DB, but all three
schema.prisma files were missing the field, causing Prisma to throw
InvalidPrismaInvocation on every team membership spend write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix early-return guard ignoring clear_budget_duration flag

When clear_budget_duration=True with all budget limits None, the guard
at the top of _upsert_budget_and_membership was short-circuiting to
disconnect the budget, bypassing the intended update.  Adding
`not clear_budget_duration` to the guard condition lets the function
reach the update path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Roy Vivat 2026-04-07 15:48:10 -07:00 committed by GitHub
parent 096893ea97
commit 19306ff4a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1157 additions and 46 deletions

View file

@ -0,0 +1,4 @@
-- Add total_spend column to LiteLLM_TeamMembership
-- Tracks lifetime (never-zeroed) spend for a user within a team,
-- independent of the current-period spend that resets periodically.
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN IF NOT EXISTS "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;

View file

@ -608,9 +608,10 @@ model LiteLLM_UserNotifications {
model LiteLLM_TeamMembership {
// 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
spend Float @default(0.0)
user_id String
team_id String
spend Float @default(0.0)
total_spend Float @default(0.0)
budget_id String?
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
@@id([user_id, team_id])

View file

@ -1943,6 +1943,7 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase):
rpm_limit: Optional[int] = None
model_max_budget: Optional[dict] = None
budget_duration: Optional[str] = None
budget_reset_at: Optional[datetime] = None
model_config = ConfigDict(protected_namespaces=())
@ -3622,6 +3623,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase):
team_id: str
budget_id: Optional[str] = None
spend: Optional[float] = 0.0
total_spend: Optional[float] = 0.0
litellm_budget_table: Optional[LiteLLM_BudgetTable]
def safe_get_team_member_rpm_limit(self) -> Optional[int]:
@ -3750,6 +3752,10 @@ class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
rpm_limit: Optional[int] = Field(
default=None, description="Requests per minute limit for this team member"
)
budget_duration: Optional[str] = Field(
default=None,
description="Budget reset period for this team member (e.g. '24h', '7d', '30d'). Determines how often their individual spend resets.",
)
class TeamMemberUpdateResponse(MemberUpdateResponse):

View file

@ -52,13 +52,17 @@ class ResetBudgetJob:
self, budgets_to_reset: List[LiteLLM_BudgetTableFull]
):
"""
Resets the budget for all LiteLLM Team Members if their budget has expired
Resets the budget for all LiteLLM Team Members if their budget has expired.
Only resets the current-period spend; total_spend is never zeroed so it
accumulates as a lifetime usage counter.
"""
budget_ids = [
budget.budget_id
for budget in budgets_to_reset
if budget.budget_id is not None
]
if not budget_ids:
return
# Reset spend counters for affected team members.
# Reset Redis directly so a transient failure doesn't leave stale
@ -658,18 +662,25 @@ class ResetBudgetJob:
duration_s = duration_in_seconds(duration=budget.budget_duration)
# Fallback for existing budgets that do not have a budget_reset_at date set, ensuring the duration is taken into account
if (
budget.budget_reset_at is None
and budget.created_at + timedelta(seconds=duration_s) > current_time
):
budget.budget_reset_at = budget.created_at + timedelta(
seconds=duration_s
)
if budget.budget_reset_at is None:
# Budget was created without a reset time. Anchor the first
# period to created_at so the reset aligns with when the
# budget was originally set up, not the current wall-clock
# time (which would be proxy-start time when this job runs
# at startup and produce a confusing "resets at startup" display).
anchor = budget.created_at + timedelta(seconds=duration_s)
while anchor <= current_time:
anchor += timedelta(seconds=duration_s)
budget.budget_reset_at = anchor
else:
budget.budget_reset_at = current_time + timedelta(
seconds=duration_s
)
# Normal roll-forward: advance from the previous reset time.
# Use a while loop so proxies that were down for multiple
# periods catch up in one pass rather than firing the
# spend-zero operation repeatedly over many scheduler ticks.
while budget.budget_reset_at <= current_time:
budget.budget_reset_at = budget.budget_reset_at + timedelta(
seconds=duration_s
)
except Exception as e:
verbose_proxy_logger.exception(
"Error resetting budget_reset_at for budget: %s. Item: %s", e, budget

View file

@ -1295,7 +1295,10 @@ class DBSpendUpdateWriter:
batcher.litellm_teammembership.update_many( # 'update_many' prevents error from being raised if no row exists
where={"team_id": team_id, "user_id": user_id},
data={"spend": {"increment": response_cost}},
data={
"spend": {"increment": response_cost},
"total_spend": {"increment": response_cost},
},
)
# Transaction succeeded, break out of retry loop
break

View file

@ -173,12 +173,38 @@ async def update_budget(
except ValueError as e:
raise HTTPException(status_code=400, detail={"error": str(e)})
update_data: dict = {
**budget_obj.model_dump(exclude_unset=True),
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}
# Recalculate budget_reset_at only when budget_duration is genuinely changing.
# We compare against the current DB value so that idempotent calls at proxy
# startup (e.g. _update_default_team_member_budget) do not overwrite a
# correctly-set future budget_reset_at with "now + duration".
# We check budget_obj.budget_duration directly (not model_fields_set) because
# internal callers set fields via attribute assignment after construction, which
# does not reliably update model_fields_set.
if budget_obj.budget_duration is not None and "budget_reset_at" not in update_data:
existing = await prisma_client.db.litellm_budgettable.find_unique(
where={"budget_id": budget_obj.budget_id}
)
existing_duration = existing.budget_duration if existing is not None else None
duration_changed = existing_duration != budget_obj.budget_duration
reset_at_missing = (
existing is None or existing.budget_reset_at is None
)
if duration_changed or reset_at_missing:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
update_data["budget_duration"] = budget_obj.budget_duration
update_data["budget_reset_at"] = get_budget_reset_time(
budget_duration=budget_obj.budget_duration
)
response = await prisma_client.db.litellm_budgettable.update(
where={"budget_id": budget_obj.budget_id},
data={
**budget_obj.model_dump(exclude_unset=True), # type: ignore
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}, # type: ignore
data=update_data, # type: ignore
)
return response

View file

@ -354,6 +354,8 @@ async def _upsert_budget_and_membership(
user_api_key_dict: UserAPIKeyAuth,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
budget_duration: Optional[str] = None,
clear_budget_duration: bool = False,
):
"""
Helper function to Create/Update or Delete the budget within the team membership
@ -366,11 +368,15 @@ async def _upsert_budget_and_membership(
user_api_key_dict: User API Key dictionary containing user information
tpm_limit: Tokens per minute limit for the team member
rpm_limit: Requests per minute limit for the team member
budget_duration: Budget reset period (e.g. '24h', '7d', '30d')
clear_budget_duration: When True, explicitly clears budget_duration (and
budget_reset_at) on an existing budget. Distinct from budget_duration=None
which means "not supplied / don't touch".
If max_budget, tpm_limit, and rpm_limit are all None, the user's budget is removed from the team membership.
If max_budget, tpm_limit, rpm_limit, and budget_duration are all None, the user's budget is removed from the team membership.
If any of these values exist, a budget is updated or created and linked to the team membership.
"""
if max_budget is None and tpm_limit is None and rpm_limit is None:
if max_budget is None and tpm_limit is None and rpm_limit is None and budget_duration is None and not clear_budget_duration:
# disconnect the budget since all limits are None
await tx.litellm_teammembership.update(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
@ -389,11 +395,34 @@ async def _upsert_budget_and_membership(
create_data["tpm_limit"] = tpm_limit
if rpm_limit is not None:
create_data["rpm_limit"] = rpm_limit
if budget_duration is not None:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
create_data["budget_duration"] = budget_duration
create_data["budget_reset_at"] = get_budget_reset_time(
budget_duration=budget_duration
)
if existing_budget_id is not None:
# Update in-place: patch only the fields that were supplied so we don't
# overwrite fields the caller didn't touch (e.g. keep max_budget when
# only budget_duration changes). Exclude created_by — that's set once.
update_data = {k: v for k, v in create_data.items() if k != "created_by"}
if clear_budget_duration:
update_data["budget_duration"] = None
update_data["budget_reset_at"] = None
await tx.litellm_budgettable.update(
where={"budget_id": existing_budget_id},
data=update_data,
)
budget_id_to_connect = existing_budget_id
else:
new_budget = await tx.litellm_budgettable.create(
data=create_data,
include={"team_membership": True},
)
budget_id_to_connect = new_budget.budget_id
new_budget = await tx.litellm_budgettable.create(
data=create_data,
include={"team_membership": True},
)
# upsert the team membership with the new/updated budget
await tx.litellm_teammembership.upsert(
where={
@ -407,12 +436,12 @@ async def _upsert_budget_and_membership(
"user_id": user_id,
"team_id": team_id,
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
"connect": {"budget_id": budget_id_to_connect},
},
},
"update": {
"litellm_budget_table": {
"connect": {"budget_id": new_budget.budget_id},
"connect": {"budget_id": budget_id_to_connect},
},
},
},

View file

@ -231,7 +231,7 @@ class TeamMemberBudgetHandler:
updated_kv["metadata"] = {}
updated_kv["metadata"]["team_member_budget_id"] = budget_row.budget_id
else: # budget does not exist
else: # budget does not exist — newly creating the template
updated_kv = await TeamMemberBudgetHandler.create_team_member_budget_table(
data=team_table,
new_team_data_json=updated_kv,
@ -242,6 +242,27 @@ class TeamMemberBudgetHandler:
team_member_budget_duration=team_member_budget_duration,
)
# Wire up existing members whose budget_id is null to the
# newly-created template budget so they immediately inherit the
# reset schedule. Only do this on creation (not on every update)
# to avoid silently constraining members who were intentionally
# left unlimited.
if team_table.team_id is not None:
new_budget_id: Optional[str] = (
updated_kv.get("metadata", {}).get("team_member_budget_id")
)
if new_budget_id is not None:
from litellm.proxy.proxy_server import prisma_client as _prisma_client
if _prisma_client is not None:
await _prisma_client.db.litellm_teammembership.update_many(
where={
"team_id": team_table.team_id,
"budget_id": None,
},
data={"budget_id": new_budget_id},
)
# Remove team member fields from updated_kv
TeamMemberBudgetHandler._clean_team_member_fields(updated_kv)
return updated_kv
@ -2427,6 +2448,11 @@ async def team_member_update(
break
### upsert new budget
# budget_duration=None means "not supplied" by default; we need to distinguish
# that from the user explicitly sending null to clear a previously-set value.
clear_budget_duration = (
"budget_duration" in data.model_fields_set and data.budget_duration is None
)
async with prisma_client.db.tx() as tx:
await _upsert_budget_and_membership(
tx=tx,
@ -2437,6 +2463,8 @@ async def team_member_update(
user_api_key_dict=user_api_key_dict,
tpm_limit=data.tpm_limit,
rpm_limit=data.rpm_limit,
budget_duration=data.budget_duration,
clear_budget_duration=clear_budget_duration,
)
### update team member role

View file

@ -6273,6 +6273,12 @@ class ProxyStartupEvent:
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
# Run once immediately at startup so expired budget_reset_at values are
# advanced before the first UI request. Without this, any budget that
# expired exactly at restart time would show a stale past timestamp
# until the first scheduled interval fires (~10 minutes later).
asyncio.create_task(budget_reset_job.reset_budget())
### UPDATE SPEND ###
scheduler.add_job(
update_spend,

View file

@ -608,9 +608,10 @@ model LiteLLM_UserNotifications {
model LiteLLM_TeamMembership {
// 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
spend Float @default(0.0)
user_id String
team_id String
spend Float @default(0.0)
total_spend Float @default(0.0)
budget_id String?
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
@@id([user_id, team_id])

View file

@ -608,9 +608,10 @@ model LiteLLM_UserNotifications {
model LiteLLM_TeamMembership {
// 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
spend Float @default(0.0)
user_id String
team_id String
spend Float @default(0.0)
total_spend Float @default(0.0)
budget_id String?
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
@@id([user_id, team_id])

View file

@ -18,10 +18,13 @@ from litellm.proxy.utils import ProxyLogging
# Mock classes for testing
class MockLiteLLMTeamMembership:
def __init__(self):
self.update_many_calls: List[Dict[str, Any]] = []
async def update_many(
self, where: Dict[str, Any], data: Dict[str, Any]
) -> Dict[str, Any]:
# Mock the update_many method for litellm_teammembership
self.update_many_calls.append({"where": where, "data": data})
return {"count": 1}
@ -41,6 +44,10 @@ class MockDB:
self.litellm_teammembership = MockLiteLLMTeamMembership()
self.litellm_verificationtoken = MockLiteLLMVerificationToken()
def reset(self):
self.litellm_teammembership = MockLiteLLMTeamMembership()
self.litellm_verificationtoken = MockLiteLLMVerificationToken()
class MockPrismaClient:
def __init__(self):
@ -444,6 +451,101 @@ def test_reset_budget_for_keys_linked_to_budgets_empty(
assert len(calls) == 0
def test_reset_budget_for_team_members_resets_spend(
reset_budget_job, mock_prisma_client
):
"""
Test that reset_budget_for_litellm_team_members resets spend to 0
for all team memberships linked to the expired budgets.
"""
now = datetime.now(timezone.utc)
test_budget = type(
"LiteLLM_BudgetTableFull",
(),
{
"max_budget": 50.0,
"budget_duration": "1mo",
"budget_reset_at": now,
"budget_id": "team-member-budget-1",
"created_at": now - timedelta(days=30),
},
)
budgets_to_reset = [test_budget]
asyncio.run(
reset_budget_job.reset_budget_for_litellm_team_members(
budgets_to_reset=budgets_to_reset
)
)
calls = mock_prisma_client.db.litellm_teammembership.update_many_calls
assert len(calls) == 1
call = calls[0]
# spend must be zeroed out for the matching budget IDs
assert call["where"]["budget_id"] == {"in": ["team-member-budget-1"]}
assert call["data"]["spend"] == 0
def test_reset_budget_for_team_members_preserves_total_spend(
reset_budget_job, mock_prisma_client
):
"""
Test that reset_budget_for_litellm_team_members only resets the current-period
spend and does NOT reset total_spend. total_spend must accumulate across all
budget periods to give a lifetime view of member usage.
"""
now = datetime.now(timezone.utc)
test_budget = type(
"LiteLLM_BudgetTableFull",
(),
{
"max_budget": 50.0,
"budget_duration": "1mo",
"budget_reset_at": now,
"budget_id": "team-member-budget-2",
"created_at": now - timedelta(days=30),
},
)
budgets_to_reset = [test_budget]
asyncio.run(
reset_budget_job.reset_budget_for_litellm_team_members(
budgets_to_reset=budgets_to_reset
)
)
calls = mock_prisma_client.db.litellm_teammembership.update_many_calls
assert len(calls) == 1
call = calls[0]
# total_spend must NOT be present in the reset data — it must never be zeroed
assert "total_spend" not in call["data"], (
"total_spend should not be reset during a budget period reset; "
"it must accumulate across all periods"
)
assert call["data"]["spend"] == 0
def test_reset_budget_for_team_members_empty_budgets(
reset_budget_job, mock_prisma_client
):
"""
Test that reset_budget_for_litellm_team_members does nothing when given
an empty list of budgets (e.g. no budget periods have expired).
"""
asyncio.run(
reset_budget_job.reset_budget_for_litellm_team_members(budgets_to_reset=[])
)
calls = mock_prisma_client.db.litellm_teammembership.update_many_calls
assert len(calls) == 0
def test_budget_table_reset_also_resets_linked_keys(
reset_budget_job, mock_prisma_client
):
@ -479,3 +581,185 @@ def test_budget_table_reset_also_resets_linked_keys(
)
assert calls[0]["where"]["budget_id"] == {"in": ["7d-budget-tier"]}
assert calls[0]["data"]["spend"] == 0
# ---------------------------------------------------------------------------
# Tests for _reset_budget_reset_at_date
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reset_budget_reset_at_date_normal_rollforward():
"""
When budget_reset_at is set and has expired, roll it forward by one
duration interval from the PREVIOUS reset time (not from now).
This ensures periods stay aligned regardless of when the job runs.
"""
from litellm.proxy._types import LiteLLM_BudgetTableFull
now = datetime.now(timezone.utc)
previous_reset = now - timedelta(days=1) # expired yesterday
budget = LiteLLM_BudgetTableFull(
budget_id="b1",
budget_duration="7d",
budget_reset_at=previous_reset,
created_at=now - timedelta(days=30),
)
result = await ResetBudgetJob._reset_budget_reset_at_date(budget, now)
expected = previous_reset + timedelta(days=7)
assert result.budget_reset_at == expected, (
"budget_reset_at should advance by exactly one duration from the previous reset"
)
assert result.budget_reset_at > now, "new reset must be in the future"
@pytest.mark.asyncio
async def test_reset_budget_reset_at_date_none_reset_at_not_yet_expired():
"""
When budget_reset_at is None but created_at + duration is still in the
future, anchor to created_at + duration (the original planned reset time).
"""
from litellm.proxy._types import LiteLLM_BudgetTableFull
now = datetime.now(timezone.utc)
created_at = now - timedelta(days=3) # created 3 days ago
# duration is 7d → first reset would be created_at + 7d = 4 days from now
budget = LiteLLM_BudgetTableFull(
budget_id="b2",
budget_duration="7d",
budget_reset_at=None,
created_at=created_at,
)
result = await ResetBudgetJob._reset_budget_reset_at_date(budget, now)
expected = created_at + timedelta(days=7)
assert result.budget_reset_at == expected
assert result.budget_reset_at > now
@pytest.mark.asyncio
async def test_reset_budget_reset_at_date_none_reset_at_already_past():
"""
Regression: when budget_reset_at is None AND created_at + duration is
already in the past (old budget that was never given a reset time), the
new reset_at must be computed by advancing from created_at in duration
steps until it's in the future — NOT set to current_time + duration.
Setting it to current_time + duration was the bug: it made the reset
display show "proxy start time + duration" because this path runs at
startup via the ResetBudgetJob scheduler.
"""
from litellm.proxy._types import LiteLLM_BudgetTableFull
now = datetime.now(timezone.utc)
# Budget was created 30 days ago with a 7-day duration → 4 full periods
# have elapsed; created_at + 7d, +14d, +21d, +28d are all in the past.
created_at = now - timedelta(days=30)
budget = LiteLLM_BudgetTableFull(
budget_id="b3",
budget_duration="7d",
budget_reset_at=None,
created_at=created_at,
)
result = await ResetBudgetJob._reset_budget_reset_at_date(budget, now)
assert result.budget_reset_at is not None
assert result.budget_reset_at > now, "reset must be in the future"
# Must be anchored to created_at, not to `now`.
# The correct next reset is created_at + N*7d where N is the smallest
# integer that puts it past `now`. For 30 days elapsed: N=5 → +35d.
expected = created_at + timedelta(days=35)
assert result.budget_reset_at == expected, (
f"Expected {expected}, got {result.budget_reset_at}. "
"reset_at must be anchored to created_at, not proxy-start time."
)
# Verify it is NOT simply now + duration (the old buggy behaviour).
buggy_value = now + timedelta(days=7)
assert result.budget_reset_at != pytest.approx(buggy_value, abs=timedelta(minutes=1)), (
"reset_at must not be set to current_time + duration (proxy-start-time bug)"
)
@pytest.mark.asyncio
async def test_reset_budget_reset_at_date_multi_period_catchup():
"""
When budget_reset_at is non-null but multiple periods have elapsed (e.g.
the proxy was down for 3 weeks and the period is 7 days), the while loop
must advance the timestamp all the way to the next future date in a single
call rather than requiring the scheduler to fire 3 times.
"""
from litellm.proxy._types import LiteLLM_BudgetTableFull
now = datetime.now(timezone.utc)
# reset_at is 22 days in the past; 7d period → 3 full periods have elapsed
previous_reset = now - timedelta(days=22)
budget = LiteLLM_BudgetTableFull(
budget_id="b-multi",
budget_duration="7d",
budget_reset_at=previous_reset,
created_at=now - timedelta(days=30),
)
result = await ResetBudgetJob._reset_budget_reset_at_date(budget, now)
assert result.budget_reset_at > now, "reset must be in the future after catch-up"
# Should be previous_reset + 28d (4 periods) = now + 6d
expected = previous_reset + timedelta(days=28)
assert result.budget_reset_at == expected, (
f"Expected {expected}, got {result.budget_reset_at}. "
"Multi-period catch-up must advance through all elapsed periods in one call."
)
@pytest.mark.asyncio
async def test_reset_budget_at_startup_advances_expired_budget_reset_at(
reset_budget_job, mock_prisma_client
):
"""
Regression: on proxy restart, any budget whose budget_reset_at has just
expired (e.g. budget created 24 h ago, reset_at = proxy_start_time) must be
advanced by the immediate reset_budget() call that now runs at startup.
Without the startup call, a budget that expired at the exact moment the proxy
restarted would show a past timestamp in the UI for up to ~10 minutes (the
first scheduled interval), even if the user checked 50 minutes later.
"""
now = datetime.now(timezone.utc)
proxy_start_time = now # simulated proxy start time
# Budget was created 24 h ago; reset_at is exactly now (proxy_start_time)
expired_budget = type(
"LiteLLM_BudgetTableFull",
(),
{
"max_budget": 10.0,
"budget_duration": "24h",
"budget_reset_at": proxy_start_time, # expired right now
"budget_id": "template-budget-1",
"created_at": proxy_start_time - timedelta(hours=24),
},
)
mock_prisma_client.data["budget"] = [expired_budget]
# Simulate what the startup asyncio.create_task fires
await reset_budget_job.reset_budget_for_litellm_budget_table()
# The budget should have been advanced in DB
updated_budgets = mock_prisma_client.updated_data.get("budget", [])
assert len(updated_budgets) == 1, "Expected the expired budget to be updated"
updated = updated_budgets[0]
assert updated.budget_reset_at > proxy_start_time, (
"After startup reset, budget_reset_at must be in the future, "
"not equal to proxy start time"
)

View file

@ -316,3 +316,105 @@ async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user):
},
},
)
# TEST: budget_duration sets budget_reset_at on the new budget
@pytest.mark.asyncio
async def test_upsert_with_budget_duration_sets_reset_at(mock_tx, fake_user):
"""
When budget_duration is provided, the new individual member budget should
have both budget_duration and budget_reset_at written so that the
Next Budget Reset column shows a real future time instead of '-'.
"""
from datetime import datetime, timezone
from unittest.mock import patch
fake_reset_time = datetime(2026, 5, 1, 0, 0, 0, tzinfo=timezone.utc)
with patch(
"litellm.proxy.common_utils.timezone_utils.get_budget_reset_time",
return_value=fake_reset_time,
) as mock_get_reset:
await _upsert_budget_and_membership(
mock_tx,
team_id="team-dur",
user_id="user-dur",
max_budget=10.0,
existing_budget_id=None,
user_api_key_dict=fake_user,
budget_duration="24h",
)
mock_get_reset.assert_called_once_with(budget_duration="24h")
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
data={
"max_budget": 10.0,
"budget_duration": "24h",
"budget_reset_at": fake_reset_time,
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
},
include={"team_membership": True},
)
# TEST: budget_duration=None alone does not create a budget (disconnect path)
@pytest.mark.asyncio
async def test_upsert_disconnect_when_only_budget_duration_none(mock_tx, fake_user):
"""
Passing only None values (including budget_duration=None) should still
hit the disconnect path, not attempt to create a budget.
"""
await _upsert_budget_and_membership(
mock_tx,
team_id="team-none",
user_id="user-none",
max_budget=None,
existing_budget_id=None,
user_api_key_dict=fake_user,
tpm_limit=None,
rpm_limit=None,
budget_duration=None,
)
mock_tx.litellm_teammembership.update.assert_awaited_once()
mock_tx.litellm_budgettable.create.assert_not_called()
# TEST: budget_duration alone (no max_budget/limits) creates a budget
@pytest.mark.asyncio
async def test_upsert_budget_duration_only_creates_budget(mock_tx, fake_user):
"""
Setting only budget_duration (no max_budget, tpm, rpm) should still create
a budget with the duration so that the member gets a reset schedule.
"""
from datetime import datetime, timezone
from unittest.mock import patch
fake_reset_time = datetime(2026, 5, 8, 0, 0, 0, tzinfo=timezone.utc)
with patch(
"litellm.proxy.common_utils.timezone_utils.get_budget_reset_time",
return_value=fake_reset_time,
):
await _upsert_budget_and_membership(
mock_tx,
team_id="team-dur-only",
user_id="user-dur-only",
max_budget=None,
existing_budget_id=None,
user_api_key_dict=fake_user,
budget_duration="7d",
)
mock_tx.litellm_budgettable.create.assert_awaited_once_with(
data={
"budget_duration": "7d",
"budget_reset_at": fake_reset_time,
"created_by": fake_user.user_id,
"updated_by": fake_user.user_id,
},
include={"team_membership": True},
)
mock_tx.litellm_teammembership.update.assert_not_called()

View file

@ -24,6 +24,12 @@ def client_and_mocks(monkeypatch):
mock_table = MagicMock()
mock_table.create = AsyncMock(side_effect=lambda *, data: data)
mock_table.update = AsyncMock(side_effect=lambda *, where, data: {**where, **data})
# Default find_unique: return a budget whose duration differs from "7d" so
# duration-change tests see a change. Individual tests override this as needed.
mock_table.find_unique = AsyncMock(return_value=types.SimpleNamespace(
budget_duration=None,
budget_reset_at=None,
))
mock_prisma.db = types.SimpleNamespace(
litellm_budgettable=mock_table,
@ -265,3 +271,251 @@ async def test_new_budget_invalid_model_max_budget(client_and_mocks, monkeypatch
assert resp.status_code in (400, 422), resp.text
detail = resp.json()["detail"]
assert "model_max_budget" in str(detail) or "dictionary" in str(detail).lower()
@pytest.mark.asyncio
async def test_update_budget_duration_recalculates_budget_reset_at(client_and_mocks):
"""
Regression test: changing budget_duration via /budget/update must also update
budget_reset_at to reflect the new period.
Previously, update_budget() stored the new duration but left the old
budget_reset_at untouched, so the next reset shown in the UI was still in the
past (the old period's reset time).
"""
from datetime import datetime, timezone
client, _, mock_table = client_and_mocks
captured_data: dict = {}
async def capture_update(*, where, data):
captured_data.update(data)
return {**where, **data}
mock_table.update = AsyncMock(side_effect=capture_update)
payload = {
"budget_id": "budget_duration_change",
"budget_duration": "7d",
}
resp = client.post("/budget/update", json=payload)
assert resp.status_code == 200, resp.text
# budget_reset_at must be present and in the future
assert "budget_reset_at" in captured_data, (
"budget_reset_at must be recalculated when budget_duration changes"
)
reset_at = captured_data["budget_reset_at"]
if isinstance(reset_at, str):
reset_at = datetime.fromisoformat(reset_at)
if reset_at.tzinfo is None:
reset_at = reset_at.replace(tzinfo=timezone.utc)
assert reset_at > datetime.now(timezone.utc), (
"budget_reset_at must be in the future after a duration change"
)
@pytest.mark.asyncio
async def test_update_budget_duration_respects_explicit_budget_reset_at(client_and_mocks):
"""
If the caller explicitly provides budget_reset_at alongside budget_duration,
the supplied value must be used as-is and not overwritten.
"""
from datetime import datetime, timezone, timedelta
client, _, mock_table = client_and_mocks
captured_data: dict = {}
async def capture_update(*, where, data):
captured_data.update(data)
return {**where, **data}
mock_table.update = AsyncMock(side_effect=capture_update)
explicit_reset = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat()
payload = {
"budget_id": "budget_explicit_reset",
"budget_duration": "7d",
"budget_reset_at": explicit_reset,
}
resp = client.post("/budget/update", json=payload)
assert resp.status_code == 200, resp.text
stored = captured_data.get("budget_reset_at")
if isinstance(stored, str):
stored = datetime.fromisoformat(stored)
if isinstance(stored, datetime) and stored.tzinfo is None:
stored = stored.replace(tzinfo=timezone.utc)
explicit_dt = datetime.fromisoformat(explicit_reset)
# Should be within a second of the explicit value
assert abs((stored - explicit_dt).total_seconds()) < 1, (
"Explicit budget_reset_at must not be overwritten by the duration recalculation"
)
@pytest.mark.asyncio
async def test_update_budget_duration_via_internal_call_path(client_and_mocks, monkeypatch):
"""
Regression test for the internal call path used by upsert_team_member_budget_table.
When the team member budget duration is changed via the team settings UI, the
backend constructs a BudgetNewRequest by first calling BudgetNewRequest(budget_id=...)
and then setting budget_duration via attribute assignment AFTER construction:
budget_request = BudgetNewRequest(budget_id=some_id)
budget_request.budget_duration = "7d" # ← attribute assignment, not __init__
await update_budget(budget_obj=budget_request, ...)
In Pydantic v2 without validate_assignment=True, attribute assignment does NOT
reliably update model_fields_set. The previous fix checked model_fields_set and
therefore silently skipped the budget_reset_at recalculation on this path.
The new fix checks budget_obj.budget_duration directly and writes budget_reset_at
explicitly into the update dict, bypassing model_fields_set entirely.
"""
import litellm.proxy.proxy_server as ps
from datetime import datetime, timezone
from litellm.proxy._types import BudgetNewRequest, UserAPIKeyAuth, LitellmUserRoles
from litellm.proxy.management_endpoints.budget_management_endpoints import update_budget
import types as _types
_, mock_prisma, mock_table = client_and_mocks
# find_unique: existing budget has no duration (None → "7d" is a real change)
mock_table.find_unique = AsyncMock(return_value=_types.SimpleNamespace(
budget_duration=None,
budget_reset_at=None,
))
captured_data: dict = {}
async def capture_update(*, where, data):
captured_data.update(data)
return {**where, **data}
mock_table.update = AsyncMock(side_effect=capture_update)
fake_user = UserAPIKeyAuth(
user_id="internal_caller",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
# Simulate exactly what upsert_team_member_budget_table does:
# construct with budget_id only, then set budget_duration via attribute assignment.
budget_request = BudgetNewRequest(budget_id="internal-budget-id")
budget_request.budget_duration = "7d" # attribute assignment — not via __init__
await update_budget(budget_obj=budget_request, user_api_key_dict=fake_user)
assert "budget_reset_at" in captured_data, (
"budget_reset_at must be recalculated even when budget_duration was set "
"via attribute assignment after BudgetNewRequest construction"
)
assert "budget_duration" in captured_data, (
"budget_duration must be present in the DB update"
)
reset_at = captured_data["budget_reset_at"]
if isinstance(reset_at, str):
reset_at = datetime.fromisoformat(reset_at)
if reset_at.tzinfo is None:
reset_at = reset_at.replace(tzinfo=timezone.utc)
assert reset_at > datetime.now(timezone.utc), (
"budget_reset_at must be a future datetime"
)
@pytest.mark.asyncio
async def test_update_budget_same_duration_does_not_overwrite_reset_at(client_and_mocks):
"""
Regression test: proxy startup calls update_team upsert_team_member_budget_table
update_budget with the SAME budget_duration that's already stored in the DB.
Before this fix, update_budget always recalculated budget_reset_at when
budget_duration was set, so every proxy restart would stamp the template
budget's reset time to "now + duration", making the UI display proxy-start
time as the "Next Budget Reset" for all members.
When the duration hasn't changed and budget_reset_at is already set to a
future value, update_budget must NOT touch budget_reset_at.
"""
import types as _types
from datetime import datetime, timezone, timedelta
client, _, mock_table = client_and_mocks
future_reset = datetime.now(timezone.utc) + timedelta(days=5)
# Simulate existing budget with the SAME duration and a valid future reset time
mock_table.find_unique = AsyncMock(return_value=_types.SimpleNamespace(
budget_duration="7d",
budget_reset_at=future_reset,
))
captured_data: dict = {}
async def capture_update(*, where, data):
captured_data.update(data)
return {**where, **data}
mock_table.update = AsyncMock(side_effect=capture_update)
payload = {
"budget_id": "template-budget-startup",
"budget_duration": "7d", # same as what's already in DB
"max_budget": 50.0,
}
resp = client.post("/budget/update", json=payload)
assert resp.status_code == 200, resp.text
assert "budget_reset_at" not in captured_data, (
"budget_reset_at must NOT be overwritten when budget_duration is unchanged "
"and a valid future reset time already exists — proxy-start-time bug"
)
@pytest.mark.asyncio
async def test_update_budget_changed_duration_recalculates_reset_at(client_and_mocks):
"""
Complementary to the no-op test: when budget_duration genuinely changes
(e.g. user switches from 7d to 1mo in the UI), budget_reset_at must be
recalculated to reflect the new period.
"""
import types as _types
from datetime import datetime, timezone, timedelta
client, _, mock_table = client_and_mocks
# Existing budget has "7d"; request changes it to "30d"
mock_table.find_unique = AsyncMock(return_value=_types.SimpleNamespace(
budget_duration="7d",
budget_reset_at=datetime.now(timezone.utc) + timedelta(days=3),
))
captured_data: dict = {}
async def capture_update(*, where, data):
captured_data.update(data)
return {**where, **data}
mock_table.update = AsyncMock(side_effect=capture_update)
payload = {
"budget_id": "template-budget-change",
"budget_duration": "30d", # different from DB value
}
resp = client.post("/budget/update", json=payload)
assert resp.status_code == 200, resp.text
assert "budget_reset_at" in captured_data, (
"budget_reset_at must be recalculated when budget_duration changes"
)
reset_at = captured_data["budget_reset_at"]
if isinstance(reset_at, str):
reset_at = datetime.fromisoformat(reset_at)
if reset_at.tzinfo is None:
reset_at = reset_at.replace(tzinfo=timezone.utc)
assert reset_at > datetime.now(timezone.utc)

View file

@ -486,3 +486,75 @@ class TestSetObjectMetadataField:
):
_set_object_metadata_field(team, "model_rpm_limit", {"x": 1})
assert team.metadata == {"model_rpm_limit": {"x": 1}}
class TestUpsertBudgetAndMembershipClearDuration:
"""
Tests for the clear_budget_duration flag in _upsert_budget_and_membership.
Regression for the bug where selecting 'No reset (unlimited)' in the UI
could not clear a previously-set budget_duration because null was filtered
out before reaching the backend.
"""
def _make_tx(self):
tx = MagicMock()
tx.litellm_budgettable = MagicMock()
tx.litellm_budgettable.update = AsyncMock()
tx.litellm_budgettable.create = AsyncMock(
return_value=MagicMock(budget_id="new-budget-id")
)
tx.litellm_teammembership = MagicMock()
tx.litellm_teammembership.update = AsyncMock()
tx.litellm_teammembership.upsert = AsyncMock()
return tx
def _make_auth(self):
return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
@pytest.mark.asyncio
async def test_clear_budget_duration_sets_null_on_existing_budget(self):
"""clear_budget_duration=True must null out budget_duration and budget_reset_at."""
from litellm.proxy.management_endpoints.common_utils import (
_upsert_budget_and_membership,
)
tx = self._make_tx()
await _upsert_budget_and_membership(
tx=tx,
team_id="team-1",
user_id="user-1",
max_budget=None,
existing_budget_id="existing-budget-id",
user_api_key_dict=self._make_auth(),
budget_duration=None,
clear_budget_duration=True,
)
tx.litellm_budgettable.update.assert_awaited_once()
update_data = tx.litellm_budgettable.update.call_args.kwargs["data"]
assert update_data["budget_duration"] is None
assert update_data["budget_reset_at"] is None
@pytest.mark.asyncio
async def test_no_clear_flag_does_not_touch_budget_duration(self):
"""Without clear_budget_duration, omitting budget_duration must not overwrite it."""
from litellm.proxy.management_endpoints.common_utils import (
_upsert_budget_and_membership,
)
tx = self._make_tx()
await _upsert_budget_and_membership(
tx=tx,
team_id="team-1",
user_id="user-1",
max_budget=50.0,
existing_budget_id="existing-budget-id",
user_api_key_dict=self._make_auth(),
budget_duration=None,
clear_budget_duration=False,
)
tx.litellm_budgettable.update.assert_awaited_once()
update_data = tx.litellm_budgettable.update.call_args.kwargs["data"]
assert "budget_duration" not in update_data
assert "budget_reset_at" not in update_data

View file

@ -1627,6 +1627,7 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
team_table = MagicMock(spec=LiteLLM_TeamTable)
team_table.metadata = {}
team_table.team_id = "test_team_id"
team_table.team_alias = "Test Team"
team_table.budget_duration = None
@ -1639,12 +1640,22 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
mock_budget_response = MagicMock()
mock_budget_response.budget_id = "new_budget_456"
with patch(
"litellm.proxy.management_endpoints.budget_management_endpoints.new_budget",
new_callable=AsyncMock
) as mock_new_budget:
mock_new_budget.return_value = mock_budget_response
mock_db = MagicMock()
mock_db.litellm_teammembership.update_many = AsyncMock(return_value={"count": 0})
mock_prisma = MagicMock()
mock_prisma.db = mock_db
with (
patch(
"litellm.proxy.management_endpoints.budget_management_endpoints.new_budget",
new_callable=AsyncMock,
return_value=mock_budget_response,
) as mock_new_budget,
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma,
),
):
result = await TeamMemberBudgetHandler.upsert_team_member_budget_table(
team_table=team_table,
user_api_key_dict=mock_user_api_key_dict,
@ -1661,6 +1672,125 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
assert "team_member_budget_duration" not in result
@pytest.mark.asyncio
async def test_upsert_team_member_budget_table_wires_up_null_budget_members_on_create():
"""
Regression test: when a team budget is being CREATED for the first time,
existing team members whose budget_id is null must be assigned the new
template budget so that their 'Next Budget Reset' column reflects the reset time.
Previously, members added before the team's budget duration was configured had
budget_id=null. Creating the template budget left those memberships disconnected
they never showed a reset date.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.proxy._types import LitellmUserRoles, LiteLLM_TeamTable, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler
mock_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
)
# No existing budget_id — triggers the create path
team_table = MagicMock(spec=LiteLLM_TeamTable)
team_table.metadata = {}
team_table.team_id = "team-xyz"
team_table.team_alias = "Test Team"
team_table.budget_duration = None
updated_kv: dict = {"team_id": "team-xyz"}
mock_budget_response = MagicMock()
mock_budget_response.budget_id = "new-template-budget-abc"
mock_db = MagicMock()
mock_db.litellm_teammembership.update_many = AsyncMock(return_value={"count": 2})
mock_prisma = MagicMock()
mock_prisma.db = mock_db
with (
patch(
"litellm.proxy.management_endpoints.budget_management_endpoints.new_budget",
new_callable=AsyncMock,
return_value=mock_budget_response,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma,
),
):
await TeamMemberBudgetHandler.upsert_team_member_budget_table(
team_table=team_table,
user_api_key_dict=mock_user,
updated_kv=updated_kv,
team_member_budget_duration="7d",
)
# update_many must have been called to wire up members with no budget
mock_db.litellm_teammembership.update_many.assert_awaited_once()
call_kwargs = mock_db.litellm_teammembership.update_many.call_args.kwargs
assert call_kwargs["where"]["team_id"] == "team-xyz"
assert call_kwargs["where"]["budget_id"] is None
assert call_kwargs["data"]["budget_id"] == "new-template-budget-abc"
@pytest.mark.asyncio
async def test_upsert_team_member_budget_table_update_does_not_wire_up_null_members():
"""
When the team already has a template budget and the settings are UPDATED,
members with budget_id=null must NOT be silently assigned the budget.
Those members were intentionally left unlimited; assigning a budget on
every settings save would be a backwards-incompatible behaviour change.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.proxy._types import LitellmUserRoles, LiteLLM_TeamTable, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler
mock_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
)
# Existing budget_id — triggers the update path
team_table = MagicMock(spec=LiteLLM_TeamTable)
team_table.metadata = {"team_member_budget_id": "template-budget-abc"}
team_table.team_id = "team-xyz"
updated_kv: dict = {"team_id": "team-xyz"}
mock_budget_response = MagicMock()
mock_budget_response.budget_id = "template-budget-abc"
mock_db = MagicMock()
mock_db.litellm_teammembership.update_many = AsyncMock(return_value={"count": 0})
mock_prisma = MagicMock()
mock_prisma.db = mock_db
with (
patch(
"litellm.proxy.management_endpoints.budget_management_endpoints.update_budget",
new_callable=AsyncMock,
return_value=mock_budget_response,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma,
),
):
await TeamMemberBudgetHandler.upsert_team_member_budget_table(
team_table=team_table,
user_api_key_dict=mock_user,
updated_kv=updated_kv,
team_member_budget_duration="7d",
)
# update_many must NOT have been called — no silent budget assignment on update
mock_db.litellm_teammembership.update_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_team_with_team_member_budget_duration():
"""
@ -6499,6 +6629,84 @@ async def test_create_team_member_budget_table_with_duration():
assert result["metadata"]["team_member_budget_id"] == "budget-abc"
@pytest.mark.asyncio
async def test_upsert_budget_and_membership_updates_existing_budget():
"""
When existing_budget_id is provided, _upsert_budget_and_membership must UPDATE
the existing budget row rather than creating a new one otherwise each call
silently orphans the previous LiteLLM_BudgetTable row.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.common_utils import _upsert_budget_and_membership
mock_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"
)
mock_tx = MagicMock()
mock_tx.litellm_budgettable.update = AsyncMock()
mock_tx.litellm_budgettable.create = AsyncMock()
mock_tx.litellm_teammembership.upsert = AsyncMock()
await _upsert_budget_and_membership(
tx=mock_tx,
team_id="team-1",
user_id="user-1",
max_budget=50.0,
existing_budget_id="budget-existing-123",
user_api_key_dict=mock_user,
)
# Must update, not create
mock_tx.litellm_budgettable.update.assert_awaited_once()
mock_tx.litellm_budgettable.create.assert_not_awaited()
# Membership must be connected to the existing budget id
upsert_call = mock_tx.litellm_teammembership.upsert.call_args
connect_id = upsert_call.kwargs["data"]["update"]["litellm_budget_table"]["connect"]["budget_id"]
assert connect_id == "budget-existing-123"
@pytest.mark.asyncio
async def test_upsert_budget_and_membership_creates_new_budget_when_none():
"""
When existing_budget_id is None, _upsert_budget_and_membership must CREATE
a new budget row and connect it to the membership.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.common_utils import _upsert_budget_and_membership
mock_user = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"
)
mock_new_budget = MagicMock()
mock_new_budget.budget_id = "budget-new-456"
mock_tx = MagicMock()
mock_tx.litellm_budgettable.update = AsyncMock()
mock_tx.litellm_budgettable.create = AsyncMock(return_value=mock_new_budget)
mock_tx.litellm_teammembership.upsert = AsyncMock()
await _upsert_budget_and_membership(
tx=mock_tx,
team_id="team-1",
user_id="user-1",
max_budget=100.0,
existing_budget_id=None,
user_api_key_dict=mock_user,
)
# Must create, not update
mock_tx.litellm_budgettable.create.assert_awaited_once()
mock_tx.litellm_budgettable.update.assert_not_awaited()
# Membership must be connected to the newly created budget id
upsert_call = mock_tx.litellm_teammembership.upsert.call_args
connect_id = upsert_call.kwargs["data"]["update"]["litellm_budget_table"]["connect"]["budget_id"]
assert connect_id == "budget-new-456"
# ---------------------------------------------------------------------------
# Tests for _batch_resolve_access_group_resources
# ---------------------------------------------------------------------------

View file

@ -3759,6 +3759,7 @@ export interface Member {
max_budget_in_team?: number | null;
tpm_limit?: number | null;
rpm_limit?: number | null;
budget_duration?: string | null;
}
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
@ -3898,6 +3899,10 @@ export const teamMemberUpdateCall = async (
if (formValues.rpm_limit !== undefined && formValues.rpm_limit !== null) {
requestBody.rpm_limit = formValues.rpm_limit;
}
if (formValues.budget_duration !== undefined) {
// Explicitly include null so the backend can clear a previously-set duration.
requestBody.budget_duration = formValues.budget_duration;
}
console.log("Final request body:", requestBody);

View file

@ -85,8 +85,8 @@ const MemberModal = <T extends BaseMember>({
const formData = Object.entries(values).reduce((acc, [key, value]) => {
if (typeof value === "string") {
const trimmedValue = value.trim();
// For empty strings on optional numeric fields, set to null
if (trimmedValue === "" && (key === "max_budget_in_team" || key === "tpm_limit" || key === "rpm_limit")) {
// For empty strings on optional fields, set to null
if (trimmedValue === "" && (key === "max_budget_in_team" || key === "tpm_limit" || key === "rpm_limit" || key === "budget_duration")) {
return { ...acc, [key]: null };
}
return { ...acc, [key]: trimmedValue };

View file

@ -57,6 +57,7 @@ export interface TeamMembership {
team_id: string;
budget_id: string;
spend: number;
total_spend: number | null;
litellm_budget_table: {
budget_id: string;
soft_budget: number | null;
@ -66,6 +67,7 @@ export interface TeamMembership {
rpm_limit: number | null;
model_max_budget: Record<string, number> | null;
budget_duration: string | null;
budget_reset_at: string | null;
};
}
@ -381,6 +383,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
max_budget_in_team: values.max_budget_in_team,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
budget_duration: values.budget_duration || null,
};
MessageManager.destroy(); // Remove all existing toasts
@ -1483,6 +1486,24 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
min: 0,
placeholder: "Requests per minute limit for this member in this team",
},
{
name: "budget_duration",
label: (
<span>
Budget Reset Period{" "}
<Tooltip title="How often this member's individual spend resets. Leave unset to inherit from team settings.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
),
type: "select" as const,
options: [
{ label: "No reset (unlimited)", value: "" },
{ label: "Daily", value: "24h" },
{ label: "Weekly", value: "7d" },
{ label: "Monthly", value: "30d" },
],
},
],
}}
/>

View file

@ -45,13 +45,27 @@ export default function TeamMemberTab({
return "0";
};
// Helper function to get spend for a user
// Helper function to get spend for a user (current budget period only)
const getUserSpend = (userId: string | null): number | null => {
if (!userId) return 0;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return membership?.spend || 0;
};
// Helper function to get total (all-time) spend for a user
const getUserTotalSpend = (userId: string | null): number | null => {
if (!userId) return 0;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return membership?.total_spend ?? 0;
};
// Helper function to get the next budget reset datetime for a user
const getUserBudgetResetAt = (userId: string | null): string | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return membership?.litellm_budget_table?.budget_reset_at ?? null;
};
const getUserBudget = (userId: string | null): string | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
@ -86,8 +100,8 @@ export default function TeamMemberTab({
{
title: (
<Space direction="horizontal">
Team Member Spend (USD)
<Tooltip title="This is the amount spent by a user in the team.">
Spend (Current Period)
<Tooltip title="Amount spent by this member in the current budget period. Resets when the budget period ends.">
<InfoCircleOutlined />
</Tooltip>
</Space>
@ -97,6 +111,20 @@ export default function TeamMemberTab({
<Typography.Text>${formatNumberWithCommas(getUserSpend(record.user_id), 4)}</Typography.Text>
),
},
{
title: (
<Space direction="horizontal">
Total Spend (USD)
<Tooltip title="Cumulative amount spent by this member across all budget periods.">
<InfoCircleOutlined />
</Tooltip>
</Space>
),
key: "total_spend",
render: (_: unknown, record: Member) => (
<Typography.Text>${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)}</Typography.Text>
),
},
{
title: "Team Member Budget (USD)",
key: "budget",
@ -109,6 +137,26 @@ export default function TeamMemberTab({
);
},
},
{
title: (
<Space direction="horizontal">
Next Budget Reset
<Tooltip title="When this member's spend resets for the next budget period.">
<InfoCircleOutlined />
</Tooltip>
</Space>
),
key: "budget_reset_at",
render: (_: unknown, record: Member) => {
const resetAt = getUserBudgetResetAt(record.user_id);
if (!resetAt) return <Typography.Text>-</Typography.Text>;
return (
<Typography.Text>
{new Date(resetAt).toLocaleString()}
</Typography.Text>
);
},
},
{
title: (
<Space direction="horizontal">
@ -138,6 +186,7 @@ export default function TeamMemberTab({
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
budget_duration: membership?.litellm_budget_table?.budget_duration || null,
};
setSelectedEditMember(enhancedMember);
setIsEditMemberModalVisible(true);