* feat(mcp): persist oauth2_flow explicitly on create instead of inferring it at read time
The UI create payload never carried oauth2_flow, so every UI-created oauth2 server
persisted a null flow and relied on _resolve_oauth2_flow's field-shape inference at
registry build. That inference cannot tell a DCR-registered interactive server
(client creds + token_url, no persisted authorization_url) from an M2M server unless
endpoint discovery succeeds first, and the dashboard cannot reproduce it at all
because credentials are redacted in responses
The create form now persists the selected flow for oauth2 servers: authorization_code
for Interactive (PKCE), client_credentials for M2M. The REST create endpoints stamp an
omitted oauth2_flow server-side with the same discriminator the legacy inference uses,
run at write time where the payload carries plaintext credentials, so the decision is
made once with full information and stored. Applied to the admin create, the BYOM
submission, and the temporary session-server endpoints
The edit form derives its flow display from oauth2_flow instead of token_url presence
(token_url is present on authorization_code servers too, so it cannot distinguish M2M)
and deliberately never writes oauth2_flow: it has no flow selector, so a write from
edit could only erase an explicit value, including the authorization_code stamp the
DCR flow persists. Regression tests pin all of this down
Second step of persisting oauth2_flow at every write site so the legacy inference can
eventually be deleted; the backfill for existing null rows lands next
* refactor(mcp): name the create-time flow stamp for its fallback-only contract
stamp_omitted_oauth2_flow with a dedicated explicit-value early return and the shape
check renamed to has_m2m_shape, so the precedence (caller's oauth2_flow always wins,
inference only fills an omitted field) reads directly off the code
* fix(responses-bridge): custom tool round-trip and allowlist preservation for Codex CLI
Convert Responses API custom tools to Chat Completions function tools and map
function_call responses back to custom_tool_call output items so Codex CLI gets
the apply_patch round-trip it expects. Preserve and validate allowed_callers
during the custom->function conversion so the Anthropic adapter's caller
allowlist is not silently dropped, which would let a tool meant to be callable
only by another tool be invoked directly by the model. Use modern type
annotations (list/dict/set/X | None) throughout to keep the ruff strict budget
within its ratcheted ceilings.
* fix(responses-bridge): address review feedback on custom tool bridge
Type convert_custom_tool_to_function_tool against Mapping/ChatCompletionToolParam
and validate allowed_callers with a strict TypeAdapter so the two new cast()
calls that tripped the LIT006 ceiling are gone. Warn when dropping Responses-only
tool types (computer_use, image_generation, namespace, shell) instead of
discarding them silently. Return output items as Pydantic models instead of
model_dump()ing every item to a dict, matching the declared return type. Apply
the same None-safe metadata pattern to the request_data paths that still used
setdefault, and drop the unused build_custom_tool_call_item helper.
* fix(responses-bridge): recover custom tool input when arguments is empty
* fix(auth): extract custom tool names for allowlist enforcement on responses route
The Responses guardrail translation handler only extracted function and mcp
tool names, so a key or team restricted by metadata.allowed_tools could invoke
a disallowed tool by declaring it with type custom now that the bridge converts
custom tools into callable Chat Completions function tools. Extract custom tool
names through the same path so check_tools_allowlist rejects them.
* fix(responses-bridge): scope input payload recovery to custom_tool_call items
Recovering tool arguments from the input field on any falsy arguments value
made plain function_call input items with empty arguments and a stray input
key get rewritten into a {"content": ...} envelope, corrupting multi-turn
replay for normal function tools. Gate the recovery on the item type so it
only applies to custom_tool_call items, which are the ones that store their
payload in input.
* fix(responses-bridge): default missing function_call arguments to empty string
With input recovery scoped to custom_tool_call items, a plain function_call
input item without an arguments key left raw_arguments as None and the
downstream str() turned it into the literal string None. Coerce to an empty
string instead, matching the pre-bridge behavior.
---------
Co-authored-by: duanhongyi <duanhongyi@doopai.com>
* feat(jwt): fall back to DB team memberships when JWT has no team claims
* style(jwt): use PEP 585/604 annotations in DB team fallback to clear strict gate
* fix(jwt): preserve DB teams on no-claim sync, model-gate DB fallback, stop team-id leak
When fallback_to_db_teams is enabled and a JWT carries no team claims,
sync_user_role_and_teams previously computed teams_to_remove as every existing
DB membership and wiped the user out of all their teams on each request, which
also left the DB fallback nothing to resolve. Skip team removal in that case so
memberships survive and the fallback can attribute usage.
Apply the same per-team model-access check the claim-based path enforces when
selecting a DB fallback team, so a team's models restriction is no longer
bypassed; a team that cannot serve the requested model is skipped in favor of
one that can.
Drop the user's team-id list from the x-litellm-team-id membership 403 detail so
a valid-JWT caller can no longer enumerate team IDs.
* fix(jwt): load team membership on DB fallback; scope header check to provisional teams
The DB-team fallback resolved a team but never loaded its team membership
row, so per-team membership budget limits were silently skipped on that
path. _resolve_db_team_fallback now fetches the resolved team's membership
when a user_id is known and returns it, matching the claim-based path so
downstream LiteLLM_TeamMembership budget enforcement works there too.
The provisional x-litellm-team-id validation also fired on any non-None
team_id, including an RBAC role-derived one, which 403'd RBAC team flows
when the asserted team was not also a DB membership. It now runs only when
team_id actually came from the header (team_id == header_team_id).
* fix(jwt): surface DB-fallback membership lookup failures at warning level
A transient get_team_membership failure on the DB team fallback path is
recoverable: the team is still resolved and the request proceeds, just
without per-team membership budget enforcement for that request. Logging
that at debug hid a silent budget-enforcement gap from operators, so it now
logs at warning and states that enforcement was skipped. Behavior is
otherwise unchanged: the resolved team is returned with a None membership
rather than failing the request, covered by
test_resolve_db_team_fallback_survives_membership_lookup_error.
* fix(jwt-auth): tighten db-team fallback gating and passthrough enforcement
Resolves four issues in the fallback_to_db_teams path:
- _resolve_db_team_fallback now surfaces a model-access denial when memberships
exist but none can access the requested model, instead of always returning
the no-membership message
- auth_builder gates the fallback on real JWT team claims via
get_all_jwt_team_ids so a configured team_id_default does not silently route
claimless tokens to the default team
- A team selected only via _resolve_db_team_fallback is re-validated against
the team's allowed_passthrough_routes; the earlier gate ran while team_id
was still None
- sync_user_role_and_teams considers both plural and singular team claim
shapes when reconciling DB memberships so singular-only tokens
(Okta/Auth0 defaults) no longer leave stale teams behind
* fix(jwt): don't upsert a provisional x-litellm-team-id before membership check
When fallback_to_db_teams is on and the JWT carries no team claims, an
x-litellm-team-id header is accepted provisionally and only validated against
the user's DB memberships later in auth_builder. With team_id_upsert also
enabled, get_team_object ran the upsert on that unvalidated header team first,
so an attacker-supplied header could create an orphaned team row before the
403 membership check. Suppress the upsert whenever the team is provisional
(db_team_fallback), since a genuine membership team already exists and an
invalid one must not be created. Regression:
test_auth_builder_provisional_header_team_is_not_upserted.
* fix(jwt): pin RBAC-asserted team against db-team-fallback header override
When a JWT carries an RBAC team role but no group claims, auth_builder already
sets team_id from the RBAC object_id. db_team_fallback still evaluated true
there, so the provisional x-litellm-team-id path accepted a header team and
silently overrode the RBAC-asserted team with any team the caller belonged to.
Gate db_team_fallback on team_id being unset, and drive the header's provisional
acceptance off db_team_fallback rather than the raw flag, so an RBAC token plus
a non-claim header team is rejected with 403 instead of substituting the team.
Regression: test_auth_builder_header_cannot_override_rbac_team_under_db_fallback.
* fix(jwt): scope dual-claim membership sync to fallback_to_db_teams
The membership sync read both plural and singular JWT team claims via
get_all_jwt_team_ids unconditionally, which silently changed reconciliation
for every deployment using sync_user_role_and_teams, not just those opting
into fallback_to_db_teams: a singular-only IdP token that previously stripped
all DB teams would now be recognized. Gate the dual-claim read on
fallback_to_db_teams so flag-off deployments keep the upstream plural-only
behavior, honoring the PR's contract that existing deployments are unchanged.
Regression: test_sync_user_role_and_teams_singular_claim_only_recognized_under_flag.
* fix(jwt): drop user team IDs from db-fallback model-access 403 detail
The model-access-denied 403 in _resolve_db_team_fallback echoed the user's
full DB team-id list in its detail. It is only the caller's own memberships,
but it is inconsistent with the membership-validation 403 in the same feature
that was deliberately scrubbed of team IDs. Replace the enumerated list with a
generic "no team you are a member of has access" message. Regression extends
test_resolve_db_team_fallback_distinguishes_no_membership_vs_model_denied to
assert the team id is absent from the detail.
* fix(jwt): keep db-team fallback off for alias-only tokens
* test(jwt): cover alias-only token skipping db-team fallback
The autofix in ed21199 added a get_team_alias clause to the db_team_fallback
gate so an alias-only JWT (team_alias_jwt_field set, no team-id claims)
resolves its alias via find_and_validate_specific_team_id instead of being
mis-attributed to the user's first DB team, but it shipped without a
regression test. This drives auth_builder with an alias-only token whose
alias resolves to a different team than the user's DB membership and asserts
the result is the alias-resolved team; reverting the get_team_alias clause
flips the result to the DB-membership team, so the test fails without the fix
* fix(jwt): prefer alias resolution over team_id_default
When the JWT only carries an alias claim and the operator configures
team_id_default, JWTHandler.get_team_id silently substitutes the
default into find_and_validate_specific_team_id. That made the helper
return the default team without ever attempting alias resolution, so
spend and access attached to the default team even though the token
identified a different team via its alias. Use get_all_jwt_team_ids
(which ignores team_id_default) to detect when the resolved team_id is
only the default and clear it so alias resolution runs first; the
default remains the fallback when no alias claim is present.
* fix(jwt): enforce team_allowed_routes in db-team fallback resolution
The claim-based path runs allowed_routes_check when selecting a team, but
_resolve_db_team_fallback selected a team purely on model access, so a
DB-resolved team could reach routes excluded by team_allowed_routes with no
downstream backstop. This mirrors the claim path's route gate in the fallback,
exempting auth-enforced passthrough routes that are gated separately by
allowed_passthrough_routes at the call site
* fix(jwt): enforce team_allowed_routes on header-team db fallback path
The auto-pick DB-team fallback already gates against team_allowed_routes, but a claimless JWT presenting x-litellm-team-id under fallback_to_db_teams set team_id directly from the header and only re-validated DB membership afterwards, skipping the route gate. A caller could reach management/info routes that the JWT config narrowed for team-role callers by supplying the header even though the auto-pick path on the same route returns no team.
* refactor(jwt): narrow db-team fallback except clauses to actual failure types
* fix(jwt): collapse provisional header team lookup failure into membership denial
A caller holding a valid claimless JWT under fallback_to_db_teams could
distinguish nonexistent teams (404 from get_team_object) from existing
teams they do not belong to (membership 403) by varying x-litellm-team-id,
giving an authenticated team-id existence oracle. The provisional header
path now rewrites the lookup failure into the exact 403 the membership
check raises, while claim-backed header teams keep the upstream 404.
Also drop the unreachable falsy-team guard in _resolve_db_team_fallback
(get_team_object returns a team or raises, never None) and stop codecov
carryforward for three dead flags whose stale sessions were measured
against old file revisions and sank patch coverage with phantom
executable lines
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
ModelArmorGuardrail.async_pre_call_hook and async_moderation_hook hardcoded
their inner should_run_guardrail event type to pre_call / during_call. The
central dispatcher already remaps call_mcp_tool -> pre_mcp_call/during_mcp_call
and passes the outer gate, but Model Armor's redundant inner gate then rejected
MCP calls for a guardrail configured with mode pre_mcp_call/during_mcp_call, so
tool-call content was silently skipped.
Remap call_mcp_tool -> pre_mcp_call/during_mcp_call in both hooks, matching the
existing behavior of the noma and cisco guardrails. Adds regression tests
covering both hooks (scan runs on MCP calls, still skipped for chat traffic).
Generated with AI
Co-Authored-By: Claude Code
Co-authored-by: eugene-yao-zocdoc <eugene.yao@zocdoc.com>
* fix(mcp): forward short OAuth state upstream, keep session in a cookie
Some upstream authorization servers reject the OAuth authorize request with
"state parameter too long" because LiteLLM replaced the client's short state
with its own long encrypted session blob (base_url, original state, PKCE, client
redirect_uri) and sent that upstream as state.
Forward a short random handle as the upstream state instead, and carry the
encrypted session in a per-flow HttpOnly, SameSite=lax cookie bound to that
handle. The browser replays the cookie on /callback, so the session is recovered
without any server-side store and the client still gets its own original state
back. /callback falls back to decoding state directly when no cookie is present,
so flows in flight across a deploy keep working.
Resolves LIT-4197
* test(mcp): cover /callback error path cookie read and clear
The happy-path regression test already asserts the short-handle -> cookie round
trip. Add a focused test for the IdP-error branch of /callback: it must recover
the client's original state from the per-flow cookie (not the short handle),
propagate the error to the client's redirect_uri, and expire the one-time
cookie. Fails if the error path stops reading or clearing the cookie.
Only the gateway-managed interactive flow reaches this persist (the public /register
routes never pass persist_credentials), so the row it writes is authorization_code by
definition. It was not recorded, which left the row as client creds + token_url with
no persisted authorization_url and a null oauth2_flow: exactly the shape the legacy
M2M inference in _resolve_oauth2_flow matches. The row normally survives because
endpoint discovery backfills authorization_url in memory before the inference runs,
but on any transient discovery failure at registry build the server flips to
client_credentials for that load, routing per-user traffic to the M2M path
Stamping the flow at the write site makes the classification explicit and permanent,
so a DCR-registered interactive server no longer depends on discovery succeeding to
classify correctly. First step of persisting oauth2_flow at every write site so the
legacy inference can eventually be deleted
* fix(security): hash Bearer-prefixed API keys in spend logs
The safety-net hash in get_logging_payload only checked for keys
starting with 'sk-', missing keys that arrived as 'Bearer sk-...'.
This caused plaintext API keys to be stored in SpendLogs for failed
requests while successful requests correctly stored SHA256 hashes.
Adds _hash_api_key_for_spend_log that strips the Bearer prefix
before hashing, applied to both the api_key column and the
metadata.user_api_key field in spend log payloads.
* fix: strip Bearer prefix from non-sk keys in spend log fallback path
---------
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
CacheCodec.serialize dumped cached Pydantic models with model_dump(exclude_none=True), which drops any None-valued key, while deserialize does a strict model_validate. For a model with a required-but-nullable field (Optional[X] with no default), a None value is dropped on write and then fails model_validate on read with "Field required", so the entry can never be read back; that is a permanent cache miss, and in readers that rebuild the model from the raw cached dict an uncaught ValidationError that surfaces to the client as a 401
Removing exclude_none makes serialize and deserialize a lossless pair, so None fields are written as null and survive the round trip. LiteLLM_ManagedVectorStoresTable, the one cached model still carrying required-nullable fields and mis-caching on every read today, also gets the None defaults its peers already have
Round out the AWS auth-field coverage of _DB_LITELLM_PARAM_ENV_REF_KEYS
so every stringy aws_* field a deployment can pin in the DB resolves
os.environ/ refs at load time:
- aws_bedrock_project_id: Bedrock project/workspace association, banned
from request bodies via _BANNED_REQUEST_BODY_PARAMS
- aws_batch_role_arn: Bedrock batches role ARN (analog of aws_role_name)
- aws_workspace_id: Claude Platform workspace ID
Verified against three independent sources:
- BaseAWSLLM.aws_authentication_params (all 11)
- LiteLLM_Params-declared AWS fields (all 5)
- every aws_* string read from litellm_params/kwargs/optional_params
across litellm/ (all 14, excluding aws_bedrock_client which is a
boto3 client object, not a string, and aws_polly which is a provider
name)
The regression test now pins all 12 newly-allowlisted fields.
PR #30867 removed request-time os.environ/ expansion in
BaseAWSLLM.get_credentials to close LIT-3831. That relies on config-load
paths pre-resolving os.environ/ refs, but the DB-load path
(_resolve_db_litellm_param) only re-expands keys in
_DB_LITELLM_PARAM_ENV_REF_KEYS, which covered api_key,
aws_access_key_id, and aws_secret_access_key but not the other AWS auth
fields. A model stored in Postgres with e.g.
aws_role_name: os.environ/BEDROCK_ASSUME_ROLE_ARN
lands on the router with the literal string, get_credentials no longer
expands it, and STS returns
ValidationError: os.environ/BEDROCK_ASSUME_ROLE_ARN is invalid
Add the remaining AWS auth params to the allowlist so DB-sourced values
resolve at model-load time (trusted, server-side), matching the
YAML-config path. Team-scoped DB rows still get resolve_env_refs=False,
so the LIT-3831 defense-in-depth path is unchanged and request-body
injection is still blocked by _BANNED_REQUEST_BODY_PARAMS.
Regression tests pin every added field as an os.environ/ DB value and
assert it resolves on the router, plus a team-scoped pin that asserts
env refs remain literal.
Previously, every guardrail request forwarded the full conversation
history to CrowdStrike AIDR. In a multi-turn conversation this means
every prior message gets re-scanned on every new call, even though those
messages were already evaluated in earlier turns.
CrowdStrike AIDR internally has a conversation boundary optimization in
place for just this scenario (ref. <https://aidr-docs.crowdstrike.com/docs/aidr/apis#messages-array-optional---array-of-message-objects-containing-a-conversation-segment-with-the-ai-system>).
However, it is nevertheless wasteful to send so much data to the API
when only a subset of it will be processed. It also risks hitting the
documented 1 MiB request size limit.
So now we filter down to system messages plus either the messages after
the last assistant turn, or the last assistant message itself when that
is what is being guarded. We also preserve the original, full message
history within the guardrail in order to stitch back any
transformations.
Co-authored-by: Kenan Yildirim <kenan@kenany.me>
* feat(router): add separate ITPM/OTPM deployment rate limits
Support input/output tokens per minute on deployments via enforce_model_rate_limits, with reservation, reconciliation, refund on failure, and rate-limit headers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(router): keep ITPM/OTPM diff minimal in router.py
Drop unrelated Black reformatting from router.py and types/router.py so the PR only contains functional ITPM/OTPM changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): make ITPM/OTPM limits separate and atomic
Address Greptile review on separate ITPM/OTPM deployment rate limits.
- OTPM is now reserved atomically pre-call with rollback, matching the ITPM
path, so concurrent requests can no longer overshoot the configured output
limit before reconciliation
- ITPM counts input tokens only; it no longer accumulates completion tokens,
so the input-token limit and x-ratelimit-limit-input-tokens header describe
input usage as their names imply
- _read_reservation_from_kwargs only falls back to litellm_params.metadata when
the top-level metadata channel is absent, so production requests carrying a
litellm_params.metadata dict still reconcile and refund their reservation
Adds regression tests for OTPM atomicity under concurrency, input-only ITPM
enforcement, and reservation lookup when litellm_params.metadata is present.
* fix(router): subtract input tokens only from remaining-input-tokens header
The in-flight replay for x-ratelimit-remaining-input-tokens subtracted total
tokens (input + output) instead of input tokens only, so clients saw remaining
input quota understated by the completion token count on every response. Now
consistent with the input-only ITPM counter.
* fix(router): make itpm/otpm vs tpm/rpm precedence explicit
When a deployment configures itpm/otpm alongside tpm/rpm, the io-token path
takes over and the tpm/rpm limits are not enforced. Log a warning the first
time such a conflicting deployment is seen so the supersession is not silent,
and document the mutual exclusivity.
Post-call reconciliation now only trues up a counter that was actually
reserved against, so the itpm/otpm keys are no longer incremented for
deployments that never configured that limit.
* fix(router): track actual io-token usage on the reservation-minute key
Post-call reconciliation now keys off the exact cache key stashed at pre-call
time rather than one recomputed from the response-time minute. This fixes two
issues: a request whose pre-call estimate was 0 now still writes its actual
billable input to the ITPM counter (previously it was skipped, leaving the
limit unenforceable for that request), and a call that finishes in a later
minute reconciles against the minute it reserved against instead of pushing a
negative delta into the next minute. Counters are only touched when their
limit is configured.
* fix(router): run io-token reconciliation before the model_id guard
async_log_success_event gated IO reconciliation behind the model_id guard that
only the TPM tracking path needs. Since reconciliation works entirely from the
cache keys stashed in kwargs, a success event whose standard_logging_object
lacks model_id would skip reconciliation and leave the reservation on the
counter until the TTL expired, wasting quota. Route the IO path first.
* fix(router): don't replay in-flight delta for itpm/otpm headers
For ITPM/OTPM model groups the counter is incremented at reservation time
(pre-call), so the remaining values returned by get_remaining_model_group_usage
already account for the current request. Replaying the in-flight delta on top
double-counted it and understated x-ratelimit-remaining-input/output-tokens by
up to max_tokens on every response. Skip the delta for io-token groups; the
legacy TPM/RPM replay path is unchanged.
* fix(router): clear io-token reservation after reconcile/refund
async_io_token_refund_failure and async_io_token_reconcile_success now clear
the stashed reservation keys from the request metadata once done. Otherwise, on
a model group mixing IO-limited and non-IO deployments, a failed IO call that
retries on a non-IO fallback left the stale sentinel in the shared request
metadata; the fallback's success handler would divert into IO reconciliation
against the already-refunded key, driving the ITPM counter negative and
skipping the non-IO deployment's TPM tracking.
* fix(router): tidy reservation channel lookup and header guard
Consolidate the reservation channel lookup into a single ordered helper shared
by read and clear, so top-level metadata always wins over litellm_params
metadata without the tangled per-iteration fallback.
Also stop gating the router rate-limit header block on the presence of
x-ratelimit-remaining-input/output-tokens. That block only emits those headers
for ITPM/OTPM groups; for a non-IO group backed by a provider that natively
returns input/output token headers, the extra conditions suppressed the
router's own remaining-tokens/requests headers.
* fix(router): strip client-supplied io-token reservation keys
The reservation sentinels (_litellm_itpm_reserved, _litellm_itpm_cache_key,
and the otpm equivalents) are server-only, but metadata is caller-controlled on
proxy requests. An authenticated caller could forge these fields with an
arbitrary cache key so the post-call reconcile/refund path would decrement any
deployment's ITPM/OTPM counter and let it exceed the configured limit. Strip
the reserved keys from the request metadata in set_io_token_rate_limit_request_kwargs,
which runs before the router stashes its own reservation, so only a genuine
server-side reservation is ever read post-call.
* fix(router): track TPM routing load for io-limited deployments
deployment_callback_on_success early-returned for any deployment with itpm/otpm
set, so its total-token usage never landed in the router's TPM routing counter.
TPM-aware routing strategies then saw 0 load for IO deployments and over-routed
to them in mixed model groups. Only skip tracking when neither tpm/rpm nor
itpm/otpm are configured; itpm/otpm enforcement still runs separately in
ModelRateLimitingCheck, so the routing counter and the enforcement counters
stay independent.
* fix(router): expose standard tpm/rpm headers for io-limited groups
get_remaining_model_group_usage returned early for ITPM/OTPM groups, so a group
that also set tpm/rpm never emitted x-ratelimit-remaining-tokens / -requests;
clients and prometheus gauges reading those saw no data. Build both header sets
instead of returning early.
Also simplify the in-flight header replay: only the tpm/rpm counters are
incremented post-response, so the delta now adjusts just those. The itpm/otpm
counters are incremented at reservation time (pre-call), so the input/output
token headers already reflect the request and are left untouched - which
removes the need for the separate io-group special case.
* fix(router): roll back ITPM on any OTPM reservation error; dedup warning per instance
Two follow-ups from review. The pre-call OTPM reservation only rolled back the
ITPM reservation on a RateLimitError, so a transient cache error while reserving
OTPM left the ITPM counter inflated until the TTL expired; catch any exception,
release the ITPM reservation, then re-raise.
Replace the module-level lru_cache warn-once (caching a logging side effect,
which never re-warns in a long-lived process) with an instance-scoped set of
already-warned deployment ids on ModelRateLimitingCheck.
* fix(router): always clear reservation stash on reconcile; don't collapse id-less warning dedup
Clear the reservation in a finally block so a mid-reconciliation cache error
still removes the stash and a duplicate success event can't re-process it.
Dedup the itpm/otpm-vs-tpm/rpm conflict warning per real deployment id; a
deployment with no id no longer collapses every id-less deployment onto the
str(None) key (which would suppress all but the first warning).
* fix(router): skip io reservation when deployment can't be keyed
_get_cache_keys returned a shared 'global_router:None:None:...' key when a
deployment was missing model_info.id or litellm_params.model, so misconfigured
deployments could share one rate-limit bucket. Return None in that case and
skip io reservation for the request.
* fix(router): honor explicit max_tokens=0 in io reservation
_resolve_max_tokens used 'max_tokens or max_completion_tokens', so an explicit
max_tokens=0 fell through to the model default. Only fall back to
max_completion_tokens when max_tokens is absent.
* fix(ci): satisfy lint budget, router coverage, and dashboard schema sync
- Modernize the new itpm/otpm module's type hints to PEP 585 lowercase
generics (Dict/Tuple/List -> dict/tuple/list) to clear the added UP006
violations; ratchet ruff-strict-budget.json's UP006 ceiling down to match.
- Replace three try/except Exception blocks that must stay broad by design
(token_counter and litellm.get_model_info raise untyped exceptions, and an
io-token refund failure must never break the logging pipeline) with
contextlib.suppress(Exception), matching the codebase's existing resolution
for this exact BLE001 pattern.
- Add direct unit tests for get_model_group_io_token_usage (multi-deployment
aggregation and the empty-model-list case) in test_router_helper_utils.py,
satisfying the router function-coverage check.
- Regenerate the dashboard's schema.d.ts so the new itpm/otpm fields on
GenericLiteLLMParams and ModelGroupInfo are reflected in the OpenAPI types.
* fix: enforce io token rate limits consistently
* fix: honor zero max tokens in otpm reservation
* fix(lint): fix UP007 violation and resync ruff-strict-budget.json to base
Convert Union[_Span, Any] to _Span | Any (safe on this repo's Python >=3.10
floor) to clear the new UP007 violation from the TYPE_CHECKING-gated Span
alias.
The previously committed ruff-strict-budget.json ratcheted UP006 down from a
stale base; litellm_internal_staging has since tightened that same ceiling
further on its own. Reset the file to the current base's committed values and
re-ratchet from there so the budget only ever moves down relative to the
actual merge-base, never against a stale snapshot.
* fix(router): attach ITPM/OTPM headers on dict responses and harden reservation
Strip itpm/otpm from provider kwargs, ensure messages are available for ITPM
estimation, honor max_output_tokens on /v1/responses, and propagate rate-limit
headers through /v1/messages dict responses via _hidden_params.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): attach ITPM/OTPM headers to streaming /v1/messages responses
Wrap bare async iterators in HiddenParamsAsyncIteratorWrapper so
set_response_headers can attach rate-limit headers to streaming Anthropic
messages responses that lack a _hidden_params slot.
Co-authored-by: Cursor <cursoragent@cursor.com>
* style: ruff format add_retry_fallback_headers.py
Fix CI ruff format check failure on get_hidden_params_dict call site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(router): extract set_response_headers helpers to fix C901 budget
Move header-attachment logic into add_retry_fallback_headers helpers so
set_response_headers stays under the strict complexity ceiling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep IO token reservation when response usage is missing
Missing usage was reconciled as zero and fully refunded the pre-call
reservation, allowing limit bypass on repeated successful calls. Only
adjust counters when usage is resolved from the response or standard
logging fields; otherwise keep the reservation until TTL expires.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: enforce RPM/TPM alongside IO-token limits on mixed deployments
Deployments with both itpm/otpm and tpm/rpm previously returned after the
IO reservation and skipped RPM/TPM checks. Run both paths and refund the
IO reservation only when RPM/TPM rejects after a successful reservation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: track TPM usage on success for mixed IO+TPM deployments
The early return after IO-token reconciliation in log_success_event and
async_log_success_event skipped the TPM counter increment, so the tpm_key
the pre-call check reads was never written and tpm_limit was never
actually enforced on deployments that also configure itpm/otpm.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: treat total-only usage as unresolved in IO-token reconcile
usage/standard_logging_object entries carrying only total_tokens (no
prompt/completion or input/output breakdown) were treated as resolved
usage, resolving to (0, 0) and refunding the full reservation. Both
_usage_is_present and the standard_logging_object fallback now require an
actual input/output breakdown before reconciling, keeping the reservation
otherwise.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: reserve minimal token when input/output estimation fails
_reservation_value(0, limit) reserved the entire limit whenever token
estimation failed (empty/unsupported input, tokenizer error), letting one
such request claim the whole bucket and 429 every concurrent request to
the deployment until it completed. Reserve 1 token instead so estimation
failures no longer serialize traffic.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: refund IO reservation synchronously before retry deployment pick
On retry, set_io_token_rate_limit_request_kwargs clears reservation
sentinels from the shared kwargs dict before a background failure handler
can refund them, stranding the counter until TTL. Refund and clear any
stale reservation in _update_kwargs_with_deployment before stripping
sentinels for the next attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(io_token_rate_limit_check): use model-specific tokenizer for ITPM estimate; document sync-refund Redis ceiling
Pass the deployment litellm_params.model to token_counter so it uses the
model's native tokenizer instead of the generic fallback, narrowing the
reservation over/under-estimate window between pre-call and post-call
reconcile.
Add a ponytail: comment to refund_stale_reservation_before_retry explaining
the known ceiling: the synchronous DualCache.increment_cache issues a
blocking Redis INCR when a Redis backend is configured. This only fires on
streaming mid-stream retries (non-streaming failures await their failure
handler before the retry picks a new deployment, leaving no sentinels to
refund). Upgrade path: make _update_kwargs_with_deployment async.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The group_by=team branch queried spend for every team in the date range
regardless of team_id; team_id was only honored in the separate branch that
also required a customer_id. Route the team report through a new
get_spend_by_team helper (sibling of get_spend_by_team_and_customer) that binds
team_id as an optional $3 predicate, so a provided team_id narrows the result
to that team and a null team_id still returns every team.
* feat(mcp): add entra_obo profile to the token_exchange (OBO) arm
Microsoft Entra On-Behalf-Of uses the RFC 7523 jwt-bearer grant rather than RFC 8693, so the existing token_exchange arm cannot mint tokens against Entra. This adds an entra_obo profile on TokenExchangeConfig that switches the request form to Entra's jwt-bearer OBO dialect (the inbound token as assertion, the target resource carried in scope, and requested_token_use=on_behalf_of), while reusing the shared caching, single-flight, TTL, and fail-closed machinery. The exchanger is renamed from Rfc8693TokenExchanger to OboTokenExchanger since it now serves both dialects
The profile is threaded through the config-load path, the DB credentials blob, and the MCPCredentials request schema, so an operator can select it from YAML or the management API. It rides in the existing credentials JSON blob, so there is no new auth_type and no DB migration
Resolves LIT-4163
* feat(mcp): propagate the Entra Conditional Access step-up challenge on the OBO 401
An entra_obo exchange that the IdP rejects for Conditional Access returns a 4xx with
error=interaction_required and a claims blob the client must satisfy to step up. The arm
dropped both and emitted a static RFC 9728 challenge, so a CA-protected Entra upstream was
unreachable through the gateway. The provider now reads the RFC 6749 error code and the claims
string off the rejection body (error_description is still never carried; it can leak IdP
internals), threads them through SubjectTokenRejected -> CredError.unauthorized, and the
challenge builder folds them into WWW-Authenticate: the machine error only when it is a plain
OAuth token (guards against header injection from a hostile body) and the claims base64-encoded
in a claims parameter, the convention MSAL-family clients decode. With neither field the header
is byte-identical to the static challenge. The multi-server aggregate still absorbs a
step-up 401 to an empty listing; only single-server routes surface it
* fix(mcp): use error=insufficient_claims for the Entra step-up challenge
Per Microsoft's claims-challenge format, a WWW-Authenticate carrying a claims challenge must set
error=insufficient_claims (the value MSAL-family clients key on to recognize the challenge and
replay the claims), not the raw token-endpoint code. The challenge now sets insufficient_claims
whenever a claims blob is present and keeps invalid_token otherwise, with a step-up-accurate
error_description in the claims case. The presence of claims now drives the error value, so the
raw oauth_error no longer needs threading from the provider through CredError to the edge; that
plumbing is removed (the provider still reads the error code for its gateway-fault classification).
Both the error value and the base64 claims are fixed-alphabet, so nothing from the IdP body reaches
the header unescaped. Cross-checked field-by-field against Microsoft Learn; a bogus jwt-bearer OBO
POST to the real login.microsoftonline.com/common endpoint confirmed Entra recognizes the grant and
returns the error shape the parser reads. Follow-up: also emit authorization_uri alongside
resource_metadata for strict non-MCP MSAL clients (RFC 9728 resource_metadata already serves MCP)
* fix(mcp): filter blank scopes on the config-load path so entra_obo fails closed
The DB-build path already runs YAML/DB scopes through _extract_scopes (which drops blanks), but the
config-load path read server_config["scopes"] raw. A YAML `scopes: [""]` therefore reached the
exchanger as a ("",) tuple: non-empty, so the entra_obo `not config.scopes` precondition skipped its
fail-closed misconfigured path and the form builder POSTed an empty scope to the IdP instead of
failing before any network call. Config-load now filters blanks the same way, so an all-blank list
normalizes to None and the precondition fails closed. Regression test loads a blank-scope entra_obo
server and asserts the exchange returns misconfigured without POSTing
* fix(proxy): keep serving reads from the read replica when the primary DB is down at startup
RoutingPrismaWrapper.connect() connected the writer first and let a writer
failure propagate, so a proxy that started during a primary outage ended up
with no Prisma client at all (startup swallows the error under
allow_requests_on_db_unavailable): DB-stored models never loaded and every
inference request failed with 400 Invalid model name, even with a healthy
DATABASE_URL_READ_REPLICA. Workers recycled via MAX_REQUESTS_BEFORE_RESTART
hit this mid-outage and stayed broken for the rest of the outage.
connect() now degrades on a writer-only failure: reads (key auth, DB-stored
model loads) are served by the reader, writes fail at call time, and the DB
health watchdog keeps retrying the writer reconnect, which clears the
degraded flag once the primary recovers. A full outage (both sides down)
still raises as before.
Resolves LIT-4159
* fix(proxy): clear degraded-writer flag when the reconnect probe finds the writer already healthy
The direct-reconnect path returns early when the writer probe succeeds
(engine already reconnected by another path, e.g. an IAM token refresh),
skipping recreate_prisma_client, which was the only runtime path clearing
_writer_unavailable. The stale flag made the watchdog fire reconnect
attempts against a healthy writer on every cooldown cycle until restart.
Clear the flag in the early-return branch and cover it with a regression
test that fails without the change
Two bugs from the upstream-error fixes: the success handler has no
status-code awareness, so removing raise_for_status() left it firing for
every upstream 4xx/5xx too, meaning the new failure hook and the existing
success handler both logged the same request (corrupting SpendLogs/cost
tracking). Separately, the failure hook was passed the raw
httpx.HTTPStatusError, which ProxyLogging's alerting only excludes
HTTPException/ProxyException from, so a normal upstream 403 would trigger a
"High" severity llm_exceptions alert. Gates the success handler (both
non-streaming and end-of-stream) to status_code < 400, and reports upstream
failures to post_call_failure_hook as an HTTPException instead of the raw
httpx error, matching how auth/rate-limit errors are already excluded from
alerting.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(policies): reject non-existent team/key/model scope entries on attachment create
Creating a policy attachment accepted arbitrary team, key, and model values with
no validation, so a typo'd or non-existent team was silently persisted (LIT-4199).
The create endpoint now rejects a concrete (non-wildcard) team, key, or model that
does not resolve to a real entity, wiring the previously-dead PolicyValidator
existence checks and reusing RouteChecks._is_wildcard_pattern so validation agrees
with request-time matching, where only a trailing "*" is a wildcard. Wildcard
patterns are still allowed through since they may match zero entities today and
more later, and tags stay free-form. The Admin UI's Teams field validates the same
rule for immediate feedback when its team list has loaded, deferring to the backend
otherwise.
* style(policies): use builtin list generics and | None in scope validator
Keeps the new find_invalid_scope_entries signature off the UP006/UP045 strict
ruff budgets instead of copying the surrounding legacy typing.List/Optional idiom.
* fix(policies): separate multiple attachment scope errors with ' | '
Addresses Greptile review: joining per-entry validation messages with a bare
space read as one run-on sentence; ' | ' makes the multi-error 400 detail easier
to parse for users and programmatically.
Follow-up to 8c9878025e: returning upstream 4xx/5xx bodies unchanged also
skipped post_call_failure_hook entirely, so spend-tracking and alerting
callbacks never fired for upstream errors, and response_body was hardcoded
to None in the log payload so the actual upstream error body never reached
logging integrations. Adds a small helper that calls post_call_failure_hook
for upstream errors without altering the client-facing response, and parses
response_body unconditionally for logging while still scoping guardrails
and managed-id rewriting to status_code < 400.
Co-authored-by: Cursor <cursoragent@cursor.com>
Generic pass-through endpoints called raise_for_status() on upstream 4xx/5xx
responses and re-raised as HTTPException, which the outer handler reshaped
into a ProxyException with the upstream body stringified into error.message.
Success responses were already forwarded as-is, so failures were the only
case where passthrough wasn't actually transparent. Removes the
raise_for_status() calls for both streaming and non-streaming passthrough so
upstream status, body, and headers reach the client unchanged, while keeping
guardrails/managed-id rewriting scoped to successful responses and leaving
internal proxy failures (auth, config, network errors before any upstream
response) on the existing ProxyException path.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(mcp): discover the OBO token endpoint via RFC 9728 to RFC 8414 (no IdP guessing)
An oauth2_token_exchange server can now have its token endpoint discovered the
same way the oauth2 (authorization_code) flow already does, instead of always
requiring token_exchange_endpoint/token_url to be configured by hand. The
existing _descovery_metadata chain (RFC 9728 protected-resource metadata ->
RFC 8414 authorization-server metadata -> token_endpoint, SSRF-guarded via
async_safe_get) is reused; both the config-load and DB-build paths gate on a new
_obo_needs_endpoint_discovery so discovery runs only when no endpoint is
configured, and an explicitly configured endpoint still wins and skips the
round-trip. The discovered token endpoint lands on token_url, which
_token_exchange_spec already reads, so no resolver change is needed.
_resolve_oauth2_flow returns None for any non-oauth2 auth_type, so a discovered
token_url on an OBO server is never mis-inferred as the M2M client_credentials
flow.
Discovery for OBO is authoritative only: the resolution order is explicitly
configured endpoint, then RFC 9728 -> RFC 8414 advertisement, then fail closed
(412, on the parent commit). The gateway never guesses the IdP. _descovery_metadata
grows an allow_origin_fallback flag, kept True for the browser oauth2 flow (a
human sees the redirect) but set False for token_exchange so the last-resort
guess that treats the resource server's own origin as its authorization server
is skipped; a subject token is never exchanged against an inferred endpoint.
* fix(mcp): surface a failed OBO exchange at connect instead of an empty tool list
A token_exchange server whose exchange fails with a subject present used to open the MCP
session anyway and mask the failure as an empty tools/list. Single-server routes now run
the exchange preemptively at the transport edge, where a rejected subject raises the RFC
9728 challenge and a gateway fault its public status; the multi-server aggregate keeps
absorbing per-server auth failures. The exchanger caches the preflight result, so the
session's list/call reuses it with no extra IdP round-trip. Discovery now also debug-logs
the authorization server's advertised issuer, grant types, and client auth methods
* fix(mcp): persist the discovered OBO token endpoint to the DB row
A DB-backed oauth2_token_exchange server with no configured endpoint had its token_url
resolved via RFC 9728 -> RFC 8414 only on the in-memory object returned from
build_mcp_server_from_table; the row kept token_url=None, so every rebuild re-ran discovery
and a transient upstream outage during a rebuild left the server with no endpoint until the
next successful discovery. Write the discovered token_url back onto the row so the guard sees
it on the next build. Best-effort and scoped to DB servers: config servers already persist
in-memory, and the write-back never fires from a user connect (only from add/update/reload,
all admin or system driven). Adds DB-path coverage for discovery firing when unset, skipping
when the credentials endpoint is configured, the write-back, and its negative guards
* Revert "fix(auth): deny model access for teamless keys with all-team-models (#32022)"
This reverts commit dfbbda4f19.
* revert: undo teamless all-team-models denial from PR #29746
Reverts the team_id guard in _resolve_key_models_for_auth_check and
get_key_models so teamless keys with all-team-models resolve to []
(unrestricted = all proxy models) rather than being denied.
Adds hardened regression tests across listing (get_key_models), inference
(_enforce_key_and_fallback_model_access, can_key_call_model,
can_key_call_resolved_model), and batch (_enforce_batch_file_model_access)
paths that enforce teamless all-team-models == all-proxy-models and will
fail if anyone re-introduces a team_id guard
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: retrigger checks
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* feat(mcp): thread the caller token into tools/list discovery for token_exchange
A token_exchange (OBO) server's tools could not be discovered through the aggregator: the list path
never threaded the caller's token, so every tools/list hit the no-subject branch. v1 masked this with
its client_credentials fallback (discovery used a service token); v2 dropped that fallback, so listing
had no credential and the OBO server's tools never appeared - and an MCP client lists before it calls.
Thread the inbound subject_token into the list path the same way the call path does, gated on
auth_type oauth2_token_exchange so the caller's bearer never leaks into other modes:
_get_tools_from_server takes an oauth2_headers param, extracts the token via _extract_bearer_token, and
passes it to _create_mcp_client; server.py forwards oauth2_headers at the list call site.
authorization_code (resolves off identity plus stored token), the static/config modes, and the
background registry refresh are unaffected, and the list path's existing graceful degradation
(catch -> empty list) is preserved.
* fix(mcp): harden token_exchange OBO from the audit (strip, TTL/expires_in, subject_token_type)
- _should_strip_caller_authorization returns True for oauth2_token_exchange, so the inbound subject
token is never forwarded upstream raw - only the IdP-exchanged token is (matches authorization_code).
- _parse_expires_in accepts a JSON float / numeric-string expires_in, and _ttl_seconds caps the cache
TTL at the token's real remaining lifetime so a short-lived exchanged token is never served stale.
- to_server_spec normalizes a falsy subject_token_type to the default URN, parity with v1.
The subject/key disambiguation (never exchange the LiteLLM key; Authorization: Bearer <litellm-key>
support for /mcp) is intentionally a separate cross-cutting PR off staging, not part of this OBO work.
* fix(mcp): stop caller header bypassing OBO exchange; thread subject into prompts/resources
The per-server x-mcp-* override guard in _create_mcp_client only kept the v2 spec
for authorization_code, so a caller-supplied header silently disabled the RFC 8693
exchange on a token_exchange server and forwarded the raw bearer upstream. Extend
the guard to token_exchange so the exchange always runs and the caller cannot
substitute an arbitrary upstream credential.
prompts/list+get, resources/list+read, and resource-templates/list never threaded
the OBO subject token, so those operations failed closed (401 / empty) on a
token_exchange server. Thread the caller's bearer as the subject for those paths
too, gated on the token_exchange mode via a shared _obo_subject_token helper.
* fix(mcp): keep the OBO/authz_code resolver credential authoritative; centralize OpenAPI strip
A guardrail (e.g. MCPJWTSigner), static_headers, or any other injected Authorization could
shadow the resolver-owned credential for token_exchange / authorization_code servers, so the
upstream would receive e.g. the signer's JWT instead of the exchanged token and reject it. In
_create_mcp_client the resolver-owned credential now wins: a conflicting header is dropped and
the minted/stored token reaches upstream. No behavior change for none/passthrough/static modes,
where an injected Authorization still wins as before.
The OpenAPI/local _request_extra_headers forwarder gated its Authorization strip on
has_client_credentials only, so an OpenAPI-backed token_exchange server with
extra_headers:[Authorization] forwarded the raw subject token upstream and never exchanged. It
now uses the centralized _should_strip_caller_authorization so it matches the managed paths.
* feat(mcp): RFC 9728 challenge for token_exchange (OBO) unauthorized
OBO previously returned an opaque 401 (Bearer error="invalid_request") with no discovery
info, and any IdP exchange failure collapsed to a retryable 503. Now an OBO server behaves like
a standards-compliant OAuth resource server:
- A missing/rejected subject token returns the RFC 9728 / RFC 6750 challenge: 401 +
WWW-Authenticate: Bearer resource_metadata="...", error="invalid_token", so a spec-compliant
MCP client can discover the IdP, SSO, and retry with a fresh subject token.
- The protected-resource metadata for a token_exchange server advertises the JWT-auth issuer(s)
(JWT_ISSUER / litellm_jwtauth.issuers) as authorization_servers -- the IdP that issues and
validates the subject -- instead of the gateway.
- An IdP 4xx (subject rejected) is now a non-retryable 401 (the challenge) instead of a 503, so a
caller with a dead token re-authenticates rather than looping; 5xx/transport stays retryable 503.
* fix(mcp): emit the OBO RFC 9728 challenge preemptively so a no-subject client can discover the IdP
A token_exchange server's tools are not discoverable without a subject token (list is lenient ->
empty), and a tool-call-time 401 is wrapped into a JSON-RPC error so the WWW-Authenticate header is
lost. So a cold-start client never saw the challenge and could not start discovery. Add a
token_exchange branch to the preemptive-401: a no-subject connect to an OBO server now returns
401 + WWW-Authenticate: Bearer resource_metadata=..., error="invalid_token" at the transport level,
so a spec-compliant client discovers the IdP (the PRM advertises the JWT-auth issuer), SSOs, and
retries with a subject token. Verified live on the per-server endpoint; the with-subject connect
still proceeds (no challenge).
(Also formats two lines from earlier commits in this stack.)
* refactor(mcp): inject root_path into the OBO/OAuth challenge edge
The adapter's raise_user_oauth_challenge and raise_token_exchange_challenge
reached into os.getenv("SERVER_ROOT_PATH") via get_server_root_path(), a
hidden ambient read in a module that is meant to be a pure edge. That coupling
made the preemptive-challenge test order-dependent under xdist: a sibling test
sets SERVER_ROOT_PATH at import without cleanup, leaking the prefix into the
challenge URL and failing the exact-match assertion.
Resolve the root path at the imperative-shell call sites and pass it in
keyword-only, so both challenge builders become pure functions of their inputs.
Extract the shared resource_metadata path construction into a single
oauth_protected_resource_path helper, collapsing the duplicated prefix/name
logic the two functions carried.
Also reduce _create_mcp_client below the strict complexity ceiling by extracting
the v2 credential resolution into _resolve_v2_auth, and extract the OBO
protected-resource-metadata branch into _obo_protected_resource_response (which
shipped without coverage) so discovery can be unit-tested directly.
Tests are now hermetic: the adapter tests pass root_path as a real input rather
than monkeypatching the environment, the stale-session preemptive test asserts
structural invariants instead of the exact prefixed URL, and five new tests
cover the OBO PRM issuer branch end to end.
* feat(mcp): OBO cache-key tenant isolation, reactive 401 retry, v1-parity logs
From a pass over the OBO behavior contract. Three changes to the
token_exchange arm, none of which alters any other auth mode.
The exchanged-token cache key now folds in the caller's tenant alongside
the subject token and exchange config, so two tenants presenting the same
opaque token can never share a cache entry; cross-tenant isolation is
structural rather than incidental to subject-token uniqueness. tenant_id is
threaded from the resolver's Subject; it is keyword-only with an empty
default so the no-tenant case and the existing call sites are unchanged.
The tool-call path gains one reactive retry. When an upstream rejects the
injected token with a 401/403, the gateway invalidates the cached exchange,
re-mints once through the IdP by rebuilding the client, and retries the call
exactly once before surfacing the upstream error, so a token revoked or
rotated upstream mid-TTL self-heals without an infinite loop. It is gated
strictly to oauth2_token_exchange; passthrough, authorization_code,
client_credentials, api_key, and none keep their single-call behavior.
MCPClient.call_tool gains a raise_on_error flag (mirroring list_tools) so
the path can tell an upstream 401 apart from an ordinary tool error and
avoid re-running a non-idempotent tool on a non-auth failure.
The exchanger also emits the v1-parity log lines it had dropped (attempt
with server, endpoint and audience; success; cache hit), while never
logging the form, subject token, secret, or minted token.
* fix(mcp): fail closed with 412 when a token_exchange server has no endpoint
A true token_exchange (OBO) server must use only an explicitly configured
token endpoint; it must never guess an IdP or silently fall back to a weaker
source. Previously an OBO server with client credentials but no
token_exchange_endpoint/token_url deferred to v1, which no-op'd and let the
request connect to the upstream with no credential (an upstream 401 rather
than a clear gateway error).
Now such a server is owned by the v2 arm: _token_exchange_spec builds the spec
even when the endpoint is absent, and the exchanger fails closed with a
precondition_required error that maps to HTTP 412 before any upstream or IdP
call, with the caller's subject token never sent anywhere. A missing
client_id/secret still maps to misconfigured (500); a present-but-rejected
subject still maps to 401; an unreachable IdP still maps to 503. The no-subject
case keeps its existing 401 RFC 9728 challenge.
* feat(mcp): log a refused non-Bearer token_type in the OBO exchange
* fix(mcp): surface OBO/authorization_code list-time 401 as a challenge instead of masking it
* feat(mcp): classify RFC 6749 gateway-fault token-exchange errors as 500, not a caller 401
* test(mcp): absorb fixture uses 500 now that 401/403 are challenge-class at list time
* style(mcp): PEP 604 union in the OBO retry signature to keep the UP007 budget flat
* feat(mcp): v2-native RFC 8693 token exchanger for the token_exchange mode
Adds the pure Rfc8693TokenExchanger plus its composition root: the OBO exchange POSTs the
RFC 8693 grant through an injected HTTP edge and returns the upstream-bound token as a typed
Result, caching and single-flighting per (subject_token, server) so a repeated caller token
skips the IdP round-trip. The audience is carried on TokenExchangeConfig and sent only when the
operator set one, matching the spec default behavior. Errors are values: a missing endpoint or
client credential is misconfigured, an IdP that returns no usable token is upstream_unavailable.
* feat(mcp): migrate the token_exchange arm to the v2 resolve_credentials
Routes RFC 8693 OBO servers through the v2 resolver: the resolver arm reads the caller's
inbound token and swaps it via the injected TokenExchanger, to_server_spec maps a complete
oauth2_token_exchange server (endpoint plus client credentials) to TokenExchangeConfig, and the
egress wires the LazyTokenExchanger in. A token_exchange server with no caller token fails closed
with a plain 401 rather than v1's fall-through to client_credentials, so the call site now scopes
the per-server browser-OAuth challenge to authorization_code and lets other modes raise their own.
* fix(mcp): bind the token-exchange cache key to the exchange config
The exchanged-token cache was keyed only by (subject_token, server_id), so rotating a server's
audience, scope, endpoint, client_id, or secret kept serving a token minted for the old config
until TTL. The key now hashes the caller token together with the config that minted it, so a config
change forces a fresh exchange. Everything is hashed, so no secret is held in the key.
* refactor(mcp): build the token exchanger eagerly, dropping the lazy wrapper
The token exchanger reads no runtime global at build time (its httpx client is acquired per call),
unlike the per-user store, so it does not need lazy first-use construction. Building it once at
egress construction removes the first-use init path entirely and keeps the process-lifetime cache.
* fix(mcp): map non-object token-exchange JSON to a miss instead of a 500
The post adapter annotated the parsed body as a dict without checking it, so a valid-but-non-object
JSON response (list/string/number) was returned as-is and crashed the field parsing with an
AttributeError. It now validates the shape at the boundary and returns None for a non-object body,
so a malformed IdP response surfaces as a typed upstream_unavailable rather than a server error.
* fix(mcp): fail closed on a non-Bearer token_type in the OBO exchange
* feat(mcp): honor token_endpoint_auth_method (client_secret_basic) in the v2 OBO exchange
* feat(mcp): reject a non-access issued_token_type in the OBO exchange
* fix: include token endpoint auth method in exchange cache key
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
_with_resolved_session_model was overwriting the nested
input_audio_transcription.model and audio.input.transcription.model with the
realtime conversation model, silently replacing a caller's transcription model
(e.g. whisper-1) since those are a different model than the realtime deployment.
It now only resolves the top-level session model.
Also restores session.model taking precedence over the top-level model in
acreate_realtime_client_secret, matching the proxy's own
_prepare_client_secret_session ordering and avoiding a backwards-incompatible flip.
Adds routing coverage for arealtime_calls (api_base resolution) and
acreate_realtime_transcription_session (api_key resolution) so all three realtime
HTTP endpoints have router credential-resolution tests, plus regression tests for
the two fixes above.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(mcp): add all-proxy-mcpservers sentinel to grant every MCP server
Teams can now be scoped to the all-proxy-mcpservers sentinel so they gain
access to every MCP server on the proxy without listing each id. The
sentinel expands to the live registry at request time, so a server added
later is picked up with no change to the team's stored permission. The team
ceiling that validates a key's MCP scope expands the sentinel too, so a key
can be scoped to any server (including one registered after the team) and
still pass subset validation
Expose the option in the team create and edit forms via a new exclusive
"All Proxy MCP Servers" choice in MCPServerSelector, mirroring the existing
"No MCP Servers" sentinel
* Update litellm/proxy/management_helpers/object_permission_utils.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(mcp): honor all-proxy-mcpservers only on the team path, never per-key
The sentinel was expanded inside the shared expand_permission_list, which
also feeds the key, org, end_user and agent resolvers. A key whose stored
object_permission ever held all-proxy-mcpservers (a stale write, a
configured default, or a bug) would silently resolve to every MCP server at
runtime, and a teamless key had nothing to cap it, so all servers got
injected. Only write-time validation stripping the value stood between that
value and a full grant
Move the expansion out of expand_permission_list and into
_get_allowed_mcp_servers_for_team so the sentinel is honored only where it is
settable (a team). Anywhere else it now passes through as an inert literal
that matches no registered server and is denied downstream. Reserved-id
protection already blocks a real server from taking that id
* fix(mcp): require proxy admin to grant a team the all-proxy MCP sentinel
Granting a team every MCP server on the proxy is a proxy-wide authorization
decision, but team create/update let any caller who can manage a team set
object_permission.mcp_servers, with no ceiling check. Org admins reach
/team/update by default (org_admin_allowed_routes) and _verify_team_access
also admits team admins, so a non-proxy-admin could set all-proxy-mcpservers
and self-grant their team access to every MCP server on the proxy, including
servers never assigned to that team
Gate the grant in new_team and update_team: a non-proxy-admin cannot add the
all-proxy-mcpservers sentinel. The check is scoped to newly adding it, so a
team a proxy admin already scoped to all-proxy can still be edited by a team
admin without being forced to strip the sentinel. The UI only offers the
"All Proxy MCP Servers" option to proxy admins in the team create and edit
forms
* fix(ui): render friendly all-proxy MCP label for non-admins editing an all-proxy team
A team scoped to the all-proxy-mcpservers sentinel could be opened in the team
edit form by a team admin or org admin (canEditTeam admits them), but the
"All Proxy MCP Servers" option in MCPServerSelector was rendered only behind the
proxy-admin-gated allowAllProxyMcpServers flag. For a non-proxy-admin the stored
sentinel was hydrated into the selected value with no matching Select.Option, so
antd showed the raw all-proxy-mcpservers literal as a chip, and adding another
server could persist a mixed [all-proxy-mcpservers, <id>] value.
Render the option whenever the sentinel is present in the value, not only when
the caller may grant it, and drive the real-option disabling off presence too so
the selection stays exclusive. A non-proxy-admin now sees the friendly label
read-only and cannot build a mixed state; only a proxy admin can newly add it,
which the backend already enforces.
Adds regression tests: the selector shows the friendly option (not the raw
literal) when the sentinel is stored but the grant flag is off, plus exclusive
emit and disabled-real-options coverage, and MCPServerPermissions renders the
green "All" state instead of the raw sentinel string.
* fix(ui): drop redundant "All servers" hint from the all-proxy MCP chip
antd renders a Select option's children inside the selected tag, so the
all-proxy option showed both "All Proxy MCP Servers" and the green "All servers"
type-hint in the chip, which say the same thing. Collapse the option to a single
green "All Proxy MCP Servers" label so the dropdown row and the chip read cleanly
without the duplication.
* fix(ui): color the all-proxy MCP label blue to match server chips
Use the same blue (#1890ff) as regular MCP server entries for the
"All Proxy MCP Servers" option/chip instead of green.
* fix(ui): make the all-proxy MCP permissions display blue, not green
Match the blue used by the selector chip and regular server entries so the
"All Proxy MCP Servers" badge and row in MCPServerPermissions are consistent
across the team/key/org detail views. The red "Blocked" state for
no-mcp-servers is unchanged.
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Realtime client_secrets, calls, and transcription_sessions were bypassing
the router and falling back to an empty OPENAI_API_KEY for wildcard, team-scoped,
and credential-name deployments.
Co-authored-by: Cursor <cursoragent@cursor.com>
decrypt_value_helper logged `Unable to decrypt value={value}` at DEBUG, which
printed the raw secret whenever decryption failed (for example after a salt or
master key change). This is the same environment_variables config path the
db-config redaction covers, so a DATABASE_URL connection string could still
leak here when the module regex scrubber is bypassed. Drop the value; the key
already identifies the failing pair.
Regression forces a decrypt failure with the redaction filter disabled and
asserts the raw value never reaches a log record while the key stays visible.
Reuse the existing recursive `_redact_secret_values_in_obj` for the worker
config log instead of a hand-rolled top-level pass, so a credential nested
under general_settings is masked at any depth and depth overrun fails closed.
Route the `_update_config_from_db` param_value log (the store_model_in_db
path) and the litellm_settings apply-loop log through the same redactors, so
master_key, database_url, and secret-named settings such as api_key stop
leaking at DEBUG when the module regex scrubber is bypassed. A plain setting
like num_retries still logs its real value.
Regression tests disable _ENABLE_SECRET_REDACTION and cover the nested worker
config shape, the db-config path, and the litellm_settings loop in both
directions.
Three startup log statements in litellm/proxy/proxy_server.py dumped
secret-bearing values in cleartext when the last-line-of-defense regex
scrubber was bypassed (LITELLM_DISABLE_REDACT_SECRETS=true, older versions
that predated the SecretRedactionFilter, or any downstream handler that
snapshots log records before the module filter runs)
ProxyConfig._load_alerting_settings logged the whole general_settings
dict under a label that only referred to the alerting callbacks; a
copy-paste bug that happened to leak master_key, database_url, and every
other secret sitting in general_settings. Now logs only the alerting
callback list
ProxyConfig.load_config logged the resolved DB URL after secret-manager
resolution. The line's stated purpose was to confirm the retrieval ran,
which does not need the value. Now logs a value-less breadcrumb
proxy_startup_event logged the raw WORKER_CONFIG blob, which docker/K8s
deployments hand the proxy as a JSON string containing master_key,
database_url, and provider API keys. Now routes through
_redact_worker_config_for_logging, which combines the segment-matching
SensitiveDataMasker (catches master_key, api_key, *_token) with an
explicit pass over _EXTRA_SECRET_GENERAL_SETTINGS_FIELDS (catches
database_url and other credential-URL fields the segment masker misses)
Regression tests disable the module-level SecretRedactionFilter so
assertions see the raw record; without the fix they would trip on the
secret substring, so a future refactor cannot silently reconstruct the
leaky string
- Prevent fail-open from registering user-supplied hashes as valid for
CCR retrieval; _call_compress now returns (messages, compressed_ok)
so apply_guardrail skips hash extraction and tool injection when
compression did not succeed
- Remove Optional wrapper from HeadroomGuardrailConfigModel.unreachable_fallback
to match BaseLitellmParams typing
- Add fail_open tests for non-JSON response, missing messages key, and
empty message list paths
- Add regression test verifying fail_open does not authorize attacker-planted
hashes
- Regenerate dashboard API types
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
async_handler.post catches httpx.TimeoutException and re-raises it as
litellm.Timeout (a subclass of openai.APITimeoutError). The except
blocks in _call_compress and _call_retrieve only listed httpx exception
types, so litellm.Timeout propagated uncaught and bypassed the
unreachable_fallback=fail_open path.
Add litellm.Timeout to both except clauses and add regression tests for
the fail_closed and fail_open timeout paths.
* fix(mcp): persist DCR client_id so interactive OAuth token refresh works
Interactive authorization_code MCP servers register an OAuth client via Dynamic
Client Registration (RFC 7591) during the authorize flow, but the minted
client_id and the discovered token_url were returned to the caller and never
written to the server row. The autonomous refresh_token grant reads client_id,
client_secret and token_url off the server, so an expired access token could not
be refreshed; the user was bounced back to re-authorize and tools/list returned
zero tools
Persist the DCR client_id (plus client_secret and token_endpoint_auth_method when
the registration returns them) and the discovered token_url onto the server row,
reusing the encrypt_credentials write that client_credentials and token exchange
already use, then refresh the in-memory registry so the value is live at refresh
time. Both the v1 refresher and the v2 AuthorizationCodeRefresher read those same
fields, so egress needs no change
* fix: reuse persisted MCP DCR clients
* fix: reuse persisted MCP DCR clients
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
litellm's async httpx client already calls raise_for_status() internally, so a
non-2xx /v1/compress response surfaced as an uncaught httpx.HTTPStatusError
instead of going through the guardrail's status_code check. Caught live by
running the guardrail against a mock headroom endpoint that returns 500:
unreachable_fallback=fail_open silently failed to forward the request until
this fix.
The bulk-update entrypoints `/key/bulk_update` and `/team/key/bulk_update`
route through `_process_single_key_update`, not through
`_validate_update_key_data`, so the `permissions` gate LIT-4092 wired
into the single-key path never fires on bulk. Currently safe by
construction: `BulkUpdateKeyRequestItem` doesn't declare `permissions`
(Pydantic silently drops it), and `KeyUpdateFields` uses
`model_config = ConfigDict(extra="forbid")` (Pydantic 422s at parse
time). Neither structural barrier is enforced by tests on the field
itself; a future widening of either allowlist to include `permissions`
would reopen the class silently.
This wires `_check_permissions_caller_permission` into
`_process_single_key_update` right after `_validate_max_budget`, before
`prepare_key_update_data`. Zero behavior change today for any caller
routing through the current bulk request models; a defense-in-depth
gate for the class.
Tests:
- test_process_single_key_update_non_admin_permissions_rejected
- test_process_single_key_update_non_admin_permissions_explicit_empty_rejected
Both mutation-killed against removing the gate. Full mapped test file
(341 tests) green.
`_check_allowed_routes_caller_permission` previously keyed its
admin-only rule on truthiness. The refactor adds an `allowed_routes_was_provided`
keyword param that raw-body call sites populate from
`"allowed_routes" in data.model_fields_set`, so a caller that omits
the field (default flows through) is distinct from one that sends
any explicit value.
Four raw-body call sites now pass `allowed_routes_was_provided=...`:
`_common_key_generation_helper`, `generate_service_account_key_fn`,
`_validate_update_key_data`, and `regenerate_key_fn`.
Two derived-value call sites keep the pre-fix shape: the
post-`handle_key_type` recheck at `_common_key_generation_helper`
and the mirror in `regenerate_key_fn`. Both pass values produced by
`handle_key_type` (not by the request body), so `allowed_routes_was_provided` stays
False and the `allow_safe_presets=True` carve-out continues to accept
the `llm_api_routes` / `info_routes` presets.
In `regenerate_key_fn` the gate runs before the `premium_user`
license check, matching the LIT-4092 ordering.
`test_non_admin_regenerate_key_allowed_routes_rejected_before_enterprise_gate`
pins the ordering; it fails on a swap of the two gates.
Tests in `tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py`
under `TestAllowedRoutesCallerPermission`:
- test_non_admin_generate_key_explicit_empty_allowed_routes_rejected
- test_non_admin_update_key_explicit_empty_allowed_routes_rejected
- test_non_admin_update_key_explicit_null_allowed_routes_rejected
- test_non_admin_regenerate_key_explicit_empty_allowed_routes_rejected
- test_non_admin_regenerate_key_allowed_routes_rejected_before_enterprise_gate
- test_helper_accepts_derived_safe_preset_for_non_admin
- test_helper_rejects_derived_unsafe_preset_for_non_admin
- test_helper_rejects_when_provided_and_none_without_typeerror
The four attack-vector tests fail on the pre-fix HEAD and pass on this
commit. Three helper-level tests pin the derived-value branch and the
load-bearing None guard; each is mutation-killed against a targeted
change to the frozenset or the guard. Full mapped test file (347 tests)
green.
`NewUserRequest` and `UpdateUserRequest` inherit `permissions` from
`GenerateRequestBase`. `/user/new` passes the field into
`generate_key_helper_fn` which persists it on the auto-created key,
so an org admin who lands on `/user/new` (the route allowlist accepts
org_admin callers when the request body names an org where they hold
that membership) can mint a key with proxy-wide capabilities such as
`get_spend_routes`.
This wires the existing `_check_permissions_caller_permission` helper
into `new_user` and `_update_single_user_helper`. The helper's presence
check keys on `data.model_fields_set`, so an omitted field flows
through untouched and an explicit `{}` / `null` from a non-admin is
rejected 403 the same as any other value.
`_update_single_user_helper` is shared by `/user/update` and
`/user/bulk_update`, so both paths inherit the gate.
Tests in
`tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py`:
- test_new_user_non_admin_permissions_non_empty_rejected
- test_new_user_non_admin_permissions_explicit_empty_rejected
- test_new_user_non_admin_omits_permissions_succeeds (control)
- test_new_user_admin_can_set_permissions (control)
- test_update_single_user_non_admin_permissions_rejected
- test_update_single_user_non_admin_permissions_explicit_empty_rejected
The four attack-vector tests fail on the pre-fix HEAD and pass on this
commit. Full mapped test file (79 tests) green.
Reuses the existing unreachable_fallback flag (already implemented by
generic_guardrail_api, akto, vigil_guard, repelloai) so headroom compression
failures can forward the request uncompressed instead of blocking it with a
502.
_enforce_key_and_fallback_model_access and can_key_call_resolved_model
both unconditionally skipped key-level model checks whenever
all-team-models was present, without verifying the key actually belongs
to a team. PR #29746 fixed the listing path (get_key_models) but these
two call-path checks were left untouched, letting teamless keys call
any model via /chat/completions while seeing an empty model list.
Add team_id is not None guard to both bypass conditions so teamless
keys fall through to can_key_call_model, which already correctly
rejects unresolvable all-team-models sentinels
Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(prometheus): bound per-request budget metric emission with a timeout (#31632)
* fix(prometheus): bound per-request budget metric emission with a timeout
Wrap the per-request budget-metric gather in asyncio.wait_for so a slow Redis or DB lookup cannot consume the whole LoggingWorker watchdog and get the success-logging event cancelled. On timeout the emission is skipped in isolation; budget gauges are still refreshed by the periodic cron. The timeout is configurable via PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT and defaults to 5.0 seconds, falling back to the default on an invalid value instead of raising
* fix(prometheus): reject non-finite and non-positive budget-metrics timeout env
float() accepts 0, negatives, nan and inf, which bypass the fallback: a value <= 0 makes asyncio.wait_for time out immediately and skip every per-request emission, and inf reintroduces the unbounded wait the timeout was meant to bound. Validate the parsed value is finite and greater than zero before using it, otherwise fall back to the default
* fix: report the blocked LLM response's real token usage (#31217)
When a guardrail blocks a post-call response, the synthetic violation response
reported hard-coded zero usage, discarding the token usage the upstream call
had already consumed.
Fix the root cause rather than re-counting tokens:
- Add an optional `original_response` field to ModifyResponseException.
- The unified guardrail's post-call success hook attaches the blocked LLM
response to the exception.
- The /v1/messages and OpenAI-format (/v1/chat/completions, /v1/completions)
block handlers report `original_response.usage` directly. Pre-call blocks
never invoked the LLM, so usage is zero.
Mock-based tests cover the helper (returns original usage / zero), the success
hook attaching original_response, and the endpoint reporting it end-to-end.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(guardrails): buffer + cleanly terminate streamed responses on block (#31389)
Streaming moderation improvements for the unified guardrail post-call
streaming iterator hook:
- streaming_buffer_until_moderated: withhold all chunks until end-of-stream
moderation passes, then release the original response (clean) or only the
block message (blocked) -- the original content is never delivered on a
block. Snapshot chunks with a shallow list() copy (end-of-stream builds a
separate assembled response; chunks aren't mutated in place).
- Clean Anthropic SSE on block: synthesize a well-formed termination sequence
instead of a bare data: {"error": ...} blob that truncates the stream.
Provider-specific synthesis lives in AnthropicMessagesHandler via
build_block_sse_chunks (format-agnostic routing stays in the hook).
- Mid-stream blocks continue the in-progress message (close open content
block, append block message, terminate) rather than emitting a second
message_start, which clients reject. Standalone envelope only when no chunks
were sent (buffered path).
- ModifyResponseException imported under TYPE_CHECKING + locally at runtime to
avoid a module-level cyclic import.
Adds regression tests for buffering (content withheld on block) and mid-stream
continuation (single message_start).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: report real usage on streaming blocks, disable buffered mode for content-rewriting guardrails
- _standalone_block_chunks and _block_continuation_chunks now read real
token usage from ModifyResponseException.original_response instead of
hardcoding zero, matching the non-streaming _blocked_response_usage path.
Shared helper moved to guardrail_translation/utils.py.
- streaming_buffer_until_moderated is now forced off when the guardrail has
mask_response_content=True, since buffered replay releases the withheld
original chunks verbatim -- unsafe for a guardrail that rewrites content
(e.g. PII masking).
- Fix inverted streaming-flag precedence comment.
* style: ruff format after greploop fixes
* fix: handle Anthropic streaming guardrail blocks
* fix(responses): check terminal event type for streaming guardrail end-of-stream detection
_check_streaming_has_ended assumed responses_so_far held ModelResponse
objects with .choices, but for the Responses API the accumulated chunks
are raw SSE event dicts, causing an AttributeError on every call
* fix: preserve Anthropic blocked stream usage
---------
Co-authored-by: FERNANDO IZAR <fizar@me.com>
Co-authored-by: Joseph Barker <156112794+seph-barker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>