Commit graph

3381 commits

Author SHA1 Message Date
Mateo Wang
450e1734ec
Merge pull request #35317 from BerriAI/litellm_fix_passthrough_guardrail_flake
test: fix order-dependent flake in passthrough guardrail call-type test
2026-07-30 21:28:23 -07:00
Mateo Wang
74d29173b8
Merge pull request #35263 from BerriAI/litellm_config_policies_survive_db_sync
fix(policy_engine): preserve config-defined policies across DB sync and expose them via list APIs
2026-07-30 21:20:45 -07:00
mateo-berri
47ebc964eb test: patch unified guardrail mapping global instead of loader to fix order-dependent flake 2026-07-30 20:55:32 -07:00
mateo-berri
b42ef469cf fix(policy_engine): warn that the config-defined policy reactivates when all DB versions are deleted 2026-07-30 19:48:35 -07:00
tin-berri
2dbcb9a999
feat(spend-logs): record when a spend log row is the auto-router's own classifier call (#35300)
The complexity router's classifier sub-call copies the parent request's metadata
verbatim, so its spend log row carries the caller's key, team and user and is
indistinguishable from traffic the caller actually sent. Nothing on the row says
otherwise: call_type is "acompletion" either way, model_group is overwritten to the
classifier's own model group so the row never looks auto-routed, and routing_decision
is absent exactly as it is on an ordinary request.

Record the fact the system already knows at call time. internal_call_origin is
declared on SpendLogsMetadata, which is the allowlist _get_spend_logs_metadata
projects onto, and stamped in _classifier_call_metadata; both classifier paths
already route through that one function and it feeds the metadata and
litellm_metadata buckets alike, so every request surface is covered at one site.
The key is reserved rather than caller-supplied, so it joins routing_decision in the
untrusted-metadata strip and a caller cannot label their own traffic as router
overhead.

