mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
fix(proxy): track team member spend when the member has no budget
add_new_member only wrote a LiteLLM_TeamMembership row when a budget id resolved, and the spend writer used update_many so a missing row failed silently. Members without a budget therefore never accrued per-member spend. The membership row is now always upserted (budget_id NULL when no budget applies) and the spend write is an upsert so members added before this fix start accruing on their next request. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
6fa34a299b
commit
de4b520153
6 changed files with 114 additions and 49 deletions
|
|
@ -1693,11 +1693,19 @@ class DBSpendUpdateWriter:
|
|||
team_id = key.split("::")[1]
|
||||
user_id = key.split("::")[3]
|
||||
|
||||
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},
|
||||
batcher.litellm_teammembership.upsert(
|
||||
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
|
||||
data={
|
||||
"spend": {"increment": response_cost},
|
||||
"total_spend": {"increment": response_cost},
|
||||
"create": {
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
"spend": response_cost,
|
||||
"total_spend": response_cost,
|
||||
},
|
||||
"update": {
|
||||
"spend": {"increment": response_cost},
|
||||
"total_spend": {"increment": response_cost},
|
||||
},
|
||||
},
|
||||
)
|
||||
# Transaction succeeded, break out of retry loop
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ class _PrismaUserTable(Protocol):
|
|||
class _PrismaTeamMembershipTable(Protocol):
|
||||
"""Team membership table actions the management helpers issue."""
|
||||
|
||||
async def create(self, *, data: Mapping[str, object], include: Mapping[str, bool]) -> _PrismaRecord: ...
|
||||
async def upsert(
|
||||
self, *, where: Mapping[str, object], data: Mapping[str, Mapping[str, object]], include: Mapping[str, bool]
|
||||
) -> _PrismaRecord: ...
|
||||
|
||||
|
||||
class MemberWriteTx(Protocol):
|
||||
|
|
@ -348,7 +350,7 @@ async def _resolve_member_budget_id(
|
|||
default member budget is cloned (with ``budget_duration`` overriding its
|
||||
reset window while keeping its other limits). A lone ``budget_duration``
|
||||
with no team default creates a window-only budget. With nothing set the
|
||||
member gets no budget.
|
||||
member gets no budget, though ``add_new_member`` still writes its membership row.
|
||||
"""
|
||||
has_explicit_limit: Final = max_budget_in_team is not None or allowed_models is not None
|
||||
|
||||
|
|
@ -415,9 +417,9 @@ async def add_new_member(
|
|||
Add a new member to a team
|
||||
|
||||
- add team id to user table
|
||||
- add team member w/ budget to team member table
|
||||
- add team member to team member table, linked to a budget when one resolves
|
||||
|
||||
Returns created/existing user + team membership w/ budget id
|
||||
Returns created/existing user + team membership (``budget_id`` is ``None`` when no budget applies)
|
||||
|
||||
Callers already inside a transaction pass it as ``tx`` so every write here runs on that
|
||||
connection instead of borrowing more from the pool while the caller's locks are held.
|
||||
|
|
@ -471,14 +473,13 @@ async def add_new_member(
|
|||
tx=tx,
|
||||
)
|
||||
|
||||
if _budget_id and returned_user is not None and returned_user.user_id is not None:
|
||||
if returned_user is not None and returned_user.user_id is not None:
|
||||
membership_table: Final[_PrismaTeamMembershipTable] = _team_membership_table(prisma_client, tx)
|
||||
_returned_team_membership: Final = await membership_table.create(
|
||||
data={
|
||||
"team_id": team_id,
|
||||
"user_id": returned_user.user_id,
|
||||
"budget_id": _budget_id,
|
||||
},
|
||||
membership_key: Final[Mapping[str, object]] = {"user_id": returned_user.user_id, "team_id": team_id}
|
||||
budget_link: Final[Mapping[str, object]] = {"budget_id": _budget_id} if _budget_id is not None else {}
|
||||
_returned_team_membership: Final = await membership_table.upsert(
|
||||
where={"user_id_team_id": membership_key},
|
||||
data={"create": {**membership_key, **budget_link}, "update": {**budget_link}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ class BatchTable(Protocol):
|
|||
|
||||
def update_many(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> None: ...
|
||||
|
||||
def upsert(self, *, where: Mapping[str, object], data: Mapping[str, Mapping[str, object]]) -> None: ...
|
||||
|
||||
|
||||
class PrismaBatch(Protocol):
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -918,7 +918,11 @@ async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total
|
|||
"""
|
||||
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.
|
||||
upsert call, using the same response_cost.
|
||||
|
||||
Regression (LIT-5502): members added without a budget had no membership row, and
|
||||
the previous update_many matched zero rows, so their spend was silently dropped.
|
||||
The upsert has to create the row seeded with this call's cost in that case.
|
||||
"""
|
||||
db_writer = DBSpendUpdateWriter()
|
||||
|
||||
|
|
@ -930,7 +934,7 @@ async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total
|
|||
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_teammembership.upsert = MagicMock()
|
||||
mock_batcher.litellm_organizationtable = MagicMock()
|
||||
mock_batcher.litellm_organizationtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_tagtable = MagicMock()
|
||||
|
|
@ -979,12 +983,21 @@ async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total
|
|||
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}
|
||||
mock_batcher.litellm_teammembership.upsert.assert_called_once()
|
||||
mock_batcher.litellm_teammembership.update_many.assert_not_called()
|
||||
call_kwargs = mock_batcher.litellm_teammembership.upsert.call_args.kwargs
|
||||
assert call_kwargs["where"] == {"user_id_team_id": {"user_id": user_id, "team_id": team_id}}
|
||||
assert call_kwargs["data"] == {
|
||||
"spend": {"increment": response_cost},
|
||||
"total_spend": {"increment": response_cost},
|
||||
"create": {
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
"spend": response_cost,
|
||||
"total_spend": response_cost,
|
||||
},
|
||||
"update": {
|
||||
"spend": {"increment": response_cost},
|
||||
"total_spend": {"increment": response_cost},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2219,9 +2232,13 @@ async def test_commit_daily_tag_spend_no_requeue_on_success():
|
|||
"team_id::team_b::user_id::user_x": 0.3,
|
||||
},
|
||||
"litellm_teammembership",
|
||||
"update_many",
|
||||
"team_id",
|
||||
["team_a", "team_b", "team_c"],
|
||||
"upsert",
|
||||
"user_id_team_id",
|
||||
[
|
||||
{"user_id": "user_x", "team_id": "team_a"},
|
||||
{"user_id": "user_x", "team_id": "team_b"},
|
||||
{"user_id": "user_x", "team_id": "team_c"},
|
||||
],
|
||||
id="team_member",
|
||||
),
|
||||
pytest.param(
|
||||
|
|
|
|||
|
|
@ -2086,7 +2086,7 @@ async def test_add_team_members_runs_member_writes_on_the_lock_holding_transacti
|
|||
tx.litellm_usertable.upsert = AsyncMock(return_value=added_user)
|
||||
tx.litellm_usertable.update_many = AsyncMock()
|
||||
tx.litellm_budgettable.create = AsyncMock(return_value=created_budget)
|
||||
tx.litellm_teammembership.create = AsyncMock(return_value=membership)
|
||||
tx.litellm_teammembership.upsert = AsyncMock(return_value=membership)
|
||||
|
||||
tx_cm = MagicMock()
|
||||
tx_cm.__aenter__ = AsyncMock(return_value=tx)
|
||||
|
|
@ -5772,7 +5772,7 @@ async def test_new_team_max_budget_within_user_limit():
|
|||
"budget_id": None,
|
||||
}
|
||||
mock_prisma.db.litellm_teammembership = MagicMock()
|
||||
mock_prisma.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_membership
|
||||
)
|
||||
|
||||
|
|
@ -5915,7 +5915,7 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit():
|
|||
"budget_id": None,
|
||||
}
|
||||
mock_prisma.db.litellm_teammembership = MagicMock()
|
||||
mock_prisma.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_membership
|
||||
)
|
||||
|
||||
|
|
@ -6063,7 +6063,7 @@ async def test_new_team_org_scoped_models_bypasses_user_limit():
|
|||
"budget_id": None,
|
||||
}
|
||||
mock_prisma.db.litellm_teammembership = MagicMock()
|
||||
mock_prisma.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_membership
|
||||
)
|
||||
|
||||
|
|
@ -9525,7 +9525,7 @@ async def test_new_team_soft_budget_validation(
|
|||
"budget_id": None,
|
||||
}
|
||||
mock_prisma.db.litellm_teammembership = MagicMock()
|
||||
mock_prisma.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_membership
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ async def test_add_new_member_clones_default_team_budget_id():
|
|||
"budget_id": test_cloned_budget_id,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ async def test_add_new_member_clones_default_team_budget_id():
|
|||
assert result_team_membership.budget_id != test_default_budget_id
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.upsert.assert_called_once()
|
||||
mock_prisma_client.db.litellm_teammembership.create.assert_called_once()
|
||||
mock_prisma_client.db.litellm_teammembership.upsert.assert_called_once()
|
||||
|
||||
# The clone must have happened: find_unique on the default, create for the clone.
|
||||
mock_prisma_client.db.litellm_budgettable.find_unique.assert_called_once_with(
|
||||
|
|
@ -274,9 +274,9 @@ async def test_add_new_member_clones_default_team_budget_id():
|
|||
assert cloned_create_data["created_by"] == user_api_key_dict.user_id
|
||||
|
||||
team_membership_call_args = (
|
||||
mock_prisma_client.db.litellm_teammembership.create.call_args
|
||||
mock_prisma_client.db.litellm_teammembership.upsert.call_args
|
||||
)
|
||||
create_data = team_membership_call_args.kwargs["data"]
|
||||
create_data = team_membership_call_args.kwargs["data"]["create"]
|
||||
assert create_data["budget_id"] == test_cloned_budget_id
|
||||
|
||||
|
||||
|
|
@ -332,7 +332,7 @@ async def test_add_new_member_budget_duration_only_clones_default_max_budget():
|
|||
"budget_id": "cloned-dc",
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -362,7 +362,8 @@ async def test_add_new_member_no_budget_when_no_default_and_no_max_budget():
|
|||
Test that add_new_member links no budget to the team membership when
|
||||
neither max_budget_in_team nor default_team_budget_id is provided.
|
||||
|
||||
When the team has no default member budget, new members get nothing.
|
||||
When the team has no default member budget, no budget row is created, but the
|
||||
membership row still is, otherwise the member's spend has nowhere to accrue.
|
||||
"""
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
|
|
@ -393,7 +394,19 @@ async def test_add_new_member_no_budget_when_no_default_and_no_max_budget():
|
|||
# Even though we mock these, they must NOT be called on the no-budget path.
|
||||
mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock()
|
||||
mock_prisma_client.db.litellm_budgettable.create = AsyncMock()
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock()
|
||||
|
||||
mock_team_membership_response = MagicMock()
|
||||
mock_team_membership_response.model_dump.return_value = {
|
||||
"team_id": test_team_id,
|
||||
"user_id": test_user_id,
|
||||
"budget_id": None,
|
||||
"spend": 0.0,
|
||||
"total_spend": 0.0,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
result_user, result_team_membership = await add_new_member(
|
||||
new_member=new_member,
|
||||
|
|
@ -408,11 +421,20 @@ async def test_add_new_member_no_budget_when_no_default_and_no_max_budget():
|
|||
assert result_user is not None
|
||||
assert result_user.user_id == test_user_id
|
||||
|
||||
# No budget id, so no team membership row is created.
|
||||
assert result_team_membership is None
|
||||
mock_prisma_client.db.litellm_budgettable.find_unique.assert_not_called()
|
||||
mock_prisma_client.db.litellm_budgettable.create.assert_not_called()
|
||||
mock_prisma_client.db.litellm_teammembership.create.assert_not_called()
|
||||
|
||||
# Regression (LIT-5502): the membership row is what per-member spend increments land on,
|
||||
# so it has to exist even when the member has no budget. Skipping it silently dropped spend.
|
||||
assert result_team_membership is not None
|
||||
assert result_team_membership.budget_id is None
|
||||
mock_prisma_client.db.litellm_teammembership.upsert.assert_awaited_once()
|
||||
upsert_kwargs = mock_prisma_client.db.litellm_teammembership.upsert.call_args.kwargs
|
||||
assert upsert_kwargs["where"] == {
|
||||
"user_id_team_id": {"user_id": test_user_id, "team_id": test_team_id}
|
||||
}
|
||||
assert upsert_kwargs["data"]["create"] == {"user_id": test_user_id, "team_id": test_team_id}
|
||||
assert "budget_id" not in upsert_kwargs["data"]["update"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -473,7 +495,7 @@ async def test_add_new_member_creates_new_budget_when_max_budget_provided():
|
|||
"budget_id": test_new_budget_id,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -502,10 +524,10 @@ async def test_add_new_member_creates_new_budget_when_max_budget_provided():
|
|||
|
||||
# Verify the team membership was created with the correct budget_id
|
||||
team_membership_call_args = (
|
||||
mock_prisma_client.db.litellm_teammembership.create.call_args
|
||||
mock_prisma_client.db.litellm_teammembership.upsert.call_args
|
||||
)
|
||||
assert team_membership_call_args is not None
|
||||
create_data = team_membership_call_args.kwargs["data"]
|
||||
create_data = team_membership_call_args.kwargs["data"]["create"]
|
||||
assert create_data["budget_id"] == test_new_budget_id
|
||||
|
||||
|
||||
|
|
@ -546,7 +568,7 @@ async def test_add_new_member_persists_budget_duration():
|
|||
"budget_id": "budget-dur",
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -610,7 +632,7 @@ async def test_add_new_member_persists_budget_duration_without_max_budget():
|
|||
"budget_id": "budget-dur2",
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -700,7 +722,7 @@ async def test_add_new_member_with_user_email_clones_default_budget():
|
|||
"budget_id": test_cloned_budget_id,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.create = AsyncMock(
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(
|
||||
return_value=mock_team_membership_response
|
||||
)
|
||||
|
||||
|
|
@ -1031,8 +1053,15 @@ async def test_add_new_member_appends_team_only_if_absent_for_existing_user():
|
|||
}
|
||||
mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_after)
|
||||
mock_prisma_client.db.litellm_usertable.update_many = AsyncMock()
|
||||
# no team default budget and no explicit budget -> no team membership row
|
||||
mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=None)
|
||||
mock_membership = MagicMock()
|
||||
mock_membership.model_dump.return_value = {
|
||||
"team_id": "team-1",
|
||||
"user_id": "existing-user",
|
||||
"budget_id": None,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(return_value=mock_membership)
|
||||
|
||||
result_user, _ = await add_new_member(
|
||||
new_member=new_member,
|
||||
|
|
@ -1099,6 +1128,14 @@ async def test_add_new_member_creates_missing_user_atomically_via_upsert():
|
|||
mock_prisma_client.db.litellm_usertable.update_many = AsyncMock()
|
||||
mock_prisma_client.db.litellm_usertable.create = AsyncMock()
|
||||
mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=None)
|
||||
mock_membership = MagicMock()
|
||||
mock_membership.model_dump.return_value = {
|
||||
"team_id": "team-1",
|
||||
"user_id": "brand-new-user",
|
||||
"budget_id": None,
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
mock_prisma_client.db.litellm_teammembership.upsert = AsyncMock(return_value=mock_membership)
|
||||
|
||||
result_user, _ = await add_new_member(
|
||||
new_member=new_member,
|
||||
|
|
@ -1147,7 +1184,7 @@ def _member_write_tx() -> MagicMock:
|
|||
tx.litellm_usertable.find_many = AsyncMock(return_value=[])
|
||||
tx.litellm_budgettable.find_unique = AsyncMock(return_value=None)
|
||||
tx.litellm_budgettable.create = AsyncMock(return_value=created_budget)
|
||||
tx.litellm_teammembership.create = AsyncMock(return_value=membership)
|
||||
tx.litellm_teammembership.upsert = AsyncMock(return_value=membership)
|
||||
return tx
|
||||
|
||||
|
||||
|
|
@ -1192,7 +1229,7 @@ async def test_add_new_member_runs_every_write_on_the_caller_transaction(new_mem
|
|||
assert result_membership.budget_id == "budget-pool"
|
||||
|
||||
assert tx.litellm_budgettable.create.await_count == 1
|
||||
assert tx.litellm_teammembership.create.await_count == 1
|
||||
assert tx.litellm_teammembership.upsert.await_count == 1
|
||||
assert tx.litellm_usertable.upsert.await_count + tx.litellm_usertable.create.await_count == 1
|
||||
|
||||
prisma_client.db.assert_not_called()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue