Commit graph

443 commits

Author SHA1 Message Date
ryan-crabbe-berri
2792887e47
fix(proxy): give proxy_admin_viewer read parity with proxy_admin (#35851)
* fix(proxy): give proxy_admin_viewer read parity with proxy_admin

Route-level checks already default-allow management GETs for the viewer
role, but ~15 handlers compared user_role to PROXY_ADMIN only, dropping
viewers into regular-user scoping (/key/list, /user/info, /model/info,
guardrails, prompts, agents, memory, workflows, MCP catalog, coordination
redis settings, credential migration check, enterprise projects). Swap
those read paths to user_api_key_has_admin_view; write gates unchanged.

The dashboard now presents the viewer session as Admin for all gating
(effectiveSessionRole) so every page fetches with admin visibility, with
userRoleLabel/isViewOnly preserving the account-menu label and the
playground cost guard. The server remains the write authority.

* refactor(agents): remove side-effectful health_check param from GET /v1/agents

Addresses a security review finding on the admin viewer read parity change:
listing agents with health_check=true made the proxy issue a server-side GET
to every agent URL, so a read-scoped caller could trigger request fan-out
beyond their object permissions. The list endpoint is now a pure read for
every role.

Removes the query param, the URL probing helper and its timeouts, the
AgentHealthCheck httpx provider tag, and the dashboard's Health Check
toggle. Requests still passing health_check=true get the full list back
with the param ignored.

* fix(proxy): keep credential encryption check proxy_admin only

The residual scan behind GET /credentials/migrate-encryption/check loads
every model, credential, MCP, team, and verification-token row and runs a
decryption attempt on each stored value. Extending it to proxy_admin_viewer
let a read-only account repeatedly trigger deployment-wide scans, so the
route keeps its original full-admin gate.

* fix(agents): restore health_check, keep list fast path proxy_admin only

Restores the agent health_check feature exactly as before this PR: the
query param, the URL probing helper, the httpx provider tag, and the
dashboard toggle all return, so existing callers keep the filtering
contract. The viewer expansion is instead reverted at its source: the
GET /v1/agents admin fast path stays PROXY_ADMIN only, so a
proxy_admin_viewer goes through the object-permission scoped branch as
before and cannot fan out health checks beyond their allowlist. The
viewer read of a single agent stays viewer-inclusive since it has no
side effects.
2026-08-05 18:33:55 +00:00
Yassin Kortam
1e265dc86c
fix(auth): name enable_jwt_auth when a JWT-shaped key is rejected (#35831)
A three-segment token presented while `general_settings.enable_jwt_auth` is
unset is never treated as JWT-shaped, so it falls through to the virtual-key
path and is rejected for not starting with 'sk-'. That reads as a missing
key in the verification table and sends the operator off to inspect virtual
keys, when the real cause is one missing config line. The rejection now
names `enable_jwt_auth`, appended to the existing text so the Prometheus
invalid-key filter and the admin UI keep matching what they match today.

The hint claims only that the key is JWT-shaped. Segment count cannot tell a
JWT from any other dotted credential, so asserting the key IS a JWT would
swap one confident misdiagnosis for a narrower one.

The enterprise gate on that same path raised a bare `ValueError`, which the
terminal handler turns into a 401. Every sibling enterprise gate answers
403, and a 401 tells the client to retry with a better credential, which no
credential can satisfy while the install is unlicensed. It now raises a 403
`ProxyException` like the SSO gate does.
2026-08-04 20:05:03 -07:00
devin-ai-integration[bot]
355ae9989b
fix(proxy): propagate user_email and bind api_key on JWT auth attribution paths (#34331)
* fix(proxy): propagate user_email and bind api_key on JWT auth paths

Standard JWT auth built UserAPIKeyAuth with user_id but never user_email, and the first auto-registered request early-returned a key with token set but api_key unset, so spend-log attribution logged user_api_key_user_email and user_api_key_hash as null. Bind api_key to the token hash on the auto-registered key, copy user_email from the resolved user object on both the standard and auto-register JWT paths, and warn when enable_jwt_auth/litellm_jwtauth are placed at the config top level where they are silently ignored.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): cover misplaced top-level JWT config warning

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: shivam <shivam@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan <ryan@berri.ai>
2026-08-04 19:05:39 +00:00
yuneng-jiang
6b3d4f2380
feat(ui): add admin-configurable user banner (#35729)
* feat(ui): add admin-configurable user banner

Proxy admins can publish a markdown announcement that renders as a
dismissible banner on every dashboard page for all authenticated users,
editable from Admin Settings > UI Settings without a redeploy. Backed by
new /get/user_banner and /update/user_banner endpoints persisting to the
existing LiteLLM_UISettings table

* fix(ui): re-surface dismissed banner on identical republish

Stamp a server-side revision on every banner update and fold it into
the client dismissal signature, so unpublishing and republishing the
same message reaches users who dismissed the earlier run

* fix(ui): stamp banner revision as an opaque uuid instead of a counter

Two overlapping admin updates could read the same prior revision and
both persist the same incremented value, letting an identical republish
collide with a previously dismissed signature. A server-generated uuid
per update makes every publication identity unique by construction with
no read-modify-write

* refactor(ui): drop the server-side banner cache

Reads go straight to the single-row table; the dashboard already
throttles fetches client-side, so the cache only added staleness
windows under concurrent updates and multiple workers

* refactor(ui): move banner storage behind a domain repository and drop the store_model_in_db gate

UserBannerRepository owns the row shape instead of the endpoint
reaching through the generic .table bridge, and publishing no longer
depends on the unrelated STORE_MODEL_IN_DB flag; a connected database
remains the only requirement
2026-08-04 09:24:29 -07:00
Classic298
c9887a1f94
perf: build log messages lazily so filtered-out log records cost nothing (#35703) 2026-08-04 04:34:52 +00:00
devin-ai-integration[bot]
5b6194f427
fix(proxy): backfill null user_email on existing users during JWT auth (#34588)
* fix(proxy): backfill null user_email on existing users during JWT auth

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): guard mapped-key email backfill and make null update atomic

Resolve Greptile review on the JWT user_email backfill:
- only backfill when the mapped virtual-key owner is the JWT principal, so a
  mismatched admin-created mapping cannot write one user's email onto another
- make the best-effort mapped-key enrichment non-fatal so a database outage on
  a cached-key request no longer fails otherwise-valid authentication
- persist the backfill with an atomic null-guarded update_many so concurrent
  writers cannot overwrite an already-populated email

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): keep cache coherent when a concurrent backfill wins the null-email update

* fix(proxy): cache DB-persisted email after JWT backfill, not the proposed value

Resolve the Greptile finding that a successful null-guarded backfill could
cache this request's proposed email even if a concurrent ordinary user update
wrote a different email first. The helper now always re-reads the row after the
atomic update and refreshes the cache from the value the database holds, so
cache-hit auth and attribution stay consistent with the persisted record.

Annotate the Prisma and model_copy dict literals to keep the LIT002 budget within its ceiling.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: shivam <shivam@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
2026-08-03 12:55:10 -07:00
Yuneng Jiang
7d6ee2a9ca
feat(proxy): let AI API keys read /model/info
Keys created with key_type=llm_api get allowed_routes=["llm_api_routes"],
which covered /v1/models but not /v1/model/info, so a client could list model
names but not read pricing, mode, or max_tokens without a second key.

Adds both /model/info and /v1/model/info (same handler) to llm_api_routes only.
Membership there is not the same as RouteChecks.is_llm_api_route(), which is
what gates DISABLE_LLM_API_ENDPOINTS, global/virtual-key budget enforcement,
enforce_user_param and JWT team attachment; /guardrails/apply_guardrail already
sits in the group the same way. /v2/model/info stays out: it is the paginated
Admin UI listing, not model metadata a caller needs at request time.

public_routes moves from set([...]) to a frozenset literal to keep the LIT002
and ruff-strict ceilings from rising; both budgets ratchet down by one.
2026-08-01 11:43:36 -07:00
yuneng-jiang
fcec1488e2
feat(proxy): add GET /management/v1/budgets (#35310)
* feat(proxy): add a generic list contract for management/v1 entity lists

Paging, sorting, filtering and search for an entity collection, declared once
as a ListSpec and served by handle_list. The route injects a ListExecutor that
owns its table, so this module never imports Prisma.

The caller's scope is derived from the caller alone and ANDed with whatever
they filtered on, so a query parameter can only narrow what they may read.

This is the shared half of the budgets list; it lands here so the endpoint has
something to register against, and drops out when the framework arrives on its
own branch.

* feat(proxy): add GET /management/v1/budgets

The Budgets page reads /budget/list, which returns the whole table as a bare
array with no way to page, sort or filter it. A customer with enough budgets to
fill the page has no way to find one.

Registers LiteLLM_BudgetTable against the management/v1 list contract: sortable
on budget_id, max_budget, tpm_limit, rpm_limit and created_at, default order
newest-first with budget_id breaking ties, search on budget_id, and filters for
budget_duration, max_budget and created_at. budget_duration is deliberately not
sortable; the column holds "7d"/"30d" strings, so a lexicographic ORDER BY puts
"30d" ahead of "7d".

tpm_limit and rpm_limit are BigInt? in Prisma, so rows validate through a
pydantic model on the way out and serialize as JSON numbers.

A caller without admin view is refused 403 as a problem document rather than
served an empty page. /budget/list is untouched.

* fix(proxy): rework the budgets list onto the merged list contract

PR #35308 landed a different shape than this branch was written against: `where`
is a tuple of frozen predicates rather than a Prisma-shaped mapping, `ListSpec`
carries both the row and the wire type, and `where_sql` / `order_by_sql` render
for a raw-SQL executor. The budgets executor now queries through `query_raw` the
way the spend logs facet does, selecting only the columns it serves.

Also casts datetime binds in `where_sql`. They cross into the query engine as
JSON, so an uncast placeholder arrives as text and Postgres refuses
`timestamp >= text` outright; every `filter[created_at][gte|lte]` was answering
500. The cast reads the bind as an instant and drops it to naive UTC to match
Prisma's TIMESTAMP(3) column, the same one /spend/logs/ui applies.

* refactor(proxy): fold the predicate renderer instead of recursing

recursive_detector flags `_render_all`, and the flag is fair: it recursed once
per predicate, so the stack grew with the number of filters on the request for
no reason. Walking a predicate list is a running bind index, which is a fold.

`_render` still re-enters for `AnyOf`, but its clauses are plain comparisons
built by `?q=`, so that nesting is one level deep and no caller can drive it
deeper.
2026-07-31 11:47:05 -07:00
mateo
84c41dcdc3 fix(proxy): treat non-token pricing as priced and propagate the unpriced-model toggle across workers
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-31 03:02:10 +00:00
ryan-crabbe-berri
6e26087cf4
fix(proxy): only enforce budgets on routes that can spend (#35274)
* fix(proxy): only enforce budgets on routes that can spend

Budget checks ran inside common_checks with no route filter, so an
over-budget user, team, organization or tag got a 429 on every
authenticated route, including the management calls the Admin UI makes
on load. An internal user who exhausted their budget could not open the
dashboard to see why, and a max_budget of 0 locked them out from the
moment the account existed.

Gate the scope budget checks on RouteChecks.is_llm_api_route, matching
the virtual key budget check, the reservation path and the global proxy
budget check, which already scope themselves this way. /health/services
keeps enforcing because it fires Slack, email and webhook sends.

The Admin UI is affected because a UI login mints a virtual key scoped
to the litellm-dashboard pseudo-team. That token was shielded from
personal budgets by the team-key exemption until #32005 removed it.

* fix(proxy): keep budget enforcement on provider-calling health routes

/health and /health/test_connection are not LLM API routes but both run
litellm.ahealth_check against real deployments, so exempting them let an
exhausted budget keep incurring provider spend.

Add them alongside /health/services in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES
and cover all three with a regression test.

* chore(ui): drop env-dependent schema.d.ts regeneration from this PR

The regenerated diff was union-member reordering only, with no change to
the represented types, and the ordering differs between a local run and
CI. Keeping the committed file as-is lets the drift check pass and keeps
this PR to the auth change.

* chore(ui): restore schema.d.ts to the branch base

The previous commit restored it from the staging tip, which pulled in
unrelated merged changes. This PR changes no backend models, so the file
should be untouched.
2026-07-30 16:06:45 -07:00
yuneng-jiang
6f1625d23b
revert(proxy)!: stop enforcing user budget on team keys (#35271)
Reverts #32005. Team-scoped keys are governed by the team and team-member
budgets only; the key owner personal max_budget no longer applies to them,
restoring the hierarchy that existed before that PR.

The skip_user_budget_on_team_key opt-out existed solely to turn the new
behavior back off, so it is removed along with the behavior: the
ConfigGeneralSettings field, the /config/list allowed_args entry that
surfaced it as an Admin UI toggle, and the argument threaded through
reserve_budget_for_request and _get_budget_counters.

Regression tests cover both enforcement points in the restored direction:
test_common_checks_personal_user_budget_skipped_for_team_key for the
read-time check and test_should_not_reserve_user_budget_counter_for_team_key
for the optimistic reservation path.
2026-07-30 20:19:49 +00:00
mateo
7e1f44f0cc feat(proxy): add admin toggle to block requests for models without pricing
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-30 02:38:28 +00:00
Mateo Wang
52fc276f05
Merge pull request #32587 from BerriAI/litellm_fix_batch_model_access_hash_32580
fix(auth): resolve managed batch/file deployment model_id to model name for team access checks
2026-07-29 18:43:39 -07:00
Mateo Wang
c56e657097
Merge pull request #34266 from BerriAI/litellm_team-model-allowlist-stale-qqg50q
fix(proxy): stop serving stale team model allowlist after /team/update
2026-07-29 11:00:38 -07:00
mateo-berri
095364fd04
test: cover the model_validate conversion sites flagged by codecov
Add regression tests for the db-fetch paths whose converted construction
lines were uncovered: the auth_checks getters (default end user budget, end
user, team membership, access group, team by alias, org by alias, object
permission, managed vector stores, project), get_all_team_memberships and
list_available_teams in team_endpoints, and the proxy admin user info
helper. Each test feeds a mocked prisma row through the real function and
asserts the validated model's fields, so a bad model_validate conversion on
any of these paths now fails a test instead of only dropping coverage.
2026-07-29 10:04:58 +00:00
Mateo Wang
c542e74b68
Merge pull request #34222 from BerriAI/litellm_jwt_v1_messages_team_route_1784693761
fix(jwt_auth): allow /v1/messages for JWT teams by default
2026-07-28 19:59:26 -07:00
milan
e3559cf1b7 test(auth): cover managed batch/file team access denial end to end
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-28 15:39:50 +00:00
Devin AI
ddaee8df16 fix(auth): resolve managed batch/file deployment model_id to model name for team access checks 2026-07-28 15:35:19 +00:00
mateo-berri
c2f0014a63 fix(jwt_auth): grant only /v1/messages routes to JWT teams by default, not all anthropic_routes 2026-07-27 17:15:09 -07:00
ryan-crabbe-berri
1a0acaa33b
fix(auth): route JWT default-team into memberships instead of the create payload (#33082)
* fix(auth): route JWT default-team into memberships instead of the create payload

JWT auto-provisioning (get_user_object with user_id_upsert) merged
litellm.default_internal_user_params verbatim into the Prisma user create,
including a teams key. When a default team is configured through the Admin
UI it is stored as a list of NewUserRequestTeam objects, but the user
table's teams column is String[], so the create raised a Prisma type error
and every JWT-authenticated request 401'd with the user never created.

Mirror the /user/new path: strip teams (and available_teams) out of the
create payload, then route the configured default team through
check_if_default_team_set / add_new_user_to_default_team so provisioned
users get real membership rows. Reuse the synthetic PROXY_ADMIN
UserAPIKeyAuth pattern already used by the team-upsert path to satisfy the
membership permission gate, and import the helpers lazily to avoid the
auth_checks <-> internal_user_endpoints import cycle.

* fix(auth): propagate max_budget_in_team when adding users to default teams

* fix: use pipe union instead of Optional for UP045 budget
2026-07-25 11:37:11 -07:00
ryan-crabbe-berri
fe5cc1eb0c
fix(proxy): global max_budget ignores budget_duration; enforce against the resettable proxy budget row (#33732)
* fix(proxy): enforce global max_budget against the resettable proxy budget row

The global proxy budget check compared litellm.max_budget against
SUM(spend) from the MonthlyGlobalSpend view, whose window is hardcoded
to a trailing 30 days. litellm.budget_duration was stored and reset on
a user row that enforcement never read, and startup budgeted the admin
user's own row (default_user_id) instead of the litellm-proxy-budget
aggregate row the spend writer increments per request. Net effect: 1d,
7d and 30d all behaved as a trailing 30 day cap that never reset on the
configured duration.

Startup now upserts the budget onto the litellm-proxy-budget row (and
zeroes lifetime accrual when first putting a row on a reset schedule),
enforcement loads global spend from that row, and ResetBudgetJob drops
the cached global spend accumulator when it resets that row so the cap
unblocks immediately after each window.

Fixes https://github.com/BerriAI/litellm/issues/31292

* refactor(proxy): address review nits on global proxy budget fix

Drop the redundant litellm_proxy_budget_name parameter from
_upsert_proxy_budget_with_reset_at_backfill; its only caller always passed
LITELLM_PROXY_BUDGET_NAME, and any other value would write the budget to a
row enforcement never reads.

Introduce GLOBAL_PROXY_SPEND_CACHE_KEY in constants.py and use it at every
site that previously built the key from litellm_proxy_admin_name (auth
loads, spend-writer increments, startup warm, reset-job invalidation), so
the reader and invalidator can no longer drift apart. The literal key value
is unchanged. Also drop the now-pointless litellm_proxy_admin_name
parameter from _warm_global_spend_cache and the proxy_server import from
the reset-job helper.
2026-07-25 11:29:39 -07:00
ryan-crabbe-berri
579f41d57f
fix(proxy): attribute org spend for team-linked credentials minted without org_id (#34577)
* fix(proxy): attribute spend to org for team-linked keys minted without org_id

Keys attached to an org-linked team but minted without an organization_id
produced spend that was never credited to the org: the spend writer reads
user_api_key_dict.org_id with no team fallback, while the org budget check
resolves the org from the team. The check therefore ran against a counter
fed by almost none of the org's traffic and never tripped.

Backfill org_id from the freshly fetched team object in
_run_centralized_common_checks, per request only, so the spend writer and
the budget check read the same org. A key with an explicitly pinned org_id
always wins, and the cached key row is never mutated, so moving a team to
a different org takes effect on the next auth once the team cache
refreshes.

* test(proxy): cover CLI session-token org backfill from team

CLI session tokens from /sso/cli/poll are minted with a real team_id but
no org_id, and their auth path decrypts the blob without the combined_view
team join that fills org for DB keys. Spend from these tokens reached the
team but never the org, so org budgets never tripped. The regression test
mints a real CLI token, runs it through the centralized checks, and
asserts the credential leaves auth with the team's org.
2026-07-24 18:20:42 -07:00
ryan-crabbe-berri
07f7fc224e
fix(proxy): reject failed atomic budget reservations under fail_closed_budget_enforcement (#34429)
* fix(proxy): reject request when budget reservation write fails under fail_closed_budget_enforcement

With general_settings.fail_closed_budget_enforcement set to true, the read-time
spend check already returns 503 when spend cannot be verified, but the atomic
pre-call reservation still failed open: reserve_budget_for_request swallowed
_CounterReservationUnavailable per counter and degraded to read-time-only
enforcement, so concurrent requests could all pass the same under-budget read
during a Redis outage and overspend past the configured budget.

Now the strict flag is threaded into reserve_budget_for_request and a failed
reservation write raises 503, releasing any counters that already reserved.
Default behavior with the flag absent or false is unchanged.

Fixes #33923

* fix(proxy): pass 503 budget-enforcement detail as plain string
2026-07-23 23:57:37 +00:00
ryan-crabbe-berri
7aaaa055b7
feat(proxy): add overwrite_user_with_key_hash to stamp outgoing user param with key hash (#34417)
* feat(proxy): add overwrite_user_with_key_hash to stamp outgoing user param with key hash

Adds a litellm_settings flag that forces the outgoing user param to the
authenticated key's hashed token before the request is forwarded to the
provider. The value overrides any caller-supplied user, so providers see
a stable, tamper-proof identifier they can rate-limit or ban on, and the
hash matches user_api_key_hash in spend logs for easy mapping back to
the key owner. Off by default

* fix(proxy): hash non-sk credentials before stamping user param

UserAPIKeyAuth only hashes sk-prefixed keys and JWTs; custom-auth
credentials stay raw on api_key, so stamping them directly would forward
auth material to the provider. Pass through the two known hashed forms
(sha256 hex, hashed-jwt-*) and hash anything else

* refactor(proxy): stamp only standard virtual keys, skip jwt and custom auth

A hashed JWT rotates on every token re-issue so it is useless as a
stable ban id, and custom-auth credentials arrive raw on api_key.
Instead of hashing whatever we hold, the stamp now applies only when
api_key is the sha256 hex digest of a standard virtual key; other auth
methods are explicitly out of scope until the stamped identifier is
configurable

* fix(proxy): gate user stamping on server-set virtual key provenance

Shape alone cannot distinguish a key hash from a raw custom-auth
credential that happens to be 64 hex chars. Adds via_virtual_key, a
server-only marker on UserAPIKeyAuth following the
mcp_admitted_user_subject pattern: stripped from all validated input so
handlers and claims cannot forge it, set by post-construction assignment
only at the DB virtual-key auth return. Stamping now requires the marker
and the hash shape

* test(proxy): prove db auth path sets via_virtual_key marker

The stamping unit tests set the marker manually, so deleting the
assignment in _user_api_key_auth_builder would pass every existing test;
this exercises the real builder path with a mocked identity store and
fails if the marker is not set

* fix(proxy): stamp master-key requests with the master key alias

Master-key auth substitutes LITELLM_PROXY_MASTER_KEY_ALIAS for api_key
so the key and its hash never propagate; that made master-key traffic
bypass stamping and pass the caller-supplied user through. The master
path now sets via_virtual_key and the stamp gate accepts the alias
alongside the sha256 shape, so admin traffic gets the same tamper-proof
id that spend logs already record for it

* fix(proxy): restore via_virtual_key marker on key-cache hits

Cached PROXY_ADMIN auth objects early-return before the marked DB and
master-key returns, and cache serialization drops the exclude=True
marker, so cached admin traffic bypassed stamping. Key-cache entries are
written only after the proxy validated a virtual key or the master key,
so the cache-hit boundary restores the marker; the UI-login JWT fallback
constructs its token from a decrypted blob, not this cache, and stays
unmarked
2026-07-23 16:38:01 -07:00
Tin Chi Lo
a78130461f feat(mcp): gateway DCR session admission at the aggregate /mcp endpoint (LIT-3637)
Admits a keyless SSO user (no virtual key) at the aggregate /mcp endpoint from a gateway DCR
session bearer, resolving team/org/SCIM/budget authorization fresh on every call.

- Aggregate DCR front door: stateless /register (sealed llm_dcrc_ client ids), SSO-backed
  /authorize + /authorize/complete, and /token minting identity-only session tokens with PKCE,
  single-use codes/flows, and rotating refresh tokens.
- Admission: a session-shaped Authorization at the aggregate scope opens via _admit_gateway_session,
  reloads the live user, and runs the centralized policy gate; failures return the RFC 9728
  invalid_token challenge. Gated on the un-forgeable, server-only mcp_admitted_user_subject marker,
  so virtual-key and JWT auth are unchanged.
- Authorization model: an admitted subject is resolved as one plain UserAPIKeyAuth per grant source
  (its own grants, plus each team it is a live roster member of), each answered by the SAME resolver
  virtual keys use, then unioned. That branch is the FIRST statement of BOTH public resolvers, so no
  single-credential prelude runs for it and a fault in a lookup it never uses cannot deny its grants. A source team counts only while it is a live grantor: roster membership, not
  blocked, and neither the team nor its owning org over budget (enforced through the SAME
  _team_max_budget_check / _organization_max_budget_check owners common_checks uses for keys).
  Each team source carries that team's own org, so the existing org
  ceiling caps it; for a keyless source the org list only ever intersects (a ceiling must not become
  a grant) and an unresolvable ceiling denies rather than silently uncapping, on both the server and
  tool axes. _roster_team_object is the single owner of "which teams count": a team whose roster no
  longer lists the user neither grants servers nor throttles, in one place.
- Rate limits: the subject is bounded by its user rpm/tpm AND by the per-server mcp_rpm_limit of
  the team a call is ATTRIBUTED to — the same single source billing charges, from the same owner. A key charges its one pinned team's bucket; a keyless
  subject has no team_id, so admission stamps each granting team's limit map onto the auth
  (server-only field, stripped from validated input like the marker) and the limiter emits that
  team's mcp_per_team descriptor. Charging every granting team instead would let one cross-team user
  drain several teams' SHARED buckets on a single call and block their other members; and a server
  the user's OWN grant reaches charges no team bucket at all, because no team provided it. Per-KEY
  MCP limits do not apply because there is no key.
- Wrapper channels: the manager-level union treats the admitted subject by the same grant model.
  The admin-role short-circuit and the absolute no_mcp_servers early-return are key-credential
  rules and never apply to it (a session bearer is a third-party client credential, not the
  dashboard, and the subject's opt-out silences only its own source). Operator-open channels
  (allow_all_keys, the user's own BYOM submissions) are owned by one operator_open_server_ids
  helper that BOTH the server union and the admitted tool resolution consult (suppress-BYOM-when-
  explicitly-scoped is a key-credential rule and never applies to the subject, whose user row
  carries the DB-default empty mcp_servers), so an open-channel
  server is default-open for tools instead of listable but uninvokable.
- Redirect URIs: one owner, validate_redirect_uri_shape, decides redirect-URI hygiene (bad scheme,
  fragment, missing host, userinfo, backslash host) and resolves allowlisted native callbacks, shared
  by DCR registration and the OAuth endpoints. Registration keeps a deliberately wider trust policy
  than validate_trusted_redirect_uri: public dynamic registration accepts any https client, and its
  controls are mandatory S256 PKCE plus the consent screen.
- Egress leak-defense: a gateway admission credential (session bearer / bridge envelope) is scrubbed
  from EVERY egress header context, anchored to the credential shape, so it can never be forwarded
  upstream and replayed.
- Single-use guard: auth-code, refresh and connect-flow claims resolve the proxy's cross-worker redis
  cache themselves rather than trusting the cache passed in, and fail CLOSED on a Redis fault instead
  of falling back to a per-worker count that a captured id could replay through another worker.
- Sign-in return_to: one shared, never-raising helper persists a safe return_to for every sign-in
  branch (SSO/Okta/generic and username/password), and every branch RESUMES through the same
  _sso_return_to_redirect the SSO callback uses, so however a deployment signs in the stored value
  is honored identically (same-origin path directly; control_plane_url via the one-time login-code
  handoff). A stale cookie is ignored rather than failing a completed sign-in.

- Budgets, both halves: ENFORCEMENT (an already over-budget team or its owning org stops being a
  grantor, in the source gate) and ACCOUNTING (a team-derived tool call is billed to the granting
  team and ITS org, so that budget accumulates and the right organization is charged). A server the
  user's own grant reaches bills the user; when several teams grant one server the pick is the
  lowest team_id, stable and auditable. Billing rides a COPY, so authorization still sees the full
  union, and it is inert when the target server cannot be resolved from the tool name.

Deferred (tracked): client-selected server scoping of the session token (LIT-4680).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:24:28 -07:00
mateo-berri
769f434bd2
fix(proxy): make team cache invalidation best-effort
Greptile review: _cache_team_object runs after a successful DB fetch in
get_team_object and after every team mutation's DB write, but
DualCache.async_delete_cache propagates backend errors, so a Redis blip
during invalidation would turn a healthy team lookup into a 404 and a
committed /team/update into a 500. Both the internal usage cache delete
and the alias-key invalidation now log a warning and continue on failure,
matching how DualCache.async_set_cache already swallows write errors.
Worst case on failure is worker-local staleness bounded by the internal
cache's in-memory TTL, the same bound other workers already have
2026-07-22 18:06:39 +00:00
mateo-berri
221b1859db
fix(proxy): stop serving stale team model allowlist after /team/update
get_team_object consults proxy_logging_obj.internal_usage_cache before
user_api_key_cache, but _cache_team_object (the refresh every team
mutation goes through) only wrote user_api_key_cache. With
enable_redis_auth_cache both caches share one Redis, so any request
backfills the internal cache's in-memory tier with the team object and
that copy keeps shadowing the freshly written team until its TTL expires.
The auth builder then wrote the team object it had just read back into
the cache after check 6, clobbering the fresh Redis value with the stale
one, which made the staleness self-sustaining under traffic: keys with
models=["all-team-models"] kept getting 403 team_model_access_denied
for models added via /team/update, and kept access to removed ones.

_cache_team_object now deletes the internal usage cache entry before
writing the refreshed team, and the auth-time write-back is removed so
only authoritative writers (DB reads and team mutations) populate the
team cache, mirroring how key objects already handle this (see
test_auth_does_not_rewrite_cached_key_object_back_into_cache).

The LIT-4000 test pinning the removed write-back is deleted; its
concern (team object cached under the canonical key) is handled by
_cache_team_object inside get_team_object's DB path and pinned by
test_cache_team_object_writes_team_id_and_invalidates_team_alias

Resolves LIT-4391
2026-07-22 17:53:38 +00:00
shivam
aca7f57324 fix(jwt_auth): allow /v1/messages for JWT teams by default
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-22 04:23:35 +00:00
yucheng-berri
065faf6e69
chore(proxy): clean up request parameter validation and provider destination handling (#34189) 2026-07-22 00:57:58 +00:00
ryan-crabbe-berri
e17f3b6e1a
fix(proxy): populate user_email on UserAPIKeyAuth for JWT auth (#34174)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
JWT auth built UserAPIKeyAuth without user_email even though the resolved
user row and the JWT email claim were both available, so the user_email
label on Prometheus metrics and user_api_key_user_email in
StandardLogging/SpendLogs metadata were always None for JWT traffic.

Plumb user_email through JWTAuthBuilderResult: auth_builder returns the
user row email when set, falling back to the user_email_jwt_field claim
(covers the scope-based proxy-admin path where no user row is loaded).
The JWT branch now stamps it on the proxy-admin return, the standard
valid_token, and the auto-registered virtual key object.

Resolves LIT-4238
2026-07-21 16:11:47 -07:00
ryan-crabbe-berri
76c9eca25d
refactor(auth): derive temp budget increase without mutating the token (#34121)
* refactor(auth): derive temp budget bump without mutation, tz-aware auth datetimes

_update_key_budget_with_temp_budget_increase mutated max_budget in place, so correctness depended on every resolution path handing it a fresh copy of the cached token; one future re-cache of a live token would compound the bump per request. Return a model_copy instead so no caller can leak an increased budget into shared state.

Also fixes the three remaining DTZ005 naive datetime.now() calls in user_api_key_auth.py (auth span start, builder start_time, service-log end_time; all consumers convert to epoch or subtract same-pair datetimes) and ratchets the DTZ005 strict budget 244 -> 241.

* test: pin non-mutation of the temp budget helper input

Adversarial mutation-testing showed reverting the helper to in-place mutation still passed every test: the cache's copy-on-read layer masks the mutation in the integration test and the direct unit test only inspected the return value. Assert the input object is left untouched and the result is a distinct object so the purity guarantee itself is load-bearing.
2026-07-21 21:02:40 +00:00
devin-ai-integration[bot]
089de50d20
fix(auth): apply temp_budget_increase for cache-hit keys (#33841)
temp_budget_increase was only applied on the DB-fetch path of _user_api_key_auth_builder, so a key served from the auth cache reverted to its original max_budget and was wrongly blocked with BudgetExceededError once spend crossed the original budget while staying under the effective budget.

Move _update_key_budget_with_temp_budget_increase out of the DB-only branch so it runs for every resolved token regardless of source. The cache stores the original budget and each cache hit returns a fresh model_copy(), so this never double-applies.

Fixes #25760

Co-authored-by: shivam <shivam@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 18:12:15 -07:00
ryan-crabbe-berri
46a80e1ef5
fix(auth): set budget_reset_at when JWT upsert seeds a budget_duration (#34050)
The JWT first-login upsert in get_user_object creates the user row by
merging default_internal_user_params straight into table.create, so a
configured budget_duration landed with budget_reset_at NULL. The reset
sweep now heals such rows (PR #33623), but until the next sweep the row
shows a null reset time and its first window starts at the sweep instead
of one full duration after creation. Compute budget_reset_at at creation
like every other write path (/user/new, UI SSO, /key/generate, /team/new)
already does
2026-07-20 17:23:44 -07:00
mateo-berri
c2bd8699be fix(proxy): require admin opt-in for request-body bedrock_tags
Caller-supplied bedrock_tags land as AWS resource tags under the proxy's
AWS identity, letting an authenticated caller forge ownership or
cost-allocation labels. Add bedrock_tags to _BANNED_REQUEST_BODY_PARAMS
so per-request tags need general_settings.allow_client_side_credentials
or configurable_clientside_auth_params on the deployment, matching the
aws_bedrock_project_id precedent. Deployment-level bedrock_tags in
litellm_params are unaffected.

Also stop an explicit empty bedrock_tags list in litellm_params from
falling through to optional_params
2026-07-20 15:00:41 -07:00
Yassin Kortam
adb1ffb119
fix(proxy): stop treating upstream model body field as a LiteLLM model on auth-enforced pass-through routes (#33710)
* fix(proxy): stop treating upstream model body field as a LiteLLM model on auth-enforced pass-through routes

An auth: true user-defined pass-through endpoint runs full virtual-key auth, and get_model_from_request unconditionally extracted the request body model field, so key/team/user/project model allowlist checks rejected requests whose model only exists upstream (key_model_access_denied), even when the key was explicitly granted the route via allowed_passthrough_routes.

The pass-through route registry moves to a leaf module (route_registry.py) that the auth layer can import without re-entering the pass_through_endpoints -> user_api_key_auth -> auth_utils import cycle. get_model_from_request now returns None for routes registered as user-defined pass-through endpoints (exact and subpath), which skips model allowlist and per-model budget enforcement on those routes while key auth, allowed_passthrough_routes, and spend/budget checks stay intact. Built-in provider passthrough routes (/vertex_ai, /gemini, ...) keep model enforcement.

Resolves LIT-4299

* fix(proxy): key pass-through model-access skip on the dispatched endpoint, not the request path

Addresses a model-authorization bypass: the first version decided whether to skip
model-allowlist extraction by matching the request path against the pass-through
route registry. That ignored the HTTP method and, more importantly, whether the
request was actually dispatched to a pass-through handler. A custom pass-through
whose path collides with a built-in route (e.g. /v1/chat/completions, or an
include_subpath prefix of one) still writes a registry entry even though FastAPI
serves the built-in handler, so a normal request to that route had its model checks
skipped and could reach a model outside the key/team/user/project allowlist.

The skip is now keyed off the FastAPI-resolved endpoint. create_pass_through_route
tags its handler with LITELLM_PASS_THROUGH_ENDPOINT_MARKER, and get_model_from_request
returns None only when request.scope["endpoint"] carries that marker. Because routing
runs before auth dependencies, this reflects the handler that actually serves the
request: on a collision the built-in handler is dispatched and carries no marker, so
model enforcement stays on. This also removes the need for the separate route_registry
module, so that extraction is reverted.

Regression tests cover a pass-through-dispatched request (model suppressed), a
built-in-dispatched request on the same path (model still enforced), and the no-request
budget path.

Resolves LIT-4299
2026-07-17 10:03:03 -07:00
Yassin Kortam
ae8dc1f39f
fix(proxy): stop stale auth cache re-publish so key updates and deletes propagate across replicas (#33565)
With enable_redis_auth_cache and multiple replicas, /key/update and
/key/delete delete the Redis auth blob and the handling pod's in-memory
entry, but two read-path writers re-published the stale blob from any other
replica's per-pod memory back to Redis with a fresh 60s TTL on every
request: the post-auth re-cache in user_api_key_auth and the spend writeback
in update_cache. Replicas whose in-memory entries expired then re-primed
themselves from the poisoned Redis entry, so key limit and access changes
never took effect fleet-wide while traffic continued, and a deleted key kept
authenticating.

The auth object is now written only by the DB-load paths
(IdentityStore._resolve_key, get_key_object): the post-auth re-cache is
removed outright (even a local-only write could race an invalidation and
resurrect a revoked key on this worker) and spend tracking no longer writes
the auth object back at all; spend is tracked through the spend🔑*
counters. The remaining spend writebacks for user, team, end-user, and tag
objects become local-only so they cannot republish stale management objects
either, with one deliberate exception: the proxy-wide
{litellm_proxy_admin_name}:spend scalar keeps its shared Redis write because
the global max_budget check reads it between authoritative DB reloads, and
it carries no limits or permissions so sharing it cannot resurrect an
invalidated auth blob.

DualCache's redis-to-memory read backfill also ignored default_in_memory_ttl,
pinning backfilled entries for InMemoryCache's 600s default instead of the
configured 60s auth TTL; the backfill now injects the configured default like
every write path already does, so a replica primed from Redis converges
within the auth cache TTL as well.

Consolidates the sibling stale-auth-recache branch; the delete-propagation
case is the duplicate ticket LIT-4350.

Resolves LIT-4219
2026-07-16 15:00:33 -07:00
yucheng-berri
7fe3dd86a4
feat(logging): add structured budget fields to budget rejection failure logs (#33460) 2026-07-16 12:39:04 -07:00
yucheng-berri
3cea243116
fix(key management): enforce minimum custom key length and mask short keys in key_name (#33462)
* fix(key management): enforce minimum custom key length and mask short keys in key_name

* fix(key management): validate new_key before assignment and sync generated schema docstrings

* fix(key management): lower minimum custom key length default from 20 to 16
2026-07-15 18:27:38 -07:00
ryan-crabbe-berri
4580ad003a
fix(cli): surface actionable CLI SSO errors when CLI and proxy versions skew (#33309)
* fix(proxy): tell outdated litellm CLIs to upgrade when CLI SSO login id is legacy sk- format

* fix(cli): surface server error detail when SSO login polling fails and stop on permanent 4xx

* fix(cli): exhaustive, actionable error handling across the CLI SSO login flow
2026-07-15 10:17:40 -07:00
mubashir1osmani
f7849f9e91
fix(auth): scope the JWT enterprise gate to actual JWTs (#33296)
With enable_jwt_auth enabled but no enterprise license (premium_user
False), the JWT premium check fired on every request before the token
was inspected, so the master key, sk- virtual keys, and the encrypted
CLI/UI SSO session token that `lite login` issues all 401'd with "JWT
Auth is an enterprise only feature" and were never decoded. That broke
`lite login`, `lite claude`, and the proxy master key on any deployment
that turned JWT auth on without a license.

Move the premium check inside the is_jwt branch so it gates only real
JWTs. Non-JWT credentials fall through to their own auth paths
regardless of license; actual JWTs still require premium, so the
enterprise gate is unchanged for the feature it protects.
2026-07-14 17:05:05 -07:00
devin-ai-integration[bot]
ffe0c4c185
fix(proxy)!: enforce user budget on team keys (read-time + reservation) with UI opt-out (#32005)
* fix: enforce user budget on team keys

User budget was skipped when the key belonged to a team, letting
users exceed their personal budget by going through a team key.

Remove the team_object guard in _user_max_budget_check so user
budgets are always enforced. Add skip_user_budget_on_team_key
general_settings flag to opt back into the old behavior.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: update test to expect user budget enforcement on team keys

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): enforce user budget on team keys in reservation path and expose skip flag in UI

Extends the read-time fix so the optimistic budget reservation also reserves the user spend counter for team-scoped keys, register skip_user_budget_on_team_key in ConfigGeneralSettings so /config/field/update accepts it, and surface it as a Boolean toggle on the Admin UI General Settings table via allowed_args in /config/list.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: assert budget_exceeded ProxyException in personal budget test

Tighten the broad pytest.raises(Exception) so the test only passes when
the auth flow rejects with a budget_exceeded ProxyException, and switch
the new ConfigGeneralSettings field to Optional[bool] to match the
surrounding annotation style

* fix: revert to bool | None to stay under UP045 strict budget

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
2026-07-14 13:21:32 -07:00
Tin Chi Lo
61c7e706dd fix(mcp): classify get_user_object's wrapped DB outage across the exception chain
get_user_object catches every DB failure in a broad except and re-raises a bare ValueError (litellm/proxy/auth/auth_checks.py), so a real outage and a missing user look identical and the original error survives only as __context__. The dcr_bridge admission path keyed its 503-vs-401 decision on the exception type, so a transient outage during a user-subject reload surfaced as a 401 rather than a retryable 503, and the regression test injected a raw ConnectionError, a shape get_user_object never produces, so it passed on a fiction

Add PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain, which walks __cause__/__context__ (bounded and cycle-safe) the PEP 3134 way, and route _raise_503_if_db_unavailable through it. Move the user's object_permission resolution inside the single classified try so an outage there is a 503 too, never an opaque 500. Pin get_user_object's wrapping with a contract test that drives the real function, and drive the reload tests with that same faithful shape so a chain-blind regression fails them
2026-07-13 13:25:41 -07:00
Yuneng Jiang
368342cdde
refactor(auth): resolve PATCH team org-context from the route template
Replace the request.path_params read (and its defensive getattr guard) with
the route template. A real Starlette request always exposes path_params, but
common_checks runs on lightweight request doubles that don't, so reading it
directly forced a getattr workaround that only existed to tolerate those
doubles.

Instead, match the route template (/team/{team_id}) to identify the RESTful
update route and take the team id from the last path segment. This drops the
path_params dependency entirely, and because the template distinguishes the
PATCH route from its single-segment siblings (/team/new, /team/list, ...), it
also avoids a spurious team lookup those routes would otherwise trigger if we
matched the resolved path shape alone.
2026-07-11 11:24:41 -07:00
Yuneng Jiang
8f0dcb23f4
feat(team): let org admins reach PATCH /team/{team_id} like POST /team/update
Wire the coarse route gate so PATCH /team/{team_id} is reachable by exactly the roles that can call POST /team/update: proxy admins, org admins of the team's own organization, and JWT admins. Regular internal users and view-only proxy admins stay blocked, matching the existing endpoint

Because the team id lives in the path rather than the body, the org-context resolver now also reads it from path_team_id for the bare /team/{team_id} route, so an org admin's organization is resolved and injected the same way it already is for POST /team/update. /team/{team_id} is added to management_routes rather than the role-agnostic self_managed_routes; the latter would have opened POST /team/new to any authenticated user through the shared /team/{team_id} path pattern
2026-07-10 23:14:01 -07:00
Yuneng Jiang
637352735f
fix(proxy): resolve team org from team_id so org admins can update team budgets
An org admin updating a team budget from the Hub UI was rejected with 401, because the route gate only recognizes an org admin when the request body carries organization_id while the UI sends team_id. For /team/update, resolve the target team's organization_id from team_id before the gate runs, so an org admin of the team's own org clears the org-scoped branch without the client passing organization_id. Team admins and cross-org admins stay denied at the gate, and callers that already pass organization_id are unaffected, so the existing /team/update authorization matrix is unchanged
2026-07-08 19:10:35 -07:00
Yassin Kortam
6f6bd45681
perf(auth): negative-cache missing user/key lookups on the request hot path (#32368) 2026-07-08 09:59:57 +03:00
Yassin Kortam
bcd52754de
feat(rate_limit): support per-tag rpm limiting on a single key (#31502)
Add a tag_rpm_limit field to virtual keys so each request tag gets its own independent RPM counter on the v3 rate limiter. A key configured with per-tag limits tracks each tag/group separately, and requests whose tag has no configured limit fall back to the key-level limit. Includes the dashboard UI to manage per-tag limits on key create and edit.

Resolves LIT-3147
2026-07-08 09:43:47 +03:00
Yassin Kortam
68f997dd09
feat(budget): throttle keys after spend limit instead of revoking access (#31300)
Add an opt-in mode so a key that exceeds its own max_budget is throttled to a
globally configured percentage of its TPM/RPM instead of being blocked entirely.

A new litellm_settings global, budget_exceeded_throttle_percentage, sets the
fraction (e.g. 0.1 = 10%). A per-key throttle_on_budget_exceeded flag (stored in
key metadata via the existing management-endpoint metadata routing) opts the key
in. When both are set and the key is over budget, the budget check records the
percentage on a request-scoped budget_throttle_pct instead of raising, and the
rate limiter scales the key's configured TPM/RPM by it. Keys without the flag
keep hard-blocking; team/user/org budgets are unaffected.

The throttle is recomputed from the key's original limits on every request and
the decision is cleared before the auth object is cached, so it never compounds
across requests. Both the budget read-time check and the budget reservation path
honor the opt-in, and both the v3 and legacy rate limiters apply the scaling.

Enabling throttle_on_budget_exceeded is proxy-admin only. It converts an
admin-imposed hard budget block into a soft throttle that keeps spending past
max_budget, so a non-admin must not be able to self-opt-in and bypass their own
spend cap. Both /key/generate and /key/update reject a non-admin setting it to
true (update only gates the transition to enabled, so a non-admin can still edit
other fields and turn the flag off). This matches the feature being wholly
proxy-admin operated: the global percentage is admin-only too.

A key that opts in but has no TPM or RPM limit has nothing to scale, so it stays
hard-blocked rather than serving unlimited requests past its budget (fail-safe).

The global budget_exceeded_throttle_percentage is configurable from the admin UI
(Settings -> General Settings), persisted through litellm_settings so it survives
a restart, not only from config.yaml.

Resolves LIT-3894. Scope for LIT-3893.
2026-07-07 09:41:01 -07:00
Mateo Wang
0855fa02b2
feat(jwt): fall back to DB team memberships when JWT has no team claims (#31356)
* feat(jwt): fall back to DB team memberships when JWT has no team claims

* style(jwt): use PEP 585/604 annotations in DB team fallback to clear strict gate

* fix(jwt): preserve DB teams on no-claim sync, model-gate DB fallback, stop team-id leak

When fallback_to_db_teams is enabled and a JWT carries no team claims,
sync_user_role_and_teams previously computed teams_to_remove as every existing
DB membership and wiped the user out of all their teams on each request, which
also left the DB fallback nothing to resolve. Skip team removal in that case so
memberships survive and the fallback can attribute usage.

Apply the same per-team model-access check the claim-based path enforces when
selecting a DB fallback team, so a team's models restriction is no longer
bypassed; a team that cannot serve the requested model is skipped in favor of
one that can.

Drop the user's team-id list from the x-litellm-team-id membership 403 detail so
a valid-JWT caller can no longer enumerate team IDs.

* fix(jwt): load team membership on DB fallback; scope header check to provisional teams

The DB-team fallback resolved a team but never loaded its team membership
row, so per-team membership budget limits were silently skipped on that
path. _resolve_db_team_fallback now fetches the resolved team's membership
when a user_id is known and returns it, matching the claim-based path so
downstream LiteLLM_TeamMembership budget enforcement works there too.

The provisional x-litellm-team-id validation also fired on any non-None
team_id, including an RBAC role-derived one, which 403'd RBAC team flows
when the asserted team was not also a DB membership. It now runs only when
team_id actually came from the header (team_id == header_team_id).

* fix(jwt): surface DB-fallback membership lookup failures at warning level

A transient get_team_membership failure on the DB team fallback path is
recoverable: the team is still resolved and the request proceeds, just
without per-team membership budget enforcement for that request. Logging
that at debug hid a silent budget-enforcement gap from operators, so it now
logs at warning and states that enforcement was skipped. Behavior is
otherwise unchanged: the resolved team is returned with a None membership
rather than failing the request, covered by
test_resolve_db_team_fallback_survives_membership_lookup_error.

* fix(jwt-auth): tighten db-team fallback gating and passthrough enforcement

Resolves four issues in the fallback_to_db_teams path:

- _resolve_db_team_fallback now surfaces a model-access denial when memberships
  exist but none can access the requested model, instead of always returning
  the no-membership message
- auth_builder gates the fallback on real JWT team claims via
  get_all_jwt_team_ids so a configured team_id_default does not silently route
  claimless tokens to the default team
- A team selected only via _resolve_db_team_fallback is re-validated against
  the team's allowed_passthrough_routes; the earlier gate ran while team_id
  was still None
- sync_user_role_and_teams considers both plural and singular team claim
  shapes when reconciling DB memberships so singular-only tokens
  (Okta/Auth0 defaults) no longer leave stale teams behind

* fix(jwt): don't upsert a provisional x-litellm-team-id before membership check

When fallback_to_db_teams is on and the JWT carries no team claims, an
x-litellm-team-id header is accepted provisionally and only validated against
the user's DB memberships later in auth_builder. With team_id_upsert also
enabled, get_team_object ran the upsert on that unvalidated header team first,
so an attacker-supplied header could create an orphaned team row before the
403 membership check. Suppress the upsert whenever the team is provisional
(db_team_fallback), since a genuine membership team already exists and an
invalid one must not be created. Regression:
test_auth_builder_provisional_header_team_is_not_upserted.

* fix(jwt): pin RBAC-asserted team against db-team-fallback header override

When a JWT carries an RBAC team role but no group claims, auth_builder already
sets team_id from the RBAC object_id. db_team_fallback still evaluated true
there, so the provisional x-litellm-team-id path accepted a header team and
silently overrode the RBAC-asserted team with any team the caller belonged to.
Gate db_team_fallback on team_id being unset, and drive the header's provisional
acceptance off db_team_fallback rather than the raw flag, so an RBAC token plus
a non-claim header team is rejected with 403 instead of substituting the team.
Regression: test_auth_builder_header_cannot_override_rbac_team_under_db_fallback.

* fix(jwt): scope dual-claim membership sync to fallback_to_db_teams

The membership sync read both plural and singular JWT team claims via
get_all_jwt_team_ids unconditionally, which silently changed reconciliation
for every deployment using sync_user_role_and_teams, not just those opting
into fallback_to_db_teams: a singular-only IdP token that previously stripped
all DB teams would now be recognized. Gate the dual-claim read on
fallback_to_db_teams so flag-off deployments keep the upstream plural-only
behavior, honoring the PR's contract that existing deployments are unchanged.
Regression: test_sync_user_role_and_teams_singular_claim_only_recognized_under_flag.

* fix(jwt): drop user team IDs from db-fallback model-access 403 detail

The model-access-denied 403 in _resolve_db_team_fallback echoed the user's
full DB team-id list in its detail. It is only the caller's own memberships,
but it is inconsistent with the membership-validation 403 in the same feature
that was deliberately scrubbed of team IDs. Replace the enumerated list with a
generic "no team you are a member of has access" message. Regression extends
test_resolve_db_team_fallback_distinguishes_no_membership_vs_model_denied to
assert the team id is absent from the detail.

* fix(jwt): keep db-team fallback off for alias-only tokens

* test(jwt): cover alias-only token skipping db-team fallback

The autofix in ed21199 added a get_team_alias clause to the db_team_fallback
gate so an alias-only JWT (team_alias_jwt_field set, no team-id claims)
resolves its alias via find_and_validate_specific_team_id instead of being
mis-attributed to the user's first DB team, but it shipped without a
regression test. This drives auth_builder with an alias-only token whose
alias resolves to a different team than the user's DB membership and asserts
the result is the alias-resolved team; reverting the get_team_alias clause
flips the result to the DB-membership team, so the test fails without the fix

* fix(jwt): prefer alias resolution over team_id_default

When the JWT only carries an alias claim and the operator configures
team_id_default, JWTHandler.get_team_id silently substitutes the
default into find_and_validate_specific_team_id. That made the helper
return the default team without ever attempting alias resolution, so
spend and access attached to the default team even though the token
identified a different team via its alias. Use get_all_jwt_team_ids
(which ignores team_id_default) to detect when the resolved team_id is
only the default and clear it so alias resolution runs first; the
default remains the fallback when no alias claim is present.

* fix(jwt): enforce team_allowed_routes in db-team fallback resolution

The claim-based path runs allowed_routes_check when selecting a team, but
_resolve_db_team_fallback selected a team purely on model access, so a
DB-resolved team could reach routes excluded by team_allowed_routes with no
downstream backstop. This mirrors the claim path's route gate in the fallback,
exempting auth-enforced passthrough routes that are gated separately by
allowed_passthrough_routes at the call site

* fix(jwt): enforce team_allowed_routes on header-team db fallback path

The auto-pick DB-team fallback already gates against team_allowed_routes, but a claimless JWT presenting x-litellm-team-id under fallback_to_db_teams set team_id directly from the header and only re-validated DB membership afterwards, skipping the route gate. A caller could reach management/info routes that the JWT config narrowed for team-role callers by supplying the header even though the auto-pick path on the same route returns no team.

* refactor(jwt): narrow db-team fallback except clauses to actual failure types

* fix(jwt): collapse provisional header team lookup failure into membership denial

A caller holding a valid claimless JWT under fallback_to_db_teams could
distinguish nonexistent teams (404 from get_team_object) from existing
teams they do not belong to (membership 403) by varying x-litellm-team-id,
giving an authenticated team-id existence oracle. The provisional header
path now rewrites the lookup failure into the exact 403 the membership
check raises, while claim-backed header teams keep the upstream 404.

Also drop the unreachable falsy-team guard in _resolve_db_team_fallback
(get_team_object returns a team or raises, never None) and stop codecov
carryforward for three dead flags whose stale sessions were measured
against old file revisions and sank patch coverage with phantom
executable lines

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-06 17:17:10 -07:00
devin-ai-integration[bot]
5ece78fb5f
revert: undo teamless all-team-models denial from #32022 and #29746 (#32032)
* Revert "fix(auth): deny model access for teamless keys with all-team-models (#32022)"

This reverts commit dfbbda4f19.

* revert: undo teamless all-team-models denial from PR #29746

Reverts the team_id guard in _resolve_key_models_for_auth_check and
get_key_models so teamless keys with all-team-models resolve to []
(unrestricted = all proxy models) rather than being denied.

Adds hardened regression tests across listing (get_key_models), inference
(_enforce_key_and_fallback_model_access, can_key_call_model,
can_key_call_resolved_model), and batch (_enforce_batch_file_model_access)
paths that enforce teamless all-team-models == all-proxy-models and will
fail if anyone re-introduces a team_id guard

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: retrigger checks

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-07-03 18:56:07 -07:00