Skip to content

Litellm individual team member budgets - #26208

Merged
yuneng-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_individual-team-member-budgets
Apr 22, 2026
Merged

Litellm individual team member budgets#26208
yuneng-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_individual-team-member-budgets

Conversation

@shivamrawat1

Copy link
Copy Markdown
Contributor

PR title

fix(proxy): give each team member an independent budget instead of sharing the team default

Cause

When a team has metadata.team_member_budget_id set, every member added without an explicit per-member budget got their LiteLLM_TeamMembership.budget_id pointed at that single shared LiteLLM_BudgetTable row.

  • add_new_member connected the new membership directly to the team's default budget id.
  • _upsert_budget_and_membership (called by POST /team/member_update) saw that an existing_budget_id was set and did an in-place update on that row.

Net effect: calling /team/member_update with max_budget_in_team for 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 no max_budget_in_team / allowed_models is 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 takes team_default_budget_id. If the membership's existing_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 — reads team_member_budget_id from team.metadata and threads it into the helper so the above distinction can be made.

Behavior matrix after the fix:

Scenario | Before | After -- | -- | -- Add member, no max_budget_in_team, team has default | Linked to shared default row | Private clone of default Add member, no max_budget_in_team, team has no default | No budget | No budget (unchanged) Update member whose budget is the shared default | Mutated the shared row (all inheritors change) | Clones, relinks, only this member changes Update member whose budget is already private | In-place update | In-place update (unchanged)

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.

…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

gitguardian Bot commented Apr 22, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
29203053 Triggered Generic Password 5770af0 .circleci/config.yml View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. 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


🦉 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-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a shared-budget mutation bug where all team members without an explicit per-member budget pointed at a single LiteLLM_BudgetTable row (the team's default), meaning updating one member's budget silently changed every other member's limits. The fix introduces clone-on-write semantics in both write paths (add_new_member and _upsert_budget_and_membership/team_member_update) so each member gets an independent private budget row seeded from the team default. Test coverage for the new behaviour is solid and the existing test changes correctly reflect fixed (not weakened) behaviour.

Confidence Score: 5/5

Safe 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

Important Files Changed

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
Loading

Comments Outside Diff (1)

  1. litellm/proxy/management_endpoints/common_utils.py, line 381-392 (link)

    P2 All-None disconnect skips clone-on-write for shared-default memberships

    When every budget field (max_budget, tpm_limit, rpm_limit, allowed_models) is None, the function immediately disconnects the budget and returns before the is_shared_default check 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

Comment on lines +436 to +444
for field in (
"max_budget",
"soft_budget",
"max_parallel_requests",
"tpm_limit",
"rpm_limit",
"model_max_budget",
"budget_duration",
"allowed_models",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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

codecov Bot commented Apr 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/management_helpers/utils.py 94.73% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@yuneng-berri
yuneng-berri merged commit eebb80f into litellm_internal_staging Apr 22, 2026
101 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_individual-team-member-budgets branch April 22, 2026 01:38
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…-member-budgets

Litellm individual team member budgets
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants