Commit graph

6293 commits

Author SHA1 Message Date
Devin AI
8dcc631dbb fix(ui): wire up cache type selectors and semantic round-trip
Backend derivation, field/type definitions, index state, and lint baseline for the Cache Type / Redis Deployment Type split.
2026-07-09 14:20:55 +00:00
Yassin Kortam
cda99a08c8
fix(proxy): surface OAuth error params in SSO callback (#32433)
When an IdP denies SSO access it redirects back to /sso/callback with
error and error_description query params and no code param. The callback
previously fell through to the provider token exchange, which failed
with a generic "'code' parameter was not found in callback request"
400 that hides the real denial reason. Raise a 401 that surfaces the
IdP's error and description instead.

Ported from #26640 with conflicts resolved against current staging
2026-07-09 11:13:13 +03:00
Yassin Kortam
1d87084212
refactor(otel): move litellm error detail keys under the litellm.* namespace (#32591)
The v2 OTel integration stamped litellm-specific error details as
error.code, error.stack_trace, and error.llm_provider, squatting on the
semconv-owned error.* namespace. They now live at
litellm.provider.error.code, litellm.provider.error.stack_trace, and
litellm.provider.error.llm_provider alongside the other vendor-extension
keys. error.type and error.message stay on the semconv keys.
2026-07-09 00:51:37 -07:00
yucheng-berri
e84a19acd5
fix(guardrails): walk Responses-API text taxonomy in shared content helpers (#32542)
* fix(guardrails): walk Responses-API text taxonomy in shared content helpers

Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.

Three defects, all in _content_utils.py:

1. _iter_text_parts_in_content recognised only part.type == "text", but the
   Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
   Responses input list containing a function_call or function_call_output
   item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
   tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
   reject with a schema error.

Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.

* style: ruff-format changed guardrail files

* test(guardrails): cover function_call_output string form; drop em-dash in new docstring

* fix(guardrails): map function_call_output straight to user role

Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.

* test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages

* docs(test): soften AIM-specific claims in LIT-4294 test docstrings

Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.

* refactor(guardrails): move unsupported-role coercion into AIM only

The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).

AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.

function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.

* refactor(guardrails): preserve role fidelity in shared _content_utils

Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).

Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
  keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
  behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
  of the chat-completions tool message shape) instead of role user, so
  Responses and chat completions produce symmetric inspection payloads.
  A caller-supplied role on the item is still preserved.

AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.
2026-07-08 23:24:11 -07:00
Mateo Wang
142d5aa12b
fix(bedrock): honor cache_control ttl on message-level cachePoint blocks (#32551)
Bedrock Converse supports cachePoint ttl (1h GA for Claude 4.5+), and
_get_cache_point_block maps cache_control.ttl -> cachePoint.ttl, but the
model parameter its allow-list gate requires was only threaded through
the system-message path. Every message-level path either called
_get_cache_point_block without model= (8 call sites in
_bedrock_converse_messages_pt / _pt_async) or hardcoded
CachePointBlock(type="default") (tool-result blocks and
_convert_to_bedrock_tool_call_invoke), so a requested 1h ttl silently
degraded to the 5-minute default - exactly on the conversation-tail
breakpoint that long-running agents need to survive tool calls longer
than 5 minutes.

- pass model= at the 8 _get_cache_point_block call sites
- tool-result blocks: capture the cache_control dict (was a boolean)
  and route through _get_cache_point_block so ttl survives
- _convert_to_bedrock_tool_call_invoke: accept optional model and route
  per-tool-call cache_control through _get_cache_point_block

Completes the ttl support added for system messages (#19848, #20326):
message-level cache_control now behaves identically.

Note: message-level cache_control on a content-less assistant message
emits no cachePoint at all today; that pre-existing gap is orthogonal
to ttl and left out of scope (per-tool-call placement covers it).

Co-authored-by: Arash <arashne@glia-ai.com>
2026-07-08 20:54:40 -07:00
ryan-crabbe-berri
febb27695b
refactor(ui): point invitation links at the dedicated /onboarding route (#30857)
* refactor(ui): point invitation links at the dedicated /onboarding route

Invitation and reset-password links were built as /ui?invitation_id=..., which lands on the dashboard index and renders the onboarding form inline. They now point at the standalone /ui/onboarding route, so the index no longer has to special-case invitations. Old links keep working unchanged; the index still renders onboarding inline for ?invitation_id until the migration closeout removes that branch.

Updates the three generators (the enterprise email builder, bulk user create, and the invitation/reset-password modal) and extracts the modal's URL building into a pure, unit-tested buildOnboardingUrl

Refs LIT-3687

* refactor(ui): guard buildOnboardingUrl against a missing invitation id

Return "" instead of emitting an invitation_id=undefined link when the id is not yet available, matching the existing empty-baseUrl guard. Placed after the SSO branch so the SSO link, which does not use the id, is unaffected

Refs LIT-3687
2026-07-08 19:47:05 -07:00
devin-ai-integration[bot]
9d745486d0
fix(rerank): log optional_rerank_params at debug to stop leaking request content (#32533)
* fix(rerank): log optional_rerank_params at debug not info to avoid leaking request content

* test(rerank): exercise sync rerank path so coverage counts the log line

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-08 18:48:03 -07:00
Mateo Wang
5aa5b8ef32
fix(vertex): forward realtime health check params (#32550)
* fix(vertex): forward realtime health check params

* refactor(vertex): resolve realtime health check params via VertexBase helpers

Address review feedback on the vertex param forwarding: pass model_params
through to _realtime_health_check and resolve vertex credentials, project,
and location inside the vertex_ai branch using the existing
VertexBase.safe_get_vertex_ai_* helpers, so provider-specific key extraction
no longer lives in litellm_core_utils and dict-typed vertex_credentials are
supported

* test(vertex): move realtime health check test to mapped unit test path

codecov/patch reported the vertex branch of _realtime_health_check as
uncovered because tests/litellm_utils_tests is not part of the unit test
groups that upload coverage. Move the test into
tests/test_litellm/litellm_core_utils/test_health_check_helpers.py, which
the core-utils group runs, keeping the same end-to-end assertions through
litellm.ahealth_check

---------

Co-authored-by: Aleksandr Liadov <72351793+AleksandrLiadov@users.noreply.github.com>
2026-07-08 17:30:46 -07:00
tin-berri
4e6ec995e7
Merge pull request #31989 from BerriAI/litellm_mcp_passthrough_delegate_modes
feat(mcp): add true_passthrough and oauth_delegate auth modes
2026-07-08 17:16:37 -07:00
Tin
b2ea36f4f1 fix(mcp): match sanitized per-server alias at the connect-time preemptive 401
The connect gate resolved x-mcp-{alias}-authorization by matching the raw
lowercased alias/server_name/name only, but dashboard clients send
x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization, and egress resolves
those through lookup_mcp_server_auth_in_headers, which also tries the sanitized
alias. So a per-server token bound with a sanitized alias (e.g. alias 'pt-server'
arriving as header key 'pt_server') was forwarded at egress but still triggered a
preemptive 401 at connect. _client_has_per_server_auth_header now resolves through
the same lookup_mcp_server_auth_in_headers egress uses, so connect and egress
agree on which header names match.
2026-07-08 16:22:05 -07:00
Tin
ddec3b2b8b fix(mcp): plug fan-out Authorization bypass in the extra_headers loop
The listing fan-out withholds the request-wide Authorization from a
true_passthrough / oauth_delegate server when another server in scope also
consumes it, so one bearer is not replayed across upstreams. The later
server.extra_headers copy loop did not honor that decision: a server listing
Authorization in extra_headers would re-copy the withheld bearer from
raw_headers. The withhold decision is now computed once and applied to both
the forwarding branch and the extra_headers loop.
2026-07-08 15:46:39 -07:00
Tin
edf00bbe23 fix(mcp): recognize per-server auth header at the connect-time preemptive 401
The preemptive 401 for true_passthrough and oauth_delegate only inspected the
request-wide Authorization, so a caller who bound the upstream token via the
per-server x-mcp-{alias}-authorization header (the required shape in a
multi-server aggregate, where the request-wide Authorization is withheld) was
spuriously 401'd at initialize even though egress already honors that header.
The gate now recognizes the per-server header for both modes via a shared
helper, mode-correctly: true_passthrough treats any Authorization or the
per-server header as the upstream token, oauth_delegate keeps requiring a
distinct x-litellm-api-key so a lone Authorization consumed for admission is
never mistaken for an upstream token. The preemptive raise is also gated to
single-server scopes so a multi-server aggregate degrades gracefully instead
of one missing token 401-ing the whole connect.
2026-07-08 15:44:36 -07:00
Tin
4a25cce114 fix(mcp): reject duplicate Authorization headers at MCP ingress
For the client-forwarded token modes the gateway relays the caller's
Authorization to the upstream, so a request carrying more than one
Authorization header would make which token is forwarded ambiguous (the
ASGI header list collapses to last-wins) and could diverge from what
admission inspected. Multiple Authorization headers is malformed for
bearer auth anyway (RFC 9110: not a comma-combinable field), so the
ingress header converter now fails closed with a 400 instead of silently
keeping one. Applies to every MCP request, not just passthrough.
2026-07-08 15:43:52 -07:00
yucheng-berri
528fa380f5
fix(guardrails): forward grayswan scan id header (#32544)
* fix(guardrails): forward grayswan scan id header

* test(guardrails): cover grayswan scan id forwarding

* fix(guardrails): prevent overwriting existing metadata headers when extracting scan id

* test(guardrails): cover header merging logic

* chore(guardrails): fix formatting

* test(guardrails): enforce case preservation

* chore(guardrails): corrected grayswan type annotations

* fix(guardrails): sanitized grayswan header metadata

* test(guardrails): covered grayswan logging headers

* fix(guardrails): guard grayswan header lookup against None and drop dead comment

- Fall back to {} when proxy_server_request is explicitly None so
  request_data.get(...).get('headers') never raises AttributeError.
- Remove the commented-out user_api_key_auth pop; it was inert and
  greptile called it out as ambiguous.

---------

Co-authored-by: Theodore Drzewinski <93957989+tediferJones@users.noreply.github.com>
2026-07-08 15:05:27 -07:00
devin-ai-integration[bot]
0f1e29b334
fix(bedrock): preserve cache_control ttl on message-level cache points (#32538)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
2026-07-08 14:00:24 -07:00
yucheng-berri
85d1fe6e2a
fix(otel): restore error.* span attributes on v2 error spans (LIT-4179) (#32524)
The v2 emitter has never stamped error.message / error.code /
error.stack_trace / error.llm_provider as span attributes; only error.type
reached the wire. Backends that flatten span attributes into label
indexes (Elastic APM labels.error_*, Datadog span tags) lost these
four fields when v2 became the active integration on v1.90+ for
otel_v2-flagged deployments. The pre-existing exception span event
carrying the full message (LIT-3758) is unchanged; the message now
rides both places at once, matching v1s shape.

SpanError grows three optional detail fields; _parse_error threads
them from StandardLoggingPayloadErrorInformation; the emitters error
branch stamps them via a new module-level helper, guarded per field so
guardrail-shape errors are not polluted with empty attributes. New
semconv constants mirror open_inference.ErrorAttributes byte-for-byte,
so v1 and v2 consumers read the same keys.

Regression tests extend the mapped test files under
tests/test_litellm/integrations/otel/. pytest reports 243 passed.
2026-07-08 13:44:48 -07:00
yucheng-berri
c3dccb54cf
fix(health): bridge litellm_metadata into logging object in _batch_health_check (#32520)
* fix(health): bridge litellm_metadata into logging object in _batch_health_check

* Update litellm/litellm_core_utils/health_check_helpers.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(health): address review - share single metadata copy, conditional api_base, add tests

- Only set api_base in litellm_params when a value actually exists;
  providers like bedrock/vertex/gemini resolve it implicitly and an
  empty string overwrites their resolution.
- Use a single .copy() for both metadata and litellm_metadata to
  prevent downstream drift between the two references.
- Add 6 unit tests covering metadata bridging, api_base omission,
  guard conditions, and dispatch routing.

Signed-off-by: pramod <pramod.b@pfizer.com>

* refactor(health): use update_from_kwargs helper for metadata bridge

Collapses the manual metadata/litellm_metadata plumbing in
_batch_health_check into a single update_from_kwargs call, matching
how the sibling batch/image/rerank/ocr surfaces bridge metadata onto
the pre-injected logging object. Drops the bare Dict typing and the
inline comment, and switches the tests to assert against the helper.

---------

Signed-off-by: pramod <pramod.b@pfizer.com>
Co-authored-by: pramod <pramod.b@pfizer.com>
Co-authored-by: Pramod B <155433727+BPRMD18@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-08 12:32:03 -07:00
tin-berri
86a9871ae9
Merge pull request #32507 from BerriAI/litellm_fix_mcp_token_exchange_secret_pairing
fix(mcp): pair token-endpoint client_secret with the same source as client_id
2026-07-08 11:47:08 -07:00
devin-ai-integration[bot]
93c047d52e
feat(proxy): make Microsoft Graph endpoint configurable for GCC High (LIT-4282) (#32517)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
2026-07-08 11:07:58 -07:00
David Katz
c0327cded4 fix(mcp): pair token-endpoint client_secret with the same source as client_id
On re-auth against a server with a persisted DCR client, register_client_with_server
short-circuits and returns a placeholder client_secret ("dummy") that the browser
echoes back to /token. exchange_token_with_server overrode the caller's client_id
with the persisted one but still fell back to the caller's secret when the server
had none stored, so a persisted public PKCE client (which has no secret) was paired
with the literal string "dummy" and the IdP rejected the exchange with 401 on every
re-authorization; the proxy surfaced that as a 500. First connects and brand-new
servers worked because a real DCR registration ran and no placeholder existed.
Resolve the secret from the server whenever the server's client_id wins, so a
secretless public client sends no client_secret at all
2026-07-08 10:43:19 -07:00
yucheng-berri
f982b67d78
fix(proxy): harden secret name validation for external secret manager integrations (LIT-4201) (#32092)
key_alias can become the secret name used by external secret manager
integrations (HashiCorp Vault, CyberArk Conjur) when store_virtual_keys is
enabled. Add raise_if_unsafe_secret_name, a shared validation check applied
unconditionally before a secret name reaches either integration or the
/key/generate, /key/update, and /key/regenerate API boundary, independent
of the existing enable_key_alias_format_validation opt-in flag.

Also hardens the Vault URL builder to percent-encode reserved characters
in secret_name (preserving "/" and "@"), and switches the Conjur policy
body to a real YAML serializer instead of raw string interpolation.
2026-07-08 10:36:00 -07:00
Mateo Wang
c2d8a17692
test(responses): replace perma-skip azure shell e2e with offline coverage (#32444) 2026-07-08 10:01:41 -07:00
Yassin Kortam
bfff5e8d86
fix(mcp): log MCP tool calls returning isError=true as failures (#32238)
An MCP tool call that completes with CallToolResult.isError=true correctly
returns HTTP 200 per the MCP spec, but the shared post-call logging helper
always fired async_success_handler, so the standard logging payload carried
status=success and OTel (whose _parse_error only marks ERROR on
status=failure) showed green spans for failed tools.

The helper now checks the result after async_post_mcp_tool_call_hook runs
(guardrails may flip isError there) and routes error results to the failure
path: success gates are consumed so the @client wrapper cannot enqueue a
success log, failure_handler and async_failure_handler fire with a new
MCPToolResultError carrying the tool's first text content, and
post_call_failure_hook records the failure the same way raised exceptions
already do. Raised exceptions never reach the helper, so no double failure
logging. HTTP wire behavior is unchanged

Resolves LIT-4081
2026-07-08 09:02:48 -07:00
Yassin Kortam
6f6bd45681
perf(auth): negative-cache missing user/key lookups on the request hot path (#32368) 2026-07-08 09:59:57 +03:00
tin-berri
1fb2b4aef4
fix(mcp): drop the cached per-user OAuth token when the credential row changes (#32302)
* fix(mcp): drop the cached per-user OAuth token when the credential row changes

The v2 authorization_code chain Cached(Refreshing(V2PerUserTokenStore)) caches a positive token
until its expires_at (or 300s without one), and CachedOAuthTokenStore.invalidate had no callers,
so a re-authorization or revocation wrote the DB while egress kept serving the replaced token
from the in-process cache until its TTL. LazyPerUserOAuthTokenStore now exposes invalidate,
MCPServerManager threads it to the write side, and the three credential write sites (the OAuth
callback, the Tools-tab persist endpoint, and the revoke endpoint) drop the cache entry after
the row changes. The v2 refresher's own persist stays untouched; RefreshingTokenStore already
feeds the rotated token back into the cache in the same fetch

* test(mcp): pin cache invalidation on the revoke already-gone branch

Greptile's review flagged that only the happy-path delete asserted the invalidate; a refactor
moving the call inside the try block would silently skip the cache drop when the row was
already deleted by a concurrent request while the cache still held the revoked token. The new
test fails on exactly that mutation

* test(mcp): cover invalidate on the redis-backed lazy store path

Codecov flagged the redis fast path of LazyPerUserOAuthTokenStore.invalidate as unexercised;
the existing invalidate tests only ran the no-redis chain. The new test builds the redis chain
via a fetch and asserts a subsequent invalidate reaches the same store instance without a
rebuild
2026-07-07 23:59:35 -07:00
Yassin Kortam
bcd52754de
feat(rate_limit): support per-tag rpm limiting on a single key (#31502)
Add a tag_rpm_limit field to virtual keys so each request tag gets its own independent RPM counter on the v3 rate limiter. A key configured with per-tag limits tracks each tag/group separately, and requests whose tag has no configured limit fall back to the key-level limit. Includes the dashboard UI to manage per-tag limits on key create and edit.

Resolves LIT-3147
2026-07-08 09:43:47 +03:00
tin-berri
d6cbf6e7e3
feat(ui): expose MCP max_concurrent_requests in server create and edit forms (#32397)
* feat(ui): expose MCP max_concurrent_requests in server create and edit forms

The proxy has enforced a per-server outbound tool-call concurrency cap
(max_concurrent_requests) across every MCP egress path since #31641, and the
management API has accepted the field on create and update all along, but the
dashboard offered no way to set it. Add an optional Max Concurrent Requests
input to the MCP server create and edit forms; it applies to every auth type
and transport, so it renders unconditionally rather than gated on auth mode.
Clearing the field on edit sends null so the stored limit is unset.

Also rebuild the per-server semaphore when the configured limit changes.
Previously the semaphore was created once per server_id and never resized, so
an edited limit only took effect after a proxy restart even though the new
value was persisted and reloaded into the registry.

* feat(ui): mark MCP max concurrent requests field label as optional

* test(ui): stop OBO create-form tests from timing out on CI

The token-exchange payload test and the Entra scope-required test filled five
text fields with user.type, which dispatches a full keystroke sequence per
character; every input event runs the antd form onValuesChange handler and
re-renders the whole CreateMCPServer tree, roughly 120 renders per test. As
the form grew the two tests reached 8s and 18s locally, which crosses the 30s
vitest timeout on slower CI containers; ui_unit_tests failed twice this way.
Switch the plain text fields to fireEvent.change (one input event per field),
matching the existing stdio test pattern. Both tests assert form output, not
keystroke behavior, and now run in about 3s each.
2026-07-07 22:47:03 -07:00
yuneng-jiang
ec4f324482
Merge pull request #32405 from BerriAI/litellm_kraken-remove-envref-gates
fix(proxy): resolve os.environ/ refs universally in DB-sourced models
2026-07-07 21:53:56 -07:00
tin-berri
f922be32f0
fix(mcp): accept integer progressToken in host progress capture (#32402) 2026-07-07 21:46:36 -07:00
Mateo Wang
b2e2a38bc0
fix(passthrough): stream non-sse passthrough responses instead of buffering in memory (#32386)
* fix(passthrough): stream non-sse passthrough responses instead of buffering in memory

Non-SSE passthrough responses were fully read into proxy memory (content = await response.aread()) before the first byte reached the client. For large non-JSON bodies such as Anthropic batch results jsonl files this ballooned proxy RSS to a multiple of the file size and produced near-total TTFB dead air, letting intermediaries kill the silent connection and truncate the download.

The upstream request is now sent with httpx stream semantics and the buffering decision is made from the response headers: application/json (and +json) bodies plus upstream errors keep the buffered behavior since spend logging, guardrails and managed-id rewriting inspect them, while every other 2xx body is relayed as a StreamingResponse that iterates upstream bytes without accumulating them, preserving status code and headers (including x-litellm-*) and firing the success-handler logging with response_body=None once the stream completes.

* fix(passthrough): log client disconnects mid-stream and derive test client cache key from production code

* test(passthrough): intercept AsyncClient.send in legacy passthrough tests and assert final wire params

* test(passthrough): fail with a clear assert when the passthrough client cache scan misses
2026-07-07 20:51:15 -07:00
Mateo Wang
734fd29e00
fix(utils): resolve bedrock regional inference profiles to regional pricing in get_model_info (LIT-4056) (#32389)
* fix(utils): resolve bedrock regional inference profiles to regional pricing in get_model_info (LIT-4056)

* test(register_model): use a triple provider prefix as the unresolvable-key fixture

get_model_info now resolves bedrock/bedrock/... like a routing prefix, so the
double-prefix fixture stopped exercising the register_model fallback path.
Lock the new double-prefix resolution in as a model-info regression test
2026-07-07 20:49:03 -07:00
thibault-linktree
4b0ac8b352
fix(responses): make response-id encoding idempotent to prevent MCP gateway double-encoding previous_response_id (#32034) 2026-07-07 19:47:11 -07:00
Tin
732832d342 fix(mcp): bind client-forwarded Authorization to a single upstream
Some checks failed
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
In a listing fan-out over a scope containing more than one server that
consumes the caller's Authorization (true_passthrough, oauth_delegate,
or the legacy delegate/passthrough shapes), the request-wide bearer is
now withheld from the new modes instead of being replayed against every
upstream (RFC 9700 cross-resource replay). Explicitly-addressed
operations (tool call, get_prompt, read_resource, single-server routes)
keep forwarding it.

Multi-server aggregates use the per-server x-mcp-{alias}-authorization
header instead: its value now feeds the passthrough resolver arm as the
inbound token and wins over the request-wide header, binding one token
to one server.
2026-07-07 19:46:23 -07:00
Mateo Wang
07aeaa17a0
fix(passthrough): stop request params from clobbering merged target query params (#32404)
* fix(passthrough): stop request params from clobbering merged target query params

* fix(passthrough): rewrite managed ids in query params before folding them into the URL
2026-07-07 18:56:56 -07:00
Mateo Wang
bd6cabee83
fix(model_prices): add gpt-realtime-2.1 models with regional processing uplift (#32387)
* fix(model_prices): add gpt-realtime-2.1 models with regional processing uplift

* fix(model_prices): add cache_read_input_audio_token_cost to gpt-realtime-2.1
2026-07-07 18:56:00 -07:00
yucheng-berri
5862be3e79 fix(proxy): resolve os.environ/ refs universally in DB-sourced models
Root cause: PR #30867 removed request-time os.environ/ expansion in
BaseAWSLLM.get_credentials. That is only safe if config-load pre-resolves
os.environ/ refs so the value reaching get_credentials is already the real
secret. The YAML config path has always done this. The DB-load path
(ProxyConfig._resolve_db_litellm_param) only re-expanded keys in a hardcoded
whitelist (_DB_LITELLM_PARAM_ENV_REF_KEYS) plus short-circuited env-ref
resolution entirely for team-scoped rows. PR #32256 extended that whitelist
to 18 keys to unblock a customer whose Bedrock model with aws_role_name:
os.environ/BEDROCK_ASSUME_ROLE_ARN broke on v1.90+, but the whitelist is
structurally fragile: every future auth field breaks the same way until
someone remembers to add it

Fix: remove the whitelist and the team-scope short-circuit. The DB-load
resolver now expands os.environ/ on every string field, matching the YAML
path. Trust boundary stays on the write side: only PROXY_ADMIN can create
team_id=None rows, only team admins of a team can create rows scoped to
that team, and the request-body vector is still blocked by
_BANNED_REQUEST_BODY_PARAMS. Team-scoped rows now resolve env refs — this
is a deliberate LIT-3831 threat-model expansion trusting team admins for
env-var reads

Regression tests in tests/test_litellm/proxy/proxy_server/test_proxy_config.py:
- test_ProxyConfig__add_deployment_resolves_env_refs_after_db_decrypt pins
  admin-scoped rows resolve every field (previously api_base stayed literal)
- test_ProxyConfig__add_deployment_resolves_team_env_refs pins team rows
  resolve env refs (previously stayed literal)
- test_ProxyConfig__add_deployment_resolves_env_refs_on_arbitrary_field pins
  the no-whitelist invariant against a made-up field name
- test_ProxyConfig__add_deployment_resolves_env_refs_for_aws_bedrock_auth_params
  (from #32256) still passes
- Path B counterparts (decrypt_model_list_from_db) mirror the above

Left as followups (not fixed here):
- /model/info and /v2/model/info still echo resolved values for fields not
  in the current pop-list (aws_role_name, aws_sts_endpoint, api_base, etc.).
  Fix is to extend remove_sensitive_info_from_deployment; separate PR
- Master-key rotation reads DB rows via decrypt_model_list_from_db which
  now resolves universally, so rotation collapses env-refs into hardcoded
  values. Pre-existing bug for the 6 previously-whitelisted fields; wider
  surface after this PR. Separate PR
2026-07-07 18:31:44 -07:00
Tin Chi Lo
9bf3907de5 fix(mcp): bind oauth_delegate discovery resource to the upstream
oauth_delegate forwards the caller's token to the upstream, which validates its
audience, so the protected-resource metadata must keep resource pointing at the
upstream (returned verbatim, like true_passthrough) rather than rewriting it to
the gateway. Rewriting to the gateway asks the client to mint a token bound to
the gateway audience, which a strict IdP (Entra) refuses to issue for an
unregistered resource and a spec-compliant upstream rejects on receipt. The
legacy is_oauth_passthrough opt-in keeps the gateway rewrite unchanged.
2026-07-07 17:32:13 -07:00
Tin Chi Lo
446a6e8cdd fix(mcp): route true_passthrough and oauth_delegate through upstream OAuth discovery
Both modes advertised LiteLLM as the authorization server and answered initialize locally, so a client with no token connected empty and was never driven into the upstream OAuth flow. The protected-resource discovery now proxies the upstream metadata for both modes (verbatim for true_passthrough, resource rewritten to the gateway for oauth_delegate), and the preemptive 401 emits the matching challenge: oauth_delegate uses the gateway-proxied resource_metadata once admission passes, true_passthrough probes the upstream anonymously and surfaces its WWW-Authenticate verbatim so the client authorizes directly against the upstream
2026-07-07 17:32:13 -07:00
Tin Chi Lo
50c90281f4 feat(mcp): add true_passthrough and oauth_delegate auth modes
Introduce two first-class MCP server auth_type values that make LiteLLM's
role in upstream authentication explicit, added alongside the existing
delegate_auth_to_upstream / oauth_passthrough flags without changing their
behavior.

true_passthrough is a transparent proxy: LiteLLM performs no admission auth,
requires no x-litellm-api-key, mints/stores/refreshes nothing, and forwards the
client's Authorization to the upstream exactly as received. oauth_delegate keeps
normal LiteLLM admission (x-litellm-api-key / SSO / JWT) and then forwards the
client's separate upstream Authorization unchanged; the admission credential is
never forwarded upstream.

Both modes forward the caller's token via the existing extra_headers path and
defer egress credential resolution to v1 (the v2 to_server_spec returns None for
them). Upstream 401/403 responses are surfaced rather than swallowed so upstream
OAuth challenges are preserved. Servers in either mode require per-user auth, so
userless health checks are skipped.
2026-07-07 17:32:13 -07:00
tin-berri
db2402754a
feat(mcp): let users select the entra_obo token_exchange profile in the UI and API (#32144)
* feat(mcp): let users select the entra_obo token_exchange profile in the UI and API

The backend token_exchange arm supports two wire dialects via token_exchange_profile
("rfc8693" default, or "entra_obo" for Microsoft Entra's On-Behalf-Of, the RFC 7523
jwt-bearer grant), but it could only be set through config.yaml. This surfaces it to the
create/update REST API and the dashboard so an admin can create an entra_obo server there,
completing the parity started in the parent PR for the other token-exchange fields.

token_exchange_profile becomes a dedicated column on LiteLLM_MCPServerTable, mirroring the
sibling fields: it is added to the request models, read column-first in
build_mcp_server_from_table with the credentials-blob as a back-compat fallback and a
default of rfc8693, and carried through both runtime-to-table builders so registry
round-trips preserve it. It is a non-secret dialect selector, so it is not scrubbed from
non-admin or virtual-key responses.

In the dashboard a Profile dropdown (RFC 8693 vs Microsoft Entra OBO) is added to the
token-exchange section. Entra OBO carries the target resource in the scope, so selecting it
makes the scope required and hints the api://<app-id>/.default form, while audience and
subject_token_type (which that dialect ignores) are hidden.

* fix(mcp): extend the blob-to-column lift and non-admin scrubbing to token_exchange_profile

token_exchange_profile gets the same storage contract as the other three
token-exchange settings: the column is authoritative, a blob copy is the legacy
shape — lifted into the column on every write and stripped from the stored
blob — and switching auth_type away from token exchange clears it
(_AUTH_FLOW_SCOPED_FIELDS). Both restricted-view sanitizers scrub it for
uniformity, and the edit form's auth-switch payload nulling includes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): assert every token-exchange setting is configurable via config.yaml

Pins the config surface: token_exchange_endpoint, audience, subject_token_type
and token_exchange_profile load from top-level config keys onto the built
server and through to the resolver spec; omitted keys resolve to their
documented defaults (RFC 8693 subject token type, rfc8693 profile), and
token_exchange servers need no oauth2_flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:39:20 -07:00
tin-berri
9652509e46
fix(mcp): apply outbound concurrency limit to OBO tool calls (#32071)
The token_exchange (OBO) branch of _call_regular_mcp_tool built its coroutine
by calling _obo_call_tool_with_retry directly, outside the
_limit_outbound_concurrency context manager that the regular branch uses. OBO
tool calls (and the internal re-mint retry, which issues a second upstream
call_tool) therefore bypassed the per-server max_concurrent_requests semaphore,
so an authenticated caller could run unlimited concurrent tool calls against an
OBO MCP server despite an admin-configured limit.

Wrap the OBO coroutine in _limit_outbound_concurrency the same way the regular
path does, holding one permit across the initial call, the on-401 re-mint, and
the retry, so OBO calls honor the configured cap.
2026-07-07 16:02:30 -07:00
Mateo Wang
c8b78d49dd
fix(bedrock): stop stale SigV4 headers clobbering fresh signature on strip-and-retry re-sign (#32371)
* fix(bedrock): stop stale SigV4 headers clobbering fresh signature on re-sign

When the Anthropic /v1/messages strip-thinking-and-retry path re-signs a
Bedrock request, _sign_request received attempt 1's already-signed headers
and copied the old Authorization and X-Amz-Date back over the freshly
computed SigV4 signature, so the retry POSTed the stripped body with a
signature for the original body and AWS returned 403 SignatureDoesNotMatch.

Skip SigV4-computed headers (authorization, x-amz-date,
x-amz-security-token, date) when restoring caller headers after signing,
and only preserve a caller-supplied Authorization that is not itself a
SigV4 header so bearer-token setups keep working.

* fix(bedrock): apply the same stale-header guard to get_request_headers
2026-07-07 16:01:46 -07:00
tin-berri
ff6dc33291
feat(mcp): support oauth2_token_exchange auth type via REST API and dashboard (#31772)
* feat(mcp): support oauth2_token_exchange auth type via REST API and dashboard

OAuth 2.0 Token Exchange (RFC 8693, a.k.a. OBO) for MCP servers could previously only be
configured through config.yaml; the create/update REST API and the dashboard had no way to
express it. This wires token_exchange_endpoint, audience, and subject_token_type end to end.

These three are persisted as dedicated columns on LiteLLM_MCPServerTable, mirroring how token_url
and oauth2_flow are stored, so the edit form prefills them and they are unaffected by the
credentials-blob clearing on auth_type change. build_mcp_server_from_table reads the columns first
and falls back to the credentials blob so servers persisted before the columns existed still load.
client_id and client_secret continue to ride the existing encrypted credentials path.

On the dashboard, "OAuth Token Exchange (OBO)" is a distinct auth-type option with its own field
section. The McpOAuthMode classifier gains a token_exchange arm keyed off auth_type; the previous
catch-all oauth2 mode was renamed from "obo" to "authorization_code" so the two on-behalf-of
mechanisms are no longer conflated. The token-exchange IdP endpoint and audience are scrubbed from
non-admin and virtual-key responses, matching how token_url is treated.

* fix(ui): gate the MCP list-401 re-auth on authorization_code, not token_exchange

The renamed 401 gate keyed off isTokenExchange, but that condition exists for authorization_code:
when a stored per-user credential is present yet the tools/list 401s (the backend's refresh could
not mint a token), the user must re-authorize via the browser flow. token_exchange has no
gateway-side authorize step, so the Authorize gate never applied to it. The prior isObo flag was
undefined (a compile error) and, per this file's convention and its tests, meant authorization_code;
renaming it to isTokenExchange changed the behavior and broke the mcp_tools auth-gate test for an
authorization_code server whose token expired with no usable refresh. Gate on isAuthorizationCode
instead and drop the now-unused isTokenExchange

* fix(mcp): clear flow-scoped endpoint config when a server's auth_type changes

Switching an existing oauth2 server to oauth2_token_exchange left the old flow's
token_url on the row. The OBO resolver treats token_exchange_endpoint or token_url
as the configured exchange endpoint, so the stale value both suppressed the RFC
9728/8414 discovery this PR adds and sent the exchange grant (client credentials
plus the user's subject token) to the previous flow's token endpoint

update_mcp_server now mirrors its existing stale-credentials rule for the
flow-scoped columns (authorization_url, token_url, registration_url, oauth2_flow,
token_exchange_endpoint, audience, subject_token_type): when auth_type changes,
each one is cleared unless the same request explicitly provides it, so a
deliberate override in the switch request still wins. Updates that keep the
auth_type never touch these columns, which keeps legacy OBO rows that use
token_url as their exchange endpoint working

The edit form sends explicit nulls for the previous flow's fields on an auth type
switch; antd preserves unmounted field values by default, so without this the old
token_url would be re-sent verbatim and read as an explicit override. Transitions
are detected against the persisted auth_type, so saves that keep the auth type
send nothing extra

Reported by Cursor Bugbot on the PR

* fix(mcp): lift legacy blob token-exchange settings into their columns on every write

The three token-exchange settings live in dedicated columns but also exist on
MCPCredentials as the pre-column REST shape. Writes now lift incoming blob
values into the columns (an explicit top-level value wins, including an
explicit null) and strip them from the stored blob; the same-auth credentials
merge migrates legacy rows the same way. The read-time column-or-blob fallback
then only ever serves rows current code has never written, so clearing a column
to re-enable RFC 9728/8414 discovery can no longer be silently undone by a
stale blob copy.

Also asserts the auth-switch clearing fires on the external fields_set path
(PUT /v1/mcp/server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mcp): single source for the RFC 8693 default subject_token_type

The default was applied at four egress build sites plus two model defaults,
each with its own copy of the literal. All sites now share
DEFAULT_SUBJECT_TOKEN_TYPE from litellm.types.mcp. A DB-level DEFAULT is
deliberately not used: Prisma writes explicit values on insert, so a column
default would rarely apply, and NULL-means-RFC-default keeps existing rows
correct.

Also documents two review decisions in place: the audience column keeps the
RFC 8693 parameter name (RFC 8707 resource indicators are already a separate
concept named resource in the v2 egress types), and the migration's
out-of-order timestamp is safe under prisma migrate deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: fix import sort order in outbound_credentials/types.py (I001 strict budget)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): purge legacy blob copies when a token-exchange column is written without credentials

The migrate-on-write in the credentials merge lifts blob values into null
columns, which is correct for legacy rows but could repopulate a column an
admin had cleared in an earlier no-credentials update (that path never touched
the blob, so the stale copy survived to be lifted later). An explicit
token-exchange column write (set or clear) now migrates the row even when the
update carries no credentials: untouched null columns are lifted, every blob
copy is stripped, and unrelated blob keys stay as-is. A cleared column can then
never be resurrected, because no write path leaves a blob copy behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): state the blob-to-column lift contract on the legacy credential keys

The three token-exchange keys on MCPCredentials are the pre-column REST shape
(the only REST shape from 2026-05 until this PR). Document on both the blob
type and the request models that the dedicated columns are authoritative and
that writes lift blob values into them and strip the stored copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): scrub subject_token_type in the non-admin and virtual-key sanitizers

The other two token-exchange fields were cleared while subject_token_type was
left visible. It is a public RFC 8693 URN with no disclosure value, but the
sanitizers' rule is that these views receive no token-exchange config at all —
cleared for uniformity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:26:12 -07:00
Mateo Wang
46d9742950
fix(vertex_ai): return create_vertex_url result directly for openai-path partner models with custom api_base (#32380) 2026-07-07 14:49:21 -07:00
Mateo Wang
b8248a21d2
fix(vertex_ai): build full request path when custom api_base has no path (#32367)
Some checks failed
CodSpeed Benchmarks / benchmarks (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
2026-07-07 13:17:07 -07:00
tin-berri
12801260ce
feat(MCP/UI): add OAuth flow selector on the MCP edit page (#32298)
* feat(ui): OAuth flow selector on the MCP edit page

The edit form had no flow selector: oauth_flow_type was watched but never registered,
so isM2MFlow was always false in edit mode and the flow could only be changed over
REST. That left the backfill's remediation for ambiguous legacy rows (client creds +
token_url, no interactive signal, left unstamped) without a dashboard path

The oauth2 section now opens with an OAuth Flow Type select. Explicit rows prefill
their stored value and re-persist it on save; legacy null rows show a placeholder
instead of a fake preselection, and an untouched save still writes nothing, so the
form never guesses on the admin's behalf. Choosing Machine-to-Machine (M2M) persists
oauth2_flow=client_credentials, choosing Interactive (PKCE) persists
authorization_code, which is exactly the assertion the backfill warning asks for.
Registering the field also brings the existing isM2MFlow gating in the edit form to
life, so M2M rows stop showing the interactive-only token-validation fields

Tests cover the prefill round-trip for both explicit values, the untouched null row
writing nothing, and both selections persisting on a legacy null-flow row

* fix(mcp): registry-to-table conversions must carry oauth2_flow

_build_mcp_server_table and the health-check table builder dropped oauth2_flow when
converting registry servers for GET /v1/mcp/server (list and by-id), so the dashboard
never received the persisted flow: the edit page could not prefill the selector, M2M
gating never activated, and the tools page classifier saw every oauth2 server as
interactive regardless of the column. Found live while proving the edit-selector
persistence path end to end; the write side was fine (PUT persists and the column
reads back correctly), the read side was dropping the field at the conversion

Both builders now carry oauth2_flow; regression test pins the conversion

* docs(mcp): flag _resolve_oauth2_flow as security-sensitive in its docstring

The prior wording ('not called directly by security sites') could read as if the
function has no security relevance, when it is the shape-inference engine both
request-time security helpers delegate to. Reword to state that plainly: it decides
M2M-vs-interactive for an unstamped row, must always be reached through
effective_oauth2_flow or resolve_oauth2_flow_for_request, and its M2M-shape branch
must not be weakened without accounting for those callers. Docstring-only; no logic
change

Raised by review on the stacked PR

* refactor(ui): extract oauth2FlowToFormValue helper for the MCP OAuth flow prefill

The edit form derived the OAuth Flow Type select value from the stored oauth2_flow
with a nested ternary duplicated at two call sites. Extract the mapping into a named
helper in types.tsx (next to getMcpOAuthMode and the flow constants): client_credentials
-> M2M, authorization_code -> Interactive, null/unset -> undefined so the select shows
its placeholder instead of a guessed default. The tool-config call site keeps its
null -> Interactive display fallback via a trailing ?? OAUTH_FLOW.INTERACTIVE, so
behavior is unchanged. Adds unit tests for the helper; the existing prefill/save tests
already cover the call sites

* feat(ui): surface and warn on an unset MCP oauth2_flow (server card + edit page)

An oauth2 MCP server whose oauth2_flow was never classified (legacy null row the
backfill left ambiguous) now advertises that it needs attention instead of silently
falling back. The server card shows an 'OAuth flow not set' warning tag for any
auth_type=oauth2 server with no oauth2_flow, so admins can spot them in the list
without opening each one. The edit page shows a warning alert directly under the new
OAuth Flow Type selector while the flow is unset, and it clears the moment a flow is
picked.

Delegate (delegate_auth_to_upstream) servers are excluded from both: they authenticate
via upstream PKCE passthrough and route to passthrough regardless of oauth2_flow, so
the M2M-vs-interactive classification does not apply and prompting for it would be a
false alarm. The edit page reads the delegate state from the watched switch when it is
mounted and falls back to the stored value otherwise (useWatch returns undefined for an
unmounted field).

Also adds end-to-end coverage of the null-flow chain the selector depends on:
build_mcp_server_from_table carries oauth2_flow=None verbatim into the GET response,
so the dashboard maps it to undefined and shows the placeholder rather than a guessed
default. Tests: backend null carry, the select prefill display for all three states,
the edit-page warning show/hide/clear-on-select and delegate exclusion, and the card
badge across oauth2/non-oauth2, stamped/unstamped, and delegate
2026-07-07 11:49:52 -07:00
tin-berri
7ce573e6e8
fix(mcp): stop 'Team doesn't exist' warnings for UI dashboard sessions (#32348)
UI session tokens carry the virtual team_id litellm-dashboard (UI_TEAM_ID),
which is never persisted. The MCP team-permission helpers passed it to
get_team_object anyway, so every dashboard MCP listing raised a 404 per
lookup that was swallowed into per-server 'Failed to get allowed tools for
server' warnings (plus the sibling 'allowed MCP servers for team' and 'MCP
access groups for team' warnings) and wasted DB queries. The 404 also
escaped past the key-level permission handling in
get_allowed_tools_for_server, dropping key tool restrictions for such
sessions.

Short-circuit the virtual team before the DB lookup in the three helpers,
mirroring the existing UI_TEAM_ID handling in agent_permission_handler.
Also reject /team/new with the reserved team_id, since a real row would
bind its budget and permissions to every UI session
2026-07-07 10:27:00 -07:00
Mateo Wang
d0c82c308d
fix(main): stop per-request custom pricing from clobbering shared model_cost pricing (#32163)
* fix(main): stop per-request custom pricing from clobbering shared model_cost pricing

A request routed through a wildcard deployment with explicit zero pricing
(e.g. openai/* with input_cost_per_token: 0) registered that pricing on the
shared {provider}/{model} key in litellm.model_cost, so sibling deployments
relying on built-in pricing logged $0 until process restart (LIT-3991).

Request-time registration in completion()/embedding() now mirrors the
router-startup isolation: router-originated requests register full pricing
under the deployment's unique model id only, while the shared backend key
receives the entry with custom pricing fields stripped. Direct SDK calls
without a router deployment id keep the legacy shared-key registration.

The stripping logic is shared via
CustomPricingLiteLLMParams.strip_custom_pricing_fields and reused by
Router._create_deployment and Router.add_deployment.

* test: update legacy tests that asserted per-request pricing leaking into shared model_cost

test_router_fallbacks_with_custom_model_costs asserted the shared
claude-sonnet-4-5-20250929 entry ends up with the deployment's 30/60
pricing, which is exactly the cross-deployment leak this PR removes; it
now asserts the shared key keeps the built-in pricing, matching the
test's stated goal.

test_cost_calc.py::test_run computed streaming cost via
completion_cost(response), which only matched the non-stream cost while
the shared gpt-3.5-turbo entry was poisoned with the per-request
2/token pricing; it now passes the request's custom pricing explicitly
via custom_cost_per_token.
2026-07-07 10:25:31 -07:00
Mateo Wang
90440d75ae
fix(bedrock): preserve stream param and decode SSE for bedrock mantle streaming (#32141)
* fix(bedrock): preserve stream param and decode SSE for bedrock mantle streaming

* refactor(bedrock): pass self positionally in mantle messages streaming delegation
2026-07-07 10:15:47 -07:00
tin-berri
733c01902f
feat(mcp)!: oauth2_flow read verbatim from DB rows and required in config; inference reduced to the request-time backstop (#32292)
* refactor(mcp): read oauth2_flow verbatim from DB rows; inference stays config-only plus a logged backstop

With every DB write site stamping oauth2_flow (#32283, #32288) and the startup
backfill healing legacy null rows, the DB build no longer needs to re-derive the
flow from field shape. build_mcp_server_from_table now reads the column verbatim
via _explicit_oauth2_flow: unknown or null values resolve to None, which
needs_user_oauth_token already treats as interactive, so an unstamped row degrades
to the safe default instead of guessing M2M from a shape that a DCR-registered
interactive server shares whenever discovery is down

Field-shape inference survives in exactly two places. config.yaml-loaded servers
keep it at load time: they are rebuilt from the config on every boot, so there is
no row to backfill and load-time resolution is their write-time stamp. And the
request-time backstop in _get_allowed_mcp_servers keeps a not-yet-backfilled M2M
row blocking caller Authorization forwarding (the P1 property); it now logs a
warning whenever it actually fires, which is the fire-rate signal for deleting it
once deployments have booted past the backfill

Regression tests pin that the DB build does not infer M2M from the credential
shape and reads an explicit column value verbatim

Fourth step of the oauth2_flow persistence sequence, stacked on the backfill

* feat(mcp): deprecation warning when config-level M2M is inferred rather than declared

A config.yaml oauth2 server whose credential shape decides client_credentials without
an explicit oauth2_flow now logs a warning at load pointing the admin at the explicit
declaration. First rung of the deprecation ladder: the docs make oauth2_flow the
recommended path, the warning surfaces configs still relying on inference, and a
future breaking release can turn it into a config validation error, at which point
config-level shape inference dies entirely. Interactive omissions stay silent since
the default matches inference there and nothing load-bearing is being guessed

* feat(mcp)!: require explicit oauth2_flow for config-defined oauth2 servers

A config.yaml server with auth_type oauth2 must now declare its flow; the load
raises a config validation error naming both values and what each means:
oauth2_flow: client_credentials for machine-to-machine (the proxy mints a shared
token at token_url using client_id/client_secret) or
oauth2_flow: authorization_code for interactive (per-user tokens via browser
sign-in, including delegate_auth_to_upstream)

This replaces the load-time shape inference for config servers entirely. The
credential shape is genuinely ambiguous (a DCR-registered interactive server
carries client creds + token_url with no authorization_url, identical to M2M),
so the config asserts the answer instead of the proxy guessing it. With this,
field-shape inference survives in exactly one place: the request-time security
backstop, which is telemetry-gated for deletion

BREAKING CHANGE: config-defined oauth2 MCP servers without oauth2_flow fail
proxy startup with the error above. Add the one line to the server block; the
error text says exactly which value to pick

* test(mcp): pin the verbatim read for authorization_code alongside client_credentials

Raised by review on the PR

* fix(mcp): fail closed on the anonymous delegate gate for unstamped M2M-shaped servers

Reading oauth2_flow verbatim (this PR) changed has_client_credentials from True to
False for a legacy null-flow row that still carries the M2M credential shape. That
value is what the anonymous upstream-delegate gate checks before skipping LiteLLM
auth entirely, so an M2M-shaped delegate server that was never stamped would newly
pass the gate: an unauthenticated caller could get it selected and then list/read
upstream data using the client credentials the request-time backstop re-infers,
running as LiteLLM's service account. This reopens the hole the gate's existing
'never delegate for M2M' guard was written to close

The gate now resolves the flow (column first, shape fallback) instead of reading the
bare column, mirroring the request-time backstop in _get_allowed_mcp_servers: both
fail closed on the ambiguous M2M shape and are removed together once no null rows
remain. A pure-PKCE delegate server (no stored credentials) resolves to a non-M2M
flow and keeps its bypass, so the common delegate case is unaffected

Tests: an unstamped M2M-shaped delegate server is denied the bypass (mutation-checked
against the bare-column regression), and a pure-PKCE delegate server still bypasses

Raised by review on the PR

* fix(mcp): centralize the request-time oauth2_flow backstop across every security site

Reading oauth2_flow verbatim made has_client_credentials unreliable for legacy null
rows, and the backstop that compensates was applied at only one reader. Review found
three more consequences of that per-site approach:

- the anonymous-delegate allowlist in get_allowed_mcp_servers read the bare column, so
  an unstamped M2M-shape delegate server was surfaced to anonymous callers (High)
- call_mcp_tool resolved allowed ids into MCPServer objects without the backstop, so a
  null-flow M2M-shape row kept has_client_credentials false on tool execution during a
  backfill gap, though the listing path was covered (High)
- the request-time warning claimed the startup backfill would stamp the row next boot,
  but the backfill deliberately leaves the ambiguous M2M shape unstamped (Low)

Rather than patch each site, introduce two helpers on MCPServerManager that are the
single choke point for request-time resolution: effective_oauth2_flow(server) for the
enum/boolean decisions (allowlist filter, anonymous-delegate gate) and
resolve_oauth2_flow_for_request(server) for the egress object copy (listing and tool
call). Both fail closed on the M2M shape and leave stamped rows and pure-PKCE rows
untouched. The gate now shares effective_oauth2_flow instead of its inline resolution,
and the corrected warning lives once inside resolve_oauth2_flow_for_request, so deleting
the whole transitional layer later is a single-site change.

Tests: helper unit coverage (stamped verbatim, null M2M-shape resolves, pure-PKCE stays
None, stamped/pure-PKCE return the same object, corrected warning text), the anonymous
allowlist excludes an unstamped M2M-shape delegate server, and the call path resolves
the flow like the listing path. The two security-integration tests are mutation-checked
against the bare-column regression.

Raised by review on the PR
2026-07-07 09:55:54 -07:00