Cron job locks now go through the shared Redis transport behind TransportPodLockStore, which owns the lock keys, the owner codec and the compare-and-delete release script. PodLockManager.is_held replaces the three direct lock reads in the budget reset, PTU rollup and spend capture-rate jobs and owns the rule that an unreadable lock counts as unheld. Old and new pods respect each other's locks
Background response polling state now goes through the shared Redis transport behind TransportResponsePollingStore, which owns the keys, codec and expiry. The wire format is kept byte for byte so old and new pods read each other's state
The transport now has every data command Python sends, request-scoped and
explicit batches on the one pipeline engine RedisBatch already used,
MULTI/EXEC batches, pub/sub, and the admin and health commands. RedisBatch
declares onto the same engine, so moved and unmoved callers in one request
still share one round trip. Cluster mget goes through the cache's
mget_nonatomic path instead of a cross-slot MGET.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rework RedisTransport into plain get, mget, set, delete and eval methods,
split the breaker step (run_under_breaker over the existing
_run_under_circuit_breaker) from the events step (RedisServiceEvents.observe,
which skips the stack walk and event when no service_callback is set), and
add eval with EVALSHA plus EVAL fallback. Fold in the store layer and the
response polling store, and replace the redis-access skill references with
the migration plan and PR sketches.
* fix(cost): bill the margin once on rebuilt streams and streamed Responses through the chat bridge
The last usage chunk and the Responses terminal event carry litellm's bill in usage.cost, and two readers took that bill for the upstream's own charge and margined it again: completion_cost on a stream_chunk_builder result, and stamp_responses_usage_cost on the chat bridge. The stamp now keeps the upstream's usage.cost under the hidden upstream_usage_cost key, the chunk builder carries it onto the rebuilt response, and the Responses stamp prices that saved figure
* fix(cost): hand the Responses stamp the upstream charge through upstream_usage_cost only, never the response-cost header
response_cost_calculator returns the response-cost header as the final bill without the margin, so a priced copy carrying the upstream's raw usage.cost there billed the client the unmargined figure. The hidden upstream_usage_cost key already feeds billed_usage_cost, which margins it once. The Responses test now prices through a real Logging object instead of a mocked calculator
* fix(cost): leave stamp_responses_usage_cost as on main
The Responses chat bridge copies the inner stream's final usage chunk, so the wrapper stamp keeping upstream_usage_cost already prices it once. Changing the Responses stamp was not needed for that and re-priced xai MCP rounds whose usage.cost is already litellm's bill (0.00512 for 0.00312 in test_xai_stream_auto_execute_bills_each_round_at_its_own_price_with_one_fixed_margin)
---------
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test: bedrock claude prompt caching on /v1/messages reports and charges cache tokens
* ci: re-run after ci-coverage and assert-shard-coverage were cancelled by the GitHub Actions incident
---------
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(responses): normalize empty function_call_output to an empty tool result
* test(responses): preserve empty tool outputs through guardrails
---------
Co-authored-by: Tin <tin@berri.ai>
* feat(ui): show ai&'s name and logo in the dashboard
The Add Model form already lists ai& from its provider metadata, but the dashboard had no entry for it, so the provider showed no logo and model tables printed the raw aiand slug. Adds ai& to the dashboard provider list with its official mark, plated in dark mode so the red and black mark keep their colours, and the aiand/openai/gpt-oss-120b model placeholder
* fix(aiand): retire deepseek-v4-flash, glm-5.2 and kimi-k2.7-code
ai& retired these three models on 2026-10-27 and https://api.aiand.com/v1/models now lists ten. The rows keep their prices with a deprecation_date, so earlier spend still prices, and aiand/* stops listing them, the same way baseten handles retired rows. The other ten rows already match the live prices, context windows and output limits
* feat(aiand): add deepseek-v4.1-flash-1017
ai& launches deepseek-ai/deepseek-v4.1-flash-1017 on 2026-11-02. It is served on https://staging-api.aiand.com/v1/models with the same price, context window, output limit and capabilities as deepseek-v4.1-flash, so its row copies that one
* fix(aiand): retire deepseek-v4.1-flash
ai& retires deepseek-ai/deepseek-v4.1-flash on 2026-10-30, now that deepseek-v4.1-flash-1017 is live in production on https://api.aiand.com/v1/models with the same price, limits and capabilities. Its row keeps its prices with a deprecation_date, like the other three retired ai& rows
* fix(aiand): retire deepseek-v4.1-flash on 2026-11-06
https://api.aiand.com/v1/models now gives deepseek-v4.1-flash a deprecation_date of 2026-11-05T15:00Z, which is 2026-11-06 00:00 in Japan, so its row uses 2026-11-06 instead of 2026-10-30 and aiand/* keeps listing it until then. The wildcard test now leaves a model out only once its retirement date has passed
* test(aiand): derive retired models from the rows instead of pinning dates
The wildcard test now reads each row's deprecation_date, so it holds whichever dates ai& sets, and the test that only restated the JSON dates is gone
* fix(aiand): retire models on their UTC date while the proxy runs, and add muse-spark-open
The wildcard listing now checks each row's deprecation_date when it expands, against the UTC date the field
documents, so a model drops on its day without a restart. deepseek-v4.1-flash now carries 2026-11-05, the
UTC day ai& retires it, and meta/muse-spark-open gets its price row from ai&'s live /v1/models
* fix(aiand): hide retired models only from bundled listings, including the wildcard health check
* fix(aiand): share the UTC retirement check with Together AI and the health check sort
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(proxy): evict a deleted key from every worker's auth cache
/key/delete dropped the key object only from the in-memory cache of the worker
that served the call. Auth serves key objects cache-first, so every other
worker, and the Redis copy when enable_redis_auth_cache is on, kept
authenticating the deleted key until user_api_key_cache_ttl expired. /key/update
already evicts and broadcasts through evict_and_broadcast, delete now does the
same for the tokens it deletes
* fix(proxy): evict only the deleted rows and broadcast them in one batch
Scope the cross-worker eviction on /key/delete to the rows the caller was allowed to delete and send it through delete_cache_key_objects, so a caller-supplied name can no longer drop another team's cached entries on every worker
* refactor(proxy): drop the comment above the delete eviction call
* fix(proxy): run the usage cache eviction beside the auth eviction so a hung Redis costs one wait
* test(proxy): wait on the broadcast instead of a sleep and type the peer eviction tests
* test(proxy): wait for the peer to apply the broadcast instead of polling its cache
* test(proxy): cover hashed keys and aliases when deleting a key warmed on a peer
* fix(key_management): evict every requested key on delete, including keys whose row is already gone
A batch delete that names a live key and a key whose row was removed outside the API evicted only the live key, so the gone key kept authenticating from every worker's cache. Every requested key hash is evicted now, while raw non-key cache keys are still left alone
* test(key_management): split the gone-row eviction assertion into one comprehension per worker
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* perf(streaming): stop raising AttributeErrors inside pydantic on every streamed chunk
Every later plain chunk on openai and azure streams raised and caught 12 AttributeErrors inside pydantic. Assigning an undeclared extra goes through a class lookup that raises in the model metaclass and is never memoized, and the hidden_params getter went through the private attribute fallback
set_extra_attribute writes the extra and fields_set directly when a cached per-class check says pydantic would do the same, and falls back to setattr otherwise. The hidden_params getter reads the private storage directly. Emitted chunk JSON is unchanged
* fix(streaming): match pydantic's own extra-field bookkeeping on every supported version
Take the direct extra write only when the installed pydantic itself adds an assigned extra to
model_fields_set (2.13 and later) and the class defines no attribute of that name, so pydantic
2.11, 2.12 and 2.14 see the same model state as a plain setattr. A deleted _hidden_params raises
AttributeError again, and the mutable test models carry frozen-ok reasons.
* fix(streaming): keep the normal write for models that do not allow extra fields
openai's construct gives every model an extras dict, so a forbid model built that way still needs
setattr's ValueError instead of a silent extra write.
* test(streaming): cover a ClassVar declared without a value in the extra-field parity test
* fix(streaming): type the streamed choice's logprobs as the parsed value main now stores
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(passthrough): log the URL model and declared provider on Gemini and Vertex failures
Pass-through failures on Gemini and Vertex routes carry the model only in the
URL, so the failure spend row and the Prometheus failure counter logged an
empty model. The failure payload now falls back to the model parsed from the
URL on those routes, and the Prometheus provider label honors the provider the
request declares before inferring one from the model name
* fix(passthrough): keep model and provider on mid-stream and Vertex failure logs
Build the failure payload from the URL at both streamed relay sites so a
200 stream that dies mid-body still logs the model, derive vertex_ai for
Vertex routes that never declare a provider, and have the Vertex logging
handler reuse get_vertex_model_id_from_url instead of its own regex
* fix(passthrough): bound the Prometheus provider label and name Vertex failures like the success handler
Declared providers only reach the api_provider label when they name a
registered provider, otherwise the label falls back to inference.
Vertex failure rows now use the success handler's model naming
(vertex_ai/ prefix on rawPredict and streamRawPredict, vertex_ai/search_api
on :search) and its host-based provider detection, and the detected-stream
failure sites are covered by tests.
* fix(passthrough): name Google failures from the URL on untracked methods too
A Gemini or Vertex pass-through failure on a method the success handler does
not track (countTokens, for one) still logged model "" and no provider,
although the model sits in the URL. The failure payload now falls back to the
Google AI host for the provider and reads /models/<name> from the URL, so
countTokens 400s name their model and provider like generateContent ones do.
Non-Google hosts keep the tracked-route rule and still log nothing extra.
* fix(passthrough): trust the declared Google provider and Vertex-shaped paths on custom bases
The failure path only named the Google provider from the URL when the
method was one the success handler tracks or the host was a Google API
host, so a Gemini countTokens failure behind a custom GEMINI_API_BASE
(or a Vertex one behind a custom base) still logged an empty model. A
route that declares gemini or vertex_ai now wins outright, and a URL
whose path carries /projects/<p>/locations/<l>/ counts as Vertex on any
host, while a tracked method on a non-Google host no longer passes as
Vertex.
* fix(pass_through): stop the URL model parser at the query string so a configured key never lands in a spend row's model
* test(pass_through): run the URL-model failure tests through the real ProxyLogging with a recording callback
* fix(passthrough): scope declared provider to pass-through failures and match Google hosts only
Prometheus trusts the resolved provider as before and reads the request-declared
provider only on pass-through failures. Vertex attribution matches Google hosts,
strips operation poll suffixes, prefixes vertex_ai/ only for rawPredict, reads the
URL path only, covers Vertex Live WebSocket failures, and labels a router alias
failure with the alias the client requested
* fix(passthrough): attribute failures from the URL only on built-in Google routes and keep other routes' metric labels
A user-defined pass-through route to a Google host keeps its empty model and provider, since its URL names an upstream model rather than a LiteLLM-managed one. Prometheus failure labels read the route's provider and model_group only for gemini and vertex_ai, so Azure, OpenRouter, Bedrock and other pass-through routes keep their earlier labels. The Gemini URL parser drops the query string, Google host matching needs a dot or dash boundary, and the Vertex Claude prefix follows alt=sse like the success handler.
* fix(passthrough): log the URL model on guardrail blocks and the named model on Vertex Live failures
A pre-call guardrail block on a pass-through route now builds its failure
payload the same way upstream failures do, so the model and provider read
from the URL reach spend logs and metrics. Vertex Live websocket failures
(connect errors, upstream rejections, and the credential failure) now carry
the model from the ?model= query parameter under the vertex_ai provider
* fix(passthrough): mark guardrail-block and Vertex Live credential failures as pass-through calls
Prometheus reads a failure's declared provider only on pass-through calls, so
without call_type it fell back to guessing from the model name. That labeled a
guardrail block on the Gemini route as vertex_ai and the Live credential
failure as None
* fix(passthrough): let the route name a failure's provider and give guardrail blocks their own spend row
* fix(pass-through): keep URL model attribution off routes that name another provider
A built-in route that names a non-Google provider, like /openai pointed at
Gemini's OpenAI-compatible host, no longer takes its failure model from the
Google URL. A guardrail block response now returns the x-litellm-call-id of
its spend row.
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(vertex_ai): forward and auto-add the compact-2026-09-04 beta on Claude routes
* test(vertex_ai): prove the compact-2026-09-04 beta reaches the wire through both Claude handlers
* fix(vertex_ai): move a client-sent body compact-2026-09-04 beta to the request header
* refactor(vertex_ai): build the Claude beta set without mutation and inject the client in the messages wire test
* fix(vertex_ai): keep client body betas in the body and answer 400 on a malformed anthropic_beta
* fix(rust): allow the compact-2026-09-04 beta on vertex_ai in the Rust beta table
The beta header config now maps compact-2026-09-04 for vertex_ai, so the Rust
table that mirrors it must stop rejecting that beta there, and the Rust Vertex
messages route adds it for a compaction request like the Python route does.
* fix(vertex_ai): keep client body betas out of the Vertex body next to litellm betas, honor drop_params, and send the compaction beta on count_tokens
Litellm's own betas replace a client's body anthropic_beta again, as on main, so a body beta
Vertex rejects no longer turns a 200 into a 400. A malformed beta drops under drop_params and
names its field otherwise. inline-tools-2026-09-15 joins compact-2026-09-04 as header-only, since
Vertex rejects both in the body. Vertex count_tokens sends compact-2026-09-04 when the request
carries compaction.
* fix(logging): log and bill Anthropic messages compaction responses on the Rust route
The Rust route hands the served Anthropic body to the logger, which validates it
against AnthropicResponse. That model had no compaction content block and no
compaction stop reason, so a compaction reply raised and wrote no spend row.
* fix(vertex_ai,azure_ai): forward compact-2026-09-04 to Azure AI Foundry, merge Vertex body betas, and redact compaction summaries
Azure AI Foundry needs the compact-2026-09-04 header for compaction, a replayed compaction block, and count tokens, so the beta map, the native compaction providers, and the Rust beta table now allow it there. Vertex keeps a client's body betas next to the ones litellm adds, as the Anthropic route does, and turn_off_message_logging now redacts compaction summaries too
* fix(vertex_ai,azure_ai): auto-add thinking display and tool change betas, redact streamed compaction summaries
Folds the header changes from #44103 into this PR. Vertex AI and Azure AI Claude
routes now add thinking-display-updates-2026-08-18 when thinking.display is
"updates" and mid-conversation-tool-changes-2026-07-01 when a tool_addition
block is present, matching the Anthropic and Bedrock routes.
With turn_off_message_logging on, compaction summaries from streamed messages
and streamed chat completions are now redacted in spend logs and callback
payloads, like the non-streamed response already was.
* fix(vertex_ai,azure_ai,rust): add tool change betas on count tokens and Rust messages, bill Rust server tool replies
Vertex and Azure count tokens now add mid-conversation-tool-changes-2026-07-01 when the history carries a tool_addition or tool_removal block, through one shared helper with the compaction beta.
The Rust /v1/messages route adds thinking-display-updates-2026-08-18 and mid-conversation-tool-changes-2026-07-01 when a request uses them, on the providers that accept each beta.
Rust-route logging parses the served body with the Anthropic response parser, so web_search, pause_turn, mcp_tool_use and container_upload replies get a spend row.
Legacy enabled thinking keeps display updates on anthropic too, and the Vertex transform reads drop_params through drops_unsupported_params.
* style(rust): rewrap beta map test line with cargo fmt
* test(rust): expect the display beta on Databricks, which main's beta list now accepts
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(vllm): send the deployment api_key on /vllm passthrough calls
VLLMModelInfo.get_api_key always returned None, so /vllm/{endpoint} calls to a router
model reached the vLLM server with no auth header even when the deployment had an
api_key, and a server started with --api-key answered 401 (#23443).
Return the deployment key (or VLLM_API_KEY) and send it as Authorization: Bearer, the
header vllm serve --api-key checks. x-api-key was never actually sent before, since the
key was always None.
* fix(vllm): send the pass-through key as a bearer on both /vllm routes
vllm serve --api-key only reads Authorization: Bearer, so a keyed vLLM
answered 401 on both pass-through routes. Send the key as a bearer and
keep x-api-key for gateways that read it. Router models send their own
deployment key and never fall back to VLLM_API_KEY, which belongs to
the /vllm route's own server.
* fix(vllm): send only the bearer on router models and stop doubling /v1 on /vllm
Router models now send the same Authorization: Bearer header /v1/chat/completions sends for that deployment, without the x-api-key copy that route never sent. The VLLM_API_BASE route keeps x-api-key next to the bearer, as it sent it before. A router model whose api_base already ends in /v1 no longer gets /v1/v1 on /vllm/v1 paths.
* fix(vllm): send no placeholder key from keyless router models and stop doubling /v1 on the VLLM_API_BASE route
* fix(vllm): send VLLM_API_KEY when a router model with no api_base is forwarded to VLLM_API_BASE
* fix(vllm): keep a router model's own key on the VLLM_API_BASE server and resolve an oidc VLLM_API_KEY
* fix(vllm): treat an empty key as no key so pass-through sends no empty bearer
* fix(vllm): send no bearer for a Google oidc key and match VLLM_API_BASE across a trailing slash
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(logging): warn when drop_params drops a requested reasoning level on the remaining paths
* fix(logging): keep guardrail and token-count reads of a request from logging drop warnings
Only the real Anthropic-messages adapter request reports a dropped thinking budget. Guardrail,
shadow-eval and token-count translations of the same request now drop it silently, so one request
logs one warning. The 422 retry also no longer hashes each loc entry, so a list inside loc cannot
raise TypeError.
* fix(bedrock): keep the DeepSeek R1 Converse reasoning_effort drop at debug level
Only allowed_openai_params reaches that branch, and it puts reasoning_effort back after the provider mapping, so a warning there claimed a drop that never happened
* fix(logging): warn on Gemini non-integer budget drops and keep the auto-router baseline silent
Gemini 2.x now logs the drop of a non-integer thinking budget. The auto-router
savings baseline builds a body it never sends, so it no longer warns. Logged
values are capped with reprlib, Azure AI marks only its drop_params retry as
gated, and Bedrock Chat Completions names the model without its route prefix.
* fix(logging): warn on the Gemini 3, Magistral, Ollama, Fireworks and DeepSeek ARN reasoning drops
Gemini 3 and later now log a dropped thinking budget, Magistral logs the
level it swaps for a reasoning prompt, Ollama logs a level it maps to
think off, Fireworks logs chat_template_kwargs effort keys dropped for a
model the map does not list as reasoning, and DeepSeek on Bedrock logs a
drop when the model is addressed by an ARN that advertises the param.
* fix(logging): warn on the remaining Ollama, DeepSeek and Tencent reasoning drops
The ollama/ completion route now shares the chat route's think mapping, so
a level it turns off logs the same warning. DeepSeek warns when it drops
thinking.budget_tokens, and Tencent warns when an adaptive-only model gets a
non-integer budget. Magistral no longer warns on low, medium, high or
enabled thinking, the values its reasoning prompt stands in for. The Gemini 3
budget warning no longer claims the model uses its default level.
* fix(reasoning): warn when Claude drops a non-string reasoning_effort
Claude chat and native /v1/messages dropped a reasoning_effort like 5 or
{"effort": 5} with no log line. Both now log one warn-only WARNING. A
Magistral thinking.type sent as a list no longer raises TypeError.
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(spend): keep a NaN or infinite cost from wedging daily spend writes
A non-finite provider-reported cost now counts as not reported, so the token price bills the request. Any other non-finite cost is logged and recorded as 0, and spend log JSON writes NaN and Infinity as null so Postgres accepts the row.
* fix(spend): price non-finite provider costs on every route and drain parked NaN batches
Perplexity, OpenRouter pass-through, and Responses streaming now treat a NaN or infinite reported cost as unreported, so the token price bills the request. A daily spend batch parked in the Redis buffer by an older version with a NaN spend now drains as 0 instead of failing every flush. The cost header never carries NaN or Infinity, and a set holding a non-finite float no longer breaks spend log JSON
* fix(spend): record non-finite savings as 0 and warn when a parked NaN batch drains
An overflowing price on a compressed request made compression savings infinite, which still wedged the daily spend batch. Every savings column now records a non-finite value as 0 with a warning. The Redis drain warns when it reads a NaN an older version parked, since the spend summed into it is lost from daily spend. The writer test now runs the real payload builder and spend log insert
* fix(spend): keep non-finite costs out of headers, responses, and admin JSON
Writing NaN as null is now opt-in for spend log columns, the Redis buffer, and
streamed frames, so admin writes and logging integrations keep their output.
The cost calculator records an overflowing total as 0 with a warning, cost
headers leave out non-finite values, a non-streaming NaN usage.cost is dropped
instead of failing the response, and a parked NaN spend log row reads back as 0.
* fix(spend): keep caller-set call ids from splitting the non-finite cost warning
The overflow warning strips newlines from litellm_call_id, and neither
non-finite cost warning prints the raw value, which said nothing beyond
"non-finite"
* fix(spend): drop non-finite Responses and OpenRouter costs and the breakdown of an unpriceable request
* fix(types): drop a usage cost breakdown that holds a non-finite number
* fix(streaming): keep a dropped non-finite final usage cost from falling back to an earlier chunk's cost
* fix(spend): keep the dropped-cost flag out of reach of provider usage keys
A provider usage key named dropped_non_finite_cost shadowed the Usage method on the instance, so the stream builder raised and dropped the request's spend. The builder now reads the flag through the class, and Usage sets the flag after copying provider keys, so a _dropped_non_finite_cost key cannot set it either.
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
* fix(router): check and count deployment itpm and otpm on every Responses WebSocket turn
The success hook only reconciled an ITPM/OTPM reservation found in top-level
metadata or the standard logging object, but on the proxy the reservation lives
in litellm_params.metadata, so no reservation was ever settled there. The hook
now always calls the reconcile, which already returns early without a reservation
and clears what it settled, so a second success event is a no-op.
A Responses WebSocket turn after the first now has its deployment's current-minute
ITPM and OTPM counters checked and is refused with a 429 frame once a counter is at
its limit. An admitted turn carries a zero reservation on the current minute's
keys, so its actual tokens land on those counters on both the native and the
managed relay.
* fix(responses): settle the socket open's itpm/otpm reservation on the first turn only
After the first turn takes its logging copy, both websocket relays drop the
open's ITPM/OTPM reservation keys from the connection metadata, the same way
the budget reservation is detached. Later turns, unsolicited backend events,
turns after limits are removed mid-socket, and sockets with no turn admission
no longer settle or refund the open's reservation a second time.
* refactor(responses): read the itpm/otpm reservation keys from core metadata helpers instead of router_utils
* fix(responses): keep a socket open's hold for its first real turn and check tokens before request limits
A turn that will settle the socket open's itpm/otpm reservation no longer counts
that reservation against itself or swaps it for a zero one, so Codex's warmup
frame on a managed provider, or a refused first frame on the native socket, no
longer blocks the first real prompt or strands the hold for the minute.
The token check now runs before the rpm/rpd checks, so a turn refused for
tokens never uses up a request slot. Token-check tests pin the clock, and the
managed relay settle test drives a real streamed call through a custom provider.
* test(router): pin the router clock in the socket turn admission tests
Both tests seeded this minute and the next to dodge a minute boundary.
They now use the file's pinned-minute fixture and seed only that minute,
so neither depends on when the test runs.
* fix(router): check a responses socket's open without reserving and read shared token counters first
A Responses WebSocket open now runs the same itpm/otpm check a turn runs and
reserves nothing. It used to reserve an estimate that falls back to the
model's max output, so a socket could not open under any otpm below that.
The open still records a zero reservation for its own minute, so the first
frame, which the relay forwards without a turn check, counts its actual
tokens like every later turn.
This drops the plumbing that only carried the open's hold into the first
turn. The turn admission callable takes no arguments again, and the native
relay detaches only the budget reservation, as before this change.
The itpm/otpm turn check reads Redis first and falls back to this
instance's in-memory count only without Redis or when the circuit breaker
is open. These counters go down when a call settles, so an in-memory copy
at the limit could refuse turns after the shared count dropped. The tpm
check keeps its in-memory shortcut, since its counter only grows within a
minute.
Responses usage that reports input_tokens_details.cached_tokens now leaves
cached input out of itpm, as chat usage already did.
* fix(router): leave a socket turn's cached input out of itpm
The native Responses WebSocket relay logs a turn with its list of events,
so the deployment rate limit hook finds no usage on the response and falls
back to the standard logging payload. That fallback read cached input from
metadata.cache_read_input_tokens, a key the payload's cleaned metadata never
carries, so every cached token of a socket turn was counted against itpm.
The fallback now reads the cached count from the payload's usage_object,
the same usage the logging path already built for cost, so a native socket
turn counts only its uncached input. The managed relay and HTTP
/v1/responses already counted correctly through the response's own usage
* fix(router): mark only responses socket opens as check-only and count a turn on the minute it finishes
The check-only branch for a socket open keyed on a websocket value in the request kwargs, which any HTTP body can carry and which the realtime route also sets. The router now passes an opens_responses_socket flag into set_io_token_rate_limit_request_kwargs only for its _aresponses_websocket call, and the flag lives in the same context value as the request kwargs, so no request body can set it and realtime opens reserve and get refused as before
A zero reservation holds nothing to refund, so a socket turn now counts its real tokens on the minute the call finishes, read from the clock at settle time. A non-zero reservation still settles on the keys it reserved on so its refund hits the same counter
When the shared Redis read returns nothing, the check now falls back to this instance's in-memory count instead of admitting the turn with no count at all
* fix(router): keep io token reservation keys out of the metadata sent to providers
The itpm/otpm pre-call check wrote its reservation keys into both metadata and litellm_metadata. On the routes the router serves through its generic helper (Responses, Anthropic Messages, and the rest), metadata is the client's own field and goes to the provider as is. So _litellm_itpm_cache_key and _litellm_otpm_cache_key, which carry the deployment id and the model name, reached the provider, came back in the echoed response metadata, and counted toward the provider's 16-key cap. The managed Responses socket relay merged each turn's reservation into the frame's metadata the same way, and a native socket opened with metadata sent the keys on every turn through its request defaults
The router now passes the limiter the metadata field it uses for its own fields on that call (metadata for chat, litellm_metadata for the generic routes), and the reservation goes only there. metadata_channel is a required keyword, so a new caller has to name the field it writes. The managed relay puts each turn's reservation only on litellm_metadata
With the reservation only on litellm_metadata, a retried call on the generic routes refunded its first attempt twice and pushed the deployment's counters below zero. The failure hook read the reservation from the logging copy in litellm_params.metadata and cleared only that copy, so the router's litellm_metadata still held it and the retry refunded it again. The hooks now also read and clear litellm_params.litellm_metadata, which is the router's own dict
The proxy does not strip client keys that start with _litellm_ on the way in, so dropping that prefix at the provider boundary would also drop keys clients send today
The io token tests now run Redis in process with fakeredis instead of a TCP fake server
* test(responses): count socket turns through real logging callbacks instead of a Logging subclass
The native relay test now hands the rate limit check to a real Logging object as its success and failure callback, so the turn's tokens go through the same dispatch path production uses.
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>