Skip to content

feat(proxy): attribute usage to every team a user belongs to (opt-in) - #37809

Draft
Hardell wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
Hardell:feat/membership-usage-attribution-staging
Draft

feat(proxy): attribute usage to every team a user belongs to (opt-in)#37809
Hardell wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
Hardell:feat/membership-usage-attribution-staging

Conversation

@Hardell

@Hardell Hardell commented Aug 21, 2026

Copy link
Copy Markdown

TLDR

Problem this solves:

  • A user in many teams charges spend to only one of them
  • Every other team's spend and budget read zero
  • Which team gets charged is unstable across pods for JWT callers
  • Team budgets are unusable for IdP-provisioned team structures

How it solves it:

  • Two opt-in general_settings, both default off
  • Spend, rollups, counters and budgets fan out to all memberships
  • Rate limits fan out under a separate second setting
  • No migration; existing unique index already supports per-team rows

User Flow

Before: a platform admin who provisions teams from an IdP cannot tell what any team consumed, because a developer's usage only ever lands on one team.

  1. The admin provisions teams and memberships from their IdP. A developer ends up in 5 teams.
  2. The developer sends POST https://litellm-domain/v1/chat/completions with their key and gets a normal 200 with a completion.
  3. The admin opens https://litellm-domain/ui/?page=teams. One team shows the spend. The other four show $0.
  4. The admin sets a monthly budget on one of the four teams and waits. It never triggers, because that team never accrues spend, so the limit is meaningless.
  5. The admin cannot answer "what did this team cost us this month?" for any team the developer's key does not name.

After: the same request charges every team the developer belongs to, so each team's spend and budget become real.

  1. The admin sets track_spend_across_all_user_teams: true in general_settings and restarts the proxy.
  2. The developer sends the same POST https://litellm-domain/v1/chat/completions with the same key and gets the same 200 with a completion.
  3. The admin opens https://litellm-domain/ui/?page=teams. All five teams now show that request's spend.
  4. The admin sets a monthly budget on any of the five. Once that team's total passes it, the developer's next request is refused with a budget-exceeded error naming that specific team, on every team they belong to.
  5. The admin can answer "what did this team cost us this month?" for all five.

Relevant issues

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally
  • My PR passes all required CI/CD checks — not yet verified, CI has not run
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5not yet, PR just opened

Test files run locally, all passing:

  • tests/test_litellm/proxy/auth/test_membership_attribution.py (21 new tests)
  • tests/test_litellm/proxy/db/test_db_spend_update_writer.py (69)
  • tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py (47)
  • tests/test_litellm/proxy/auth/test_multi_budget_windows.py
  • ruff check litellm/proxy/ and ruff format --check clean

Most of the new tests are OFF-case regression guards, since both settings default to off and the untouched default path is the thing most worth protecting.

Screenshots / Proof Of Fix

Outstanding — this is why the PR is a draft.

I have not yet run the end-to-end proof this template requires: a live proxy, real provider calls, and the before/after team spend captured at the merge base and at the PR tip. I did not want to open a PR claiming e2e verification I have not performed.

What is verified so far is unit-level only, listed in the checklist above.

I will add the full Before/After section against a live proxy before marking this ready for review. If maintainers would rather see a specific shape of proof — particular endpoints, or a particular team/org fixture — say so and I will capture that instead.

Type

🆕 New Feature

Caveats (if any)

  • Summing team spend now exceeds real spend, by design
  • One over-budget team blocks the caller on every team
  • Effective rate limit becomes the minimum across memberships
  • Org rate limits still apply to the stamped org only
  • Includes one adjacent fix: per-slot Redis Lua calls were sequential
  • No team hierarchy introduced; teams stay a flat set
  • CLA signature pending

Notes on two of those

The adjacent Redis fix. _execute_lua_script_by_hash_tag awaited one round trip per hash-tag group in sequence. On non-cluster Redis there is a single group, so it never showed. On Redis Cluster the groups are per-slot, and one descriptor per team would have meant one round trip per team on the hot path. The calls now run concurrently, results still consumed in group order. It is correct independently of this feature — happy to split it into its own PR if you would prefer that for scope.

Relationship to the existing MCP stance. _admitted_subject_team_rpm_limits deliberately avoids charging several team buckets for one call, so a cross-team user cannot drain several teams' shared buckets. That reasoning holds when the stamped team is the real owner of the call and the others are incidental. Under membership attribution there is no primary team — the caller's activity genuinely belongs to all their teams, which is the premise of the feature — so charging all of them is the consistent choice. The default stays off, so today's behavior remains the default everywhere.

Implementation notes

