-
-
Notifications
You must be signed in to change notification settings - Fork 10.8k
test(e2e): user budget across keys and team member budget isolation #33745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mubashir1osmani
merged 2 commits into
litellm_internal_staging
from
litellm_e2e_user_team_budget_isolation
Jul 17, 2026
+228
−1
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
tests/e2e/quota_management/budgets/test_team_member_budget_isolation_e2e.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| """Live e2e: per-team-member budgets are enforced independently between members. | ||
|
|
||
| Two members share one team that has a large team budget. The tight member is capped | ||
| at a tiny per-team budget and spends past it; the roomy member has plenty of room. | ||
| Once the tight member is blocked with budget_exceeded, the roomy member still serves | ||
| on the same team, its calls land in the spend logs under its own user id, and the | ||
| tight member stays blocked. A shared or leaky member counter would either block the | ||
| roomy member too or let the tight member back through once its peer spent. | ||
| """ | ||
|
|
||
| import time | ||
| from collections.abc import Iterator | ||
| from dataclasses import dataclass | ||
|
|
||
| import pytest | ||
|
|
||
| from budget_client import BudgetClient, is_budget_block | ||
| from e2e_config import unique_marker | ||
| from e2e_http import Success, require_successful_call | ||
| from lifecycle import ResourceManager | ||
| from models import ChatBody, ChatMessage | ||
|
|
||
| pytestmark = pytest.mark.e2e | ||
|
|
||
| MODEL = "gpt-5.5" | ||
| TEAM_BUDGET = 100.0 | ||
| TIGHT_MEMBER_BUDGET = 3e-6 | ||
| ROOMY_MEMBER_BUDGET = 100.0 | ||
| ROOMY_BURST = 3 | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class _Pair: | ||
| team_id: str | ||
| tight_user_id: str | ||
| roomy_user_id: str | ||
| tight_key: str | ||
| roomy_key: str | ||
|
|
||
|
|
||
| @pytest.fixture(scope="class") | ||
| def pair(client: BudgetClient) -> Iterator[_Pair]: | ||
| """One team with a large budget and two members on it: a tight member capped at | ||
| a tiny per-team budget and a roomy member with headroom, each with their own key. | ||
| Shared across the class and torn down LIFO best-effort when it finishes.""" | ||
| resources = ResourceManager(client=client.gateway) | ||
| try: | ||
| marker = unique_marker() | ||
| team_id = client.create_team(alias=f"e2e-member-iso-{marker}", max_budget=TEAM_BUDGET) | ||
| resources.defer(lambda: client.delete_team(team_id)) | ||
| tight_user = client.create_user(max_budget=TEAM_BUDGET) | ||
| resources.defer(lambda: client.delete_user(tight_user)) | ||
| roomy_user = client.create_user(max_budget=TEAM_BUDGET) | ||
| resources.defer(lambda: client.delete_user(roomy_user)) | ||
| client.add_team_member(team_id, tight_user, max_budget_in_team=TIGHT_MEMBER_BUDGET) | ||
| client.add_team_member(team_id, roomy_user, max_budget_in_team=ROOMY_MEMBER_BUDGET) | ||
| tight_key = client.generate_key(team_id=team_id, user_id=tight_user) | ||
| resources.defer(lambda: client.delete_key(tight_key)) | ||
| roomy_key = client.generate_key(team_id=team_id, user_id=roomy_user) | ||
| resources.defer(lambda: client.delete_key(roomy_key)) | ||
| yield _Pair( | ||
| team_id=team_id, | ||
| tight_user_id=tight_user, | ||
| roomy_user_id=roomy_user, | ||
| tight_key=tight_key, | ||
| roomy_key=roomy_key, | ||
| ) | ||
| finally: | ||
| resources.teardown() | ||
|
|
||
|
|
||
| def _roomy_send(client: BudgetClient, key: str) -> str: | ||
| """One roomy-member call that must go through; returns its request id.""" | ||
| match client.gateway.chat( | ||
| key, | ||
| ChatBody( | ||
| model=MODEL, | ||
| messages=[ChatMessage(role="user", content=f"roomy {unique_marker()}")], | ||
| max_tokens=16, | ||
| ), | ||
| ): | ||
| case Success(data=response): | ||
| assert response.id is not None, "roomy member call returned no id" | ||
| return response.id | ||
| case other: | ||
| pytest.fail(f"roomy member call failed while a peer was over budget: {other}") | ||
|
|
||
|
|
||
| class TestTeamMemberBudgetIsolation: | ||
| @pytest.mark.covers("quota_management.budget.team_member.isolates_per_member") | ||
| def test_blocked_member_does_not_block_peer(self, client: BudgetClient, pair: _Pair) -> None: | ||
| blocked = False | ||
| for _ in range(40): | ||
| result = client.chat(pair.tight_key, MODEL, f"tight {unique_marker()}", max_tokens=16) | ||
| if is_budget_block(result): | ||
| blocked = True | ||
| break | ||
| require_successful_call(result) | ||
| time.sleep(2) | ||
| assert blocked, "tight member's per-team budget never enforced" | ||
|
|
||
| sent = frozenset(_roomy_send(client, pair.roomy_key) for _ in range(ROOMY_BURST)) | ||
|
|
||
| assert is_budget_block( | ||
| client.chat(pair.tight_key, MODEL, f"tight {unique_marker()}", max_tokens=16) | ||
| ), "tight member stopped being blocked once the peer spent" | ||
|
|
||
| rows = client.gateway.poll_logs_for_key( | ||
| pair.roomy_key, predicate=lambda rs: bool(sent & {r.request_id for r in rs}) | ||
| ) | ||
| logged = [row for row in rows if row.request_id in sent] | ||
| assert logged, "none of the roomy member's calls reached the spend logs" | ||
| for row in logged: | ||
| assert row.user == pair.roomy_user_id, ( | ||
| f"roomy call {row.request_id} logged under user {row.user}, not {pair.roomy_user_id}" | ||
| ) | ||
| assert row.team_id == pair.team_id, ( | ||
| f"roomy call {row.request_id} logged under team {row.team_id}, not {pair.team_id}" | ||
| ) |
79 changes: 79 additions & 0 deletions
79
tests/e2e/quota_management/budgets/test_user_budget_across_keys_e2e.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Live e2e: a per-user max_budget is enforced across ALL of that user's keys. | ||
|
|
||
| An internal user's budget governs every personal key it owns, not only the one | ||
| that happened to spend it down. One user with a tiny max_budget owns two keys: | ||
| driving the first key to a budget_exceeded block then makes a fresh, untouched | ||
| second key of the same user (which carries no budget of its own, so nothing but the | ||
| shared user budget can block it) reject the same way, and the user's recorded spend | ||
| has crossed the cap. A key-scoped-only budget would leave the second key serving. | ||
| """ | ||
|
|
||
| import time | ||
|
|
||
| import pytest | ||
|
|
||
| from budget_client import BudgetClient, is_budget_block | ||
| from e2e_config import unique_marker | ||
| from e2e_http import StreamingResponse, require_successful_call | ||
| from lifecycle import ResourceManager | ||
|
|
||
| pytestmark = pytest.mark.e2e | ||
|
|
||
| MODEL = "gpt-5.5" | ||
| TINY_CAP = 3e-6 | ||
| RECORDED_SPEND_DEADLINE_SECONDS = 90 | ||
| SECOND_KEY_BLOCK_ATTEMPTS = 6 | ||
|
|
||
|
|
||
| def _call(client: BudgetClient, key: str) -> StreamingResponse: | ||
| return client.chat(key, MODEL, f"across {unique_marker()}", max_tokens=16) | ||
|
|
||
|
|
||
| def _drive_to_block(client: BudgetClient, key: str, subject: str) -> None: | ||
| for _ in range(40): | ||
| result = _call(client, key) | ||
| if is_budget_block(result): | ||
| return | ||
| require_successful_call(result) | ||
| time.sleep(2) | ||
| pytest.fail(f"user budget never enforced on {subject} within the call budget") | ||
|
|
||
|
|
||
| def _expect_prompt_block(client: BudgetClient, key: str, subject: str) -> None: | ||
| """The shared user budget is already exhausted before this key makes a single | ||
| call, so a key with no budget of its own must be rejected promptly. The small | ||
| bounded retry only absorbs spend-propagation lag between the two keys; it is far | ||
| below the spend a key-scoped budget would need to accumulate to block itself, so | ||
| a block here can only come from the shared user budget.""" | ||
| for _ in range(SECOND_KEY_BLOCK_ATTEMPTS): | ||
| result = _call(client, key) | ||
| if is_budget_block(result): | ||
| return | ||
| require_successful_call(result) | ||
| time.sleep(2) | ||
| pytest.fail( | ||
| f"{subject} was not blocked by the shared user budget within {SECOND_KEY_BLOCK_ATTEMPTS} calls" | ||
| ) | ||
|
|
||
|
|
||
| class TestUserBudgetAcrossKeys: | ||
| @pytest.mark.covers("quota_management.budget.internal_user.enforced_across_keys") | ||
| def test_user_budget_blocks_a_second_key(self, client: BudgetClient, resources: ResourceManager) -> None: | ||
| user_id = client.create_user(max_budget=TINY_CAP) | ||
| resources.defer(lambda: client.delete_user(user_id)) | ||
|
|
||
| first_key = client.generate_key(user_id=user_id) | ||
| resources.defer(lambda: client.delete_key(first_key)) | ||
| second_key = client.generate_key(user_id=user_id) | ||
| resources.defer(lambda: client.delete_key(second_key)) | ||
|
|
||
| _drive_to_block(client, first_key, "the first key") | ||
| _expect_prompt_block(client, second_key, "the second key") | ||
|
|
||
| deadline = time.monotonic() + RECORDED_SPEND_DEADLINE_SECONDS | ||
| while time.monotonic() < deadline: | ||
| info = client.user_info(user_id) | ||
| if info is not None and (info.spend or 0.0) >= TINY_CAP: | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| return | ||
| time.sleep(5) | ||
| pytest.fail(f"user spend never reached the {TINY_CAP} cap in the recorded state") | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.