The classifier call also inherited no session identity, so the router minted a fresh
trace id and the row landed in a session of its own. Forwarding the parent's session
puts it in the trace of the request that triggered it, which is where an operator
looks for what the routing cost.
2026-07-30 19:26:42 -07:00
mateo-berri
35f770f43e fix(policy_engine): restore config policy immediately when its DB override is removed and keep same-named DB drafts reachable in the UI 2026-07-30 19:23:50 -07:00
Mateo Wang
030370012c
Merge pull request #35259 from BerriAI/litellm_config_guardrail_info_lookup
fix(guardrails): serve config guardrails from list and info endpoints without a DB and make their ids stable
2026-07-30 18:59:21 -07:00
tin-berri
b408b1d6dc
fix(guardrails/headroom): stop compressing the turn the model must act on (#35294)
The Headroom guardrail sent every message to /v1/compress, including the
system prompt and the user's current instruction. On an agentic /v1/messages
request the live turn is the largest compressible blob, so it came back as a
hash marker; the model then called headroom_retrieve and got its own
instruction returned in a tool_result block, which reads as data it fetched
rather than a request to act on, so it described the content instead of doing
the work.

litellm already owns the policy for what a compressor may never rewrite:
get_protected_indices covers the system rows, the last user row and the last
assistant row, and compress() expands it over whole tool exchanges. Headroom
now consults it (promoted from a private name and given tests) and expands it
the same way, so the trailing tool result cannot come back as a marker
standing in for the result of the call the model just made. Protected rows are
withheld from the payload rather than pinned afterwards, so their tokens are
not reported as savings that are never applied; the write-back discards a
compressed system prompt outright, so that saving never existed. The cost is
that a query-aware service no longer sees the newest user message.

A response whose row count differs from what was sent can no longer be
interleaved with the withheld rows, so it goes through the configured fail
policy instead of being adopted. Fail-open now returns the caller's own inputs
object: translation handlers detect a rewrite by identity, so a rebuilt copy
sent an unchanged request through the Anthropic write-back for nothing.

That write-back rebuilt the request with one anthropic_messages_pt call, which
merges every run of consecutive user/tool rows, so a tool_result turn and the
user turn after it arrived fused. Converting a row at a time would separate
them but breaks tool pairing: with modify_params on, an assistant row whose
results are converted separately reads as an orphaned tool call and the
sanitizer answers it with a synthetic "tool execution skipped" result while
dropping the real one. Conversion is now grouped by tool_call_id ownership,
which satisfies both, and the same grouping decides which rows headroom
protects, so the two agree by construction.

The CCR follow-up also dropped any text the model wrote alongside its tool
call, and echoed tool calls it had no results for. Both are fixed by reusing
compresr's extraction helper, now shared instead of duplicated.

Resolves LIT-5018
2026-07-30 18:53:31 -07:00
Mateo Wang
f0d13624be
Merge pull request #35278 from BerriAI/litellm_v3_limiter_contextvar_stash
refactor(rate-limits): move the v3 limiter per-request stash off request metadata onto a ContextVar
2026-07-30 18:13:38 -07:00
Mateo Wang
f2113410ad
Merge pull request #35290 from BerriAI/litellm_stream_usage_by_default
fix(proxy): request stream usage upstream by default and strip it from client streams
2026-07-30 18:12:51 -07:00
mateo-berri
c0de87d08d fix(proxy): resolve team-alias models in the stream usage support gate
Team-scoped models store an internal model_name_{team_id}_{uuid} name
with the public alias only in team_public_model_name, so resolving them
through get_model_list without team_id returned no deployments and the
gate skipped injection, leaving those streams on tiktoken estimates.
Thread user_api_key_dict.team_id through the gate.
2026-07-30 17:46:37 -07:00
tin-berri
79d49620e7
feat(mcp): extend keyless gateway OAuth flow to per-server MCP URL paths (#34856)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
The keyless flow (gateway as authorization server, no virtual key) worked
only at the aggregate /mcp scope: the session-bearer admission arm was
gated on _is_aggregate_mcp_scope, the 401 fallback only challenged at
aggregate scope, and per-server protected-resource metadata for plain
oauth2 servers pointed clients at the per-server relay, whose flow
returns the raw upstream token that ingress can never accept keylessly
(401 "LiteLLM Virtual Key expected. Received=gho_****").

Per-server spellings now join the same gateway flow for gateway-managed
oauth2 servers (auth_type oauth2 without delegate_auth_to_upstream, new
MCPServer.is_gateway_managed_oauth2 owner):

- the session-bearer arm admits at any MCP scope; downstream grant
  resolution already intersects the admitted subject's servers with the
  path or header targets fail-closed, so a narrower scope never broadens
- the 401 challenge is scope-aware: a single gateway-managed oauth2 path
  target gets the per-server resource_metadata in the spelling the
  request used, everything else gets the aggregate document; unknown
  names, CSV multi-target paths, and every client-forwarded or delegated
  mode keep their existing behavior
- per-server PRM for explicitly named gateway-managed oauth2 servers
  advertises the gateway AS ({base}/mcp); delegate, passthrough, bridge,
  OBO, and the root-resolved unnamed shape are byte-identical
- the preemptive 401 for an admitted keyless subject with no vaulted
  token challenges with resource_metadata (re-entering the gateway flow,
  whose authorize interlude vaults the upstream token) instead of the
  relay authorization_uri, which cannot vault without a litellm key

The per-server challenge URL builder moved from server.py to
oauth_utils.py (shared with the auth module) and now inserts the
SERVER_ROOT_PATH segment exactly as the discovery routes do.

Resolves LIT-4864
2026-07-30 17:40:32 -07:00
mateo-berri
a770b437d5 fix(proxy): gate default stream usage injection on provider support and neutralize client-sent strip marker
Bytez and OCI param maps raise on stream_options when drop_params is
unset, so the default injection would have broken every streamed chat
completion routed to them. Injection now only happens when every router
deployment behind the requested model (wildcards and aliases included)
declares stream_options in its supported OpenAI params; providers that
do not declare it either reject the param or already stream usage
natively, so skipping them keeps old behavior instead of erroring.

_litellm_strip_stream_usage arriving in the client request body is now
overwritten at ingress (and popped in the experimental queue endpoint),
so a client can no longer suppress the usage chunk it explicitly
requested by planting the internal marker.
2026-07-30 17:01:16 -07:00
Mateo Wang
8e287652c6
Merge pull request #35260 from BerriAI/litellm_messages_streaming_post_call_guardrails
fix(proxy): run post_call guardrails on /v1/messages streaming via unified guardrail translation
2026-07-30 16:48:15 -07:00
mateo-berri
43efd02d35 fix(proxy): request stream usage upstream by default and strip it from client streams
Streamed chat completions that did not opt into stream_options.include_usage
were logged with tiktoken estimates over the visible text, so hidden
reasoning tokens (billed as output by OpenAI-compatible providers) were
never counted and SpendLogs could undercount output tokens by 90%+ on
reasoning models. The proxy now injects include_usage upstream for
/v1/chat/completions streams by default and strips the injection artifacts
(the final usage chunk and the empty prompt-filter chunk) from the
client-facing SSE stream, so accounting uses provider-billed usage while
the client-visible stream stays byte-identical to today.

always_include_stream_usage keeps its existing semantics: true forwards
the usage chunk to clients as before, and an explicit false now acts as a
kill switch that disables the upstream injection for OpenAI-compatible
backends that reject stream_options.
2026-07-30 16:35:23 -07:00
ryan-crabbe-berri
6e26087cf4
fix(proxy): only enforce budgets on routes that can spend (#35274)
* fix(proxy): only enforce budgets on routes that can spend

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

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

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

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

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

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

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

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

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

The previous commit restored it from the staging tip, which pulled in
unrelated merged changes. This PR changes no backend models, so the file
should be untouched.
2026-07-30 16:06:45 -07:00
mateo-berri
ec016d1bd8 fix(policy_engine): decide config policy suppression from fresh db query only 2026-07-30 16:01:57 -07:00
Yassin Kortam
87c2e03af8
feat(db): opt-in REPLICA IDENTITY FULL after prisma migrations (#35267)
Logical replication consumers need FULL replica identity to reconstruct the
old row of an UPDATE or DELETE, and prisma leaves every table it creates at
the postgres default. Operators had to re-apply the setting by hand after
each migration run.

Setting LITELLM_SET_REPLICA_IDENTITY_FULL now re-asserts it on every LiteLLM
table at the end of a successful migration run, through the prisma CLI so the
dependency-free proxy-extras package stays that way. Tables that are already
FULL are skipped, foreign tables in the same schema are left alone, and a
database that refuses the ALTER is reported rather than failing the run.

Resolves LIT-3022
2026-07-30 15:45:40 -07:00
mateo-berri
91290c6020 fix(policy_engine): only hide config policies behind production DB versions in policies list 2026-07-30 15:33:52 -07:00
mateo-berri
23f3e10012 fix(proxy): recognize inherited apply_guardrail overrides and keep masking guardrails on their own stream hook 2026-07-30 15:33:49 -07:00
tin-berri
fb79a4ee3b
Merge pull request #34848 from BerriAI/litellm_lit4863_headless_oauth
feat(mcp): manual authorization-code delivery for headless MCP clients
2026-07-30 15:16:18 -07:00
mateo-berri
4d2de1d475 Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_v3_limiter_contextvar_stash
# Conflicts:
#	litellm/proxy/hooks/parallel_request_limiter_v3.py
#	tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py
2026-07-30 15:08:26 -07:00
mateo-berri
f507a118af fix(rate-limits): pin the request stash to its owning litellm_call_id so nested calls cannot release it 2026-07-30 14:54:28 -07:00
devin-ai-integration[bot]
66ca72ce08
fix(rate-limits): keep the v3 limiter out of provider-facing metadata on responses routes (#35207)
* fix(rate-limits): stop the v3 limiter from creating provider-facing metadata on responses routes

* Update tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <yucheng@berri.ai>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-30 21:38:07 +00:00
Yassin Kortam
5c16132074
feat(guardrails): scan and mask MCP tool results via post_mcp_call (#35155)
Guardrails could only see the MCP tool call request (pre_mcp_call /
during_mcp_call); the tool result went back to the client unscanned, so a tool
that returns sensitive data bypassed every configured guardrail.

Adds a `post_mcp_call` event hook that runs after the tool executes and routes
the result through the unified apply_guardrail seam, so a text guardrail (e.g.
presidio) can mask sensitive values in the tool output or reject the result
without any MCP-specific code of its own.

- MCPGuardrailTranslationHandler.process_output_response now extracts the tool
  result's text content into GenericGuardrailAPIInputs["texts"], calls
  apply_guardrail with input_type="response", and writes the returned text back
  into the content list in place (the logging payload already references that
  object, so a copy would leave the unmasked text in the spend log)
- ProxyLogging.post_mcp_call_hook dispatches guardrails that implement
  apply_guardrail, gated on should_run_guardrail(post_mcp_call); guardrails
  implementing async_post_mcp_tool_call_hook keep their existing dispatch and
  are not run twice
- both MCP tool-call paths (mcp_server and the Responses API handler) now honor
  the rewritten result, and the REST path no longer swallows a guardrail
  rejection as a logging failure
- shared, duck-typed MCP content helpers live in mcp_server/utils.py next to
  extract_mcp_tool_result_error_message
- documents that async_post_mcp_tool_call_hook's return value is discarded by
  every call site, so that hook only takes effect by mutating in place
2026-07-30 14:10:26 -07:00
mateo-berri
631c02fe12 refactor(rate-limits): move the v3 limiter per-request stash off request metadata onto a ContextVar
The v3 parallel-request limiter stashed its per-request bookkeeping (TPM
reservation, descriptors, parallel slot, rate-limit response snapshot,
released flag) in the request body's metadata channels. On routes where
metadata is a provider request parameter (Responses API and the other
LITELLM_METADATA_ROUTES) that leaked internal keys upstream and produced
HTTP 400s, and it required denylist stripping plus dual-channel writes to
contain.

The stash now lives on an asyncio ContextVar holding a single typed
RequestRateLimiterStash per request. The pre-call hook writes it, and the
success/failure callbacks, disconnect release, and post-call hooks read
and clear the same shared instance, which keeps the refund and slot
release idempotent across sibling callbacks. The request body is never
touched, so the stash-key stripping, the metadata mirror writes, and the
all_litellm_params denylist entries are removed
2026-07-30 14:00:20 -07:00
yuneng-jiang
6f1625d23b
revert(proxy)!: stop enforcing user budget on team keys (#35271)
Reverts #32005. Team-scoped keys are governed by the team and team-member
budgets only; the key owner personal max_budget no longer applies to them,
restoring the hierarchy that existed before that PR.

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

Regression tests cover both enforcement points in the restored direction:
test_common_checks_personal_user_budget_skipped_for_team_key for the
read-time check and test_should_not_reserve_user_budget_counter_for_team_key
for the optimistic reservation path.
2026-07-30 20:19:49 +00:00
mateo-berri
4e9df55392 fix(policy_engine): preserve config-defined policies across DB sync and expose them via list APIs 2026-07-30 12:21:03 -07:00
mateo-berri
d2e99a9220 fix(proxy): run post_call guardrails on /v1/messages streaming via unified guardrail translation 2026-07-30 12:07:03 -07:00
Yassin Kortam
a187cb9886
feat(mcp): enforce per-user MCP tool-call entitlements in the auth module (#35146)
The MCP gateway resolved a caller's allowed servers and per-server tool
allowlists from the key, the team, the end user and the agent, but never from
the internal user row, so an admin had no way to bound what a person may call
across every key they hold. Anything the key allowed went through

The internal user now carries the same object_permission an admin already
attaches to a key or a team, and the resolver applies it as a ceiling: the
caller ends up with the intersection of what the key allows and what the user
allows, so adding a user entitlement can only narrow, never widen. A level
that names no server and no tool places no ceiling, which keeps every existing
deployment on its current behavior

/user/new and /user/update accept object_permission and reuse the same
create-or-update helper the team endpoints use, so the row is written once and
the three cached views of it (the user row, the object-permission link and the
permission itself) are invalidated on write. Clearing it with an empty object
now really unlinks the permission instead of being swallowed as an empty value

A row that cannot be read at all places no ceiling, but a row that names a
permission the database cannot return denies the call rather than falling
through to the wider set, so a partial outage cannot hand out access the admin
withheld

The users page grows the MCP servers, access groups, toolsets and per-server
tool pickers the key and team pages already have. A save keeps a tool
allowlist whenever an access group or toolset the admin retained could still
supply that server, since an allowlist is what narrows a grant and an absent
one reads as no restriction; it drops the allowlist once nothing indirect
survives to supply the server, so removing a grant really removes it
2026-07-30 12:06:33 -07:00
mateo-berri
5ae1f1530c fix(guardrails): serve config guardrails from list and info endpoints without a DB and make their ids stable 2026-07-30 11:59:46 -07:00
tin-berri
71dfab7177
feat(router): record why the auto-router picked a tier and show it in the logs (#35016)
Auto-routed requests were indistinguishable from ordinary ones once logged:
the spend log recorded the requested model group and the resolved deployment,
but nothing about which tier was chosen or what chose it. That information
existed only inside verbose_router_logger f-strings, so answering "why did my
prompt land on the cheap model" required log access and a running proxy.

The complexity, quality, and adaptive pre-routing strategies now return a typed
StandardLoggingRoutingDecision on their PreRoutingHookResponse, and
Router.async_pre_routing_hook records it once for every attempt. Those three
previously side-channelled their own state through three different metadata
keys; the decision now travels on the hook contract itself, so the bucket is
resolved in one place, through get_or_create_metadata_bucket, which already
owns the question of which dict holds proxy-internal metadata and replaces a
non-dict value instead of skipping the write. Recording happens on every
attempt rather than only on a successful route: a fallback from an auto-router
group to a plain group re-enters the hook with the same request kwargs, and a
decision left behind there would attribute the first router's tier to the
deployment that actually served the retry. The log details drawer renders the
result as a Routing card between Request Details and Metrics; the card is
absent on rows that carry no decision, so ordinary and pre-upgrade rows are
unchanged.

Three defects surfaced while making the recorded cause truthful, each of which
would have persisted a wrong answer. The complexity router hardcoded
cause=complexity_scorer even when the LLM classifier decided, and its silent
fallback to the heuristic on classifier failure meant a row could claim an LLM
verdict the LLM never gave; the cause now reports the path that actually ran.
The keyword that triggered a tier rule was discarded before logging, as was
the escalation keyword. The 2-reasoning-marker override returned REASONING with
a score far below the REASONING boundary and no marker saying so, which reads
as a scoring bug to anyone comparing the two; it now emits a reasoning-override
signal, and the card labels those rows as an override instead of claiming the
score met a boundary. The LLM path no longer reports a synthetic score of 1.0,
and heuristic decisions carry a snapshot of the tier boundaries that mapped the
score, so a historical row stays interpretable after the boundaries change.

Signals name a matched term only when the caller's own message contains it.
Scoring still reads the system prompt, but a term matched solely there is
reported as a count, since signals reach a spend row the caller can read and
naming one would disclose a term from a prompt it cannot see.

routing_decision is stripped from caller-supplied metadata at ingress, so a
client cannot forge its own provenance.
2026-07-30 11:55:10 -07:00
Mateo Wang
43d7cda88f
Merge pull request #35205 from BerriAI/litellm_batch_output_single_pass
refactor(batches): aggregate batch output cost, usage, and models in a single pass
2026-07-30 11:55:03 -07:00
tin-berri
708d010115
Merge pull request #35009 from BerriAI/litellm_routing_nav_autorouter
feat(ui): give auto-routers their own tab on Models + Endpoints
2026-07-30 11:54:18 -07:00
mateo-berri
0b09588685 refactor(batches): aggregate batch output cost, usage, and models in a single pass
Completed-batch cost tracking parsed the whole output file into a list of
dicts, pretty-printed it into debug strings even with debug logging off, and
walked the list three times (cost, usage, models), so a large batch output
could pin a worker's memory. The output is now folded line by line into small
per-line stats records via _aggregate_batch_cost_usage_models, the eager
json.dumps debug calls are gone, and the raw-vertex path computes cost and
usage in one call instead of two. _get_batch_output_file_content_as_dictionary
becomes _fetch_batch_output_file_content (returns bytes); the superseded
three-pass helpers are deleted and their tests migrated
2026-07-29 22:00:01 -07:00
Tin Chi Lo
fec7f5f246 feat(ui): give auto-routers their own tab on Models + Endpoints
Auto-routers had no home and no list. The create form was mounted in two unrelated places,
inside Models + Endpoints > Add Model and again under Cost Optimization, and neither showed
which auto routers already existed; seeing or editing one meant finding its row in the models
table and drilling in. They now get a dedicated Auto-Routers tab beside All Models, listing
every auto_router/* deployment with create, edit and delete in one place, and both former
entry points are removed.

Creating opens in a shadcn dialog rather than swapping the whole panel out, so the list stays
on screen behind it; the dialog caps its height and scrolls, since the complexity form is long.
The form's own heading goes with it, the dialog header owning that now.

An auto router is a routing construct rather than a deployment, so it also comes off the All
Models table. That table pages server-side off total_count, so a client-side filter would page
over a total including rows it never renders; /v2/model/info therefore gains
exclude_auto_routers (default false, so every existing caller is unaffected) and the filter
runs before the count. /v1/models is untouched, so clients still see auto-routers as models.

Clicking a router opens the same `?model=` drill-in the All Models table uses, so it lands in
ModelInfoView with the full Model Settings, Edit Settings, Edit Auto Router and Delete. An
earlier revision had a bespoke detail page here, which was a partial reimplementation of that
view and showed the router's type twice, once as a Type pill and again as a "Routing strategy"
field saying the same thing. Both are gone.

The auto-router list is keyed under the same `models/list` namespace as the models table
rather than a private one. It reads the same /v2/model/info data, and six call sites across
the app already invalidate ["models","list"] after a write; a separate key meant an edit made
through ModelInfoView left the tab stale until a full reload, and every future writer would
have had to remember a second key.

An auto router has no upstream credential, so its detail header drops Update API Key and
Re-use Credentials, and the destructive action names what it removes rather than saying model.
Test Connection was gated on the editor-aware predicate, which let adaptive and quality routers
through to a check that builds its targets from complexity config they do not have; it now
gates on the deployment predicate.

The edit modal also applies the semantic-matching guard the create form has. It renders those
controls now, and the backend raises on semantic_keyword_matching without an embedding model or
keyword rules, so skipping the shared validator turned an inline message into a raw 400.

Whether a row is writable has two independent axes and the dashboard needs both. STRATEGY:
there are four auto_router/* kinds and only complexity and semantic have a form here, so
adaptive and quality must not be handed an editor that would write auto_router_config onto a
deployment storing its settings elsewhere. ORIGIN: a config.yaml row reports db_model false and
the API refuses it whatever its strategy (PATCH /model/{id}/update 404s, POST /model/delete
400s). Capability is derived per capability rather than as one editable flag, because the
constraints differ: editing needs an editor, deleting removes a row by id and never reads its
config, so a DB-created adaptive router stays deletable. Both axes live in
add_model/auto_router_strategies.ts as a declarative table, one record per strategy, so a fifth
strategy is a table row rather than another branch. That also retired four copies of "is this a
complexity router", one of which was written twice in a row in model_info_view.

Creation narrows to the complexity router, which the UI calls Auto-Router v2; the semantic
option was already badged "to be deprecated" in the picker, so the picker goes away along with
the semantic submit path and its validation helper. Existing semantic routers stay editable.

The edit modal mounted ComplexityRouterConfig without the keyword, escalation and
semantic-matching handlers, so those sections never rendered and could only be set at create
time. It now hydrates them from the stored config, and the five keys become managed only when a
caller supplies that state, so a caller rendering no such control still carries them through. A
component-level round-trip test covers it: a payload-builder test cannot see a hydration bug.

A complexity tier is str | list[str] on the backend, and the UI carried three readers of that
rule, one of which dropped a pinned string. They collapse into one owner,
add_model/complexity_router_tiers.ts.
2026-07-29 19:45:02 -07:00
Mateo Wang
52fc276f05
Merge pull request #32587 from BerriAI/litellm_fix_batch_model_access_hash_32580
fix(auth): resolve managed batch/file deployment model_id to model name for team access checks
2026-07-29 18:43:39 -07:00
ryan-crabbe-berri
581f5c319e
feat(cli): read base_url from persistent config file (#35015)
* feat(cli): read base_url from persistent config file

Adds a lite config command group (set/get/unset) backed by
~/.litellm/config.json so users no longer need to export
LITELLM_PROXY_URL in every shell session. Resolution order is
--base-url flag, then LITELLM_PROXY_URL, then the config file,
then the localhost default. A config-file base_url counts as an
explicit server choice for lite auth print-token, matching the
env var semantics it replaces.

* fix(cli): harden config persistence after review feedback

Rejects base_url values containing a query string or fragment,
including bare trailing ? or # which parse as empty but still
corrupt every joined request URL. Writes config.json and token.json
atomically through a shared write_private_json helper (0600 at
creation, fsync, os.replace) so an interrupted save can no longer
truncate the file or leave it world-readable. Warns on stderr when
an existing config file is invalid instead of silently ignoring it,
including invalid UTF-8. Resolves the eager --version flag through
the same env, config file, default chain as every other command,
and reads the config file once per invocation so base_url and
base_url_explicit always come from the same snapshot.

* fix(cli): resolve --version after option parsing

The eager --version callback ran before --base-url and --api-key were
parsed, so it could not see an explicitly named server. Combined with
the env fallback added for config-file support, that sent the resolved
API key to whichever server the config file pointed at even when the
user named a different one on the command line. Making the flag a
normal option and handling it in the group callback gives the version
request the same flag, env, config, default precedence as every other
command, and lets the stored-token lookup stay origin-checked.
2026-07-29 18:25:05 -07:00
Tin Chi Lo
7041f5768f fix(mcp): never write discovery results to the row, heal rows a release already stamped, and retry failed discovery with backoff
An interactive oauth2 MCP server created with explicit endpoint URLs and no issuer served
400 "authorization url is not configured" from /authorize about a minute after creation,
with the admin's endpoints intact in the row the whole time (#34985). Discovery wrote its
trust-on-first-use issuer into the same column an admin writes, so the next registry build
read the gateway's own output back as an admin pin, anchored the server to RFC 8414
section 3.3, and discarded the stored endpoint columns; one transient metadata fetch
failure then had nothing to serve, and the reload fast path pinned the broken entry until
an unrelated config write

The core of the fix is a deletion. The gateway no longer writes discovery results anywhere:
the OAuth columns and credentials.scopes carry admin intent alone, and everything discovery
learns lives on the in-memory registry entry, as the existing carry-forward already
assumes. With no gateway write there is no value whose provenance a later build can
misread, so the accidental anchoring cannot be expressed

Deleting the write cannot fix a row a released version already stamped, which still reads
as pinned, so a one-time startup heal clears those stamps. The signal is necessarily a
heuristic: updated_by records only the most recent writer and no audit trail says which
field it touched. A row is therefore healed only on the full signature of the defect, which
is discovery as the last writer plus an issuer plus at least one configured endpoint column
that anchoring is actively discarding; rows with an issuer but no configured endpoints are
left alone, since for them both paths resolve from the same upstream document. Every heal
logs the cleared value so an admin who pinned deliberately can re-pin, and the heal records
its own actor, which makes it idempotent

The reload fast path exempts servers missing an endpoint their flow needs, so failed
discovery retries on the normal reload cadence rather than waiting for a config write. Flow
requirements are read through effective_oauth2_flow, the column-first shape-fallback judge
every flow decision uses, so a legacy null-flow M2M row is classified exactly as the
request path classifies it instead of re-discovering forever; a dcr_bridge server with no
configured client needs its registration endpoint for the relay arm, and an entra_obo
server needs a scope, both of which discovery can supply. Retries back off per server,
doubling from one reload cadence to a fifteen-minute cap, so a permanently unresolvable
server cannot re-run the RFC 9728 to 8414 chain and re-log its warning every cycle forever

Deployments with store_model_in_db unset or false loaded MCP servers exactly once at
startup, leaving that retry with no driver, so they now refresh the registry on the same
reload interval. That job deliberately calls a reload-only entry point rather than the
startup composite, keeping the one-time oauth2_flow backfill and issuer heal out of a
recurring path

Losing the persisted trust-on-first-use issuer also means the issuer column no longer
changes underneath the OAuth token identity, so user tokens are purged only when an admin
actually edits the server

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-29 17:51:22 -07:00
Mateo Wang
8b03315ac6
Merge pull request #35028 from BerriAI/litellm_batch_provider_credentials
fix(proxy): resolve named credentials on provider-only batch and files calls
2026-07-29 11:59:09 -07:00
Mateo Wang
c56e657097
Merge pull request #34266 from BerriAI/litellm_team-model-allowlist-stale-qqg50q
fix(proxy): stop serving stale team model allowlist after /team/update
2026-07-29 11:00:38 -07:00
Mateo Wang
2348ccc977
Merge pull request #35107 from BerriAI/litellm_usage_public_model_names
fix(ui): show public model names in usage breakdowns
2026-07-29 10:42:13 -07:00
ryan-crabbe-berri
fdea50daa2
feat(ui): shareable log links via log_id query param on the logs page (#34879)
* feat(ui): shareable log links via log_id query param on the logs page

Clicking a log row now writes ?log_id=<request_id> to the URL, closing the
drawer removes it, and loading the logs page with ?log_id= opens the drawer
for that log. When the log is not in the loaded page, it is fetched by
request_id (the backend already drops the date window for id lookups), so
links keep working for logs of any age. Drawer open state derives from the
URL, mirroring the models page ?model= pattern.

* fix(ui): close the log drawer on browser back after opening via session id

Session opens now write ?session_id= to the URL instead of holding local
state, so back removes both params and the drawer closes (Greptile P1).
Session views become shareable links as a side effect. In-drawer log
switching now replaces the history entry instead of pushing, so back
always closes the drawer in one step rather than replaying every viewed
log.

* fix(proxy): scope /spend/logs/session/ui to the requesting user's visible logs

Non-admin callers now only receive session rows they could already see on
/spend/logs/ui: their own logs plus logs of teams where they hold the
spend-logs permission. Previously any authenticated user could read any
session's log metadata by id, which shareable ?session_id= links made
trivial to trigger. Admin views are unchanged. Also, clicking a log row
now clears a lingering ?session_id= from the URL so the drawer shows the
clicked log instead of a stale session (Greptile P1).
2026-07-29 17:01:09 +00:00
mateo-berri
d17387e2e1 fix(proxy): fall back on empty-string model_group in aggregated usage SQL 2026-07-29 09:59:34 -07:00
ryan-crabbe-berri
9b7a6b9b90
feat(ui): split failed requests into their own series on the cache dashboard (#34862)
* feat(ui): chart failed requests as their own series on the cache dashboard

Spend logs for failed requests are stored with an empty call_type, so the
Cache Hits vs API Requests chart lumped them into an Unknown bar that read
as normal LLM API traffic. The activity query now also returns a per-group
failed_rows count (status = 'failure') and the dashboard charts it as a
third stacked series, so failures are visibly separate from successful
requests and cache hits. The chart data transform moves into a pure
summarizeCacheActivity helper with unit tests; header stats keep their
existing semantics (cache hit ratio still counts failures in the
denominator).

* refactor(ui): move cache dashboard aggregation server-side with a typed response

The /global/activity/cache_hits endpoint previously returned raw per
(key, call_type, model) spend-log aggregates typed as LiteLLM_SpendLogs
(wrong), and the dashboard reduced them in the browser: grouping by
call_type, relabeling empty call_type as Unknown, and computing the stat
card totals. All of that now happens server-side. The SQL groups per
call_type and splits cache hits vs successful vs failed requests, a new
cache_activity module validates rows into Pydantic models and computes
totals plus the key-alias/model filter options, and the endpoint declares
a real response_model so schema.d.ts types it correctly. The dashboard
consumes it through a typed $api react-query hook (filters ride the
query key and are applied in SQL instead of the browser), the hand-rolled
summarizeCacheActivity transform and the adminGlobalCacheActivity fetch
helper are deleted, and the refresh button now actually refetches.

The endpoint is UI-internal (hidden from the public swagger), so the
response reshape is not a public API break.
2026-07-29 09:48:17 -07:00
ryan-crabbe-berri
40878a1ed5
fix(proxy): allow /key/update to identify the key by key_alias (#34851)
* fix(proxy): allow /key/update to identify the key by key_alias

* fix(ui): drop machine-dependent union-order churn from generated schema.d.ts
2026-07-29 09:48:08 -07:00
mateo-berri
802ed1c74f fix(ui): show public model names in usage breakdowns 2026-07-29 09:46:49 -07:00
Mateo Wang
e299d2b970
Merge pull request #35074 from BerriAI/litellm_daily_any_cleanup_07_29_2026
chore(typing): clear basedpyright Any errors in proxy management endpoints
2026-07-29 09:42:19 -07:00
ryan-crabbe-berri
25ebe5600a
fix(scim): stop provisioning nested group ids as internal users (#34997)
* fix(scim): stop provisioning nested group ids as internal users

POST/PUT/PATCH /scim/v2/Groups treated every member.value as a user id, so
with the default scim_upsert_user=true an unknown id was auto-created as an
internal user. Entra sends nested groups as members carrying "type": "Group",
which meant every nested group produced a phantom internal user whose id and
email were the group GUID, and those users counted toward licensed seats.

Group members are now classified before they are used: members typed "Group"
are skipped without a database hit, an id that names an existing team is
skipped too (Okta sends untyped ids through filtered paths, so the type alone
is not enough), and only ids that resolve to a user, or that resolve to
nothing at all, keep today's behavior. The user lookup runs before the team
lookup so a user whose id collides with a team id keeps syncing.

The type was previously dropped at parse time on POST/PUT because SCIMMember
had no such field, and on PATCH because the raw member dicts were reduced to
bare ids; both paths now share one resolver and one parser that preserves it.

Member removals no longer upsert: a remove of an id we do not know is an
idempotent no-op rather than a reason to create a user and immediately drop
it, and strict mode (scim_upsert_user=false) no longer rejects it. Removal of
an id that is on the roster but has no user row still cleans up membership.

Responses now state members are of type "User" instead of emitting a null,
and the advertised Group schema documents the members.type sub-attribute.

* fix(scim): harden group member classification after adversarial review

Removals now bypass classification and drop exactly the ids they name,
restoring cleanup of roster entries the old bug left behind. The
team-id fallback only applies to untyped members, so an explicit User
type always provisions even when the id collides with a team. Member
types are normalized before matching; a type other than User or Group
only skips when the id is not an existing user. Non-string type values
are tolerated as absent on every verb instead of failing validation.
Admitted member ids are deduped order-preserving, which also closes a
pre-existing duplicate-row hazard on group creation.

* fix(scim): only treat scim-managed teams as nested groups

A PR reviewer flagged that an untyped SCIM member whose id collides
with an admin-created team was silently skipped, suppressing that
user's provisioning. SCIM group writes (POST, PUT, and every PATCH)
now stamp the team with scim_managed metadata, and the typeless
team-id skip only applies to teams carrying that marker or the
scim_data blob older PUTs already wrote. Admin-created teams stay
unmarked, so a colliding untyped member provisions the user in
permissive mode and returns the standard unknown-user 400 in strict
mode. Teams SCIM touched before this change adopt the marker on their
next group write.
2026-07-29 09:38:17 -07:00
mateo-berri
095364fd04
test: cover the model_validate conversion sites flagged by codecov
Add regression tests for the db-fetch paths whose converted construction
lines were uncovered: the auth_checks getters (default end user budget, end
user, team membership, access group, team by alias, org by alias, object
permission, managed vector stores, project), get_all_team_memberships and
list_available_teams in team_endpoints, and the proxy admin user info
helper. Each test feeds a mocked prisma row through the real function and
asserts the validated model's fields, so a bad model_validate conversion on
any of these paths now fails a test instead of only dropping coverage.
2026-07-29 10:04:58 +00:00