fix(proxy): gate non-admin /key/generate budget_limits and permissions (VERIA-392) - #31469
Conversation
Greptile SummaryThis PR hardens key-management delegation for generated keys. The main changes are:
Confidence Score: 4/5Merge should wait for the remaining key-management authorization gaps to be addressed. The changes improve generated-key controls, but update and regenerate paths still need matching field-level enforcement to make the contract consistent. litellm/proxy/management_endpoints/key_management_endpoints.py
What T-Rex did
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
PR overviewThis PR updates LiteLLM proxy key management so non-admin key creation and rotation flows handle configured budget limits and permissions consistently. The touched code centers on the Most previously identified issues have been addressed, but one significant gap remains in the key regeneration path. Open issues (1)
Fixed/addressed: 4 · PR risk: 7/10 |
…ession-token + NaN Mock review on PR #31469 surfaced four follow-ups, all in the same attack surface: - B1: a CLI session token (max_budget=None) writing budget_limits to a personal key had zero delegation authority but the new check let it through via the delegation_ceiling is None early return. Mirror the existing scalar guard. - B2: /key/update never gated the permissions dict, so a key owner could flip allow_pii_controls / custom_admin on their own key (permissions is not a budget field so the personal-key fast path applied). /key/regenerate gated neither permissions nor budget_limits. Add the gates. - M1: a NaN window max_budget bypasses the > ceiling check (NaN > x is False) and disables downstream enforcement (spend > NaN is always False). math.isfinite rejects it. - M2: docstrings on the four endpoints still advertised the permissions dict to non-admins. Reword to admin-only.
|
@greptileai please re-review HEAD 4426589 — the follow-up commit closes B1 (session-token + personal-key bypass) and M1 (NaN window) that the last two passes flagged @veria-ai please re-review HEAD 4426589 — the follow-up commit closes the NaN bypass on budget_limits that the last pass flagged |
mateo-berri
left a comment
There was a problem hiding this comment.
Is the "Validate finite windows first" a problem?
Greptile P1 on PR #31469: the math.isfinite check sat below the admin and UI-session early returns, so a payload like budget_limits=[{max_budget: NaN, ...}] from either of those callers slipped through to storage. NaN disables downstream enforcement because spend > NaN is always False, so the window's budget cap is silently inert. Data hygiene is not role-dependent. Move the finite check above the role/session returns and keep only the ceiling comparison admin- exempt. Add a regression test for the admin-NaN path.
|
i move the finite check first, then the role base check, if that addresses your question |
mateo-berri
left a comment
There was a problem hiding this comment.
check veria concern. Legit?
Veria-ai Medium on PR #31469: two gaps in the permissions gate. Part A: the helper used `if not permissions: return`, so a non-admin caller submitting an explicit empty dict or null on /key/update or /key/regenerate slipped through. That update overwrites the stored JSON with the new value, clearing an admin-set capability such as `enable_llm_guard_check` and silently turning the matching hook off on that key. Part B: /key/bulk_update and /team/key/bulk_update reach the database through _process_single_key_update, which never called the permissions gate. A team admin or a team member with KEY_UPDATE could ship an `update_fields.permissions` payload through that path and bypass the per-key /key/update gate that lives in _validate_update_key_data. Both addressed in this commit. The helper now takes `field_was_set`; the create call sites pass None (the empty {} default is the legitimate non-admin shape), the update / regenerate call sites pass "permissions" in data.model_fields_set. The gate is also invoked inside _process_single_key_update so the two bulk handlers and any future caller of that helper inherit it.
|
@veria-ai you were right on both counts. Pushed fec3a0c. Part A (explicit clear bypass): Part B (bulk paths): Three new unit tests, all mutation-killed against the prior commit:
318 tests pass. Note on the title of fec3a0c — it carries a misleading heading from a prior commit. Body and content match this Veria fix; not amending because the SHA-stable history matters for review-bot re-attachment more than a clean log here. @greptileai please re-review HEAD fec3a0c |
|
@greptileai please re-review HEAD fec3a0c @veria-ai please re-review HEAD fec3a0c @cursor please review |
|
@veria-ai @greptileai bumping for a re-review on HEAD fec3a0c For reference, the open concerns since the last bot pass were:
318 unit tests, live verify on the proxy clean across the attack matrix |
…ession-token + NaN Mock review on PR #31469 surfaced four follow-ups, all in the same attack surface: - B1: a CLI session token (max_budget=None) writing budget_limits to a personal key had zero delegation authority but the new check let it through via the delegation_ceiling is None early return. Mirror the existing scalar guard. - B2: /key/update never gated the permissions dict, so a key owner could flip allow_pii_controls / custom_admin on their own key (permissions is not a budget field so the personal-key fast path applied). /key/regenerate gated neither permissions nor budget_limits. Add the gates. - M1: a NaN window max_budget bypasses the > ceiling check (NaN > x is False) and disables downstream enforcement (spend > NaN is always False). math.isfinite rejects it. - M2: docstrings on the four endpoints still advertised the permissions dict to non-admins. Reword to admin-only.
Greptile P1 on PR #31469: the math.isfinite check sat below the admin and UI-session early returns, so a payload like budget_limits=[{max_budget: NaN, ...}] from either of those callers slipped through to storage. NaN disables downstream enforcement because spend > NaN is always False, so the window's budget cap is silently inert. Data hygiene is not role-dependent. Move the finite check above the role/session returns and keep only the ceiling comparison admin- exempt. Add a regression test for the admin-NaN path.
3f0a54a to
ea4c9ea
Compare
Veria-ai Medium on PR #31469: two gaps in the permissions gate. Part A: the helper used `if not permissions: return`, so a non-admin caller submitting an explicit empty dict or null on /key/update or /key/regenerate slipped through. That update overwrites the stored JSON with the new value, clearing an admin-set capability such as `enable_llm_guard_check` and silently turning the matching hook off on that key. Part B: /key/bulk_update and /team/key/bulk_update reach the database through _process_single_key_update, which never called the permissions gate. A team admin or a team member with KEY_UPDATE could ship an `update_fields.permissions` payload through that path and bypass the per-key /key/update gate that lives in _validate_update_key_data. Both addressed in this commit. The helper now takes `field_was_set`; the create call sites pass None (the empty {} default is the legitimate non-admin shape), the update / regenerate call sites pass "permissions" in data.model_fields_set. The gate is also invoked inside _process_single_key_update so the two bulk handlers and any future caller of that helper inherit it.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Team bulk skips budget ceiling
- Added delegation-ceiling validation for team bulk key updates and regression coverage for oversized and non-finite budget fields.
You can send follow-ups to the cloud agent here.
|
Replacement PR: #31543. Single coherent commit implementing the centralized policy described in the close-out comment. |
c016a01 to
c149544
Compare
|
Reopened and reset to the scoped fix. The branch was force-pushed back to a single commit (c149544) that does only what VERIA-392 / LIT-4072 originally describes: gate Everything else that grew this PR over the prior iterations was real but out of scope for this ticket. Filed as follow-up Linear tickets so each lands as its own focused PR:
PR #31543 (the centralized helper attempt) is closed. @greptileai please re-review HEAD c149544 |
|
bugbot run |
|
@greptileai please review HEAD c149544 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: NaN budget_limits bypass ceiling check
- budget_limits entries now reject non-finite max_budget values before any delegation bypass can occur.
- ✅ Fixed: Session token skips budget_limits guard
- Session-token personal key generation now rejects budget_limits instead of treating a missing ceiling as unlimited delegation authority.
You can send follow-ups to the cloud agent here.
| return | ||
| if delegation_ceiling is None: | ||
| return | ||
| over_ceiling = next((w for w in budget_limits if w.max_budget > delegation_ceiling), None) |
There was a problem hiding this comment.
NaN budget_limits bypass ceiling check
High Severity
The new _check_budget_limits_delegation_ceiling only rejects windows where max_budget is greater than the caller ceiling. Non-finite values such as NaN fail that comparison, so /key/generate can still persist a window budget that downstream enforcement never applies because spend checks require a finite limit.
Reviewed by Cursor Bugbot for commit c149544. Configure here.
| if is_ui_session_team_key: | ||
| return | ||
| if delegation_ceiling is None: | ||
| return |
There was a problem hiding this comment.
Session token skips budget_limits guard
Medium Severity
The new budget-limits delegation helper returns immediately when delegation_ceiling is None. CLI session tokens on personal keys hit that path while only scalar max_budget is blocked nearby, so a session caller can mint a personal key with large budget_limits and no team_id.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c149544. Configure here.
…(LIT-4094) Builds on the VERIA-392 fix (#31469). The new _check_budget_limits_delegation_ceiling helper only compared windows against the caller's ceiling with `>`, but NaN > x is always False, so a non-admin (or admin) submitting `budget_limits=[{1d, NaN}]` slipped past the comparison and got persisted. Once persisted, `spend > NaN` is also always False so the window's budget enforcement is permanently inert for the lifetime of the key. Add a math.isfinite check at the top of the helper, before the role / ceiling early returns. Hygiene is not role-dependent — even a fat-fingered proxy admin payload with NaN is rejected, because the poisoned value would silently disable enforcement. Six new regression tests (NaN / +inf / -inf for both non-admin and admin); all six mutation-killed against the PR #31469 base.
…s (VERIA-392)
/key/generate validated the caller's delegation ceiling against
data.max_budget only. The per-window entries in data.budget_limits
bypassed the check, so a non-admin caller could mint a key whose
1-day window vastly exceeded their own max_budget. The data.permissions
dict also went unvalidated for non-admin callers, so they could
self-grant capabilities like allow_pii_controls (and on Enterprise,
get_spend_routes).
Both gates now live in _common_key_generation_helper, covering
/key/generate and /key/service-account/generate. The existing empty
{} default on permissions still passes for non-admin callers.
9581582 to
68f7688
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
2 issues from previous reviews remain unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 68f7688. Configure here.
|
@greptileai please review |
7a1ba95
into
litellm_internal_staging
…s (VERIA-392) (BerriAI#31469) /key/generate validated the caller's delegation ceiling against data.max_budget only. The per-window entries in data.budget_limits bypassed the check, so a non-admin caller could mint a key whose 1-day window vastly exceeded their own max_budget. The data.permissions dict also went unvalidated for non-admin callers, so they could self-grant capabilities like allow_pii_controls (and on Enterprise, get_spend_routes). Both gates now live in _common_key_generation_helper, covering /key/generate and /key/service-account/generate. The existing empty {} default on permissions still passes for non-admin callers.
…erate (LIT-4092) VERIA-392 (#31469) introduced `_check_permissions_caller_permission` and wired it into `/key/generate`. Two follow-on gaps stayed open `/key/update` never called the helper. A non-admin owner on the personal-key fast path could set any `permissions` on their own key, including self-granting `get_spend_routes` or arbitrary guardrail toggles `/key/regenerate` also never called the helper. Regenerate routes through `prepare_key_update_data`, not `_validate_update_key_data`, so the gate lives at the handler and it was missing The helper additionally used `if not permissions: return`, which on update/regenerate paths let `permissions: {}` and `permissions: null` from a non-admin silently clear an admin-set capability such as `enable_llm_guard_check` Gate now keys on `"permissions" in data.model_fields_set`, so an explicit `{}` / `null` on update/regenerate is caught. On create paths the semantics are unchanged for the legitimate omit-case (Pydantic does not add omitted fields to `model_fields_set`, so the model-level default flows through); an explicit `permissions: {}` on `/key/generate` now also 403s for non-admins, closing the same clear-attack primitive on the create path Verified on a live proxy + real Postgres for `/key/update` and, with a LITELLM_LICENSE, for `/key/regenerate`. Alice self-mints a personal key on her own auth; on unfixed code she successfully self-grants `get_spend_routes` and clears admin's `enable_llm_guard_check` via `permissions: {}` on both endpoints; on fixed code both are 403 and admin's capability survives. Controls: admin still writes any `permissions` value including the explicit empty clear; alice's `/key/update` on non-permissions fields still hits the personal-key fast path; alice's `/key/generate {}` still succeeds Regression tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate Six attack-vector tests fail on the pre-fix HEAD; all eight pass on this commit. Full mapped test file (338 tests) green
…erate (LIT-4092) VERIA-392 (#31469) introduced `_check_permissions_caller_permission` and wired it into `/key/generate`. Two follow-on gaps stayed open `/key/update` never called the helper. A non-admin owner on the personal-key fast path could set any `permissions` on their own key, including self-granting `get_spend_routes` or arbitrary guardrail toggles `/key/regenerate` also never called the helper. Regenerate routes through `prepare_key_update_data`, not `_validate_update_key_data`, so the gate lives at the handler and it was missing The helper additionally used `if not permissions: return`, which on update/regenerate paths let `permissions: {}` and `permissions: null` from a non-admin silently clear an admin-set capability such as `get_spend_routes` Gate now keys on `"permissions" in data.model_fields_set`, so an explicit `{}` / `null` on update/regenerate is caught. On create paths the semantics are unchanged for the legitimate omit-case (Pydantic does not add omitted fields to `model_fields_set`, so the model-level default flows through); an explicit `permissions: {}` on `/key/generate` now also 403s for non-admins, closing the same clear-attack primitive on the create path Verified on a live proxy + real Postgres for `/key/update` and, with a LITELLM_LICENSE, for `/key/regenerate`. Alice self-mints a personal key on her own auth; on unfixed code she successfully self-grants `get_spend_routes` and clears admin's `get_spend_routes` via `permissions: {}` on both endpoints; on fixed code both are 403 and admin's capability survives. Controls: admin still writes any `permissions` value including the explicit empty clear; alice's `/key/update` on non-permissions fields still hits the personal-key fast path; alice's `/key/generate {}` still succeeds Regression tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Seven attack-vector tests fail on the pre-fix HEAD; all nine pass on this commit. Full mapped test file (339 tests) green
…te (LIT-4092) The `_check_permissions_caller_permission` helper introduced in #31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
…te (LIT-4092) (#31810) The `_check_permissions_caller_permission` helper introduced in #31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
…te (LIT-4092) (BerriAI#31810) The `_check_permissions_caller_permission` helper introduced in BerriAI#31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
…s (VERIA-392) (BerriAI#31469) /key/generate validated the caller's delegation ceiling against data.max_budget only. The per-window entries in data.budget_limits bypassed the check, so a non-admin caller could mint a key whose 1-day window vastly exceeded their own max_budget. The data.permissions dict also went unvalidated for non-admin callers, so they could self-grant capabilities like allow_pii_controls (and on Enterprise, get_spend_routes). Both gates now live in _common_key_generation_helper, covering /key/generate and /key/service-account/generate. The existing empty {} default on permissions still passes for non-admin callers.
…te (LIT-4092) (BerriAI#31810) The `_check_permissions_caller_permission` helper introduced in BerriAI#31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
* fix(realtime): stop second Gemini Live setup, retry hung handshake, close guardrail bypass (#31519)
* fix(realtime): stop sending a second Gemini Live setup on follow-up session.update
Gemini Live (BidiGenerateContent) accepts setup as the first-and-only client
message; a second setup closes the socket with 1007 Request contains an invalid
argument. The AI Studio Gemini path forwarded every client session.update after
the first as a follow-up setup, and GA clients (pipecat) send several while
configuring the session, so the second one tore the session down before the
first turn. Callers saw silence after the first response, exponential per-turn
latency from reconnect/retry churn, and intermittent 1011 errors.
Drop subsequent session.updates instead of resending setup, matching what the
Vertex subclass already does. Tools and instructions must ride on the first
session.update before any conversation content.
Adds regression tests covering the plain follow-up, a follow-up that adds tools
(the case the previous identical-only dedup still forwarded), and the guardrail
create_response=False warning path.
* fix(realtime): retry the backend open handshake instead of failing with 1011
The upstream Live API open handshake (e.g. Gemini Live) intermittently hangs;
waiting longer never recovers a hung attempt, but a fresh attempt almost always
connects in ~1s. The proxy opened the backend websocket once with the default
open_timeout and no retry, so a single slow handshake surfaced to the caller as
a fatal 1011 internal error and dropped the call.
Bound each open attempt with a short open_timeout and retry; a bounded attempt
that already timed out spaces out the next try, so no backoff is needed.
Deterministic handshake-status rejections (auth/4xx) are not retried, and the
retry only ever wraps the open, never a live session.
Adds tests for retry-then-succeed, raise-after-max-attempts, and
no-retry-on-auth-failure.
* fix(realtime): close guardrail bypass + surface handshake status; drop obsolete tests
Three review fixes on the Gemini Live realtime path.
Transcription-guardrail bypass: Gemini Live rejects a second setup (1007), so once
the initial setup is sent the guardrail's automaticActivityDetection.disabled=true
can no longer be delivered as a follow-up session.update. With that follow-up now
dropped, the model's auto-response stayed enabled and a realtime_input_transcription
guardrail was bypassed (the model answered before the proxy could gate the turn).
Fold the disable into the one-and-only setup instead: the handler injects it into
the auto-sent setup (gemini_live_defer_setup false) and _send_to_backend injects it
into the deferred first setup. OpenAI sessions accept follow-up updates and are left
untouched.
Backend handshake status: the open-retry treated only InvalidStatusCode as
deterministic; websockets>=15 raises InvalidStatus for a rejected client handshake,
so a 401/403 fell into the broad WebSocketException branch and was retried before
the caller closed the client with 1011 instead of the upstream status. Treat both as
non-retryable.
Obsolete tests: the four tests asserting a follow-up session.update is merged and
re-sent as a second setup asserted behavior that crashes Gemini Live with 1007
(verified directly against the API). Removed; the drop is covered by new regression
tests.
* style(realtime): reformat changed files to ruff line-length 120
Post-merge with litellm_internal_staging, which unified ruff format width to 120
(#31518). The realtime change set was formatted at 88, so the changed lines
tripped the whole-file ruff format check. Reformat with ruff 0.15.3 at the repo's
120 width; no logic changes.
* feat: declarative fallback generalizations for unknown models (#29718)
* feat: declarative fallback generalizations for unknown models
Unknown or newly-released models previously degraded (missed cost lookups,
wrong supports_* flags, broken provider routing) and were patched with one-off
hardcoded regexes scattered across Python. This adds a single data-driven source
of truth: a fallback_generalizations block in model_prices_and_context_window.json
holding ordered, case-insensitive regex rules that map a model name to the
metadata to apply when it has no exact entry.
A new fallback_generalizations module owns the rules and a compiled-regex cache
that is built once and invalidated on reload, so the O(n) scan runs only on a
cache miss. get_llm_provider now routes an otherwise-unknown model via the first
matching rule's litellm_provider, replacing the hardcoded _CLAUDE_PATTERN and
_matches_claude_model_pattern. _get_model_info_helper falls back to a matching
rule's model_info after the exact lookups miss, so get_model_info and the
supports_* helpers resolve unknown models from the same rule. get_model_cost_map
extracts the block out of the returned map, and the integrity check now counts
real model entries (excluding reserved meta keys) so the new key cannot mask a
genuinely shrunk upstream file.
The top level of the file stays a flat map of models so existing litellm releases
that fetch the live file keep working and keep receiving updates; the block ships
in both the root file and the bundled backup. An anthropic-claude rule reproduces
the old future-claude routing and additionally supplies capability flags and a
context window
https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo
* refactor(anthropic): derive adaptive-thinking from a version threshold; harden generalizations
Replace the per-minor-version _is_claude_4_6_model / _is_claude_4_7_model substring
matchers with a single _claude_version_at_least predicate that parses the Claude
family version from the model name and compares against 4.6. This covers 4.8/4.9/5.x
without a code change (the old matchers missed 4.8 entirely) while keeping an explicit
supports_adaptive_thinking flag authoritative when present, so there is one source of
truth. The two direct call sites in the chat transformation now route through
_is_adaptive_thinking_model instead of the deleted matchers.
Also address review feedback on the generalizations module: return a copy of the
matched model_info so a future caller cannot mutate the compiled-rule cache, document
that patterns are matched with re.search and must anchor with ^ and $, and reindent
the fallback_generalizations block to the file's 2-space style in both JSON files.
https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo
* fix(anthropic): surface adaptive-thinking from the cost map; fix date misparse
supports_adaptive_thinking shipped in the model cost map but was never declared
on ModelInfo nor copied during construction, so get_model_info (and the supports_*
factory) silently dropped it for every provider-prefixed or generalized name; only
a bare base entry resolved. Wire it through ModelInfo like the other capability
flags and backfill the flag onto the genuine Claude 4.6/4.7/4.8 entries across
providers so the data, not code, declares the capability. The anthropic-claude
fallback rule also carries the flag (and now accepts a dotted minor, e.g. 4.6) so
an unmapped future Claude degrades to adaptive thinking without a code change.
Tighten the Claude version parser so an eight-digit date suffix
(claude-opus-4-20250514, the non-adaptive Opus 4.0) is no longer read as minor
4.20250514. The cost map stays authoritative; the version check is only a fallback
for provider-prefixed names (bedrock/invoke routes, -v1-less ids) that resolve to
no mapped entry and so cannot be reached by an exact lookup or the bare-name rule.
https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo
* fix(anthropic): date-safe adaptive-thinking version fallback, conservative fallback pricing, ruff strict gate
Reconcile adaptive-thinking detection after merging litellm_internal_staging.
Keep the cost-map resolver (_supports_model_capability) as the source of truth and
add a date-safe opus/sonnet/haiku >= 4.6 name version as a fallback for
provider-prefixed ids the cost map cannot resolve (e.g.
bedrock/invoke/us.anthropic.claude-opus-4-6). A two-digit cap on the minor keeps an
eight-digit date suffix from being misread as a minor version, so the dated Claude
4.0 release stays non-adaptive
Price the shipped anthropic-claude fallback rule at the Opus tier so an unknown or
newly released Claude is over-costed rather than billed as free
Drop the module-level global state in fallback_generalizations (PLW0603) in favor of
a small registry object, and switch its annotations plus the new utils helper to
builtin generics (UP006), bringing the ruff strict-rule totals back under ceiling
* refactor(anthropic): drive adaptive-thinking version gate from a declarative rule
Replace the bespoke _claude_version_at_least heuristic with a version-gated fallback_generalizations rule. Unmapped Claude ids now resolve adaptive thinking purely from the cost map: an explicit entry, or the new self-contained anthropic-claude-adaptive-thinking rule that matches opus/sonnet/haiku >= 4.6 (covering 5.x, 6.x and beyond with no code change). New families ship via Price Data Reload instead of a code edit
The rule carries the same Opus-tier pricing as the broad anthropic-claude rule plus supports_adaptive_thinking, and is matched first; the broad rule stays version-neutral, so an unmapped >= 4.6 Claude resolves to full pricing and the adaptive flag from one rule, while a sub-4.6 alias such as claude-opus-4-0 is still priced yet stays non-adaptive. The regex caps the minor at two digits so a dated 4.0 id (...-4-20250514) is never read as a >= 4.6 minor
* refactor(anthropic): dedupe adaptive-thinking rule via declarative extends
The version-gated anthropic-claude-adaptive-thinking rule duplicated the
broad anthropic-claude rule's entire Opus-tier price block because rules do
not merge: first match wins and returns one rule's whole model_info, so the
adaptive rule had to be self-contained.
Add a declarative extends field to fallback_generalizations: a rule names a
parent and inherits its model_info, with its own keys overriding. Inheritance
is resolved once at install time against each rule's raw model_info, so the
adaptive rule now carries only its delta (supports_adaptive_thinking) and
inherits pricing from the broad rule. Runtime matching, provider routing and
gating are unchanged; the broad rule stays anchored and first-match-wins still
holds.
* docs(anthropic): add ignored description key documenting each generalization regex
* fix(anthropic): drop fabricated pricing from the anthropic-claude fallback rule
Per review feedback, the base rule no longer carries input/output/cache costs, and the
adaptive-thinking rule that extends it inherits that no-pricing model_info. Pricing an
unmapped model at a guessed tier reports a confidently-wrong cost without the caller
knowing; dropping it keeps the standard unpriced behavior (zero, not a fabricated
number) so a missing price stays visible. The rules still supply provider routing,
context window, and capability flags, so a brand-new Claude can still be called and its
capabilities (including adaptive thinking for >= 4.6) resolved. Description and tests
updated to match
* chore(router): simplify unknown-model error message construction
The error string is already produced by the f-string interpolation; the
trailing .format() call on it was redundant. Add a regression test that
the message renders the model name verbatim.
* chore(router): drop unreachable unknown-model error branch
get_model_list always returns a list, never None, so the is-None branch
could not execute. Collapse to the single reachable message.
* test(videos): add 1:1 test file scaffold for videos component paths (#30631)
Keep only video test files and CI workflow entries; drop unrelated
production code and non-video test changes from this branch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(batches): add 1:1 test file scaffold for batches component paths (#30529)
* test(batches): add 1:1 test file scaffold for batches component paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add harness test for create batch endpoint
* Add retrieve endpoint harness tests
* Add list endpoint harness tests
* Add cancel endpoint harness tests
* Add cancel endpoint harness tests
* Add test for litellm/batches/main.py
* Add test for litellm/tests/test_litellm/batches/test_batch_utils.py
* Add handler and transformation tests for all providers
* Fix: run batches tests in cicd
* fix(tests): remove azure/__init__.py that shadowed azure namespace package
Adding __init__.py to tests/test_litellm/llms/azure/ caused pytest to
insert tests/test_litellm/llms/ into sys.path[0], making our empty
azure/ dir shadow the real azure-identity namespace package. Any test
that patched azure.identity.* would then fail with AttributeError.
* style(tests): apply ruff format to test_batch_utils.py
Base migrated the formatter from black to ruff format (#31317); reformat the
batches scaffold test file to match.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* feat(a2a): support a2a-sdk 1.x proxy routing for 0.3 and 1.0 agents (#30950)
* feat(a2a): support a2a-sdk 1.x proxy routing for 0.3 and 1.0 agents
Bump a2a-sdk to 1.x and wire send/stream through compat conversions so the proxy accepts A2A 1.0 JSON-RPC while preserving 0.3 wire clients.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add user controlled protocol version in agents
* Fix exeception mapping
* Fix a2a base url
* Add e2e test for a2a
* Fix lint
* Fix lint
* fix(a2a): harden card version detection and header isolation coverage
Use protocolVersion when inferring agent card wire format, assert distinct httpx cache keys in the header-isolation test, and suppress targeted basedpyright errors for optional SDK imports.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): suppress reportArgumentType for SDK compat types and fix streaming trace ID
- Add pyright: ignore[reportArgumentType] to SendMessageSuccessResponse id= and
result= args in _send_message, and SendStreamingMessageResponse root= in
_stream_messages, where a2a-sdk compat types diverge from basedpyright's
inferred signature, reducing the reportArgumentType count back within budget.
- Fix streaming trace ID in astream_a2a_message to use str(request.id) when
available instead of always generating a new uuid4(), restoring JSON-RPC
request-ID correlation for observability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style(a2a): expand SendStreamingMessageResponse for black formatting
Move pyright: ignore comment to the root= argument line so Black
accepts the expanded multi-line form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(a2a): fix 2 reportArgumentType errors without suppression
- main.py: narrow logging_obj from object|None to Optional[Logging] via
isinstance check before A2AStreamingIterator call, fixing the
"Logging | object" argument type mismatch at line 699.
- a2a_endpoints.py: extract response_dict with explicit isinstance(dict)
guard before passing to normalize_jsonrpc_response, fixing the
"LLMResponseTypes | dict[str, Any]" type mismatch at line 835.
- Remove spurious pyright: ignore comments added in previous commits that
were not suppressing the actual errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(a2a): rewrite upstream URL for 1.0 agent cards in getAuthenticatedExtendedCard
1.0 upstream agent cards store the endpoint URL in supportedInterfaces[0].url
rather than a top-level url field. The previous guard only rewrote url when
it existed at the top level, so after normalize_agent_card lowered a 1.0 card
to 0.3 the upstream internal address leaked into the url field of the 0.3
response.
Fix: rewrite both url and supportedInterfaces[0].url to the proxy address
before calling normalize_agent_card, ensuring the upstream address is never
visible to downstream clients regardless of the upstream card's wire format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: extend _served_version to all PascalCase methods; add direct httpx-client isolation proof
- _served_version now checks `_PASCAL_TO_WIRE` membership instead of two
hardcoded names, so GetTask/CancelTask/etc. are promoted to 1.0 wire format
alongside SendMessage — prevents mixed wire formats mid-session
- test_create_a2a_client_uses_fresh_httpx_client now asserts
a2a_client_a._litellm_httpx_client is not a2a_client_b._litellm_httpx_client
(direct proof that header bleed cannot occur), in addition to the cache-key
inequality check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: id:0 silently dropped in version_convert; explicit continue in stream retry
- version_convert.py: replace `request_id or ""` with
`str(request_id) if request_id is not None else ""` in both
_send_result_to and _stream_result_to; id=0 is valid JSON-RPC and
must not be coerced to "" which breaks response correlation
- main.py: add explicit `continue` after the A2ALocalhostURLError retry
in _execute_a2a_stream_with_retry so the control flow (retry → next
iteration → stream_succeeded guard) is unambiguous
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: preserve a2a retry and discovery card urls
* Fix black
* Fix test
* fix(a2a): avoid KeyError in discovery log after 0.3→1.0 card normalization
When a 0.3-style agent card is normalized to 1.0, the top-level url key is
replaced by supportedInterfaces; log the already-computed proxy_url instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): preserve taskId when lowering push notification config set params
Flatten 1.x create envelope fields before parsing into TaskPushNotificationConfig so 1.0 clients forwarding to 0.3 upstream keep taskId and config.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): ignore unknown fields in message/send proto fallback
ParseDict in _build_message_send_params now matches other inbound paths so 1.0 clients with extra proto fields are not rejected with -32602.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): normalize tasks/list params and response across protocol versions
Convert list task entries on the response path and lower ListTasksRequest params including status filters when forwarding 1.0 clients to 0.3 upstream.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): avoid reportArgumentType in _lower_list_tasks_params; use local var instead of _parse return
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(a2a): drop private SDK symbol in tasks/list status lowering
_lower_list_tasks_params imported _CORE_TO_COMPAT_TASK_STATE, a private
a2a-sdk symbol that could disappear on a patch release and silently break
status-filter lowering. Derive the 0.3 wire string from the public
protobuf enum name instead (TASK_STATE_<NAME> maps to the 0.3 value once
the prefix is dropped and underscores become dashes) and validate the
result against the 0.3 TaskState enum's own values via a fully-typed pure
helper. Behavior is unchanged for every state; unspecified or unrecognized
states still drop the filter. Adds parametrized regression tests covering
dashed wire values (input-required, auth-required) and the unspecified drop.
* fix(a2a): drop redundant push-notification envelope key; unify MessageToDict import
_flatten_create_push_notification_params used `config or pushNotificationConfig`,
which short-circuits so a co-present pushNotificationConfig key was never popped and
leaked into the flattened params. Pop both keys unconditionally and prefer config
when present. Adds a regression test on the helper that fails on the old leak.
Also import MessageToDict from a2a.compat.v0_3.conversions in _lower_list_tasks_params
to match every other conversion helper in the module instead of pulling it straight
from google.protobuf.json_format.
* fix(a2a): reject invalid message/stream params early with -32602
_handle_stream_message built MessageSendParams lazily inside the
stream_response() generator, so malformed 1.0 params surfaced as a generic
-32603 after the 200 status line was already committed. The non-streaming
path validates up front and returns -32602 (Invalid params). Validate
eagerly before returning the StreamingResponse and emit -32602 on failure
so both paths reject malformed params identically. Adds a regression test
asserting the streamed error code is -32602.
* fix(a2a): raise clear error when non-streaming send ends on an update event
_send_message fed the SDK iterator's last event straight into
SendMessageSuccessResponse, whose result only accepts Message or Task. A
non-standard upstream whose final event is a TaskStatusUpdateEvent or
TaskArtifactUpdateEvent made the response construction raise an opaque
pydantic ValidationError. Guard the converted result and raise a clear
RuntimeError instead, consistent with the no-response guard above it.
Adds regression tests for the Message happy path and the update-event
rejection via an injected fake client.
* test(a2a): lock in clean merged agent-card URL without PROXY_BASE_URL
Regression coverage proving _build_merged_agent_card produces no double
slash in supportedInterfaces[0].url when PROXY_BASE_URL is unset and
request.base_url carries a trailing slash. get_custom_url routes through
join_paths, which rstrips the base, so the f-string join stays clean.
* style(a2a): modernize type annotations to satisfy strict ruff budget
After merging the black->ruff-format migration from base, the A2A files
owned by this PR still used Optional[X]/quoted annotations that pushed
UP037/UP045 over their lowered ceilings. Convert to X | None, drop the
now-unnecessary quoted local annotation in _send_message, and remove the
imports left unused by the rewrite. Type semantics are unchanged.
* style(a2a): type a2a_endpoints dict params as dict[str, Any]
The merge with the formatter-migration baseline tightened the
reportUnknownArgumentType ceiling; bare dict annotations made every value
Unknown and pushed the codebase total over cap. Annotate the JSON-RPC
params, body, metadata, and litellm_params dicts as dict[str, Any] so
their values are typed, dropping the unknown-argument count back under the
ceiling. No behavior change.
* fix(a2a): guard localhost retry against a missing agent card
handle_a2a_localhost_retry rewrote the card URL and called create_client
with whatever agent_card it received. The caller resolves the card from
the SDK client (Optional), so a None card reached set_agent_card_url and
create_client, surfacing an opaque SDK error instead of a clear one. Add
an early RuntimeError guard mirroring the httpx-client check, drop the now
always-true card None-check on the stash line, and cover it with a
regression test.
* style(a2a): disable reportUnknownArgumentType in a2a-sdk boundary modules
The lint env type-checks without the optional a2a-sdk/protobuf installed, so
every call into the protobuf-generated compat conversions counts as an
Unknown-typed argument and the new A2A code pushed the codebase
reportUnknownArgumentType total over its ceiling. These three modules are
the A2A SDK boundary; turn the rule off file-wide with a documented reason
instead of scattering dozens of per-line ignores across every SDK call.
* fix(a2a): tolerate unknown fields when lowering 1.0->0.3; align streaming trace id
Two issues greptile flagged:
version_convert: the 1.0->0.3 lowering paths (_send_result_to, _task_to,
_stream_result_to) called ParseDict without ignore_unknown_fields=True, so a
1.0 upstream response carrying vendor extensions raised and best-effort fell
back to passing the un-lowered 1.0 shape to a 0.3 client. Set the flag to match
the agent-card path and every inbound path; unknown fields are now dropped and
the result is correctly lowered.
main.py: asend_message_streaming derived X-LiteLLM-Trace-Id from the JSON-RPC
request id, unlike asend_message which uses the logging object's
litellm_trace_id. Prefer the logging trace id (then request id, then a uuid) so
streamed and non-streamed calls correlate under the same trace.
Adds regression tests for both, including the stream-event lowering path.
* style(a2a): apply ruff format to a2a protocol and proxy modules
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* feat(proxy): add AES-256-GCM at-rest credential encryption with versioned format and re-encryption migration (#31215)
* feat(proxy): add AES-256-GCM at-rest credential encryption with versioned format and re-encryption migration
* test(proxy): add behavior scenarios for credential migration endpoints
* fix(proxy): scan covered tables in encryption check, fix CI lint and route types
* fix(proxy): migrate callback_settings credentials, clear CI lint/recursion gates, add encryption endpoint+CLI tests
* fix(proxy): correct dry-run/real-run migrated vs residual-legacy counters in config and SSO walkers
* fix(proxy): make callback-vars residual detection gate-independent in encryption check
* fix(ui): keep virtual-keys filters across delete and refresh (LIT-4080) (#31533)
* fix(ui): keep virtual-keys filters across delete and refresh (LIT-4080)
Filtering virtual keys by User ID and then deleting a key reset the filter to show all keys, and re-clicking Fetch did not re-apply it. The page ran two competing fetch paths: useKeys (React Query) fetched the page unfiltered while a separate useFilterLogic hook held its own filteredKeys list and, on any refresh, only re-applied Team and Organization client-side, silently dropping the User ID and Key Alias filters. Delete refreshed through the unfiltered useKeys path, so the filtered view collapsed back to everything
VirtualKeysTable now owns its filter state and feeds every filter (team, organization, key alias, user id, key hash) straight into the useKeys options, so the filters are part of the React Query key. Any refetch or invalidation re-runs the same filtered query, which makes the reset-on-delete bug structurally impossible. Free-text inputs are debounced with @tanstack/react-pacer, sorting and pagination are server-side, and changing a filter or sort resets to page 1
Delete now invalidates keyKeys.lists() from key_info_view, matching the create path, instead of prop-drilling a refetch; the window "storage" refetch effect is removed. The dual-path useFilterLogic hook (and its test) are deleted
Regression coverage: VirtualKeysTable threads an active User ID filter into the useKeys query and clears it on reset, useKeys encodes filter options in its query key so a filter change refetches, and key_info_view invalidates the keys list on delete
* refactor(ui): simplify virtual-keys table data flow
VirtualKeysTable now fetches its own teams and organizations via
useOrganizations and the existing all-teams query instead of taking them
as props, so the prop-drill through UserDashboard and the two page
callers (page.tsx, ApiKeysDashboard) is gone along with their redundant
organization state and fetch
Filter state collapses from a useState plus a useDebouncedState mirror
into a single source whose debounced copy is derived with
useDebouncedValue, and one typed toKeyListFilters adapter maps it to the
key/list query options. Behavior is unchanged; same 300ms debounce and
the same reset timing
The unused onSortChange/currentSort props and their sync effect are
removed since no caller passed them, leaving sorting fully internal
Adds a created_by_user alias-over-email regression test that fails if the
display precedence is swapped
* test(ui): add required last_active to useKeys mock fixtures
The KeyResponse type requires last_active, so the typed mockKeys
fixtures were missing it. Add it so the file type-checks cleanly.
* chore(ui): ratchet lint budgets after virtual-keys refactor
Deleting filter_logic.tsx and simplifying VirtualKeysTable lowered the
no-explicit-any (2026 to 2016) and complexity (128 to 127) counts, so the
eslint-metrics.json baseline was stale and failed the frontend-lint
budget gate. Regenerate it, and drop the now-dead filter_logic.tsx
suppression entry for the file this PR removed.
* fix(ui): show a loading state for data-backed filter dropdowns
The Team ID and Organization ID filters source their options from async
hooks (teams / organizations). While that data was still loading the
dropdowns rendered 'No results found', so they looked empty rather than
loading. Add an opt-in loading flag to FilterOption that the searchable
select surfaces as a spinner and a 'Loading...' empty state, and wire it
from the teams and organizations query loading states. While loading, the
filter no longer caches an empty initial-options list, so the real
options appear once the data arrives.
* perf(ui): load virtual-keys team filter from the fast v2 endpoint (#31638)
* perf(ui): load virtual-keys team filter from the fast v2 endpoint
The virtual-keys table sourced all teams through fetchAllTeams, which
hits the unpaginated /team/list. On a proxy with 125 teams that call
takes ~9.5s, so the Team ID filter and the team-alias/budget columns sat
empty for that whole window. The key list itself does not carry
team_alias or team_max_budget, so the table genuinely needs a team
lookup and cannot just drop the fetch.
Add useAllTeams, which pages the fast /v2/team/list to completion (~0.6s
per 100-team page, so ~1.2s for 125 vs ~9.5s), and point VirtualKeysTable
at it instead of fetchAllTeams. The allTeams shape, the filter searchFn,
the column lookups, and the loading indicator are all unchanged; only the
source endpoint changes. fetchAllTeams stays for its other callers.
* test(ui): tighten team-filter test readability and robustness
Address adversarial review of the added tests. Rename the single-page
useAllTeams test to match what it asserts (one request for a one-page
result) rather than implying it guards the early-return, and drop the
unread, misleading total: 125 from the mock page response. Scope the
created_by alias-over-email assertion to the key's table row so it checks
the visible cell value; the hover popover that also holds the email is
portaled out of the row, so the previous document-wide negative assertion
was relying on antd's lazy popover mounting.
* fix(ui): scope useAllTeams cache by access token
The previous /team/list query keyed on accessToken, so a user switch in
the same SPA session produced a distinct cache entry. useAllTeams dropped
that, so team IDs and aliases could be briefly reused across users until
the staleTime expired. Put accessToken back in the query key to restore
per-identity isolation, and add a regression test that a token switch
triggers a refetch rather than serving the cached list.
* fix(databricks): split parallel tool calls so each tool message follows tool_calls (#31633)
* fix(databricks): split parallel tool calls so each tool message follows tool_calls
Databricks OpenAI-compatible serving (e.g. GPT models) 400s with "messages with
role 'tool' must be a response to a preceeding message with 'tool_calls'" when an
assistant turn makes parallel tool calls. LiteLLM faithfully sends one assistant
message holding all tool_calls followed by one 'tool' message per result, so every
result after the first is preceded by another 'tool' message rather than the
assistant tool_calls message, which Databricks rejects.
Re-emit each result immediately after an assistant message that carries only its
matching tool_call, turning assistant(tool_calls=[A, B]), tool(A), tool(B) into
assistant(tool_calls=[A]), tool(A), assistant(tool_calls=[B]), tool(B). The
rewrite is a no-op when the turn is already valid (single call), the group is
incomplete, or ids don't line up, so no tool call is ever dropped. Scoped to
non-Claude models, matching the existing OpenAI-shaped transformation path.
* style(databricks): use builtin list generics in parallel tool-call split
Switch the List[...] annotations introduced by _split_parallel_tool_calls
to lowercase list[...] so the UP006 strict-rule budget stays within its
ceiling.
* fix(proxy): gate non-admin /key/generate budget_limits and permissions (VERIA-392) (#31469)
/key/generate validated the caller's delegation ceiling against
data.max_budget only. The per-window entries in data.budget_limits
bypassed the check, so a non-admin caller could mint a key whose
1-day window vastly exceeded their own max_budget. The data.permissions
dict also went unvalidated for non-admin callers, so they could
self-grant capabilities like allow_pii_controls (and on Enterprise,
get_spend_routes).
Both gates now live in _common_key_generation_helper, covering
/key/generate and /key/service-account/generate. The existing empty
{} default on permissions still passes for non-admin callers.
* perf(auth): gather independent pre-call budget-enforcement reads (#31604)
increment_spend_counters was parallelized in #31578, but the dominant
per-request cost under high concurrency is the pre-call budget enforcement
in common_checks, which still ran a Redis-first get_current_spend per scope
(team, team windows, key windows, org, tag, user, team member, end user)
one sequential await after another inside the auth span.
The per-scope reads target distinct counter keys with no cross-scope
ordering dependency, so they now run concurrently under asyncio.gather.
Key metadata.tags injection still runs before the gather so the tag budget
check sees it, and every scope settles before the first error in
scope-priority order propagates, preserving the previous rejection semantics.
Resolves LIT-4090
* fix(proxy): reject non-finite budget_limits windows on /key/generate (#31630)
Enforce that every `budget_limits[*].max_budget` is a finite number;
applies to every caller including proxy admin and runs before the
role / ceiling checks. Six parametrized regression tests cover NaN /
+inf / -inf for both non-admin and admin callers.
* fix(proxy): hard-reject CLI session token personal-key budget_limits (#31631)
Mirror the scalar `max_budget` guard in `_common_key_generation_helper`
for the per-window check: a CLI session token caller (carrying
`max_budget=None`) cannot set `budget_limits` on a personal key. Pass
`team_table` into the helper so it can detect the personal-key shape;
reject before the `delegation_ceiling is None` early return.
Four new regression tests cover the personal-key reject, the team-key
happy path, the team-key over-team-budget path, and the proxy-admin
exemption.
* refactor(types): host ObjectPermissionDict in litellm/types/ for SDK reuse
Defines the TypedDict mirror of LiteLLM_ObjectPermissionBase under
litellm/types/object_permission.py so SDK-side modules can adopt the
type without violating the SDK-must-not-import-from-proxy layering
rule. litellm/proxy/_types.py re-exports it for existing callers.
Supports the validator-surface retypes in the preceding proxy refactor
commit.
* fix: preserve normalized mcp permissions on key regenerate
* fix(vertex_ai/files): single media upload for batch files to fix 499s on large uploads (#31653)
* fix(vertex_ai/files): upload batch files in a single media request to fix 499s on large uploads
PR #31036 switched the vertex batch file upload from a single GCS media
upload to a chunked resumable session. The resumable path sends the body as
many sequential PUTs, each waiting a full round-trip to GCS before the next,
so a multi-GB upload accumulates hundreds of round-trips and overruns the
client/load-balancer request timeout, surfacing as 499s (client closed
connection) on files as small as 500MB. This was a regression from the
last-known-good commit, where the upload completed as one continuous request.
Revert the batch upload to a single uploadType=media request, but stage the
transformed payload to a temp file first so peak memory stays bounded (the
goal of the resumable rewrite) without the per-chunk round-trips. The temp
file is closed deterministically (TemporaryFile unlinks on close), not left
to the GC. The now-unused resumable chunked-upload plumbing is removed.
Also swap the per-row transform's stdlib json for orjson (parse + serialize),
which is ~4x faster on this hot path; the streaming body now emits compact
orjson bytes.
The request stays synchronous, so the returned file object is real and
POST /v1/batches keeps working immediately against the uploaded object.
Tests: single media request carries the whole payload with a real
Content-Length (no chunked transfer-encoding); failed upload raises; the
staged temp file is closed deterministically; byte-for-byte transform parity.
* test(vertex_ai/files): mock single media upload POST instead of removed resumable method
test_avertex_batch_prediction patched BaseLLMHTTPHandler._aresumable_chunked_upload, which was removed when the batch jsonl upload moved from a chunked resumable GCS session to a single uploadType=media request. Patch the raw httpx.AsyncClient.post that _astage_and_upload_media issues so the real staging, upload and response transform run while the GCS object response is mocked, and assert the media URL and Content-Type.
* fix(vertex_ai/files): forward request timeout to media upload, drop orjson, sort imports
Forward the per-request timeout through _stage_and_upload_media /
_astage_and_upload_media to the GCS POST. Every other upload branch forwards
it; the new media path was dropping it, so a caller-provided timeout was
silently ignored (the files path passes 600s by default, but a custom
request_timeout would not have reached this upload). Regression test asserts
the resolved timeout reaches the request (mutation-verified).
Revert the orjson swap in the batch transform: importing orjson at module load
in this core-path file broke `import litellm` on environments without orjson
(the Windows import test). Back to stdlib json; the upload leg dominates large
uploads anyway, so the transform-side win was marginal.
Fix import ordering in llm_http_handler.py (I001) introduced by the new imports.
* fix(vertex_ai/files): stream batch upload to GCS instead of staging to a temp file
Addresses a disk-exhaustion concern: staging the full transformed batch body to
a local temp file before the GCS request meant an authenticated user could fill
the proxy's temp volume with large concurrent uploads (on top of Starlette's
input spool).
GCS's simple/media upload accepts chunked transfer-encoding, so stream the
transform straight to the single media request instead. Each block is produced
on a worker thread (the transform never runs on the event loop) and sent
chunked, so the body is neither buffered in memory nor written to disk, and the
upload is still one continuous request (no per-chunk round-trips, no 499). Drops
the temp-file staging, the tempfile/IO imports, and Content-Length computation.
Regression test asserts the upload streams (chunked transfer-encoding, no
Content-Length) and creates no temp file; mutation-verified that reintroducing
staging fails it.
* fix(proxy): count only active users toward license seat limit (#31227)
* fix(proxy): count only active users toward license seat limit
SCIM-deactivated users (metadata.scim_active == false) are kept in LiteLLM_UserTable for audit and reactivation, but they were still counted toward the per-user license limit, so deactivating a user never freed a seat. Okta never sends a SCIM DELETE and Entra only hard-deletes well after deactivation, so deactivation has to be what frees the seat
Add UserRepository.count_billable_users(), which counts every row except those where metadata.scim_active is false (absent, null, and true all count), and route the user-create license gate, the free-SSO 5-user cap, and the enterprise /user/available_users display through it. A separate litellm_active_users Prometheus gauge reports the billable count while litellm_total_users keeps its original meaning so existing dashboards are unaffected
* fix(proxy): floor billable user count at zero
count_billable_users() runs two separate count queries (total, then deactivated). Under a burst of deactivations between them, the deactivated count can momentarily exceed the earlier total and produce a negative result, which would flow into is_over_limit as a negative and show a negative seat count in the display and gauge. Clamp the result to zero so a transient race can never yield a nonsensical negative; the value self-corrects on the next call
Addresses Greptile P1 on the PR
* refactor(proxy): count teams via TeamRepository in available_users
* style: ruff format changed files at line-length 120
* fix(mcp): resolve per-user OAuth identity authoritatively at the token endpoint (#31657)
* fix(mcp): resolve per-user OAuth identity authoritatively at the token endpoint
The OAuth token endpoint stored a user's per-server token under the identity
returned by _extract_user_id_from_request, which read only the Authorization
header and did getattr(cached, "user_id") on a raw user_api_key_cache lookup
with no model_type rehydration and no DB fallback. That silently returned None
in two common cases on a multi-replica gateway: the LiteLLM key arrives on
x-litellm-api-key (what MCP clients such as Claude Desktop and Claude Code
send) rather than Authorization, and a cross-replica cache hit deserializes to
a plain dict rather than a UserAPIKeyAuth, so getattr finds no attribute. When
it returned None the token was not persisted.
This was survivable until the authorization_code v2 migration began stripping
the caller's Authorization for migrated per-user OAuth servers and routing the
preemptive 401 existence check through the stored token, so a persist miss now
hard-fails: the egress challenges with 401 on every reconnect (the client sees
"rejected them on reconnect" or a successful connect with zero tools).
Resolve identity through get_key_object, the canonical resolver that reads the
cache with model_type and falls back to the DB, and accept the key from
x-litellm-api-key as well as Authorization. The silent persist skip is now a
warning. The caller-Authorization stripping stays as is, since reinstating it
would reopen the cross-user credential override it was added to prevent.
* fix(mcp): reject blocked or expired keys when resolving the token-endpoint identity
The OAuth token endpoint is unauthenticated, and get_key_object resolves a key row without the
blocked/expiry checks the main user_api_key_auth pipeline runs (that pipeline is bypassed here). So
a holder of a revoked or expired LiteLLM key could POST a valid upstream authorization code with
that key in x-litellm-api-key/Authorization and write or overwrite the stored per-user OAuth token
for that key's user. The cache-only resolver this replaced incidentally dropped blocked keys
(blocking purges the cache entry), so moving to the authoritative cache-then-DB resolution removed
that accidental shield.
Validate the resolved key before trusting its identity: return None when blocked or expired, so the
upsert is skipped. Deleted keys are already rejected, since get_key_object raises on a missing row.
Regression tests cover the blocked and expired cases and fail without the guard.
* refactor(ui): colocate search-tools into route-level _components (#31658)
* fix(passthrough): drop top-level additional_drop_params on /v1/messages (#31645)
* fix(passthrough): drop top-level additional_drop_params on /v1/messages
On the Anthropic Messages pass-through path, additional_drop_params only
stripped nested dotted paths, so plain top-level keys like `thinking` and
`context_management` were forwarded to the provider. Bedrock rejects these
with "Extra inputs are not permitted", returning a 400 to Claude App/CLI
even when the user configured `additional_drop_params: ["thinking"]`.
delete_nested_value already handles plain top-level fields, so route every
drop param through it and remove the nested-only filter. Fixes #25931.
* fix(passthrough): drop thinking for bedrock inference-profile ARNs on /v1/messages
Opaque Bedrock Application Inference Profile ARNs contain neither "anthropic"
nor "claude", so is_anthropic_claude_model returned False and the thinking
param was rewritten to reasoning_effort before additional_drop_params ran.
That made additional_drop_params: ["thinking"] a no-op for the converse-ARN
form, and the Bedrock Converse transform re-expanded reasoning_effort back into
additionalModelRequestFields.thinking, so the request 400'd.
Extend the thinking-translation gates to also accept bedrock ARNs via the
existing is_bedrock_arn_model helper, mirroring the cache_control path, so
thinking is preserved as thinking and additional_drop_params can drop it.
* fix(guardrails): scan file and document attachments with Model Armor (#31655)
The Model Armor guardrail only sent text extracted from user messages to
sanitizeUserPrompt, so harmful content inside attached PDFs, Office docs,
and CSVs reached the LLM unscanned. A file-only message had no extractable
text, so the pre-call and moderation hooks returned early and the document
was never submitted to Model Armor at all.
Wire inline document/file scanning into async_pre_call_hook and
async_moderation_hook. extract_file_attachments walks message content blocks
(OpenAI type:file file_data and Anthropic type:document source), decodes the
base64 bytes, maps the MIME type to a Model Armor byteDataType, and skips
remote URLs, bare file_id references, oversize files past the 4 MB limit, and
unsupported types. Each attachment is sent through the byte API and a
MATCH_FOUND blocks the request before it reaches the LLM.
Resolves LIT-4084
* fix: skip health check for semantic auto_router deployments (#31668)
* fix: skip health check for semantic auto_router deployments
auto_router/<name> deployments are semantic meta-routers that select among
real LLM deployments at request time. They have no LLM endpoint to probe.
The health check was passing model=auto_router/router_1 to get_llm_provider(),
which raised BadRequestError: "Unmapped LLM provider for this endpoint" because
auto_router is not a real LLM provider, causing these deployments to always
appear unhealthy and curl requests to hang.
Detect semantic auto_router deployments in _run_model_health_check and return
{} (healthy) without calling litellm.ahealth_check. Sub-strategies
(complexity_router, adaptive_router, quality_router) are excluded from this
fast path and continue to be health-checked normally.
* ci: trigger circleci
* fix(bedrock): drop unmappable Responses tools instead of failing the request (LIT-3858) (#31663)
* fix(bedrock): drop unmappable Responses tools instead of failing the request (LIT-3858)
When an OpenAI Responses request is routed to a Bedrock Converse Anthropic model,
litellm translates the tools array into Bedrock toolConfig. Responses built-in tool
types beyond function (web_search, image_generation, namespace, tool_search, custom)
have no Bedrock equivalent, and previously caused two failures.
A web_search tool is derived into a web_search_options param. Bedrock Anthropic
models do not list web_search_options in get_supported_openai_params, so the request
raised UnsupportedParamsError (HTTP 400) even though it never needed web search. The
derived param is now dropped on the Bedrock chat-completion bridge for models that
do not support it, scoped to Bedrock so other providers are untouched and without
requiring drop_params. Nova still keeps it since it maps to a nova_grounding systemTool.
The remaining non-function tools reached _bedrock_tools_pt and were emitted as junk
litellm_unnamed_tool_N toolSpecs with empty schemas, polluting toolConfig with tools
the model could hallucinate calls to. They are now dropped because they carry neither
an OpenAI function nor an Anthropic input_schema, while mappable function and
input_schema tools survive untouched.
* refactor(responses): drop derived web_search_options via provider config
Greptile flagged that the LIT-3858 fix put Bedrock-specific logic in the
generic Responses->Chat Completion bridge: it imported AmazonConverseConfig
and branched on custom_llm_provider.startswith("bedrock").
Read web_search_options support from each provider's own
get_supported_openai_params instead, so the bridge stays provider-agnostic
and Bedrock capability knowledge lives in the Bedrock config that already
owns it. Behavior is unchanged for the cases the PR targeted (Bedrock
Anthropic drops, Bedrock Nova and OpenAI keep) and now generalizes correctly
to any provider whose config does not support the derived param.
Add a Cohere regression test proving the drop is provider-agnostic; it fails
under the old bedrock-only check and passes now.
* fix(responses): drop derived web_search_options for bedrock_converse alias
Greptile/T-Rex caught that the provider-agnostic drop regressed the
bedrock_converse route: get_supported_openai_params did not map the
bedrock_converse alias (only "bedrock"), so it returned None (unmapped) and
the derived web_search_options was forwarded for
model="bedrock/converse/us.anthropic.claude-sonnet-4-6",
custom_llm_provider="bedrock_converse" instead of being dropped. The previous
startswith("bedrock") check happened to match the alias.
Map bedrock_converse through AmazonConverseConfig in get_supported_openai_params,
mirroring the existing ["bedrock", "bedrock_converse"] pairing in
_strip_model_name. Add regression tests at both levels: the alias now drops the
derived param for Anthropic Converse models, still keeps it for Nova, and the
helper resolves identically to "bedrock".
* ci(linting): generate prisma client before basedpyright typecheck (#31673)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(proxy): emit x-litellm-response-cost header on /messages and /generateContent (LIT-4076) (#31675)
* fix(proxy): emit x-litellm-response-cost header on /messages and /generateContent (LIT-4076)
The Anthropic /v1/messages and Google native :generateContent routes return
TypedDict results (AnthropicMessagesResponse, GenerateContentResponseBody) that
are plain dicts at runtime and cannot hold a _hidden_params attribute. The cost
is computed by update_response_metadata, but ResponseMetadata.apply() only
persists _hidden_params back when the result object has that attribute, so for
those two routes the computed response_cost was dropped. The non-streaming
header build in base_process_llm_request then saw an empty response_cost and
get_custom_headers filtered the x-litellm-response-cost header out, even though
the other x-litellm-* headers still appeared.
The non-streaming success path now recovers the cost from the logging object
when the response cannot carry _hidden_params, preferring the value already
stored in model_call_details and recomputing from the same calculator only when
it has not been stored yet. Object responses (ModelResponse, ResponsesAPIResponse)
keep their existing behavior, so chat/completions, /responses, and the Anthropic
error path that intentionally emits a zero cost are unaffected. Streaming stays
out of scope because the header is emitted at stream start, before the cost is
known.
* fix(proxy): also recover response cost header for /generateContent responses with _hidden_params (LIT-4076)
* fix(proxy): compute generateContent response cost synchronously so cost header is emitted (LIT-4076)
* fix(lint): suppress BLE001 on generate_content cost normalization guard
The defensive blind except keeps cost normalization from ever breaking the
response path; mark it noqa so it does not breach the strict-rule budget.
* fix(mcp): support client_secret_basic for upstream OAuth token endpoints (#31635)
The MCP gateway authenticated to upstream OAuth token endpoints only with
client_secret_post (client_secret placed in the POST body). Providers that
require HTTP Basic client authentication (client_secret_basic, the OIDC
default) reject that with invalid_client, which surfaced as a 500 on the
/<server>/token exchange and broke both the initial authorization_code
exchange and refresh.
Add a per-server token_endpoint_auth_method ("client_secret_basic" |
"client_secret_post") and a single helper that builds the right headers and
body for the configured method, then route every upstream token-endpoint POST
through it: the inbound exchange and refresh in discoverable_endpoints, the v1
per-user refresh in db, the v2 authorization_code refresher, the M2M
client_credentials fetch, and the RFC 8693 token exchange. The default stays
client_secret_post so existing servers are unaffected; basic sends
Authorization: Basic base64(form-urlencode(client_id):form-urlencode(secret))
per RFC 6749 section 2.3.1 and omits the secret from the body.
client_secret_basic is a confidential-client method, so a server configured for
it with a missing client_id/secret raises rather than silently downgrading to a
body request (no-silent-fallback); the inbound endpoint maps that to a 400 and
the refresh paths to a failed-refresh / needs-reauth. A secretless client_id
under the default method stays valid for public clients authenticating with PKCE.
Resolves LIT-4091
* feat(cost_calculator): log per-token-type reasoning and cache cost breakdown (#31623) (#31686)
Reasoning-token cost was computed but folded into output_cost, and cache
cost was only populated from the top-level cache_read_input_tokens attribute,
so providers that report cache tokens under prompt_tokens_details (Gemini,
OpenAI, Vertex) never got a cache breakdown.
Adds a provider-agnostic get_token_type_cost_breakdown helper that derives
reasoning, cache-read and cache-creation cost from the normalized usage object
using the same rate-resolution primitives as the total-cost path, so the
breakdown reconciles with the totals. completion_cost stores these via
set_cost_breakdown, surfacing reasoning_cost (new), cache_read_cost and
cache_creation_cost in StandardLoggingPayload.cost_breakdown and the spend logs.
Co-authored-by: Kunal Nayyar <48790070+kunal2002@users.noreply.github.com>
* fix(anthropic): drop unsignable thinking blocks and allow null signature in logging (LIT-4007) (#31654)
* fix(anthropic): drop unsignable thinking blocks and allow null signature in logging (LIT-4007)
Open-source reasoning models (DeepSeek-R1 and distills, Qwen3/QwQ, IBM
Granite 3.2 via vLLM/Ollama/OpenRouter/DeepSeek) return reasoning_content
with no Anthropic-style signature, which LiteLLM represents as a thinking
block with a null signature.
Two failures resulted. First, ChatCompletionThinkingBlock.signature was a
required str, so building the StandardLoggingObject raised a ValidationError
on signature=None and the success log record was silently dropped while the
request still returned 200; relaxing it to Optional[str] lets the log build.
Second, replaying such a turn to a real Anthropic model forwarded the
null-signature thinking block unchanged and Anthropic rejected it with
400 thinking.signature.str; since Anthropic verifies the signature
cryptographically, a null, empty, or missing signature cannot be repaired,
so anthropic_messages_pt now drops the unsignable thinking block while
preserving the assistant text and keeping genuinely signed blocks.
* style: use builtin generics for thinking-block filter helpers
* fix(ui): regenerate schema.d.ts for nullable thinking-block signature
* fix(ui): revert Duration and TTFT column widths to default
The explicit 90px/80px sizes were too narrow for the Duration (s) and
TTFT (s) headers once the sort arrows were factored in, cramping the
header labels. Dropping the size lets these two columns fall back to the
default width like before
* fix(ui): revert Request ID width to default, tighten Session ID
Drop the explicit size on Request ID so it falls back to the default
width like the other reverted columns. Narrow Session ID from 160px to
120px since its truncated value needs less room
* chore(docs): remove docs accidentally committed to litellm repo (#31691)
Docs live in BerriAI/litellm-docs; these four files were swept into the
repo by unrelated PRs. The Crusoe provider and XecGuard guardrail pages
are migrated to litellm-docs (BerriAI/litellm-docs#438); plugin_architecture.md
is already covered there by docs/proxy/plugins.md, and the orphaned image
was referenced by no doc.
* chore: remove _experimental/out (#31546)
* chore: remove _experimental/out
* fix(ci): recreate _experimental/out before copying UI build output
The build scripts cp the Next.js output into litellm/proxy/_experimental/out,
which was removed from git. cp failed because the target directory no longer
existed; mkdir -p recreates it before the copy.
* fix(proxy): make UI serving resilient to a missing _experimental/out
Removing the committed UI export means the source/test tree no longer
ships litellm/proxy/_experimental/out. Three things assumed it was always
present and broke once it was gone:
- get_favicon hard-coded the built favicon path and 404'd without it; it
now falls back to the bundled swagger/favicon.ico
- the /_next and /ui static mounts raised at construction when the export
was absent, so the whole UI-setup block was swallowed and no mounts
registered; they now use check_dir=False
- _restructure_ui_html_files was a nested function only exposed as a
module attribute when that block happened to succeed; it is now a real
module-level function
test_admin_ui_export_serves_nested_extensionless_routes validated the
committed artifact, whose premise this PR removes; it now drives the same
MCP OAuth callback restructure guarantee through a synthetic export.
* chore(greptile): ignore generated _experimental/out so review fits the file limit
* Revert "chore(greptile): ignore generated _experimental/out so review fits the file limit"
ignorePatterns is applied after Greptile counts the files changed, so it
does not bring the diff under the file limit; the config had no effect.
* feat(prometheus): add litellm_total_overhead_latency_metric (SDK overhead + guardrails) (#31593)
litellm_overhead_latency_metric only covers the SDK wrapper window and excludes
proxy guardrails. Add a histogram that sums SDK overhead plus pre/post-call
guardrail durations (during-call excluded since it runs concurrently with the LLM
call, alongside logging_only and MCP modes that never block the response),
recorded next to the existing overhead metric with the same labels and buckets.
No existing metric's value is changed.
* chore: shift CI lint left with an opt-in `make pre-commit` and CLAUDE.md rule (#31544)
* chore: shift CI lint left with a pre-commit hook and CLAUDE.md rule
Add an opt-in pre-commit hook (.githooks/pre-commit, active after
make install-hooks) that runs the CI-equivalent checks against staged
files: make lint for Python, prettier plus eslint for the dashboard,
and a gen:api drift check for the proxy OpenAPI types. Document the
same expectation in CLAUDE.md so reds surface locally instead of in CI.
* fix: make `make lint` isomorphic to the CI lint job
`make lint` diverged from test-linting.yml in ways that produced both
false reds and false greens: its format-check ran over the whole repo
(CI scopes it to changed files vs the base), its ruff-strict budget ran
in absolute mode (CI runs it as a delta vs base), and it omitted the
type-discipline gate entirely. Recompose `lint` to replay CI's exact
sequence: diff-scoped ruff format check, whole-tree ruff check, the
strict / type-discipline / basedpyright budgets as a delta resolved the
same way CI resolves it (merge-base with origin/litellm_internal_staging),
then circular-import and import-safety. Factor the repeated base fetch
into one shared prerequisite so the chain hits the network once.
Align the pre-commit hook's eslint invocation with the CI frontend-lint
job (`--pass-on-unpruned-suppressions`) and fix the CLAUDE.md guidance to
point at the diff-scoped frontend commands instead of the whole-folder
npm scripts, which are broader than CI.
* fix(githooks): make pre-commit 1:1 with CI frontend-lint, lint, and type-gen
The shift-left pre-commit hook diverged from the CI jobs it claims to mirror, so a clean commit did not actually mean a green CI lint.
The dashboard block only ran prettier and eslint over js/jsx/ts/tsx/mjs/cjs, but CI's frontend-lint runs prettier over a wider set (also json, css, scss, md, mdx, yml, yaml, html) and additionally gates the whole-folder eslint lint budgets via scripts/check-lint-budgets.mjs. The hook now mirrors that split and runs the budget check, so a dashboard commit that passes locally passes the job.
The API-types block ran npm run gen:api without LITELLM_PYTHON, so it shelled out to the system python3 which has no litellm installed and always failed with a false 'could not regenerate API types' red. It now passes LITELLM_PYTHON="uv run --no-sync python" the way check-ui-api-types.yml does.
make lint format-checks the files in origin/base...HEAD, which at pre-commit time predates the staged change, so a brand-new commit's formatting went unchecked. The Python block now also runs ruff format --check over the staged litellm files directly to cover that case, and its trigger is scoped to staged litellm/ files (the only tree CI's lint job inspects) so a tests-only or scripts-only commit skips the slow make lint instead of wasting time on a run that could not catch anything.
CLAUDE.md's shift-left rule was cut off mid-sentence and understated the frontend checks; it now describes all three gates accurately and points agents at make install-hooks to run them automatically before each commit.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(githooks): scope the API-types check to all of check-ui-api-types.yml's triggers
spec_files was filtered from the staged Python files, so the gen:api drift
check only fired for .py changes under litellm/proxy or litellm/types. CI's
check-ui-api-types.yml triggers on any file under those directories (Prisma
schema, configs) plus the generator script and the dashboard package files,
so a non-Python proxy/types change could pass the hook and still fail CI.
Match the workflow's full trigger set instead.
* fix(pre-commit): run prisma generate before gen:api to mirror CI
* refactor(githooks): run shift-left lint via on-demand make pre-commit, not an auto-firing hook
The pre-commit hook ran make lint plus the dashboard eslint budgets, which are minutes of work (basedpyright over litellm/, a whole-folder eslint . pass at ~40s). Wiring that into core.hooksPath via make install-hooks meant every human commit, not just an agent's, paid that cost, which is real friction for interactive committers.
Move the staged-file checks out of .githooks/ into scripts/pre_commit_lint.sh and expose them as make pre-commit, and keep .githooks/ to only the fast Conventional Commits / Branches hooks so make install-hooks no longer makes commits slow. Agents run make pre-commit right before each commit (CLAUDE.md instructs this), so the slow gates fire only for the commits an agent is making and never auto-fire for a human typing git commit. The script stays hook-compatible for anyone who still wants it to fire automatically via a symlink.
Preferred this over sniffing an agent env var to auto-fire only for agents: that is fragile (misses agents when the var is unset, fires on humans when it leaks into their shell, and silently no-ops a hook a human deliberately installed), whereas an on-demand command achieves the same humans-never, agents-per-commit outcome deterministically.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(pre-commit): run make lint last so it can't prune the proxy deps gen:api needs
make lint's install-dev prerequisite runs uv sync --frozen, which prunes the proxy extras (prisma, websockets, ...) from the venv. With the Python block running first, the subsequent API-types block then failed: gen:api imports litellm.proxy.proxy_server, which needs those deps, so every litellm/proxy change (the main trigger for the API-types check) hit a false 'could not regenerate API types' red. Run the dashboard and API-types blocks before the Python block so gen:api sees an intact env; CI is unaffected because there the lint and check-ui-api-types jobs run in separate environments.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix: make CLAUDE.md more concise
* fix(makefile): give make lint the CI lint env and stop it pruning the venv
make lint diverged from test-linting.yml's lint job in two ways: it never generated the Prisma client (so basedpyright resolved the DB wrappers as Unknown, drifting from CI's counts), and its bare uv sync --frozen pruned the proxy extras (prisma, websockets, ...) out of the venv on every run, which broke the gen:api step that imports litellm.proxy.proxy_server and left a dev unable to run the proxy until re-syncing.
Add a lint-install target that mirrors the job's environment (the proxy-dev group plus prisma generate) and runs before the checks, and make …


Relevant issues
Linear ticket
Resolves LIT-4072
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
/key/generate,/key/update, and/key/regenerateshared the same two delegation gaps. The single-valuemax_budgetwas gated against the caller's ceiling, but the per-window entries inbudget_limitsand thepermissionsdict were ungated. A non-admin withmax_budget=10could mint, update, or regenerate a key with a $1M daily window, or self-grant ambient capabilities likeget_spend_routesandenable_llm_guard_check. A CLI session token bypassed the ceiling on the same path; a NaN window value bypassed both the gate and downstream enforcementSetup: admin creates user
alicewith roleinternal_user,max_budget=10, models["gpt-3.5-turbo"], and a personal key for herAfter (fixed code)
/key/generatebudget_limits over-ceiling{"error":{"message":"{'error': \"budget_limits entry max_budget (1000000.0) cannot exceed the caller's own max_budget (10.0).\"}","code":"400"}}/key/generateNaN window{"error":{"message":"{'error': 'budget_limits entry max_budget (nan) must be a finite number.'}","code":"400"}}/key/updatenon-admin permissions on own key{"error":{"message":"{'error': 'Only proxy admins can set `permissions` on a key.'}","code":"403"}}/key/regeneratenon-admin budget_limits on own key{"error":{"message":"{'error': \"budget_limits entry max_budget (1000000.0) cannot exceed the caller's own max_budget (10.0).\"}","code":"400"}}/key/regeneratenon-admin permissions on own key{"error":{"message":"{'error': 'Only proxy admins can set `permissions` on a key.'}","code":"403"}}Controls
Admin can still set both fields, including over-ceiling
{"key":"sk-1T_510Ww7uo...","permissions":{"get_spend_routes":true},"budget_limits":[{"budget_duration":"1d","max_budget":1000000.0,"reset_at":"2026-06-28T00:00:00Z"}]}Non-admin within her ceiling still mints
{"key":"sk-401tRPzoNES...","budget_limits":[{"budget_duration":"1d","max_budget":5.0,"reset_at":"2026-06-28T00:00:00Z"}]}Type
🐛 Bug Fix
Changes
Two helpers placed next to
_check_allowed_routes_caller_permission_check_budget_limits_delegation_ceilingwalks everyBudgetLimitEntryindata.budget_limits. It rejects non-finite values (NaN bypassed>becauseNaN > xis False, and the proxy never trips downstream becausespend > NaNis always False), then refuses a CLI session token writingbudget_limitsto a personal key (the symmetric guard for the scalarmax_budgetalready existed;Nonefrom a session token is zero delegation authority, not unlimited), then enforces the per-window ceiling. Carve-outs match the existing scalar check (proxy admin, UI-session team key,delegation_ceiling is Nonefor regular non-admins whose admin granted them an unlimited budget)_check_permissions_caller_permissionrequiresPROXY_ADMINfor any non-emptypermissionsdict. The default{}still passes for non-admin callersThree call sites:
_common_key_generation_helpercovers/key/generateand/key/service-account/generate_validate_update_key_datacovers/key/update(budget_limitswas already admin-only here via_is_budget_change, onlypermissionswas open)regenerate_key_fncovers/key/regenerate(both fields were open; regenerate goes throughprepare_key_update_datanot_validate_update_key_data, so the gate lives at the handler)PermissionsDictis a new TypedDict inlitellm/proxy/_types.pynext to where #31471 introducedObjectPermissionDict. It names the keys the proxy actually reads off the dict (get_spend_routes,enable_llm_guard_check);total=Falselets the dispatcher inguardrail_helpers.pyaccept arbitrary guardrail-name keys. Applied to_check_permissions_caller_permission,generate_key_helper_fn, and threeVerificationTokenRepositorybuilders. The wire-Pydantic field and the DB roundtrip stay asOptional[dict]per the precedent #31471 set forObjectPermissionBaseBehavior change called out: docstrings on the four endpoints previously advertised
permissions={"pii": false}andpermissions={"allow_pii_controls": true}to non-admin callers. Disabling PII masking on your own key is exactly the control bypass this PR closes, sopermissionsis now admin-only. Docstring examples now use{"get_spend_routes": true}, a key the codebase actually honors.allow_pii_controlswas removed; a grep oflitellm/andenterprise/shows zero live readers (it appears only in docstrings and thepresidio.py:731comment)ui/litellm-dashboard/src/lib/http/schema.d.tsregenerated vianpm run gen:apito keep the dashboard types in sync with the updated FastAPI docstringsRegression tests in
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py:test_budget_limits_window_cannot_exceed_caller_max_budget(non-admin, oversized window, 400)test_budget_limits_window_within_caller_max_budget_allowed(non-admin, in-ceiling, 200)test_budget_limits_window_equal_to_ceiling_allowed(boundary, pins>vs>=)test_budget_limits_admin_unrestricted(admin)test_budget_limits_session_token_personal_key_blocked(CLI session-token guard)test_budget_limits_nan_window_rejected(NaN finiteness guard)test_permissions_field_rejected_for_non_admin(generate, 403)test_permissions_empty_default_allowed_for_non_admin(default{}still passes)test_permissions_admin_can_set_any(admin)test_update_key_non_admin_permissions_rejected(/key/update)test_regenerate_key_non_admin_permissions_rejected(/key/regenerate)test_regenerate_key_non_admin_budget_limits_rejected(/key/regenerate)Mutation check: each attack test fails on prior commits before each fix was applied; controls pass throughout. Full mapped test file is 314 tests, all green
Note
High Risk
Security fix in proxy key delegation: closes privilege escalation via ungated
budget_limitsandpermissionson key create paths.Overview
Closes delegation gaps on
/key/generate(and shared_common_key_generation_helperpath) where scalarmax_budgetwas already capped butbudget_limitswindows andpermissionswere not.Adds
_check_budget_limits_delegation_ceiling, which rejects any per-windowmax_budgetabove the caller’s delegation ceiling for non-admins (with the same carve-outs as the existing scalar check: proxy admin, UI-session team keys, anddelegation_ceiling is None). Adds_check_permissions_caller_permission, which returns 403 if a non-admin sets a non-emptypermissionsdict (empty{}still allowed).Both checks run in
_common_key_generation_helperimmediately after the existingmax_budgetceiling logic. Regression tests cover over-ceiling windows, in-ceiling success, admin bypass, non-adminpermissions, and default empty permissions.Reviewed by Cursor Bugbot for commit 68f7688. Bugbot is set up for automated code reviews on this repo. Configure here.