11# tests/litellm/proxy/common_utils/test_upsert_budget_membership.py
22import types
3- import pytest
43from unittest .mock import AsyncMock , MagicMock
54
5+ import pytest
6+
67from litellm .proxy .management_endpoints .common_utils import (
78 _upsert_budget_and_membership ,
89)
910
10-
1111# ---------------------------------------------------------------------------
1212# Fixtures: a fake Prisma transaction and a fake UserAPIKeyAuth object
1313# ---------------------------------------------------------------------------
@@ -63,25 +63,51 @@ async def test_upsert_disconnect(mock_tx, fake_user):
6363 mock_tx .litellm_teammembership .upsert .assert_not_called ()
6464
6565
66- # TEST: existing budget id, update only
66+ # TEST: existing budget id, creates new budget (current behavior)
6767@pytest .mark .asyncio
68- async def test_upsert_update_existing (mock_tx , fake_user ):
68+ async def test_upsert_with_existing_budget_id_creates_new (mock_tx , fake_user ):
69+ """
70+ Test that even when existing_budget_id is provided, the function creates a new budget.
71+ This reflects the current implementation behavior.
72+ """
6973 await _upsert_budget_and_membership (
7074 mock_tx ,
7175 team_id = "team-2" ,
7276 user_id = "user-2" ,
7377 max_budget = 42.0 ,
74- existing_budget_id = "bud-999" ,
78+ existing_budget_id = "bud-999" , # This parameter is currently unused
7579 user_api_key_dict = fake_user ,
7680 )
7781
78- mock_tx .litellm_budgettable .update .assert_awaited_once_with (
79- where = {"budget_id" : "bud-999" },
80- data = {"max_budget" : 42.0 },
82+ # Should create a new budget, not update existing
83+ mock_tx .litellm_budgettable .create .assert_awaited_once_with (
84+ data = {
85+ "max_budget" : 42.0 ,
86+ "created_by" : fake_user .user_id ,
87+ "updated_by" : fake_user .user_id ,
88+ },
89+ include = {"team_membership" : True },
90+ )
91+
92+ # Should upsert team membership with the new budget ID
93+ new_budget_id = mock_tx .litellm_budgettable .create .return_value .budget_id
94+ mock_tx .litellm_teammembership .upsert .assert_awaited_once_with (
95+ where = {"user_id_team_id" : {"user_id" : "user-2" , "team_id" : "team-2" }},
96+ data = {
97+ "create" : {
98+ "user_id" : "user-2" ,
99+ "team_id" : "team-2" ,
100+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
101+ },
102+ "update" : {
103+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
104+ },
105+ },
81106 )
107+
108+ # Should NOT update existing budget
109+ mock_tx .litellm_budgettable .update .assert_not_called ()
82110 mock_tx .litellm_teammembership .update .assert_not_called ()
83- mock_tx .litellm_budgettable .create .assert_not_called ()
84- mock_tx .litellm_teammembership .upsert .assert_not_called ()
85111
86112
87113# TEST: create new budget and link membership
@@ -126,9 +152,13 @@ async def test_upsert_create_and_link(mock_tx, fake_user):
126152 mock_tx .litellm_budgettable .update .assert_not_called ()
127153
128154
129- # TEST: create new budget and link membership, then update
155+ # TEST: create new budget and link membership, then create another new budget
130156@pytest .mark .asyncio
131- async def test_upsert_create_then_update (mock_tx , fake_user ):
157+ async def test_upsert_create_then_create_another (mock_tx , fake_user ):
158+ """
159+ Test that multiple calls to _upsert_budget_and_membership create separate budgets,
160+ reflecting the current implementation behavior.
161+ """
132162 # FIRST CALL – create new budget and link membership
133163 await _upsert_budget_and_membership (
134164 mock_tx ,
@@ -146,25 +176,143 @@ async def test_upsert_create_then_update(mock_tx, fake_user):
146176 mock_tx .litellm_budgettable .create .assert_awaited_once ()
147177 mock_tx .litellm_teammembership .upsert .assert_awaited_once ()
148178
149- # SECOND CALL – pretend the same membership already exists, and
150- # reset call history so the next assertions are clear
179+ # SECOND CALL – reset call history and create another budget
151180 mock_tx .litellm_budgettable .create .reset_mock ()
152181 mock_tx .litellm_teammembership .upsert .reset_mock ()
153182 mock_tx .litellm_budgettable .update .reset_mock ()
154183
184+ # Set up a new budget ID for the second create call
185+ mock_tx .litellm_budgettable .create .return_value = types .SimpleNamespace (budget_id = "new-budget-456" )
186+
155187 await _upsert_budget_and_membership (
156188 mock_tx ,
157189 team_id = "team-42" ,
158190 user_id = "user-42" ,
159191 max_budget = 25.0 , # new limit
160- existing_budget_id = created_bid , # now we say it exists
192+ existing_budget_id = created_bid , # this is ignored in current implementation
161193 user_api_key_dict = fake_user ,
162194 )
163195
164- # Now we expect ONLY an update to fire
165- mock_tx .litellm_budgettable .update .assert_awaited_once_with (
166- where = {"budget_id" : created_bid },
167- data = {"max_budget" : 25.0 },
196+ # Should create another new budget (not update existing)
197+ mock_tx .litellm_budgettable .create .assert_awaited_once_with (
198+ data = {
199+ "max_budget" : 25.0 ,
200+ "created_by" : fake_user .user_id ,
201+ "updated_by" : fake_user .user_id ,
202+ },
203+ include = {"team_membership" : True },
204+ )
205+
206+ # Should upsert team membership with the new budget ID
207+ new_budget_id = mock_tx .litellm_budgettable .create .return_value .budget_id
208+ mock_tx .litellm_teammembership .upsert .assert_awaited_once_with (
209+ where = {"user_id_team_id" : {"user_id" : "user-42" , "team_id" : "team-42" }},
210+ data = {
211+ "create" : {
212+ "user_id" : "user-42" ,
213+ "team_id" : "team-42" ,
214+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
215+ },
216+ "update" : {
217+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
218+ },
219+ },
220+ )
221+
222+ # Should NOT call update
223+ mock_tx .litellm_budgettable .update .assert_not_called ()
224+
225+
226+ # TEST: update rpm_limit for member with existing budget_id
227+ @pytest .mark .asyncio
228+ async def test_upsert_rpm_limit_update_creates_new_budget (mock_tx , fake_user ):
229+ """
230+ Test that updating rpm_limit for a member with an existing budget_id
231+ creates a new budget with the new rpm/tpm limits and assigns it to the user.
232+ """
233+ existing_budget_id = "existing-budget-456"
234+
235+ await _upsert_budget_and_membership (
236+ mock_tx ,
237+ team_id = "team-rpm-test" ,
238+ user_id = "user-rpm-test" ,
239+ max_budget = 50.0 ,
240+ existing_budget_id = existing_budget_id ,
241+ user_api_key_dict = fake_user ,
242+ tpm_limit = 1000 ,
243+ rpm_limit = 100 , # updating rpm_limit
244+ )
245+
246+ # Should create a new budget with all the specified limits
247+ mock_tx .litellm_budgettable .create .assert_awaited_once_with (
248+ data = {
249+ "max_budget" : 50.0 ,
250+ "tpm_limit" : 1000 ,
251+ "rpm_limit" : 100 ,
252+ "created_by" : fake_user .user_id ,
253+ "updated_by" : fake_user .user_id ,
254+ },
255+ include = {"team_membership" : True },
256+ )
257+
258+ # Should NOT update the existing budget
259+ mock_tx .litellm_budgettable .update .assert_not_called ()
260+
261+ # Should upsert team membership with the new budget ID
262+ new_budget_id = mock_tx .litellm_budgettable .create .return_value .budget_id
263+ mock_tx .litellm_teammembership .upsert .assert_awaited_once_with (
264+ where = {"user_id_team_id" : {"user_id" : "user-rpm-test" , "team_id" : "team-rpm-test" }},
265+ data = {
266+ "create" : {
267+ "user_id" : "user-rpm-test" ,
268+ "team_id" : "team-rpm-test" ,
269+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
270+ },
271+ "update" : {
272+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
273+ },
274+ },
275+ )
276+
277+
278+ # TEST: create new budget with only rpm_limit (no max_budget)
279+ @pytest .mark .asyncio
280+ async def test_upsert_rpm_only_creates_new_budget (mock_tx , fake_user ):
281+ """
282+ Test that setting only rpm_limit creates a new budget with just the rpm_limit.
283+ """
284+ await _upsert_budget_and_membership (
285+ mock_tx ,
286+ team_id = "team-rpm-only" ,
287+ user_id = "user-rpm-only" ,
288+ max_budget = None ,
289+ existing_budget_id = None ,
290+ user_api_key_dict = fake_user ,
291+ rpm_limit = 50 ,
292+ )
293+
294+ # Should create a new budget with only rpm_limit
295+ mock_tx .litellm_budgettable .create .assert_awaited_once_with (
296+ data = {
297+ "rpm_limit" : 50 ,
298+ "created_by" : fake_user .user_id ,
299+ "updated_by" : fake_user .user_id ,
300+ },
301+ include = {"team_membership" : True },
302+ )
303+
304+ # Should upsert team membership with the new budget ID
305+ new_budget_id = mock_tx .litellm_budgettable .create .return_value .budget_id
306+ mock_tx .litellm_teammembership .upsert .assert_awaited_once_with (
307+ where = {"user_id_team_id" : {"user_id" : "user-rpm-only" , "team_id" : "team-rpm-only" }},
308+ data = {
309+ "create" : {
310+ "user_id" : "user-rpm-only" ,
311+ "team_id" : "team-rpm-only" ,
312+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
313+ },
314+ "update" : {
315+ "litellm_budget_table" : {"connect" : {"budget_id" : new_budget_id }},
316+ },
317+ },
168318 )
169- mock_tx .litellm_budgettable .create .assert_not_called ()
170- mock_tx .litellm_teammembership .upsert .assert_not_called ()
0 commit comments