Commit graph

47825 commits

Author SHA1 Message Date
yuneng-jiang
1a1ca0eb5a
Merge branch 'litellm_internal_staging' into litellm_model_update_null_clear
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Staging landed a general replica read-back primitive (await_everywhere,
replicas_for, read_body_back_everywhere) that subsumes the model-specific
one this branch added, so the duplicate polling helpers and their unit
tests are dropped and the model lifecycle test reads back through the
shared helper instead. Also keeps both blocks of new coverage registry
rows and takes staging's DataDog reader, which already contains this
branch's credential-hiding change.
2026-09-09 06:10:20 +00:00
yuneng-jiang
36bd7f1138
fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e (#40022)
* fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e

PUT /v1/mcp/toolset dumped its payload with exclude_none, so a field sent as
null looked exactly like one the caller left out and the stored value
survived. An admin could not clear a toolset's description: the save reported
success and the old text came straight back. It now dumps with exclude_unset,
so absent keeps and null clears, which is what PUT /v1/mcp/server already did.
A null tools list clears the selection to empty, and a null toolset_name is
ignored because a toolset always has a name.

Adds create, read, partial-update, clear and delete e2e coverage for MCP
servers and toolsets, with every read-back polled on every replica so an edit
that lands on one replica and not another fails the test, plus an enforcement
test proving a key granted a toolset lists exactly that toolset's tools
against the real Datadog upstream.

* fix(e2e): refuse a read-back that no replica serves

A read-back over an empty replica mapping satisfied every predicate and
returned as if it had converged, so it would have asserted nothing and
passed. No wiring can produce that today, since the replica list always
falls back to at least one URL, but a helper whose whole job is proving a
write reached every replica should not have a shape that passes vacuously.

* fix(mcp): keep a null tools list a no-op on toolset update

Treating a null tools list as a clear meant an existing client that sends
tools=null during a partial update, meaning "leave the selection alone",
silently lost every tool the toolset grants. That is a permission surface,
so the quiet version of it is the worst version.

A toolset always has a tool list, the same way it always has a name, so a
null on either is now a no-op. Emptying the selection is an explicit [],
which cannot be confused with a field the caller left out, and which is
what the dashboard already sends.

* fix(e2e): keep MCP admin routes on the data plane

/v1/mcp/* is a lazily mounted feature, so a gateway registers it on the first
matching request, which happens after the startup route trim that drops
management endpoints. Routing it to the control plane therefore sent every MCP
call to the one backend process: the new lifecycle read-backs proved a single
process rather than every replica, and mcp_client's await_registered barrier
waited on a registry that does not serve the tools/list call it guards, so the
existing MCP suites polled a gateway that had not synced yet until poll_timeout

Verified against a two-gateway split stack (backend on 4001, gateways on 4010
and 4011, one postgres): both gateways answer /v1/mcp/server and /v1/mcp/toolset,
and each served 6 server reads and 7 toolset reads over the run

* fix(e2e): grant the toolset by the tool's own name, not the wire name

tools/list serves a tool as <prefix><tool_name>, but a toolset grants by the
tool's own name: resolve_toolset_permissions reads toolset.tools[].tool_name
straight through, and the prefix is added on the way out. The test built the
toolset from the names tools/list reported, so the grant matched nothing, the
scoped key listed no tools, and await_tools ran out its whole poll_timeout
before failing

Measure the prefix off search_datadog_logs, whose own name is known, rather than
guessing it from the alias, since the proxy can be configured to prefix with a
short server id instead. The expectation compared against tools/list stays in
wire names; only what the toolset stores crosses back

* test(mcp): build immutable lifecycle updates and replica results

* test: validate opaque stream IDs and hide log-reader credentials

* test: isolate auto-router scenarios and clean partial setup

* test: honor Datadog search rate-limit reset headers

* test: share the Datadog read-back deadline across retries

* test: preserve captured MCP toolset update fields
2026-09-08 22:50:13 -07:00
yuneng-jiang
b3151073d2
test(e2e/ui): cover key budget window, non-admin model scope edit, and key blocking (#40027)
* test(e2e/ui): cover key budget window, non-admin model scope edit, and key blocking

Three Playwright specs for the Virtual Keys flows customers hit most, each
reading its result back through /key/info and /v1/chat/completions rather
than trusting the toast:

- a monthly spend cap and reset window set through Edit Settings, surviving
  a reload, with clearing the window leaving the cap in place
- a team member narrowing their own team key's models, and the proxy
  refusing the model they dropped
- blocking a key from its detail page, then unblocking it

Each test owns the key it edits and deletes it on teardown, so retries and
--repeat-each never run out of fixtures.

* test(e2e/ui): tighten virtual key specs from review feedback

Replace the mutable suite-level key state with a Playwright fixture, so the
alias and token are never reassigned and cleanup stays tied to the test.

Assert /key/delete succeeded instead of discarding the response, so a failed
cleanup surfaces rather than leaving rows behind.

Drop the explanatory JSDoc the repo's comment policy disallows, keeping only
the one line explaining why Date.now() alone is not unique enough.

Type the master-key POST helper against a real guard instead of casting to
Record<string, any>.

Assert the unblocked key is served with a 200, not just the response text,
and that clearing the reset window also clears budget_reset_at.

* test(ui): assert the team response through Playwright
2026-09-08 22:49:54 -07:00
yuneng-jiang
0721163cac
test(e2e/ui): cover team-scoped model visibility, re-editing litellm params, and model health checks (#40039)
* test(e2e/ui): cover team-scoped model visibility, re-editing litellm params, and model health checks

Three Models and Endpoints flows had no end-to-end coverage, and all three
keep coming back as bug reports.

modelsByTeam walks an internal user through the Current team control and
asserts the table lists exactly what each team grants. It creates one
deployment that belongs to no team, proves that deployment is visible under
Personal, then proves it is absent under both seeded teams, so an empty
table cannot pass the same assertions.

editLitellmParams adds a temperature and a custom pair to a deployment,
saves, then re-edits the temperature and drops the custom pair. It checks
both update request bodies, polls the stored deployment until the new
temperature is there, reloads the page to confirm the second save is what
renders, and sends one chat completion to prove the deployment still serves.

modelHealthStatus runs the health check on a reachable deployment and on one
pointed at a dead port, asserts the healthy and unhealthy cells and the two
detail dialogs, and reloads to confirm both statuses are stored.

Every deployment these specs create carries a unique name and is deleted in
afterEach, including on the failure path.

* test(e2e/ui): find health rows across every page of the health table

The health table pages server-side at 50 rows with no search box, so on a
proxy carrying more deployments than that the two deployments the spec
creates can land on a later page and the lookup finds nothing.

Row lookups now walk the pages, using the table's own page indicator to
know when to advance and when to wrap back to the first page.

* test(e2e/ui): build the created deployment ids without mutating the array

* test(ui): scope model deployments to Playwright fixtures
2026-09-08 22:49:40 -07:00
Mateo Wang
ee7c7e14f3
Merge pull request #40189 from BerriAI/litellm_lit_3157_azure_ai_catalog_models
fix(azure_ai): price seven Foundry catalog names and charge the model router fee once
2026-09-08 20:08:40 -07:00
tin-berri
902dd7b2b6
fix(mcp): log proxy tool dispatch exceptions (#40351) 2026-09-08 19:56:21 -07:00
Mateo Wang
24ef3ec63b
Merge pull request #37781 from ZXT-zjbiliy/fix/build-base-response-empty-choices
fix(stream_chunk_builder): guard empty choices and missing role in build_base_response
2026-09-08 19:14:03 -07:00
Mateo Wang
f8e456d105
Merge pull request #40275 from BerriAI/litellm_lit6852_spend_attribution
fix(spend-tracking): recover key alias for session tokens from spend logs
2026-09-08 19:05:43 -07:00
tin-berri
1a9c6ce390
fix(mcp): preserve proxy logging and authorization coverage (#40337)
* test(mcp): exercise /mcp/proxy authorization against the real registry instead of patched manager methods

* fix(mcp): preserve proxy logging and authorization coverage

* test(mcp): respect the proxy FastAPI import boundary
2026-09-09 02:01:01 +00:00
yuneng-jiang
0d62970865
Merge pull request #40334 from BerriAI/litellm_/release-version-bump-787548
chore: bump litellm-enterprise 0.1.65 -> 0.1.66
2026-09-08 18:53:51 -07:00
yuneng-jiang
86ee031217
Merge branch 'litellm_internal_staging' into litellm_/release-version-bump-787548 2026-09-08 18:44:54 -07:00
yuneng-jiang
1fbd1cb9ce
Merge pull request #40347 from BerriAI/litellm_fix_mcp_proxy_test_isolation
test(mcp): fix proxy fixture isolation after manager reload
2026-09-08 18:44:45 -07:00
Mateo Wang
d75aa4445d
Merge pull request #40179 from BerriAI/litellm_lit_2133_cost_map_provenance
feat(cost_map): report which revision of the price map the proxy is serving
2026-09-08 18:42:19 -07:00
Yuneng Jiang
fb21852f7b
test(mcp): resolve current manager in proxy fixtures 2026-09-08 18:35:25 -07:00
tin-berri
314e573529
feat(auto-router): refresh family reasoning presets (#40341) 2026-09-08 18:28:46 -07:00
ryan-crabbe-berri
f5e4aa38ba
Merge pull request #40342 from BerriAI/litellm_prompt_cache_key_session_id
fix(anthropic): key the /v1/messages prompt cache on Claude Code's session_id only
2026-09-08 18:20:56 -07:00
ryan-crabbe-berri
634852a183 fix(anthropic): key the /v1/messages prompt cache on Claude Code's session_id only
The bridges derived prompt_cache_key as the first 64 chars of metadata.user_id.
Claude Code packs a JSON object into that field whose prefix is the per-install
device_id, so every session and subagent on one machine shared a single key,
and a plain end-user id pinned all of that user's conversations to one slot.

Parse the JSON and use session_id; send no key otherwise so the provider falls
back to its own prompt-prefix hashing. An explicit prompt_cache_key still wins.

Fixes #39145
2026-09-08 18:06:39 -07:00
yuneng-jiang
a99ecacffd
Merge pull request #40336 from BerriAI/litellm_extend_diskcache_deadline_oct1
chore(ci): extend diskcache scan exception to October 1
2026-09-08 18:05:00 -07:00
devin-ai-integration[bot]
43a1b2992a
fix(otel v2): restore the Datadog auth span and the last-wins callback merge (#40335)
* fix(otel v2): restore the Datadog auth span and the last-wins callback merge

Move @tracer.wrap() back onto user_api_key_auth so USE_DDTRACE=true emits the
auth span again, and let a failure entry's callback_vars take part in the
destination merge so the resolver picks the same account the runtime parser does

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel v2): drop docstrings from the two regression tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rerun proxy-infra after the flaky test_check_migration process-tree test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 01:00:26 +00:00
yuneng-jiang
9b186680c4
Merge branch 'litellm_internal_staging' into litellm_extend_diskcache_deadline_oct1 2026-09-08 17:55:38 -07:00
yuneng-jiang
c417e1084d
Merge branch 'litellm_internal_staging' into litellm_/release-version-bump-787548 2026-09-08 17:54:07 -07:00
yuneng-jiang
fb36c3c5a5
Merge pull request #40333 from BerriAI/litellm_fix_prisma_timeout_test_cleanup
test(proxy): fix Prisma timeout cleanup after subreaper tests
2026-09-08 17:53:56 -07:00
mateo-berri
529b8706ee test(spend-tracking): mock the spend-log scan through the transaction its caller now opens 2026-09-08 17:51:13 -07:00
Yuneng Jiang
810d48f28f
chore(ci): extend diskcache scan exception to October 1 2026-09-08 17:41:27 -07:00
Yuneng Jiang
94a81f003e
bump: litellm-enterprise 0.1.65 -> 0.1.66 2026-09-08 17:40:34 -07:00
Yuneng Jiang
5a7919f3f5
test(proxy): isolate reaper state and reap Prisma fixture children 2026-09-08 17:35:43 -07:00
tin-berri
754a2afe12
feat(mcp): add schema discovery proxy mode (#40298) 2026-09-09 00:30:20 +00:00
Mateo Wang
599daea985
Merge pull request #36718 from BerriAI/litellm_fix_count_tokens_budget_reservation_leak
fix(budget_reservation): don't reserve budget on token counting routes
2026-09-08 17:29:27 -07:00
moe-berri
6112274350
Merge pull request #40273 from BerriAI/litellm_non_reasoning_tier
feat(auto_router): opt-in NON_REASONING tier below SIMPLE
2026-09-08 17:27:35 -07:00
mateo-berri
268b944081 fix(spend-tracking): bound the spend-log scan with a statement timeout and name only unanimous alias, team, and owner 2026-09-08 17:24:22 -07:00
Mateo Wang
402351d980
Merge pull request #40268 from BerriAI/litellm_fireworks_responses_reasoning_instructions
fix(fireworks_ai): fold instructions and developer items into one leading system message on the Responses path
2026-09-08 17:16:52 -07:00
mateo-berri
831a2a13fb fix(azure): price azure_ai transcriptions at the azure_ai cost-map entry 2026-09-08 17:12:18 -07:00
devin-ai-integration[bot]
075655c7ee
test(azure_sentinel): pin batch_size as a per-request bound under concurrent events (#40320)
* test(azure_sentinel): pin batch_size as a per-request bound under concurrent events

Adds a regression test to the mapped Azure Sentinel test file for the concurrency scenario from LIT-6920: 40 records logged concurrently at batch_size=5 while each ingestion request is still in flight. Asserts no request carries more than batch_size records, every record arrives exactly once in order, and the queue is empty afterwards. Runs for both the standard log queue and the audit log queue.

The test fails on the tree before #39880 (whole shared queue serialized per threshold send, then cleared) and passes on current staging. It is independent of the size-split coverage that #39880 added for LIT-5899.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(azure_sentinel): gate the first send on events so later records provably arrive while it is in flight

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 16:55:40 -07:00
moe-berri
9d7e09e4e8 fix(ui): release the plan-mode floor when the non-reasoning tier is cleared
Turning the tier off, or switching to a classifier that cannot emit it, dropped
the flag and the pool but left plan_mode_min_tier naming a tier that is no longer
active. The backend rejects that on save, and the switch is disabled after a
classifier change, so the operator had no way to clear it.

Both paths now release the floor when it points at the cleared tier. An orphaned
keyword rule is left alone on purpose: getKeywordTierRulesError already names it
at the save gate, which is how a removed custom tier behaves.
2026-09-08 16:53:52 -07:00
yuneng-jiang
54dc1d7644
Merge pull request #40323 from BerriAI/litellm_merge_main_into_staging
chore(ci): merge main into internal staging
2026-09-08 16:44:52 -07:00
mateo-berri
7a6c0cbf08 fix(spend-tracking): drop the owner of a digest shared by several users and back off failed scans 2026-09-08 16:43:28 -07:00
Mateo Wang
2b9a69d783
Merge pull request #39536 from BerriAI/litellm_openai_error_payload_non_llm_routes
fix(proxy): stop shipping the literal string "None" as error type and param
2026-09-08 16:41:05 -07:00
Mateo Wang
568c5713ef
Merge pull request #40180 from BerriAI/litellm_lit_4352_marengo_embed_3
feat(bedrock): add TwelveLabs Marengo Embed 3.0 embeddings
2026-09-08 16:38:07 -07:00
Yuneng Jiang
3fd88222b8
merge: reconcile main with staging after MongoDB sidecar migration 2026-09-08 16:33:59 -07:00
yuneng-jiang
8280d7ca9d
Merge pull request #40312 from BerriAI/litellm_dashboard_deps_20260908
chore(ui): update dashboard dependencies
2026-09-08 16:25:23 -07:00
yucheng-berri
3165951e6d
feat(otel v2): send a key's or team's whole trace to its own destination (#39654)
* feat(otel v2): send a key's or team's whole trace to its own destination

A key or team that configures its own Langfuse, Arize, Weave or New Relic
credentials used to get a single detached span in its account while the rest of
the request trace stayed on the operator's backend, so neither side held a
complete trace. Resolve the destination during auth, forward every span of the
request to it, and hold the same request back from the operator's exporter for
that backend, so the tenant gets the tree the operator would have seen and the
operator gets nothing for that request.

Also let a credential-mandatory preset build without the operator's own env
credentials. Without that, a proxy whose teams each bring their own account fell
back to the legacy integration and never ran a line of the v2 path.

* fix(otel v2): validate tenant destinations and match each backend's own endpoint

Review round on the tenant destination routing.

- A key/team Langfuse host is user-supplied input, so it goes through the
  proxy's SSRF guard. A private address is refused, the operator keeps the
  trace, and the warning names user_url_allowed_hosts. The operator's own
  LANGFUSE_HOST is not checked.
- Arize and Weave destinations now resolve their endpoint and transport
  through the backend's own config, so an ARIZE_HTTP_ENDPOINT collector and
  a self-hosted WANDB_HOST are honoured instead of the cloud default.
- A half-configured backend no longer resolves: several dynamic header
  builders gate each credential separately, so an api key with no space id
  produced a non-empty but unusable header set that suppressed the
  operator's exporter.
- A callback_type of "failure" no longer takes over the trace. The
  destination is resolved during auth, before the outcome is known.
- The fan-out cache evicts without shutting the processor down, matching
  ArizePhoenixLogger: a concurrent on_end may still hold it.
- The stdout placeholder is identified by what it does rather than by
  equality with an import-time default, so an operator's OTEL_EXPORTER_OTLP_*
  collector survives the credential-less path.

* refactor(otel v2): reuse the proxy's own destination allowlist for tenant hosts

A tenant-supplied Langfuse host is the same threat as a URL-valued `model`, so
it now goes through `is_url_destination_allowed_by_host` against
`provider_url_destination_allowed_hosts` instead of a second, DNS-based check
of its own. The DNS lookup would have blocked the asyncio auth path on a
hostname the caller picked, and its cached verdicts could blackhole a real host
after one resolver blip.

Evicting a destination processor now retires it to drain rather than shutting it
down, since `on_end` hands a processor back and exports outside the lock. The
retirees are capped so they cannot accumulate a thread each.

`credential_gated_exporters` tells the synthesized stdout placeholder from a
real exporter by transport rather than by the literal kind `console`, so an
unrecognized kind is not mistaken for a configured collector, and an exporter
the operator did configure survives. That also stops a weave test's env writes
from making this look like a real OTLP exporter later in the same CI worker.

* fix(otel v2): read the tenant's stored callback config the way the sibling parser does

Three divergences between the destination resolver and
`convert_key_logging_metadata_to_callback`, which read the same stored config:

- A key whose callbacks are disabled stores an empty list, and `or` treated that
  as "the key configured nothing", so the request inherited the team's
  destination. The sibling parser treats an empty list as configured.
- Two entries naming one backend now merge their `callback_vars` last-wins,
  matching the sibling, instead of the resolver taking the first entry and the
  per-request tracer routing taking the last.
- `credential_gated_exporters` dropped any exporter whose kind had no transport,
  which also dropped an `in_memory` exporter the operator asked for. The
  placeholder is the spec with every field still at its default, so that is what
  the predicate now says.

Arize's `allow_missing_credentials` branch was unreachable: `get_arize_config`
resolves every credential with `os.environ.get` and always supplies an endpoint,
so it never raises. Dropped it and corrected the protocol docstring.

* fix(otel v2): keep the destination merge immutable

The per-backend var merge seeded a plain dict and the gated exporter list a plain
list, both of which the LIT budget counts. Wrap the merge in MappingProxyType and
hand the exporters back as a tuple.

* fix(otel v2): scope the fan-out to its own backend and close shed processors off the export path

Three problems in the fan-out, two of them in the eviction added last round:

- Every v2 logger carries its own provider and emits its own copy of a gen-AI
  span, so a proxy running two of them handed the tenant the same model call
  twice. A provider now forwards only destinations for the backend it speaks
  for; the tenant's own backend always has a logger, since naming it in the key
  or team config is what builds one. Reproduced live against a self-hosted
  Langfuse on an arize-only proxy and on the bare `otel` callback.
- Eviction could close a processor another thread was still exporting through,
  which drops that span. Exports are now counted, and a retired processor is
  closed only once its count reaches zero.
- That close ran inside `on_end`, where `shutdown` flushes over the network, so
  one unreachable tenant collector stalled every other tenant's spans. It now
  runs on a short-lived thread, which also retires the retiree cap: a retiree
  drains as soon as its export finishes.

* fix(otel v2): deliver tenant destinations from the published global provider

Scoping the fan-out by callback name in the previous commit left every backend
that is not the canonical logger with a one-span trace: only the published
global provider sees the FastAPI server span, the auth span and the post-call
database spans, so an arize-only proxy handed a team's Langfuse just the model
call. Attach the fan-out once, to that provider, and let it forward every
destination.

An overridden backend now skips per-request tracer routing outright rather than
only clearing its credential headers, since a key or team otel_service_name was
still enough to detach the model call onto a second provider. The destination
carries that service name as a resource attribute instead.

Shed processors drain on a two-thread pool rather than a thread each, so a
tenant cycling its destination config cannot spawn threads as fast as it sends
requests.

* fix(otel v2): drain shed destination processors on daemon workers

A ThreadPoolExecutor joins its workers at interpreter exit, so one unreachable
tenant collector would hold the whole proxy open for its export timeout on the
way down. Two long-lived daemon workers off a queue keep the thread count
bounded without blocking shutdown.

* fix(otel v2): give the fan-out its own drain pool instead of a lazy singleton

functools.lru_cache does not hold a lock across the call it caches, so
concurrent first evictions each finish building a queue and start its
workers, and every queue but the winner is abandoned with two daemon
threads blocked on it forever.

* fix(otel v2): close no destination processor under a span still in flight

The fan-out now refuses new work once shutdown starts and waits out the
spans already being forwarded, so teardown neither drops a trace mid-forward
nor hands the next caller an exporter nothing will ever close. The wait is
bounded so a dead collector cannot hold the proxy open.

* fix(otel v2): retire the drain workers with the fan-out that started them

A proxy that rebuilds its telemetry builds another fan-out, so workers that
outlive the one that started them are two more threads per reload. Shutdown
now retires them once everything queued is closed, and a processor shed
afterwards is closed inline rather than queued to nobody.

* fix(otel v2): guard the fan-out's closed state with the lock that gates it

An Event read on its own leaves room for shutdown to run in the gap. A cache
miss then inserted a live exporter into a map that had been cleared, and a
shed processor landed behind sentinels every drain worker had exited on.
The drain pool takes its queue by injection so both interleavings are
reachable from a test without patching.

* feat(otel v2): let a tenant destination export alongside the operator's own

Override stays the default: a key or team destination replaces the operator's
exporter for that backend. Operators running one org-wide backend across every
team set litellm_settings.otel_tenant_destination_mode to additive, and the
same trace lands in both places. A team that names the operator's own project
is still written once, since the fan-out skips a destination the operator's
exporter is already sending that span to.

* fix(otel v2): let a straggling export close its own destination processor

Shutdown waits out the exports in flight, but the wait has to be bounded or a
tenant collector that stops answering holds the proxy open on the way down.
Past the bound it closed everything anyway, which is the case it was written to
avoid: a processor closed under the span it is carrying loses that span.

Keep the bound and retire the stragglers instead. The thread still exporting one
closes it through the drain as soon as its export returns, so teardown stays
bounded and no span is dropped mid-forward.

* fix(otel v2): identify a destination account by its credentials, not its header names

Under additive the fan-out skips a destination the operator's own exporter
already writes to, so the same account is not written twice. It compared header
names as well as values, and one account answers to more than one spelling:
the operator's Arize exporter sends space_id where a team destination sends
arize-space-id, so every span landed in the operator's own space twice.

The credentials are the identity. Compare those and leave the spelling to each
backend.

* fix(otel v2): keep the credential's role in a destination's account identity

Comparing values alone folds two accounts together whenever they hold the same
strings in different roles, and the second team would then get no trace at all.
Compare the credential under a normalized name instead, and fold the one alias
that actually exists: Arize's space_id and arize-space-id.

* fix(otel v2): build one destination processor per destination, not per racing span

Building outside the cache lock meant a cold cache met by a burst of concurrent
requests constructed an exporter per thread, kept one, and handed the rest to the
drain, so a batch worker and a connection pool per losing thread sat in a queue
two workers service.

Build under the lock that reads the cache. Opening an exporter connects to
nothing, so the lock is held for a constructor, once per destination, and the
race it was avoiding stops existing.

* fix(otel v2): bound the teardown that closes a destination, not the one that never blocks

The five-second bound guarded the wait for spans still inside on_end, but a
batching processor's on_end only queues the span and returns, so that counter is
empty and the bound engaged against nothing. The blocking half was the serial
close, which flushes over the network and joins the SDK's own worker thread with
no timeout of its own, so a single tenant collector that answers and never
finishes held process teardown open for as long as it liked.

Hand every close to the drain, whose workers are daemons, and give the whole
teardown one deadline.

* fix(otel v2): preserve operator spans on destination failure

* fix(otel v2): anchor destinations off the published provider, refuse headerless tenant transports

set_tracer_provider keeps the first provider it is handed, so a process whose
OTel global was claimed before the proxy published (auto-instrumentation, a
legacy logger) had no fan-out on the global and auth anchored no destination.
Auth now reads the fan-out off the registered logger's own provider.

A destination whose protocol maps to a headerless exporter kind is no longer
buildable: the console fallback would drop the tenant's credentials and print
the spans to stdout while the operator's exporter stood down for them.

* fix(otel): anchor tenant fan-out to the published provider

A legacy v1 logger can occupy proxy_server.open_telemetry_logger, in which case
the proxy publishes with registered=None and the fan-out lands on a v2 logger
taken from _in_memory_loggers. Reading the registered slot found no v2 logger
and the OTel global belonged to v1, so auth refused every tenant destination.

* fix(otel): preserve registered provider fallback

* test(otel): cover pre-publish provider fallback

* fix(otel): attach fan-out on fallback provider

* fix(otel): serialize first fan-out attach

* fix(otel): keep the operator's database endpoint out of tenant traces

A database span forwarded to a key or team destination carried the proxy's own
Postgres host, port and schema, and on failure the Prisma error text naming them.
The fan-out now hands tenants a view of each database span without those keys,
its events or its status text, while the operator's own copy is untouched and
model endpoints such as server.address on the LLM span still travel

* fix(otel): keep relabelled spans in the fan-out and honour disabled callbacks for destinations

A key or team otel_service_name used to move a backend's span onto a second
provider even when another backend had a destination, so the fan-out never saw
the model call and the tenant's trace lost it. A service name alone now stays on
the published provider whenever the request has a destination; credential and
project routing to a tenant's own account is unchanged

Destinations now skip a backend the request disabled dynamically, reading the
x-litellm-disable-callbacks header and the key's litellm_disabled_callbacks with
the same precedence and premium gate dispatch applies, so a disabled backend is
neither delivered to nor withheld from the operator

* test(otel): project routing survives a sibling backend destination

* docs(otel): state why a disabled backend still routes its own span

* fix(otel): keep a degraded backend's spans off a collector another v2 logger already serves

* test(otel): a credentialed preset beside another v2 logger keeps every exporter

* fix(otel): keep credentialless fallback on base path

* test(otel): cover legacy callback carrier rejection

* fix(otel): preserve valid exporter beside gated preset

* fix(otel): avoid console export without operator destination

* refactor(otel): share the console placeholder check with the presets

* fix(otel): bound shed destination processors waiting on a dead collector

* fix(otel): preserve explicit console exporters

* fix(otel): avoid mutable field-set construction

* fix(otel): close drain saturation race

* fix(otel): drop captured request headers from tenant spans

* test(otel v2): give the newrelic dispatch tests operator credentials, since a credential-less preset now falls back

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): rebuild an anchored destination's processor past drain saturation

A destination deliverable() accepted at auth can be evicted by other tenants' auths
before its request's spans end, and that eviction is what tips the drain over. The
saturation gate then refused the rebuild at on_end, and with the operator's exporter
already stood down for that backend the span went nowhere. The gate now applies only
while a request decides whether to anchor

* fix(otel): hold destination eviction while the drain is saturated

An anchored destination evicted by other tenants' auths is rebuilt on its
next span, and that rebuild evicted another anchored one, so with more
destinations in flight than the cache holds every span cost one more
processor, one more batch thread and one more close queued behind a collector
that never answers. Eviction now holds while the drain is saturated, so the
cache keeps one entry per destination in flight and trims back to its cap on
the next hit or build once the drain has room

* fix(otel): keep the proxy's own error text out of tenant traces

A tenant destination received every span the request produced, error text
included, so a Prisma failure during auth handed a team admin's collector the
operator's Postgres endpoint, and the exception event on any failed span carried
a stack trace naming the proxy's install paths.

Spans the tenant's own call produced (the model call, MCP, guardrails) keep their
error text. Every other span keeps the failure without the prose: its type, its
provider error code and its status code, with the message, the events and the
status description dropped. Stack traces come off every span, attribute and event
alike.

A destination's resource attributes now merge onto the span's resource instead of
rebuilding one per span, which was re-running resource detection on every export.

* fix(otel): redact tenant URL query parameters

* fix(otel): close final tenant routing gaps

* fix(otel): refresh destinations for stateful MCP messages

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 16:23:21 -07:00
devin-ai-integration[bot]
b624fd8c4f
fix(mlflow): prevent _stream_id_to_span leak and mlflow 2.x end_trace TypeError (#39049) 2026-09-08 16:18:06 -07:00
Yuneng Jiang
d75176f14a
test(ui): align workflow expectations with Vitest 4 2026-09-08 16:13:58 -07:00
moe-berri
a4f865b1be refactor(ui): extract hydrateBuiltInTiers so the edit modal stays under max-lines
Upstream's edit_auto_router_modal.tsx sits at 799 countable lines, one under the
800 cap, so this PR's 11 added lines put the merge result over. The built-in tier
hydration moves next to its sibling hydrators in build_complexity_router_config,
which is where hydrateCustomTierSet and hydrateTierLabels already live.
2026-09-08 16:04:16 -07:00
Yuneng Jiang
bc8305810f
fix(ui): align SDK mocks and remaining dependency patches 2026-09-08 16:03:34 -07:00
Mateo Wang
d9b63efc92
Merge pull request #39556 from BerriAI/litellm_fix_spend_log_flush_event_loop_binding
fix(proxy): give each spend-log queue monitor its own flush event
2026-09-08 15:59:11 -07:00
yucheng-berri
90731576e3
feat(team): let a team admin manage their own team's logging callbacks (#37667)
* feat(team): let a team admin manage their own team's logging callbacks

The team callback endpoints already authorize correctly: POST, GET and DELETE
each call _verify_team_access, which admits a proxy admin, an org admin for the
team, or an admin of that team, and 403s everyone else. The route-permission
layer never let a team admin reach them, so it answered 401 naming proxy admin
and the handler's own check was dead code for the caller it was written for.

Adding the two paths to self_managed_routes is how every other team-admin route
works: /team/member_add, /team/member_delete, /team/member_update and
/team/permissions_update all sit in that list and scope per team inside the
handler. The entries use the :path converter the routes are registered with, so
a team id containing a slash resolves the same way at the gate as at the router.

Because any authenticated caller now reaches these handlers, an unknown team had
to stop being distinguishable from one the caller may not manage. All three
handlers looked the team up and raised a distinct 'does not exist' before the
access check, which would have let any valid key probe for team ids. That branch
now returns the same 403 body _verify_team_access raises, and keeps the
diagnosable error for a proxy admin.

disable_logging stays out of the grant. That is a scope decision rather than a
security boundary, since a team admin holding DELETE can clear callbacks one at
a time; it differs only in also clearing the deprecated callback_settings shape.

* fix(team): reach the callback routes for a team id containing a colon

The route gate expands {team_id:path} to "[^:]+" so a colon-suffixed provider
route is not swallowed, which means the two entries added here matched a team
id with a slash but not one with a colon, while the router accepts both.
team_id is a free-form string, so a team whose id contains a colon kept the
old proxy-admin-only denial and its admin could not manage its own callbacks.
List both spellings rather than relaxing the shared matcher, which every
":path" route depends on. The comment claimed the two matchers agree; they do
not, so it now says what each placeholder actually accepts.

* fix(auth): match a :path placeholder the way the router's converter does

A team id may carry a slash, a colon, or both. The gate expanded {x:path}
to "[^:]+", so an id with a colon in it matched no self_managed_routes
entry and its team admin got the proxy-admin-only denial on a route the
router had already resolved for them. Listing a second {x} spelling covered
a colon or a slash but never both.

Expand {x:path} to ".+" instead, except when the template puts a ":"
literal of its own after the placeholder, which is where the narrower form
was earning its keep: the Google routes end in ":generateContent" and
friends, and there the value has to stop before that suffix rather than
swallow it and match a different verb.

That lets self_managed_routes drop back to the two :path spellings the
router itself mounts.

* test(auth): pin that the callback grant reaches no neighbouring team route

The grant is two templates ending in the callback suffix, and the
placeholder now takes slashes and colons. Every other route under
/team/{team_id} registers an ordinary single-segment placeholder, so no
URL the router sends to one of them can end in the callback suffix.

Pin that, so adding a path-converter route beside these fails here
rather than by handing a caller a handler the grant never covered.

* fix(team): make one entry own a credential family end to end

Every stored entry's callback_vars are flattened into one dict before a
request reads them, and that dict is what the exporter authenticates and
addresses with. So an entry naming only a destination is enough to
redirect a credential written somewhere else: a host on a second entry
pairs with the key pair from the first, and the request carries that key
pair to the new host. A team admin cannot read the team's masked Langfuse
secret, but could add such an entry and receive it.

Reject, for writers who are not proxy admins, an entry using a credential
family another entry already holds. Family rather than callback name,
because langfuse and langfuse_otel configure one Langfuse project and
would otherwise redirect each other, and because a destination like
dd_agent_host that no integration registry lists still pairs with the
Datadog credentials beside it.

A proxy admin already holds every credential the proxy has, so the rule
would buy nothing there and would break configs that predate it. A team
admin who does want to move a family deletes the entry holding it first,
which reveals nothing.

* fix(team): let one integration cover both callback events

The family rule compared variable names only, so a team admin who registered
an integration for the success event could not register the same integration,
with the same values, for the failure event.

Compare the values as well: repeating what the owning entry already stores
flattens to the same dict, so there is nothing to redirect. The stored side is
decrypted first, because the credentials are encrypted at rest and ciphertext
never equals the plaintext coming in.

* fix(team): compare the family's values, not its variable names

Comparing per variable rejected a credential written under its other spelling:
langfuse_secret and langfuse_secret_key are one key, so repeating the stored
secret under the other name read as a new value.

Ask instead whether the value is one the owning entries already carry. A
destination the caller controls is by definition not, so the redirect stays
closed, and no alias table has to stay complete for that to hold.

* fix(team): pin the family's configured variables as well as its values

Asking only whether a value is one the family holds let a held variable be
given another of the family's values, so the exporter would address or
authenticate with it.

Keep the value membership rule for a variable the family does not configure
yet, which is what lets one credential go in under its other spelling, and
require a variable it does configure to keep the value it has. Between them no
value the caller chose can enter the family.

* fix(auth): keep a newline in a :path value visible to the route gate

"." stops at a newline and the router's path converter does not, so a %0A
anywhere in a :path segment left the route unmatched here while still reaching
the handler. Every list built on this matcher inherited that: on a proxy with
DISABLE_ADMIN_ENDPOINTS set, DELETE /v1/mcp/server/abc%0Adef reached the MCP
handler instead of the 403 the same request gets without the %0A.

Match with a class that spans newlines.
2026-09-08 15:58:58 -07:00
moe-berri
17e3dc1c79 refactor(ui): move nonReasoningTierFields into its own module
Upstream grew ClassificationMethodConfig.tsx to 783 lines, so the 14 lines this
PR added there pushed the merge result past the 800-line max-lines cap. The
helper is standalone logic with its own unit tests, so it moves out rather than
the cap moving up.
2026-09-08 15:57:08 -07:00
Yassin Kortam
4b9c289a72
fix(a2a): forward caller identity headers on message/send and message/stream (#40305)
* fix(a2a): forward caller identity headers on message/send and message/stream

_forwarding_headers() stamped X-LiteLLM-User-Id/-Team-Id from the authenticated
caller, but was only wired into the tasks/* and tasks/resubscribe branches. The
primary message/send and message/stream conversational path forwarded
agent_extra_headers unchanged, so a downstream agent never learned which end
user was calling it except on secondary task-management calls. This broke
per-user MCP scoping and per-customer FinOps budget enforcement for any agent
invoked through the normal conversational flow.

Resolves LIT-7342

* fix(a2a): snapshot key-bound caller identity before pre-call processing

user_header_mappings lets add_litellm_data_to_request rewrite
user_api_key_dict.user_id from a client header, so the identity stamped
onto X-LiteLLM-User-Id is now captured before that step runs. Header
tests updated to expect the caller identity on message/send.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(a2a): drop redundant docstrings from message identity tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(a2a): type the message method test helpers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(a2a): make message method test helpers immutable

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:56:52 -07:00
devin-ai-integration[bot]
c6a9387319
fix(proxy): default max_idle_connection_lifetime on componentized DB URLs (#40285)
* fix(proxy): default max_idle_connection_lifetime on componentized DB URLs

DatabaseURLSettings.apply_to_env() now appends max_idle_connection_lifetime=60
(or DATABASE_MAX_IDLE_CONNECTION_LIFETIME) to DATABASE_URL and DIRECT_URL before
the reader inherits the writer's connection params, so the gateway, backend and
migrations entrypoints get the same idle-connection reaping as the classic CLI.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): keep DB URL connection params across IAM/Entra token refresh

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: retrigger proxy-infra after process-tree test flake

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:54:31 -07:00