mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Track per-member total spend on team memberships
Adds total_spend column to LiteLLM_TeamMembership that accumulates continuously and is not zeroed by the budget cycle reset job. This enables UI surfaces to distinguish current-cycle spend (the existing spend column, which resets) from lifetime spend per team member. Also exposes budget_reset_at on LiteLLM_BudgetTable so /team/info callers can see when a member's budget window next resets. The field was already stored in the DB but stripped by the response Pydantic model. Includes regression tests that: - Guard the reset job against ever writing total_spend: 0 - Verify the spend writer increments both spend and total_spend in one UPDATE statement.
This commit is contained in:
parent
165c503434
commit
e5f3e15969
8 changed files with 122 additions and 2 deletions
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
|
||||
|
||||
|
|
@ -616,6 +616,7 @@ model LiteLLM_TeamMembership {
|
|||
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])
|
||||
|
|
|
|||
|
|
@ -2007,6 +2007,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
|
||||
allowed_models: Optional[List[str]] = (
|
||||
None # per-member model scope; empty = inherit team models
|
||||
)
|
||||
|
|
@ -2017,7 +2018,6 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase):
|
|||
class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable):
|
||||
"""Represents all params for a LiteLLM_BudgetTable record"""
|
||||
|
||||
budget_reset_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
|
@ -3695,6 +3695,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]:
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,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
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ model LiteLLM_TeamMembership {
|
|||
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])
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ model LiteLLM_TeamMembership {
|
|||
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])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import sys
|
|||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -784,3 +785,37 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li
|
|||
assert len(find_many_calls) == 0
|
||||
|
||||
litellm.max_end_user_budget_id = None
|
||||
|
||||
|
||||
def test_reset_budget_for_team_members_preserves_total_spend():
|
||||
"""Regression guard: reset_budget_for_litellm_team_members must zero `spend`
|
||||
but leave `total_spend` untouched.
|
||||
|
||||
The reset writes `data={"spend": 0}` explicitly. If a future refactor adds
|
||||
`"total_spend": 0` to that dict, this test fails immediately.
|
||||
"""
|
||||
expired_budget = type(
|
||||
"LiteLLM_BudgetTableFull",
|
||||
(),
|
||||
{"budget_id": "budget-1"},
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_teammembership.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma_client.db.litellm_teammembership.update_many = AsyncMock(
|
||||
return_value={"count": 1}
|
||||
)
|
||||
|
||||
job = ResetBudgetJob(
|
||||
proxy_logging_obj=MagicMock(), prisma_client=mock_prisma_client
|
||||
)
|
||||
|
||||
asyncio.run(job.reset_budget_for_litellm_team_members([expired_budget]))
|
||||
|
||||
mock_prisma_client.db.litellm_teammembership.update_many.assert_called_once()
|
||||
call_kwargs = (
|
||||
mock_prisma_client.db.litellm_teammembership.update_many.call_args.kwargs
|
||||
)
|
||||
assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"]
|
||||
assert call_kwargs["data"] == {"spend": 0}
|
||||
assert "total_spend" not in call_kwargs["data"]
|
||||
|
|
|
|||
|
|
@ -642,6 +642,81 @@ async def test_commit_spend_updates_to_db_increments_agent_spend():
|
|||
assert call_kwargs["data"] == {"spend": {"increment": response_cost}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total_spend():
|
||||
"""
|
||||
Verify that _commit_spend_updates_to_db increments BOTH spend (cycle-scoped)
|
||||
and total_spend (non-resetting) on LiteLLM_TeamMembership in a single
|
||||
update_many call, using the same response_cost.
|
||||
"""
|
||||
db_writer = DBSpendUpdateWriter()
|
||||
|
||||
mock_batcher = MagicMock()
|
||||
mock_batcher.litellm_verificationtoken = MagicMock()
|
||||
mock_batcher.litellm_verificationtoken.update_many = MagicMock()
|
||||
mock_batcher.litellm_usertable = MagicMock()
|
||||
mock_batcher.litellm_usertable.update_many = MagicMock()
|
||||
mock_batcher.litellm_teamtable = MagicMock()
|
||||
mock_batcher.litellm_teamtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_teammembership = MagicMock()
|
||||
mock_batcher.litellm_teammembership.update_many = MagicMock()
|
||||
mock_batcher.litellm_organizationtable = MagicMock()
|
||||
mock_batcher.litellm_organizationtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_tagtable = MagicMock()
|
||||
mock_batcher.litellm_tagtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_agentstable = MagicMock()
|
||||
mock_batcher.litellm_agentstable.update_many = MagicMock()
|
||||
|
||||
mock_transaction = AsyncMock()
|
||||
mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction)
|
||||
mock_transaction.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_transaction.batch_ = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_batcher),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db = MagicMock()
|
||||
mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction)
|
||||
|
||||
mock_proxy_logging = MagicMock()
|
||||
# Skip team-membership cache invalidation — out of scope for this test.
|
||||
mock_proxy_logging.call_details.get = MagicMock(return_value=None)
|
||||
|
||||
team_id = "team-abc"
|
||||
user_id = "user-xyz"
|
||||
response_cost = 0.75
|
||||
entity_id = f"team_id::{team_id}::user_id::{user_id}"
|
||||
db_spend_update_transactions = {
|
||||
"user_list_transactions": {},
|
||||
"end_user_list_transactions": {},
|
||||
"key_list_transactions": {},
|
||||
"team_list_transactions": {},
|
||||
"team_member_list_transactions": {entity_id: response_cost},
|
||||
"org_list_transactions": {},
|
||||
"tag_list_transactions": {},
|
||||
"agent_list_transactions": {},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.utils._raise_failed_update_spend_exception"):
|
||||
await db_writer._commit_spend_updates_to_db(
|
||||
prisma_client=mock_prisma_client,
|
||||
n_retry_times=0,
|
||||
proxy_logging_obj=mock_proxy_logging,
|
||||
db_spend_update_transactions=db_spend_update_transactions,
|
||||
)
|
||||
|
||||
mock_batcher.litellm_teammembership.update_many.assert_called_once()
|
||||
call_kwargs = mock_batcher.litellm_teammembership.update_many.call_args[1]
|
||||
assert call_kwargs["where"] == {"team_id": team_id, "user_id": user_id}
|
||||
assert call_kwargs["data"] == {
|
||||
"spend": {"increment": response_cost},
|
||||
"total_spend": {"increment": response_cost},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_spend_log_transaction_to_daily_tag_transaction_with_request_id():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue