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:
ryan 2026-09-16 01:12:56 +00:00 committed by ryan-crabbe-berri
parent 6fa34a299b
commit de4b520153
6 changed files with 114 additions and 49 deletions

View file

@ -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

View file

@ -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},
)

View file

@ -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

View file

@ -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(

View file

@ -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
)

View file

@ -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()