Commit graph

41784 commits

Author SHA1 Message Date
Mateo Wang
81ff7cb38f
Merge pull request #35292 from BerriAI/litellm_lit4512_messages_guardrail_info
fix(logging): bind litellm_metadata by reference in function_setup so guardrail info reaches spend logs
2026-07-30 19:53:15 -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
tin-berri
c8bec20443
fix: give ComplexityRouter LLM classifier prior-turn context (LIT-4981) (#35185)
The ComplexityRouter's LLM classifier saw only the last user message, so on a
multi-turn conversation it classified whatever happened to be last rather than
what the human actually asked, and a near-constant classifier input pinned a whole
session to one tier.

The blindness turned out to be narrower than first diagnosed, and the fix is
correspondingly smaller. Tool output was never the problem: on the Messages surface
it rides a user turn as tool_result content blocks, which are not text parts, so
flattening to `type == "text"` already dropped those turns; on chat completions it
arrives on a `tool` role the extractor never read. Both surfaces were already
handled before this change. What actually leaked through was the harness
`<system-reminder>` block, which arrives as ordinary text, survives flattening, and
became the current ask on any turn that carried one.

So reminders are stripped rather than used to reject the turn, because a harness
injects them alongside the live ask and not as a turn of their own; rejecting the
turn would lose the ask, and keeping the block would feed the classifier the
near-constant boilerplate that flattens tier selection in the first place. An
earlier revision of this change also pattern-matched serialized tool_result
payloads. That check only ever fired on a hand-serialized string neither request
surface produces, it was where every review finding in this PR lived, and it is
deleted here; the tests now pin the real shapes instead of the synthetic one they
were built on.

The classifier call is split into a system role carrying the rubric plus the
caller's own system prompt, which stays byte-stable across a session so a provider
can prompt-cache it, and a user role carrying the variable context: a bounded
window of prior user turns, a conversation-depth signal, and the current ask. The
caller's system prompt rides every turn, so task constraints are never dropped.
The depth signal measures content-parts messages too, since counting only string
content reported ~0 tokens for exactly the deep Messages-surface conversations that
most need an expensive tier, and it is omitted entirely on the prompt-only path
rather than asserting a false zero.

Prior turns are excluded by matching the current ask rather than by dropping the
newest turn positionally, because `aclassify` takes `prompt` and `messages`
separately and a caller may classify something other than the newest turn.
Truncated turns carry a marker so the classifier can tell a turn was clipped.

Only the LLM classifier's input changes. The heuristic scorer, keyword overrides,
escalation matching and semantic embedding still read the extracted current ask,
which is why that extraction has to yield one clean human-authored string: those
are substring and vector matchers, and an escalation keyword sitting inside a
reminder blob would otherwise trip a tier jump on its own.

Defaults keep single-turn classification equivalent to before. The prior-turn
window is on by default so existing LLM-classifier deployments actually get the
fix; the config field documents that those turns reach the classifier model, which
may be a different provider than the routed completion model, and that the call
already carries the current ask and the caller's system prompt in full.

Scoped to the ComplexityRouter; the semantic AutoRouter is not touched.
2026-07-30 19:23:52 -07:00
yucheng-berri
1018d18e6b
fix(anthropic): split mixed stream chunks by payload kind (#35289)
* fix(anthropic): split mixed reasoning stream chunks

* style: use builtin generic annotation

* fix(anthropic): split mixed stream chunks by payload kind

The mixed-chunk split cleared only the fields it knew about on each
deep-copied piece, so any other payload riding the chunk survived on
both pieces: tool_calls were emitted as two tool_use blocks with the
same id, thinking_blocks on the text piece emitted duplicated thinking
into a text block while dropping the answer text, and chunks whose
reasoning arrived only as thinking_blocks never split at all

Rebuild each piece's delta from scratch with exactly one payload kind
(reasoning, text, tool calls), ordered to match native Anthropic block
order. Fresh Delta construction keeps unset attributes deleted, which
matters because the translators branch on hasattr, and prevents future
Delta fields from riding along on every piece

* fix(anthropic): keep continuation and multi-choice chunks unsplit, emit signature-less thinking once

Adversarial verification against the merge-base found three shapes where
the payload-kind split changed behavior beyond its target: a mixed chunk
carrying a tool argument continuation was torn into a truncated block
plus a fabricated one, a multi-choice chunk lost its secondary choices'
payload, and a signature-less thinking_blocks piece inherited the
non-empty block start body so accumulators collected the thinking twice

Continuation and multi-choice chunks now pass through the splitter
untouched, matching the merge-base byte for byte, and signature-less
thinking_blocks pieces are normalized to reasoning_content so the block
start opens empty and the thinking text is emitted exactly once

---------

Co-authored-by: Napuh <naamanynadiemas@gmail.com>
2026-07-31 02:05:18 +00: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
ryan-crabbe-berri
7d97bbc3bb
fix(ui): let the internal user and org forms save sub-cent budgets (#35302)
The Default User Settings form on Internal Users, the org settings form and
the org create dialog all rendered their money fields as
`<input type="number" step={0.01}>` inside a form that never opted out of
native constraint validation. Any value with more than two decimals, such as
a 0.001 max budget, failed the browser's step check, so Chrome vetoed the
submit before react-hook-form ran. No request went out, no field error was
shown, and the read view kept displaying the old value; it looked like the
budget silently refused to stick.

Money fields now use `step="any"`, and the three react-hook-form forms carry
`noValidate` so zod stays the only validator and a DOM-level constraint can
never swallow a submit again.
2026-07-30 18:56:28 -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
ryan-crabbe-berri
8ccbc3e735
test(e2e): skip the batch rate-limiter spend-row test pending LIT-5027 (#35301)
The batch rate limiter counts input tokens by awaiting litellm.afile_content
with no timeout, so a slow Files API holds POST /v1/batches open past any
client deadline; stage saw 63.6s against the harness's 60s read timeout. The
test times out before reaching the unattributed-spend-row assertion it exists
to guard, so it reports an infrastructure hang rather than the contract.

Skipping keeps the signal honest until the fetch is bounded.
2026-07-31 01:20:04 +00: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
Yucheng Zhu
899ed67860 fix(logging): bind litellm_metadata by reference in function_setup so guardrail info reaches spend logs 2026-07-30 17:38:43 -07:00
mateo-berri
0732122536 refactor(proxy): use a walrus assignment in the deployment model gate
The single-element tuple loop that bound the extracted deployment model
inside the comprehension read poorly; an assignment expression in the
filter clause does the same call-once-and-filter in one line.
2026-07-30 17:33:33 -07:00
Mateo Wang
c3da12161b
Merge pull request #35174 from BerriAI/litellm_fix_fireworks_kimi_output_limits
fix(fireworks_ai): correct Kimi K2.5/K2.6/K2.7 max output token limits
2026-07-30 17:17:11 -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
Mateo Wang
d5dc9d1a0b
Merge pull request #35282 from BerriAI/litellm_fix_embedding_cache_provider
fix(caching): stamp provider on embedding cache-hit spend logs
2026-07-30 16:35:20 -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
yuneng-jiang
abb0e36ca5
Merge pull request #35268 from BerriAI/litellm_/budgets-table-truncation-4b98ec
fix(ui): stop clamping the budgets Budget ID column at 15 characters
2026-07-30 15:54:23 -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
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
mateo-berri
3b62b90b55 test(rate-limits): drop the removed data kwarg from the v3 dynamic limiter raise-branch test 2026-07-30 15:20:44 -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
milan
15c7d850e5 fix(caching): stamp provider on embedding cache-hit logs so spend logs record provider
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-30 22:11:01 +00: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
Mateo Wang
6794755992
Merge pull request #35280 from BerriAI/litellm_pr_template_e2e_all_endpoints
docs(pr-template): require e2e proof on all three LLM endpoints when applicable
2026-07-30 14:46:58 -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
9ec900f964
fix(redis): stop an unreachable Redis from blocking every request (#35273)
Two defects combined to make a Redis outage take the proxy down rather than
degrade it.

First, connection kwargs were dropped whenever Redis was configured by url.
_get_redis_url_kwargs built its allowlist from
inspect.getfullargspec(redis.Redis.from_url); from_url is declared
(cls, url, **kwargs), so the argspec carried no connection kwargs and the
function returned ['cls', 'url', 'url']. socket_timeout went with the rest,
and socket_connect_timeout falls back to it, so both ended up None and a
Redis host that drops packets rather than refusing them blocked callers
indefinitely. get_redis_connection_pool's url branch lost the same kwargs by
a different route, rebuilding its pool kwargs from scratch.

The allowlist now comes from the connection class redis-py actually forwards
those kwargs to, walking the MRO because redis-py splits them between
AbstractConnection and its subclasses. Deriving it from the client instead
would admit client-only settings such as single_connection_client and the
SSLConnection-only ssl_* family, which reach AbstractConnection and raise
TypeError on first connect.

Second, the circuit breaker could not trip even once calls failed fast.
_redis_circuit_breaker_guard inferred success from the method returning, but
async_get_cache, async_batch_get_cache, async_set_cache, async_set_cache_pipeline,
async_set_cache_sadd and async_get_ttl catch their own connection errors and
return a default so callers degrade. Each failed call therefore reset the
failure streak and the breaker never opened, so an unreachable Redis stayed in
the pool and every request kept paying a full socket timeout on it. Those
methods now mark the failure and the guard records success only when nothing
failed while the method ran. Lua script execution went through none of this,
which mattered most because the rate limiter issues all of its Redis traffic
that way, so the guard is now a small helper shared by both.

The per-call marker is a ContextVar rather than a counter on the breaker.
Breakers are shared by every concurrent caller, so a shared counter cannot
tell "my call failed" from "some other in-flight call failed", and a success
overlapping someone else's failure would be discarded until a Redis that was
still answering got evicted from the pool anyway.

Only connectivity failures feed the breaker. Command and data errors say
nothing about whether Redis is reachable, and counting them would let a caller
provoke evictions on demand (an INCR against a non-numeric value, say),
dropping rate limiting to per-process counters that spreading traffic across
replicas can outrun.
2026-07-30 14:36:28 -07:00
mateo-berri
a00757ce80 docs(pr-template): require e2e proof on all three LLM endpoints when applicable 2026-07-30 14:30:33 -07:00
Mateo Wang
4007e912d9
Merge pull request #35266 from BerriAI/litellm_claude_md_bot_reply_length
docs(claude): require 15-25 word human-readable replies to AI PR review bots
2026-07-30 14:26:44 -07: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
yuneng-jiang
eb8870065b
test(e2e): align budget e2e with the team-key budget hierarchy (#35276)
#35271 restored the hierarchy where a team-scoped key is governed by the
team and team-member budgets only; the owner's personal max_budget applies
to their personal keys. Three places in the e2e suite still encoded the
old direction and would fail against a proxy built from staging.

test_user_budget_enforced_across_all_their_keys asserted that the owner's
team-member key is refused once their personal budget is exhausted. It now
asserts only the personal keys are refused, and keeps the team key as the
control that must keep serving, which pins the restored direction instead
of leaving it unasserted. Renamed to match what it now covers.

test_team_member_key_user_budget_resets_after_window drove a team key to a
block off the owner's personal budget, so nothing can block it any more and
_drive_to_block could never succeed. Its premise is gone rather than moved,
so it is removed; the sibling personal-key test still covers
quota_management.budget.internal_user.resets_after_window.

The registry rationale for that row dropped its "and team-member keys"
clause for the same reason.
2026-07-30 21:09:50 +00:00
Yuneng Jiang
683d716ca2
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/budgets-table-truncation-4b98ec 2026-07-30 14:04:18 -07:00
Yuneng Jiang
7eee260ca8
fix(ui): stop clamping the budgets Budget ID column at 15 characters
Reverts the shared IdCell change from the previous commit and scopes the
fix to the budgets table instead

IdCell truncates with `block max-w-[15ch]`, a character-count clamp with
no relationship to the column's width. On budgets the Budget ID column
renders 509px wide at a 1400px container while the ID stays pinned at
108px, so UUIDs ellipsize with ~400px of empty space beside them

Changing that clamp in IdCell itself is wrong today because nothing else
bounds the column. DataTable emits `width: <size>px` on each cell but
leaves the table in `table-auto`, where `width` is only a hint and
`max-width` on a cell is ignored outright (measured: a 120px request
yields a 938px column). Only `table-fixed` binds `size`, and DataTable
enables it solely under `enableColumnResizing`, which 4 of 40 tables use.
So an unbounded IdCell lets content drive the column: Request Logs would
render a 64-char key hash in full, taking its key_hash column from 124px
to 494px and pushing the table from 1918px to 2326px, introducing
horizontal scroll at 1920 where there was none

Scope it to the call site instead. `cn` is tailwind-merge backed, so a
`max-w-*` passed via className dissolves the base clamp while leaving
`truncate` in place; budget IDs render in full and still ellipsize at the
cell edge if one ever outgrows the column. No other table moves

This is a workaround. The real fix is to make column `size` authoritative
by separating a fixed-layout option from `enableColumnResizing`, then
dropping the per-cell clamps; 307 of 321 column defs already declare a
size, so the mechanical gap is small, but ~20 tables would gain
horizontal scroll at 1440 and that needs its own review
2026-07-30 14:04:13 -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
Yassin Kortam
abd239f903
fix(otel): label retrieval and agent metrics correctly and emit gen_ai.provider.name (#35151)
* fix(otel): label retrieval and agent metrics correctly and emit gen_ai.provider.name

The GenAI metric attribute builder mapped only chat, text completion, embedding,
responses and MCP tool calls to an operation name, so vector-store searches and
A2A agent sends fell through to the "chat" default. Their duration and cost then
landed in the same series a Grafana GenAI dashboard reads chat latency off, with
no way to tell them apart. Both now map to the operation names the convention
defines for them, retrieval and invoke_agent, and an unmapped call type says so
at debug instead of silently becoming chat.

The provider label used gen_ai.system, which the convention deprecated in favor
of gen_ai.provider.name; the dashboards built on that vocabulary find nothing
under the old key. Metrics now carry gen_ai.provider.name with the semconv
provider value (bedrock -> aws.bedrock) via the resolve_provider helper the span
path already uses, and keep dual-emitting gen_ai.system with its raw value so a
dashboard already querying it keeps matching. A request litellm cannot attribute
to a provider gets no provider label at all rather than a placeholder "Unknown"
that minted a permanent series nobody can act on.

Resolves LIT-4954
Resolves LIT-4959

* fix(otel): map the rest of the vector-store call types off the chat default

Mapping only the search left the store lifecycle (create, retrieve, list,
update, delete) and the file operations (create, list, retrieve, content,
update, delete) falling through to chat, so vector-store admin traffic kept
polluting the same series a dashboard reads chat latency off. A live run
confirmed it: all 20 metric datapoints from a create, retrieve, list, file-list
and delete came out labelled chat.

The convention names no operation for vector-store management, so these take
vendor values under the litellm. prefix, litellm.vector_store_management and
litellm.vector_store_file_management, one per REST resource. Its note on
gen_ai.operation.name directs instrumentation to use a system-specific name
when no predefined value applies, which is the same allowance resolve_provider
already relies on for unmapped providers. Excluding them from the GenAI metrics
altogether was the alternative; it deletes series an operator may be watching
today and is far harder to reverse than a rename, so it stays available as a
follow-up rather than being decided here. Mapping them onto the semconv memory
store family was rejected: litellm vector stores hold documents, not agent
memory records, and borrowing those names would put document admin calls into
whatever charts agent-memory operations, which is the bug this fixes.

/rag/query reaches the same recorder and is the same operation as a vector-store
search, so query and aquery map to retrieval too; leaving them would have left
the defect alive on a second retrieval surface. /rag/ingest is a write with no
semconv equivalent and no retrieval or agent confusion, so it is left for the
RAG owners to name.

Resolves LIT-4954

* fix(otel): give the streaming A2A path a call type so it labels as invoke_agent

The streaming logging object is built by hand and never runs through
update_environment_variables, the only place call_type reaches
model_call_details, so every streamed agent turn arrived at the recorder
with no call type and fell back to chat. Stamp it, and map the streaming
spelling alongside the non-streaming ones.
2026-07-30 13:48:59 -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
Yuneng Jiang
2756695258
fix(ui): clamp table ID cells to the cell box instead of a fixed 15ch
IdCell truncated with `block max-w-[15ch]`, a character-count clamp that
ignores how much room the column actually has. On the budgets table the
Budget ID column renders 509px wide at a 1400px container while the ID
itself was pinned to 108px, so every UUID showed an ellipsis with roughly
400px of empty space beside it. The same held at 900px and 520px
containers; the clamp never moved because it was never a function of the
available width

Switch to `inline-block max-w-full truncate`, the standard CSS idiom for
shrink-to-fit text that ellipsizes at its container. IDs now render in
full whenever the column has room and clip at the cell edge when it does
not. `inline-block` keeps the pill variant sized to its content rather
than stretching the blue background across the column, which a plain
`block` would do once the character clamp is gone

Measured in Chrome across 1400/900/520px containers and both variants:
row height is unchanged, short IDs shrink from a padded 108px to 51px
(plain) and 67px (pill), and the 36-char UUID renders fully at 260px
2026-07-30 12:41:36 -07:00
Mateo Wang
d33e6fe16c
chore: make it concise 2026-07-30 12:37:58 -07:00
mateo
23bf657112 docs(claude): require 15-25 word human-readable replies to AI PR review bots
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-30 19:36:36 +00:00
yuneng-jiang
4eecf7a050
bump: litellm 1.95.0 -> 1.96.0 (#35254) 2026-07-30 12:12:13 -07:00
Yassin Kortam
8bb8628ab5
fix(otel): record the GenAI duration metric on failed requests (#35152)
* feat(otel): record the GenAI duration metric on failed requests

`_record_metrics` ran only from `async_log_success_event`, so
`gen_ai.client.operation.duration` counted only the requests that worked.
Latency read off it during an incident was the latency of the surviving
traffic, and with no error dimension anywhere there was no way to build a
failure-rate panel or a success/failure split per model.

A failed call now records the same duration histogram, tagged with the
semconv `error.type` (the mapped provider exception's class name, bounded by
construction; the message stays on the span). Success attributes are
untouched, so an existing query can still isolate the old series with
`error_type=""`. The other five instruments describe a completed generation
and are skipped rather than filled with a fabricated zero: litellm hands the
failure callback no `response_obj`, so there is no usage to split and no
completion-token count, and it zeroes `response_cost` on failure. A
proxy-gate rejection (auth / rate limit) records nothing, for the same
reason it gets no span; no upstream call happened.

`error.type` is stamped after the cardinality filter, like
`gen_ai.token.type`, so an `otel.attributes` include/exclude list cannot
strip the discriminator and silently merge failures into the success series.

Resolves LIT-4955

* fix(otel): bound the failure metric's attribute set

The failure datapoint reused the success path's full attribute set, which
carries client-supplied fields (`metadata.requester_metadata`,
`metadata.spend_logs_metadata`, the end-user id taken from the request's
`user` field) and per-request ones (the `hidden_params` blob holding the
provider's response headers). A failed request needs no provider spend, so
nothing rate-limits a caller who puts a unique value in a field they control
and mints one histogram series per request.

A failure now carries a bounded allowlist: the operation enum, provider,
request model, framework, the key/alias/team/org/user identifiers, and
`error.type`. Every entry is a fixed enum or an operator-provisioned
identifier, so the failure series count is bounded by the deployment's own
key, team and user count while the labels still answer which team on which
model is failing and how. The user email is left out as PII duplicating the
user id already on the series. The operator's `otel.attributes` filter layers
on top, so it narrows the allowlist further and never widens it.

* fix(otel): cap metric attributes so series count does not grow with traffic (#35166)

`GenAIMetricRecorder._common_attributes` dumped the whole `hidden_params` object
onto every metric datapoint as one label value. That object is per-request by
construction: `response_cost`, `litellm_overhead_time_ms`, `cache_key`,
`usage_object` and the provider's `additional_headers` rate-limit counters all
move on every call. A unique label value is a new time series, and all six GenAI
instruments share those attributes, so one request minted up to six series that
would never be written to again

That is the steady-state behavior of the feature rather than an abuse case, and
it is wrong twice over. Hosted backends bill on series count, so recommending
metrics be enabled would have meant a bill proportional to traffic. And a
histogram whose every datapoint sits in its own series cannot be aggregated, so
the dashboards would have looked populated while answering nothing

Both paths now cap their attributes at METRIC_ATTRIBUTE_CEILING, which replaces
the failure-only allowlist so the two paths cannot drift. The cap runs before the
operator's `otel.attributes` filter, so an operator can narrow it and never widen
it back to an unbounded label. Client-supplied and per-request metadata
(`requester_metadata`, `spend_logs_metadata`, `user_api_key_end_user_id`,
`requester_ip_address`) is metric-ineligible and stays on the span, which already
carries it and where cardinality is free. `hidden_params` survives as a label but
carries only `model_id` and `api_base`, which are bounded by the router's own
deployment list and are the part a per-deployment panel reads

Four tests fail against the previous behavior, the load-bearing one being that
two requests differing only in per-request fields must land in one series rather
than two
2026-07-30 19:11:26 +00: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
Yassin Kortam
bf8e4af0e2
fix(otel): cap tool-definition attributes so they cannot evict gen_ai.* from the LLM span (#34828)
* fix(otel): cap tool-definition attributes so they cannot evict gen_ai.* from the LLM span

The genai and legacy mappers each spelled out every declared tool as
per-index span attributes. A request declaring hundreds of tools produced
roughly 500 attributes against the OTel SDK's default 128-attribute span
limit, which evicts oldest-first, so the canonical gen_ai.* set written
first was discarded and the span exported with only a tail of tool
schemas. Cap the family at 8 tools, shared by both vocabularies, and
carry the declared total on litellm.request.tools.declared so the
truncation is visible rather than silent.

* fix(otel): apply the tool-definition cap to the OpenInference mapper

The OpenInference vocabulary emits its own unbounded llm.tools.{idx}.*
family, which Arize and Phoenix layer on top of the default two, so those
configurations still overran the span attribute limit and evicted the
core gen_ai.* attributes. Route it through the same shared cap and cover
the layered-mapper path with a test.

* fix(otel): share one span-wide tool-definition budget across vocabularies

Capping the tool-definition family per mapper left each active vocabulary
its own allowance, and several vocabularies write to the same span. With
every vendor vocabulary configured, the three that spell tools out per
index still summed past the SDK's 128-attribute span limit, so the core
gen_ai.* set written first was evicted exactly as before: measured at 128
attributes with 7 dropped and gen_ai.request.model gone.

Reserve a quarter of the span for tool detail and split that ceiling
across the distinct tool-emitting vocabularies at mapper-resolution time,
so the family is bounded span-wide no matter how many are configured. The
same worst case now exports 90 attributes with nothing dropped.
2026-07-30 12:01:10 -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