Memberships come from LiteLLM_UserTable.teams, already maintained by SCIM and JWT sync, so there is no new source of truth. Resolution happens once per request in the auth path and is carried on server-only UserAPIKeyAuth fields with exclude=True, popped in the check_api_key validator exactly like mcp_source_team_rpm_limits — a caller who could set them would pick their own budget and rate-limit buckets. There is a test for that.

Every consumer reads through one helper that falls back to the stamped id, so with both settings off the resolved list is exactly [team_id].

Resolution fails open: a team that cannot be loaded is skipped, not raised. Attribution is bookkeeping layered on an authorization decision already made, and it must not turn an authorized request into a 500.

Attributed team rate-limit descriptors reuse key="team", so a team shares one bucket whether it is stamped on the key or merely a membership. should_rate_limit already rejects when any descriptor is over limit, so the limiter engine itself is unchanged.

No migration: LiteLLM_DailyTeamSpend is already unique on (team_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint), and the spend queue aggregates by entity_type:entity_id. LiteLLM_SpendLogs keeps naming the stamped team — fanning out per-request log rows would multiply the highest-volume table, so the daily rollups carry the multi-team truth instead.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Today a request is attributed to the single team stamped on the virtual
key (or resolved from one JWT claim) and to that team's organization. A
user who belongs to many teams contributes spend to whichever team the
key happens to name and nothing to the rest, so "what did this team
consume?" only has an answer for keys that name it.

Add two opt-in general_settings, both defaulting to off:

- track_spend_across_all_user_teams: spend increments, daily rollups,
  and budget gates apply to every team the caller belongs to, and to
  every organization reached through those teams.
- enforce_rate_limits_across_all_user_teams: the same expansion for the
  RPM/TPM limiter, so a request must fit inside every membership's limit.

They are separate settings because they carry different costs. Spend
attribution is additive bookkeeping. Rate-limit expansion makes the
caller's effective limit the minimum across their memberships, which is
a live behavior change for anyone in a busy team.

Memberships come from LiteLLM_UserTable.teams, already maintained by
SCIM and JWT sync. Resolution happens once in the auth path and is
carried on server-only UserAPIKeyAuth fields, stripped from validated
input like mcp_source_team_rpm_limits so a caller cannot choose which
buckets they are charged against.

No migration. LiteLLM_DailyTeamSpend is already unique per
(team_id, date, api_key, model, custom_llm_provider,
mcp_namespaced_tool_name, endpoint), and the spend queue aggregates by
entity_type:entity_id, so N teams means N rows and N counters.

Also make the rate limiter's per-hash-tag Lua calls concurrent. They
were sequential, so on Redis Cluster one descriptor per team would have
meant one round trip per team on the hot path. Non-cluster Redis was and
remains a single call.

Known limitation: organization rate limits still apply to the stamped
org only. Multi-org spend attribution works; multi-org rate limiting
would need per-org limits precomputed in auth and is left out
deliberately to keep this change reviewable.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


mzolota seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing Hardell:feat/membership-usage-attribution-staging (a133a3c) with litellm_internal_staging (ff02d5c)

Open in CodSpeed

mzolota added 2 commits August 21, 2026 12:29
- Import attribution_targets in proxy_server. increment_spend_counters
  referenced it without an import, raising NameError on every spend
  increment. Caught by proxy-server and proxy-infra shards; my local runs
  never exercised that function.
- Run ruff format over auth_checks.py.
- Regenerate the two ConfigGeneralSettings entries in schema.d.ts.
- Widen a test stub in test_spend_tracking_utils.py whose fixed signature
  predates the two new update_database parameters.

Also close a real test gap the coverage report exposed: the two new budget
gates and the request-metadata stamping had no tests at all. Adds nine,
covering an over-budget non-stamped team, an over-budget non-stamped org,
the stamped team being skipped so it cannot raise twice, an unloadable team
contributing no ceiling, and both settings-off paths. Adds two counter tests
pinning that spend fans out to every attributed team and that it does not
when the setting is off.
The strict gate ratchets per-rule totals against ruff-strict-budget.json,
and this change had pushed two of them up by one each:

- ANN401/TID251: membership_attribution.py used typing.Any for the otel span
  and the team-object tuples. Both now carry concrete types
  (Span, LiteLLM_TeamTableCachedObj) imported under TYPE_CHECKING, so the
  module no longer references Any at all.
- ANN202: the two new budget-check functions had no return annotation.

Also moves a return out of a try block into an else (TRY300).

Verified per-file against the base commit across every rule the strict
config selects, not just the two the gate reported: zero new violations in
all nine changed files. scripts/ruff_strict_gate.py itself needs fcntl and
cannot run on Windows, so this was checked by counting each rule in each
changed file at the base blob and at HEAD and diffing.
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