Litellm individual team member budgets - #26208
Conversation
…he team default Previously, members added to a team without an explicit per-member budget were all linked to the same `litellm_budgettable` row referenced by the team's `metadata.team_member_budget_id`. Updating one member's budget via `/team/member_update` mutated the shared row and silently changed every other member's budget too. Now both write paths produce a private, per-member budget: - `add_new_member` clones the team's default budget into a fresh row when a member is added without `max_budget_in_team`/`allowed_models`. If no team default exists, the membership is created with no budget. - `_upsert_budget_and_membership` detects when an existing membership still points at the team's default budget id and clones-on-write, relinking the membership to the new private budget before applying the update. - `team_member_update` reads `team_member_budget_id` from team metadata and passes it through so the helper can make this distinction. Adds unit tests for clone-on-write, in-place update of a private budget, and the no-default-no-budget add path. Made-with: Cursor
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 29203053 | Triggered | Generic Password | 5770af0 | .circleci/config.yml | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
Greptile SummaryThis PR fixes a shared-budget mutation bug where all team members without an explicit per-member budget pointed at a single Confidence Score: 5/5Safe to merge; all remaining findings are P2 style/maintenance suggestions with no impact on correctness The core logic is correct, tests are comprehensive, and the test changes reflect genuinely fixed behavior rather than weakened assertions. The only open item is a DRY violation (duplicate clonable-fields list) that is easy to address as a follow-up. litellm/proxy/management_endpoints/common_utils.py — inline fields list duplicates _CLONABLE_BUDGET_FIELDS from utils.py
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/common_utils.py | Adds team_default_budget_id parameter to _upsert_budget_and_membership; implements clone-on-write when membership points at shared default — logic is correct but duplicates the clonable-fields list already defined in utils.py |
| litellm/proxy/management_helpers/utils.py | Adds _CLONABLE_BUDGET_FIELDS, _clone_team_default_budget_for_member, and updates add_new_member to clone the team default budget rather than sharing the same row; implementation and guard conditions look correct |
| litellm/proxy/management_endpoints/team_endpoints.py | Reads team_member_budget_id from team metadata and threads it through to _upsert_budget_and_membership; safe, minimal change in the right place |
| tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py | Adds two well-structured tests covering clone-on-write and in-place-update scenarios; assertions are accurate and non-trivial |
| tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py | Correctly updates existing tests to assert cloned budget IDs instead of the shared default, and adds a new no-budget path test; test changes reflect the fixed behavior, not a weakened assertion |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["POST /team/member_add or /team/member_update"] --> B{max_budget_in_team\nor allowed_models set?}
B -- Yes --> C["Create brand-new budget row\nwith caller-provided values"]
C --> D["Link membership → new budget"]
B -- No --> E{default_team_budget_id\nset on team?}
E -- No --> F["_budget_id = None\n(no membership row created)"]
E -- Yes --> G["_clone_team_default_budget_for_member\nor clone-on-write in _upsert_budget_and_membership"]
G --> H["DB: find_unique(default budget row)"]
H --> I["DB: create new private budget\n(seeded with default values)"]
I --> D
A2["POST /team/member_update"] --> J{existing_budget_id\n== team_default_budget_id?}
J -- Yes --> G
J -- No\n(already private) --> K["In-place update on\nexisting private budget"]
J -- No existing budget --> I
Comments Outside Diff (1)
-
litellm/proxy/management_endpoints/common_utils.py, line 381-392 (link)All-None disconnect skips clone-on-write for shared-default memberships
When every budget field (
max_budget,tpm_limit,rpm_limit,allowed_models) isNone, the function immediately disconnects the budget and returns before theis_shared_defaultcheck is evaluated. This is technically correct (removing budget constraints for a single member doesn't mutate the shared row), but it also means the membership is silently unlinked from the shared default rather than cloned-then-disconnected.Whether this is the intended behaviour for the "remove budget" scenario is worth confirming: after the disconnect the member has no budget at all, and if they are later given budget limits again a brand-new row will be created — consistent with the new model. Just flagging as a clarification point since the behaviour matrix in the PR description doesn't cover this case explicitly.
Reviews (1): Last reviewed commit: "Merge branch 'litellm_internal_staging' ..." | Re-trigger Greptile
| for field in ( | ||
| "max_budget", | ||
| "soft_budget", | ||
| "max_parallel_requests", | ||
| "tpm_limit", | ||
| "rpm_limit", | ||
| "model_max_budget", | ||
| "budget_duration", | ||
| "allowed_models", |
There was a problem hiding this comment.
Duplicate clonable-fields list drifts from
_CLONABLE_BUDGET_FIELDS
The eight fields enumerated in this inline tuple are identical to the _CLONABLE_BUDGET_FIELDS constant defined in management_helpers/utils.py (used by _clone_team_default_budget_for_member). Having two independent lists means a new budget field added to one won't automatically appear in the other — the member_update clone path and the add_new_member clone path would silently diverge.
Consider importing and reusing _CLONABLE_BUDGET_FIELDS here instead:
from litellm.proxy.management_helpers.utils import _CLONABLE_BUDGET_FIELDS
# …
for field in _CLONABLE_BUDGET_FIELDS:
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…-member-budgets Litellm individual team member budgets
PR title
fix(proxy): give each team member an independent budget instead of sharing the team defaultCause
When a team has
metadata.team_member_budget_idset, every member added without an explicit per-member budget got theirLiteLLM_TeamMembership.budget_idpointed at that single sharedLiteLLM_BudgetTablerow.add_new_memberconnected the new membership directly to the team's default budget id._upsert_budget_and_membership(called byPOST /team/member_update) saw that anexisting_budget_idwas set and did an in-placeupdateon that row.Net effect: calling
/team/member_updatewithmax_budget_in_teamfor one user mutated the shared row, silently changing the budget of every other member that was inheriting the default.Fix
Make both write paths produce a private, per-member budget row (clone-on-write / clone-on-add):
add_new_member— if nomax_budget_in_team/allowed_modelsis supplied but the team has a default member budget, clone it into a new row for this user (_clone_team_default_budget_for_member). If there is no team default either, the membership is created with no budget (not linked to the shared default)._upsert_budget_and_membership— now takesteam_default_budget_id. If the membership'sexisting_budget_id == team_default_budget_id, it no longer updates that row; it creates a new budget (cloning the default's values, overridden by the incoming fields) and relinks the membership to it. Memberships that already point at a private budget continue to update in place./team/member_update— readsteam_member_budget_idfromteam.metadataand threads it into the helper so the above distinction can be made.Behavior matrix after the fix:
Tests
test_upsert_clones_when_pointing_at_shared_default— verifies clone-on-write + relink.test_upsert_updates_in_place_when_member_has_private_budget— verifies we don't over-clone.test_add_new_member_clones_default_team_budget_id/..._with_user_email_clones_default_budget— verify clone-on-add.test_add_new_member_no_budget_when_no_default_and_no_max_budget— verifies no-default-no-budget path.