LiteLLM v1.110.0-rc.2 Release Analysis: Credential Injection Banned, Rust Router Repaired
Sources
- GitHub release v1.110.0-rc.2 (cosign-signed, security backport)
- PR #51254 - security(proxy): stop request body fields from conflicting with admin-set deployment fields
- PR #51263 - security backport to rc/1.110.0
- PR #50178 - refactor(router): delete the python router core
- PR #50260 - fix(router): honor request fallbacks in the mid-stream continuation check
- PR #50798 - mid-stream fallback fix backport to rc/1.110.0
- PR #50933 - fix(router): hold the registry lock across router-core writes and mirror reads
- PR #50993 - registry lock fix backport to rc/1.110.0
- PR #50641 - perf(router): skip unused deployment reads, cache anthropic capability probes
- PR #50496 - feat(proxy): total_timeout bounds a whole non-streaming request
- Issue #49751 - router retries and Retry-After sleeps ignore the caller's request timeout
- PR #50751 - fix(redis): free each request's Redis batch scope once the request ends
- PR #50949 - Redis batch scope leak fix backport to rc/1.110.0
- PR #50565 - fix(accounting): stop delivery metrics from scanning retained receipts on every flush
- PR #50578 - fix(completion): accept a tool_choice that forces an OpenAI hosted tool
- PR #49128 - fix(proxy)!: price expanded wildcard /model/info rows as the model their calls route to
- PR #50592 - fix(bedrock): derive the web identity session policy per AWS caller
- PR #50600 - fix(proxy): align ownership and scope handling
- Source at tag: litellm/proxy/auth/auth_utils.py
- Source at tag: litellm/litellm_core_utils/total_deadline.py
- Source at tag: litellm/accounting/worker.py
- Source at tag: litellm/proxy/hooks/responses_id_security.py
- Release v1.109.1 - security backport to stable/1.109.x
- Release v1.106.9 - security backport to stable/1.106.x
What This Release Actually Is
LiteLLM v1.110.0-rc.2 (published 2026-11-12 02:53 UTC) is a release candidate that exists for one reason: a security fix that could not wait for the stable train. The rc.2 delta over rc.1 is a single commit-class change — the backport of PR #51254, security(proxy): stop request body fields from conflicting with admin-set deployment fields — and that same fix landed on the stable lines within the same hour as v1.109.1 (02:07 UTC, via PR #51264) and v1.106.9 (01:06 UTC, via PR #51261). If you run LiteLLM in production on any supported line, that coordination, not the RC label, is the story of your week.
But the v1.110.0 line itself is the more consequential thing to evaluate, because it is the first minor developed on a single architecture: PR #50178 (refactor(router): delete the python router core, merged 2026-11-05) removed the Python router core entirely, leaving the Rust core as the only core. The v1.110.0 cycle has consequently been a regression-and-repair arc: the Rust core shipped with a hot path that was slower than the Python core it replaced, a native Rust Redis batch layer that leaked worker memory on every request, and a registry read path that could pair one version's deployment indices with another version's model list. Most of those wounds are now closed — inside this RC. That makes v1.110.0-rc.2 the most interesting kind of release to review: the one where the migration guide is a post-mortem.
The compare from v1.109.0 to v1.110.0-rc.2 spans 401 commits across 300 files (GitHub compare API), so this analysis concentrates on the changes that alter production behavior in ways an operator can observe: what requests get rejected, what requests silently change answer, what pods leak memory, and what bills differently.
2026-11-10 20:24 UTC v1.103.11 published (stable patch train, older line)
2026-11-10 20:29 UTC v1.104.9 published (stable patch train, older line)
2026-11-10 22:51 UTC v1.108.5 published (lts/1.108.x patch)
2026-11-11 00:06 UTC v1.110.0-rc.1 published (next minor, rc.1)
2026-11-11 00:56 UTC v1.109.0 published (stable minor)
2026-11-11 22:44 UTC PR #51254 opened (security: credential-confusion fix)
2026-11-11 23:07 UTC PR #51263 opened (backport -> rc/1.110.0)
2026-11-12 01:06 UTC v1.106.9 published (security backport, stable/1.106.x)
2026-11-12 01:12 UTC v1.108.6 published (lts patch + backports)
2026-11-12 02:07 UTC v1.109.1 published (security backport, stable/1.109.x)
2026-11-12 02:53 UTC v1.110.0-rc.2 published (security backport, rc/1.110.0)
^ the same fix, three lines, one hour
Everything below is mined from the release bodies, the pull requests themselves, and the source tree at the v1.110.0-rc.2 tag. Where a number is a maintainer benchmark rather than an independent measurement, it says so.
The Headline Fix: Client-Side Credential Injection Is Now Banned (#51254)
The security backport in rc.2 closes a class of vulnerability that every multi-tenant LLM gateway eventually rediscovers: fields a client can put in a request body that the proxy will treat as deployment configuration. Before this fix, a caller could send tenant_id, client_id, client_secret, azure_username, azure_password, or azure_scope in the body of POST /v1/chat/completions, and the proxy would merge those values with the sign-in fields the admin had configured on the deployment. The documented failure mode was an opaque 500 from the Azure sign-in step — the confusing-behavior case. The dangerous case is what the ban-list source comments at the tag describe explicitly: an attacker-controlled endpoint or credential field "either exfiltrates the request payload (incl. messages + admin-set tokens) to the attacker's host, or coerces the proxy into authenticating against the attacker's host with admin secrets."
After the fix, the request is refused up front with a 401 that names the field, unless the value arrives in metadata or an admin explicitly opted in. The exact enforcement message, read from litellm/proxy/auth/auth_utils.py at the tag:
Rejected Request: {param} is not allowed in request body.
Clientside passthrough requires explicit admin opt-in via either
`general_settings.allow_client_side_credentials = true` (proxy-wide) or
`configurable_clientside_auth_params` on the deployment in your proxy config.yaml.
Relevant Issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997
That huntr.com/bounties/ reference in the shipped error string matters for triage: the ban-list architecture this fix extends came in through a disclosed vulnerability report, not proactive hardening. Treat the pre-fix behavior as exploitable, not merely sloppy.
The Six New Fields, and the Much Longer List They Joined
PR #51254 itself is a tight diff — auth_utils.py +93/−2 plus tests — adding the six Entra credential fields and two value-format rules to a ban list that already existed at scale. The full _BANNED_REQUEST_BODY_PARAMS tuple at the v1.110.0-rc.2 tag is worth reading as an inventory of every way a request body could previously steer the proxy:
Endpoint retargeting api_base, base_url, aws_bedrock_runtime_endpoint,
aws_bedrock_control_plane_endpoint,
sagemaker_base_url, s3_endpoint_url, deployment_url,
nvcf_function_id, use_ssl, riva_offline
AWS identity selection aws_sts_endpoint, aws_web_identity_token,
aws_role_name, aws_profile_name, aws_session_name,
aws_external_id, aws_session_tags
(aws_profile_name signs Bedrock/S3 requests as
ANY profile present on the proxy host)
Azure / Entra credentials azure_ad_token (resolved via get_secret, then sent
as the bearer token) + the six new fields:
tenant_id, client_id, client_secret,
azure_username, azure_password, azure_scope
GCP / Vertex vertex_credentials, vertex_ai_credentials
Bedrock tenancy + isolation aws_bedrock_project_id, workspace_id,
aws_workspace_id, anthropic_workspace_id,
anthropic-workspace-id, bedrock_tags,
agentcore_runtime_session_id
(every caller-chosen AgentCore session value
provisions another microVM)
Storage s3_bucket_name, s3_output_bucket_name,
allow_legacy_cloud_file_ids
(redirect Bedrock file uploads to attacker S3)
Router / transport choice rust (picks the Rust transport + its callback
surface the admin did not choose), model_list,
user_config, fireworks_forward_user_id,
user_provider_credentials, github_copilot_*,
observability callback params (derived from the
canonical allowlist: new integrations are
banned by default), custom-pricing params
Two value-format rules ship with it. First, credential fields now refuse values written in the admin-config formats — os.environ/... and oidc/... secret references — outside deployment-management routes. The rejection message is direct: "Rejected Request: {param} holds a secret reference, which only a deployment or credential a proxy admin configures can carry." That closes the sharpest version of the injection: a client writing os.environ/LITELLM_MASTER_KEY-shaped references into a credential field and getting the proxy to resolve its own secrets on a request-shaped path.
Second, the ban now reaches the metadata path, not just the request-body root. The source comment is unusually honest about why: "a value under metadata.langfuse_host redirects the same Langfuse client and leaks the same credentials as the root-level langfuse_host, but the original check only walked the request-body root, so the metadata path was an unintentional bypass." Per the backport note in PR #51263, the rc/1.110.0 line previously had no separate metadata ban list at all; the backport adds one that leaves out only the six Entra names — meaning those six are the ones callers can legitimately relocate into metadata.
BEFORE #51254 AFTER #51254
---------------- ----------------
client client
| body: tenant_id, client_secret, azure_ad_token | body: tenant_id, client_secret
v v
[proxy endpoint] [proxy endpoint]
| |
| (no field-level gate) +-- is_request_body_safe()
v | banned param present?
merge w/ admin-set deployment fields |-- yes -> 401, field named
| | (opt-in checked:
+-- os.environ/ or oidc/ values | allow_client_side_credentials
| get_secret()-resolved against | OR configurable_clientside_auth_params
| the PROXY's secret store | OR metadata-carried six)
v +-- os.environ/ or oidc/ value in
provider call (attacker-retargetable) a credential field? -> reject
v
admin-configured deployment fields only
|
v
provider call (admin-pinned target)
The Opt-In Matrix
The migration question every operator has to answer: which of your callers legitimately need to pass these fields? The fix defines three escape hatches, and the YAML below shows all of them as they appear in a real config:
general_settings:
# Proxy-wide: re-enables ALL banned fields for every deployment. Blunt.
allow_client_side_credentials: false
# Narrower: value allowlists for the S3-shaped params specifically
allowed_client_side_s3_buckets:
- "acme-agent-uploads"
allowed_client_side_s3_endpoint_urls:
- "https://s3.us-east-1.amazonaws.com"
model_list:
- model_name: "azure/eu-gpt"
litellm_params:
model: "azure/eu-deployment"
api_base: "https://eu.example.openai.azure.com"
# Per-deployment: allow ONLY the named fields from request bodies
configurable_clientside_auth_params:
- "tenant_id"
- "client_id"
- "client_secret"
Note the asymmetry the S3 allowlists introduce: s3_bucket_name and s3_endpoint_url can be admitted by value (specific buckets, specific endpoints), while credential fields are admitted by name (the whole field or nothing). That is the right shape — an allowlist of secrets by value is not a thing — but it means the per-deployment opt-in is all-or-nothing per field name. Audit which keys actually use it.
What Breaks on Upgrade
- Callers that set the six Entra fields as top-level body fields now receive 401 instead of a confusing provider error or, worse, a working request. The documented migration: move them into
metadata(the six names are metadata-exempt) or have an admin opt in per deployment. - Anything writing
os.environ/oroidc/references into credential-shaped fields outside deployment-management routes is rejected outright. If you built tooling that injects those formats from the request side, that tooling is now broken by design. - Fields that previously looked inert —
rust,fireworks_forward_user_id, observability callback params — are now hard 401s. A caller flippingrust: falsein a body to dodge the Rust transport will notice first. - The carve-outs are narrow and route-specific:
client_secretstays legal on realtime/v1/realtime/transcription_sessionsroutes;client_id/client_secretstay legal on the MCP OAuth client form and/introspect. Nothing else inherits these names' old behavior.
For teams running Azure deployments where developers carried their own service-principal fields in request bodies (a pattern that worked because the proxy mixed them in), this is a forced rearchitecture of client config, not a config tweak. That is the honest migration toil: the fix is correct, and the blast radius is every client that leaned on the bug.
The Rust Router Core Grew Up Under Fire
v1.110.0 is the first minor where there is no fallback architecture. PR #50178 deleted the Python router core outright, which retroactively explains most of this cycle's churn: the Rust core's host-side glue (the Python code that mirrors Rust state and answers Python-side questions) was written in a hurry, and the v1.110.0 line has been paying for it. Three of the repairs are in this RC.
Mid-Stream Fallback Stops Inventing Answers (#50260, backport #50798)
LiteLLM's enable_mid_stream_fallback_continuation router setting does something genuinely valuable for agent workloads: when a streaming response dies mid-answer, the router can resume the conversation on a fallback deployment and hand the caller one continuous stream instead of a partial answer plus an error. The bug PR #50260 fixed is what that continuation did to request-level fallbacks: the mid-stream continuation check read only the router's fallback list, never the request's.
The failure reads like a correctness horror story, and the PR's user flow is worth internalizing: a caller sends "stream": true with a request-level fallback that carries its own messages — a restart the conversation on failure policy. The primary stream gets cut after "Alpha beta gamma". The continuation logic splices the primary's partial prefix onto the fallback's restart messages, and the caller reads "Alpha beta gamma omega." with no error at all. A silent wrong answer from a router is the worst failure class a gateway has; after the fix, that same request surfaces the primary's error after the partial text, because the check now reads the request's fallbacks first and only falls back to the router's list when the request doesn't set one — the same precedence the normal fallback walk already used.
stream cut mid-answer ("Alpha beta gamma")
|
v
+-----------------------------------+ BEFORE #50260: check reads
| mid-stream continuation check | ONLY router fallbacks
+-----------------------------------+ |
| v
| request has its own fallbacks? router fallback: backup
| (they restart the conversation) -> splice primary prefix
| | onto fallback messages
v v -> caller reads
AFTER #50260: request fallbacks "Alpha beta gamma omega."
take precedence. A request fallback
that brings its own `messages`
cannot carry the prefill:
-> surface the primary's error
after the partial text
(no fallback messages: continue
on router fallbacks, as before)
Two architecture facts matter more than the bug. First, the fix is a one-argument change in the Rust core's continuation check — this logic now lives in litellm-rust/, and the regression was latent while the Python core served by default: the PR notes the test only started failing when #50178 made the Rust core the only core. Second, the caveats section is honest about a remaining gap: the Responses API continuation "never checks fallback targets in either core — it reads no fallback list at all." If you are building on /v1/responses streaming with mid-stream fallback expectations, this release does not yet have that story.
The Registry Race: Positions, Mirrors, and One New Lock (#50933, backport #50993)
This is the deepest fix in the release. The Rust core's registry API returns positions — indices into the deployment list — and the Python host then reads HostedModelList, its Python mirror of that list, at those positions. Before PR #50933, writes applied the changeset to the Rust core first and refreshed the mirror after, with no lock between the two. A reader on another thread inside that window paired one registry version's positions with the other version's list. The PR documents both observable failures:
- After an append (the end of a build, where
end_builddrops shown entries before reading back): the position is past the mirror, raising anIndexErrorindriver.finish— after the upstream call already succeeded. A won request reported as a crash. - After a removal: positions shifted, so
model-1's lookup returnedmodel-0's deployment rows — key-0 included. Deployment selection itself stayed correct (Rust picks), but the wrong rows reached model-group info, remaining-usage headers, andget_model_list. In a multi-tenant gateway, one tenant's deployment metadata surfacing under another tenant's model name is a data-isolation incident waiting for a dashboard.
WRITER thread READER thread (before #50933)
------------ --------------------------
[1] apply changeset to Rust core
|
| <<<<< window: Rust state updated, Python mirror stale >>>>>
| [2] registry lookup ->
| Rust returns positions
| for the NEW list
| [3] read HostedModelList
| at those positions
| -> it is the OLD list
v
[4] refresh Python mirror
outcome A: append happened -> position > len(mirror) -> IndexError
(raised in driver.finish AFTER upstream success)
outcome B: removal happened -> positions shifted ->
model-1's lookup returns model-0's deployments (key-0 included)
-> wrong rows reach model-group info, usage headers, get_model_list
AFTER #50933: the registry's existing RLock (_deployment_index_lock) is
held across each router-core write AND the mirror read-back that follows;
the 21 registry readers that pair lookups with mirror reads hold the same
lock. Build steps (validate / provider / secrets) answer OUTSIDE the lock,
so a secret-manager call can never block readers.
The fix reuses the registry's existing _deployment_index_lock (an RLock), passing it into HostedModelList so refresh, build phases, append, replace, and the single-delete path all hold it across the Rust write plus the Python read-back. The cost is characterized precisely: lookups already hold the GIL on the Python side, so the change adds "about one uncontended RLock acquire per lookup." The stress numbers are the kind operators should ask vendors for more often: an 8-thread completion load against an upsert loop with sys.setswitchinterval(1e-6) produced 6 IndexErrores in 4,000 calls on main and zero in three runs of 4,000 on the fix branch.
The caveats deserve equal billing, because they define the remaining exposure: a set_model_list replace-build still shows the new list in the mirror while the Rust core serves the old one until the build ends (closing that would mean holding the lock across secret resolution, so it is deferred), and an upsert is still two writes — remove, then add — so a read between them sees the group without that deployment, same as the old Python router. The long-term plan, per the PR, is for lookups to return deployment IDs instead of positions, which removes the paired-state problem as a class. Until then: treat deployment churn (frequent /model/new, /model/update traffic) as the trigger condition for this race, and be glad it is locked.
Host-Path Performance: The Rust Core Was Slower Than What It Replaced (#50641)
The most instructive numbers in this release are in PR #50641, because they quantify the cost of the single-core transition before anyone repaired it. Two host hot paths ran on every request and were written naively against the Rust bridge: the router's callback_filters step read every candidate deployment back into Python — 1,000 reads per pick at 1,000 deployments — and membership was tested with list scans, O(n²), about 7 ms per pick at 1,000 deployments. The anthropic capability probes (12 probes per request) ran fresh on every call.
The maintainer-reported benchmark table (SDK, in-process, mock upstream, fat LTO, 2 interleaved rounds; baseline 85bc5808 is the last commit with both cores) is the honest record:
| Case (CPU per call) | Python core (last dual-core commit) | Main before PR | This PR |
|---|---|---|---|
| select, simple-shuffle, 100 deployments | 460 µs | 829 µs | 354 µs |
| select, simple-shuffle, 1,000 | 3.8 ms | 17.5 ms | 5.9 ms |
| acompletion, simple-shuffle, 1,000 | 11.6 ms | 30.9 ms | 16.9 ms |
| select, latency-based, 1,000 | 36.2 ms | 65.5 ms | 52.4 ms |
| /v1/messages direct (no router) | 4.0 ms | 5.1 ms | 3.7 ms |
| /v1/messages, router, 1,000 deployments | 29.5 ms | 28.4 ms | 14.6 ms |
Read the middle column against the first: before these fixes, the Rust-only router spent 17.5 ms of CPU per select at 1,000 deployments where the Python core it replaced spent 3.8 ms — roughly 4.6× slower on the hot path. The two host-path fixes (return candidate IDs without per-deployment reads; frozenset membership; lru_cache(256) on capability probes keyed to the model-map state) brought the callback_filters step from 12.2 ms to 0.12 ms at 1,000 deployments — about a 100× drop — and the probe path from 1.7 ms to 0.36 ms on direct /v1/messages. The PR's own caveat keeps it honest: even after the fix, the Rust core is still ~1.5× the old Python core's select cost at 1,000 deployments, with the remainder in per-select usage reads, Python-parsed cooldown keys, and the crate's 2.3 ms final pick. The rewrite is not a performance story yet; it is a correctness-and-velocity story with a performance debt line item.
One operational nuance from the capability cache: it keys on the model-map state — the map object, the mutation generation, its size, and the identity of every entry the probes read — so register_model and litellm.model_cost[key] = ... assignments are still seen on the next request. An in-place edit of an existing entry dict (litellm.model_cost[key]["supports_reasoning"] = True) without register_model is not. If you mutate pricing dicts in place from hooks, this cache will serve you stale capability answers.
total_timeout: One Wall-Clock Budget for the Whole Request (#50496)
The feature release of this cycle addresses the loudest agent-infrastructure complaint about retrying routers. Issue #49751 (filed by an outside user, 2026-11-03) documents it precisely: x-litellm-timeout bounds only one upstream attempt, so Router.async_function_with_retries keeps retrying and sleeping Retry-After intervals with no check on how much of the caller's budget remains. Their repro: fast 503s with Retry-After: 6, num_retries: 3, client timeout 10 seconds — the proxy spends ~20 seconds retrying a caller who already hung up. The consequence is worse than wasted latency: "the client has already timed out and sent its own retry, so upstream load doubles exactly when the provider is struggling." A retry-amplification feedback loop during provider brownouts, driven by the gateway. (The issue also notes the OpenAI SDK sends its timeout as x-stainless-timeout on every request and the proxy never read it — meaning default SDK clients got no deadline at all.)
PR #50496 answers with a new opt-in wall-clock budget. The module docstring in litellm/litellm_core_utils/total_deadline.py states the semantics in three sentences worth quoting whole: "One wall-clock budget for a whole non-streaming proxy request (total_timeout): the proxy opens it, and the router reads it to refuse a retry backoff that cannot finish in time. A request that turns out to stream or run in the background is not bounded: a body total_timeout refuses it, a header one (often set globally on a client) lets it go unbounded."
client proxy internals
------ --------------
POST /v1/chat/completions
body: "total_timeout": 10 <-- strict: refuse if request streams
header: x-litellm-total-timeout: 10 <-- lenient: ignore on streams
|
v
[preparation]-----+
[routing]---------+--> ONE wall-clock budget (10 s)
[attempt 1]-------+ - covers EVERYTHING:
503 retry-after: 6 preparation, retries, backoffs,
[backoff sleep 6s]-+ moderation, post-call hooks
[attempt 2]-------+ - a backoff that CANNOT fit inside the
503 retry-after: 6 remaining budget is not started:
[refuse: next backoff the last upstream error returns
needs 6s, only 3.7s left] immediately
|
v
HTTP 408 "the request did not finish within its
total_timeout of 2.0s" + upstream call cancelled
|
v
billing: like a client disconnect, at estimated prompt cost
streaming / background requests: NOT bounded
- body total_timeout -> 400 (refuses the request)
- header total_timeout -> silently ignored (global client
headers stay safe)
The before/after timing from the PR's verification run tells the operational story: 503s with retry-after: 6, num_retries: 3, total_timeout: 10 — before, the caller waits 25.5 seconds for the final 503; after, one retry fits, the next 6-second backoff does not, and the 503 returns at 6.4 seconds with no further attempts. Against a hard-hung upstream with total_timeout: 2, before you wait for your own client timeout; after you get the 408 at 2.0 seconds with the upstream call closed. It builds on PR #50486, which makes a cancelled attempt release the resources it holds.
Agent-platform operators should note what this is not: a streaming budget. Long agentic completions are streaming completions, and the header form is explicitly allowed to pass through unbounded because clients set global headers. The feature protects the non-streaming blast radius of retry storms; it does not bound your agents' 90-second streams. Budget those client-side, as before.
Two Regressions This Cycle Shipped, and This Cycle Repaired
Both of the following are v1.110.0-dev regressions — introduced in the same native-Rust push that deleted the Python core, then fixed inside the v1.110.0 line before its stable cut. That is the right way round, but it means anyone who rode the dev builds lived through both.
The Redis Batch Scope Leak: 40 KiB Per Request, Forever (#50751, backport #50949)
PR #50751 (by an outside contributor) documents a leak with an unusually clean causal chain: with Redis enabled for routing, coordination, and the spend transaction buffer, every proxy worker grew about 40 KiB of RSS per request served, permanently. Every request's native Redis batch scope stayed in memory for good; requests slowed as the leaked heap grew; Kubernetes eventually evicted the pods. The PR's soak evidence is specific: 1,000 warmup requests, then five rounds of 1,000 requests at 20 concurrency, reading VmRSS from /proc/<pid>/status after every round. The root cause is named without sentiment: the native Redis batch (added in PR #50169 as the Rust Redis foundation, served on every request since PR #50170, regression since v1.110.0-dev.1) was built inside the request's own context, so a finished request's scope was never collectable. The fix builds the batch outside the request context and adds a regression test asserting that a closed request scope is freed.
Two details deserve an operator's respect. First, the PR includes a live A/B that ruled out the suspected cause: the SpendLogs ledger named in the tracking issue leaked at the same rate with the ledger on and off. Second, the leak sat on a path every request exercises — auth, budget checks, routing, spend tracking — not on an optional feature. If you run Redis-backed LiteLLM on 1.110.0-dev builds, your OOMKills were this, and the fix is only in this RC and forward.
Accounting Flushes Scanned the Entire Delivery Table (#50565)
The second dev-line regression is a quieter database killer. The default spend ledger's flush cycle ran a delivery-metrics query that scanned every record in SpendLogsDelivery — retained receipts included — on every flush. Query cost grew with retained data even at zero backlog, and once the delivery table crossed the storage-alert threshold, a High alert landed in Slack on every flush, roughly every 10 seconds, forever. PR #50565 reshapes the path: the backlog query reads only pending and parked rows through the existing index, the batch count and table size run once per interval per worker process, and storage/delay alerts repeat at most once per interval. /metrics still reports pending, parked, oldest-pending-age, retained batches, and table bytes.
The interval is env-driven and hot-read on each flush, so config.yaml environment_variables apply without a restart:
environment_variables:
# How often storage/delivery health is checked per worker.
# Default: 900 (15 minutes), read on every flush.
LITELLM_ACCOUNTING_DELIVERY_CHECK_INTERVAL_SECONDS: "900"
# Storage alert thresholds (defaults):
# batch count >= 1,000,000 batches, OR
# table bytes >= 1,073,741,824 (1 GiB)
LITELLM_ACCOUNTING_BATCH_DELIVERY_ALERT_COUNT: "1000000"
LITELLM_ACCOUNTING_DELIVERY_ALERT_BYTES: "1073741824"
If your DB query-insights dashboards show a repeating accounting query whose cost tracks your retention rather than your traffic, you were on the wrong side of this one.
Everything Else That Changes Behavior
Hosted-Tool tool_choice on the Chat Route (#50578)
litellm.completion previously returned a 400 on tool_choice: {"type": "web_search"} before the request ever left the building — while the identical body on /v1/responses worked, because the Responses bridge already passed such dicts through. PR #50578 makes validate_chat_completion_tool_choice pass a dict whose type is a known OpenAI hosted tool through unchanged, so the Chat route can force hosted tools (web search being the case in the tests) and return the live-search answer as a normal chat.completion. The provider guard matters as much as the feature: the same body pointed at an Anthropic deployment now stops with "tool_choice type 'web_search' forces an OpenAI hosted tool and is only supported for azure, litellm_proxy, openai" instead of Anthropic quietly answering without the tool. Agent frameworks that had to maintain two code paths (the PR cites langchain-litellm falling back to tool_choice="required") get one.
Breaking: Wildcard /model/info Rows Show What Calls Bill (#49128)
Marked breaking by its own author, and the ! in the title is earned. Before, a wildcard-expanded /model/info row renamed by an alias or a routing group showed its own listed price while calls actually billed a different model. The PR's repro says it exactly: alias my-openai/gpt-5.5 to my-openai/gpt-5.4-mini, and the row showed gpt-5.5 pricing while the response header billed gpt-5.4-mini's rate. Worse, replaying that row into PATCH /model/{id}/update pinned the row's displayed metadata (key, max_tokens) onto the whole wildcard. After the change, each row follows the caller's aliases and route candidates and shows the model its calls actually bill, with that model's limits; a row goes blank only when its target deployments bill more than one model or declare their own rate — and the PR is careful to note a blank is not a zero price, since a zero reads as free. The save routes now compare against every cost-map entry the row can display, so a replay stores only what the admin actually changed. If you have automation that parses /model/info prices — chargeback scripts, internal model catalogs — re-verify it against this release before you trust its output.
Bedrock: Per-Caller STS Session Policies Instead of One Bedrock-Only Policy (#50592)
A sharp fix for web-identity (federated) auth on AWS: previously every AWS call carried a single Bedrock-only session policy, so most non-Bedrock AWS routes, the AWS loggers, Bedrock realtime, async invoke, Mantle token counting, and the agent-runtime pass-through all received 403s even when the role allowed them — while static keys and IRSA sent no session policy at all and worked. PR #50592 gives each AWS caller class its own session policy listing only the actions its routes make, and adds the policy to the credential cache key (two callers with the same role and token previously shared one cached session, so a logger could reuse a Bedrock-only session and still 403). One role and token can now open up to 19 STS sessions per worker, one per caller policy, each refreshed on expiry. Three scoping decisions in the PR are worth reading as security review: knowledge-base ingestion can now only create, edit, and pass roles named litellm-bedrock-kb-* (previously the session could rewrite the proxy's own role); realtime gets bedrock:InvokeModel + bedrock:ApplyGuardrail with no aws:SecureTransport condition, because a live probe showed Bedrock evaluates a Nova Sonic realtime stream as bedrock:InvokeModel and reports aws:SecureTransport as false (denying the stream outright); and every policy granting bedrock:InvokeModel also grants bedrock:ApplyGuardrail, because an enforced guardrail denies invokes whose caller cannot apply it.
Responses-ID Ownership Becomes a 403 (#50600)
The ownership/scope alignment PR carries one explicitly intentional product change: stored response IDs with no recorded owner are now rejected with 403 Forbidden. This response id has no associated owner, unless general_settings.disable_responses_id_security is set. The hook's own docstring states the goal plainly: "Security hook to prevent user B from seeing response from user A," batch-writing response IDs through DBSpendUpdateWriter rather than a synchronous write per request. Cross-instance flows (create a stored response on one proxy instance, continue it on another) are the QA path in the PR. If your tooling shares Responses API IDs across users or service accounts with no ownership semantics, this is a break you will find in production, not in the changelog.
Smaller Sharp Edges
- Key-info 400 vs 500 (#43577):
/key/infowith no key provided now returns a 400 instead of an internal 500. Watch your alerting rules keyed on 5xx rates from that endpoint. - Gemini 3.1 Pro Preview Flex pricing (#50607): adds the Flex above-200k cache-read price rows, so cost attribution for long-context Flex traffic stops under-reporting.
- Admin UI budget reset times labeled UTC (#50591): cosmetic until someone pages you about budgets resetting at the wrong hour, at which point it is essential.
- Local login cleanup (#50098): the local login ends cleanly when
litellm logoutcannot reach the OS keychain, instead of leaving a dangling session.
Migration Toil Assessment
Pick your line by what you need, not by the RC label. The security fix (#51254) is already on stable: v1.109.1 for the 1.109.x line and v1.106.9 for the 1.106.x line shipped it in the same hour as rc.2. The rc line additionally carries everything the stable lines do not have yet: the mid-stream fallback continuation fix, the registry lock, the host-path performance work, total_timeout, the Redis batch scope leak fix, the accounting flush repair, and the full v1.110.0 feature set.
security mid-stream registry total_ redis accounting
#51254 fallback lock timeout leak fix flush fix
v1.106.9 (stable) YES -- -- -- -- --
v1.108.6 (lts) -- -- -- -- -- --
v1.109.1 (stable) YES -- -- -- -- --
v1.110.0-rc.2 (rc) YES YES YES YES YES YES
-- = not present on that line per its release notes.
v1.108.6 (lts) is the backport lane for tool_choice fixes (#49043, #50467),
idempotent budget resets (#50298), and a breaking zscaler_ai_guard backport
(#50139) - read its release body before taking the lts bump.
The breaking-change checklist, in order of blast radius:
- Credential passthrough callers — the six Entra fields in request bodies now 401. Inventory your SDK wrappers and internal clients for those names before you deploy anything from this train; the fix is on stable lines too, so this is not avoidable by staying behind.
/model/infoconsumers — wildcard row prices change (some to blank). Chargeback and catalog tooling must be re-verified.- Responses-API ID flows — unowned IDs 403; same-user cross-instance continuation is the supported shape.
- Hosted-tool tool_choice on non-OpenAI providers — a clear 400 replaces a silent tool-less answer. Tests that asserted the old behavior will fail, correctly.
Supply chain: all LiteLLM Docker images are cosign-signed, and this release's body recommends verifying against the pinned signing-key commit rather than the tag (tags are mutable pointers protected only by branch rules):
# Strongest: pin the signing key to the immutable commit hash
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub \
ghcr.io/berriai/litellm:v1.110.0-rc.2
# Expected output:
# The following checks were performed on each of these signatures:
# - The cosign claims were validated
# - The signatures were verified against the specified public key
Who Should Skip This Release
- Production stability pins. This is a release candidate of a line that spent its cycle deleting its own fallback core, then repairing the result. The repairs are good; the RC label is honest. If you only wanted the security fix, it is already on v1.109.1 and v1.106.9 — take the stable patch and wait for v1.110.0 stable.
- Teams with legitimate client-side credential passthrough. If your architecture intentionally lets callers set
tenant_idor Azure service-principal fields per request, this release turns those calls into 401s until you redesign the flow (metadata, or per-deployment opt-in). Budget that work before upgrading any line that carries the ban — which, again, is every line that took today's security patch. - Streaming-heavy agent platforms. If your interest in this RC is the mid-stream story: the Chat-route continuation is fixed and correct now, but the Responses-API continuation still reads no fallback list at all (PR #50260's caveats), and
total_timeoutexplicitly does not bound streaming requests. Neither of your two most likely reasons to upgrade applies to streaming workloads yet. - Anyone still on 1.103–1.105. The patch train reached your line (v1.103.11, v1.104.9 shipped 2026-11-10), but verify your line's release body for the security backport specifically before assuming — the verified same-hour #51254 backports this analysis confirmed are v1.106.9, v1.109.1, and rc.2.
- Wild-model-catalog operators. If
/model/infofeeds your billing or catalog, and you rely on wildcard-expanded rows, do not upgrade any line until you have re-run your price ingestion against the new row semantics.
Verdict
LiteLLM v1.110.0-rc.2 is two releases in one artifact: an emergency security patch that is already on your stable line, and the release candidate of the first single-core (Rust-only) minor — which means it is also the repair bill for that transition. The security fix is correctly scoped, long overdue, and honest about its own history (the huntr bounty reference ships in the error message); its blast radius is every caller that leaned on request-body credential passthrough, and that migration is the real cost of this train on every line that took it. The routing repairs fix two of the worst failure classes a gateway can have — silent wrong answers from mid-stream fallback splicing, and cross-model deployment metadata from the registry race — and the perf PR publishes the numbers that show how much debt the core swap still carries. Take the stable security patch today regardless of what else you do; evaluate the RC itself if deployment churn, Redis-backed worker memory, or retry amplification under Retry-After is actively hurting you — those fixes exist nowhere else yet. And when v1.110.0 stable lands, re-read its notes before assuming the remaining Rust-core caveats (the set_model_list window, the two-write upsert, the Responses-API fallback gap, the still-unbounded streaming path) have moved: as of rc.2, they have not.