chore(release): backport #29311, #29444, #29447, #29598, #30480, #30543, #30542, #30573 to stable/1.87.x and cut 1.87.4#30890
Conversation
(cherry picked from commit 581c30f)
…quest body (#29447) * fix: stop use_chat_completions_api flag from leaking into provider request body use_chat_completions_api is a LiteLLM control flag that forces the /responses -> /chat/completions bridge. It was missing from all_litellm_params, so get_non_default_completion_params treated it as a model-specific param and forwarded it to the upstream provider. A model-level "use_chat_completions_api: true" in the proxy config therefore reached the chat-completions path and was rejected by strict providers (OpenAI/Anthropic) with HTTP 400 for an unknown body field. Register it as a known internal param so it is stripped on every path (completion, the responses bridge that calls litellm.completion, and filter_out_litellm_params). Adds a regression test driving litellm.completion() with a mocked OpenAI client that asserts the flag never reaches the request body. * test: clarify extra_body assertion in use_chat_completions_api leak test Replace the misleading 'not in ... or {}' precedence idiom with an explicit parenthesized guard that also handles extra_body being None. (cherry picked from commit 65b6e04)
* fix duplicate cost callbacks for anthropic streaming pass-through Two bugs caused _PROXY_track_cost_callback to see stream=True + complete_streaming_response=None on every streaming pass-through request, making the dedup guard in dispatch_success_handlers permanently inactive: 1. pass_through_endpoints.py created the Logging object with stream=False for all requests. _is_assembled_stream_success short-circuits on self.stream is not True, so has_dispatched_final_stream_success was never set and any second dispatch went through unchecked. Fix: set logging_obj.stream = True after stream detection. 2. _create_anthropic_response_logging_payload set complete_streaming_response inside the try block after litellm.completion_cost(), so a pricing error caused an early return without setting it on model_call_details. Fix: set complete_streaming_response before the try block. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix stream * add stream to logging obj * test(pass_through): give mock logging object a real model_call_details dict The anthropic passthrough logging payload now records the assembled response on model_call_details before cost calculation, which requires model_call_details to support item assignment. In production it is always a dict; the existing unit test stubbed the logging object with a bare Mock whose attribute is not subscriptable, so the new assignment raised TypeError. Use a real dict to match the production logging object. * test(pass_through): cover streaming logging-obj stream flag The streaming branch of pass_through_request that marks the logging object as streaming (logging_obj.stream and model_call_details["stream"]) had no unit coverage, so the patch coverage gate flagged it. Add a regression test that drives a streaming pass-through request through pass_through_request and asserts the logging object is flagged as a stream before dispatch. * test(pass_through): cover SSE-response stream flag fallback branch The auto-detected streaming branch of pass_through_request (when a request that was not flagged as streaming returns a text/event-stream response) sets logging_obj.stream and model_call_details["stream"] but had no unit coverage, so the codecov patch gate failed at 60%. Drive a non-streaming pass-through request whose upstream response is SSE through pass_through_request and assert the logging object is flagged as a stream before dispatch. * fix(pass_through): gate complete_streaming_response on stream flag perform_redaction only scrubs complete_streaming_response when model_call_details["stream"] is True. Setting it unconditionally for non-streaming Anthropic pass-through responses left the assembled response unredacted in model_call_details, which is handed to logging callbacks as kwargs when message logging is disabled. Only record it for actual streaming responses so redaction always applies. --------- Co-authored-by: mubashir1osmani <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> (cherry picked from commit 2bbdbfa)
…30480) * fix(integrations): cap Anthropic cache_control injection at 4 blocks Respect Anthropic's 4 cache_control breakpoint limit by counting client-supplied blocks, skipping messages that already carry cache_control, and stopping further auto-injection once the limit is reached. Co-authored-by: Cursor <[email protected]> * fix(integrations): reserve cache slot for tool_config and short-circuit cap Address review feedback on the cache_control cap: break out of the injection loop before resolving target indices once the limit is reached, and reserve one of the four breakpoint slots when a tool_config injection point is present so the cachePoint appended by the Bedrock transform does not push the total past Anthropic's limit. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]> (cherry picked from commit fc9d789)
…30543) * fix(guardrails): run pre_call hook once for model-level guardrails A CustomGuardrail attached to a deployment via litellm_params.guardrails gets its async_pre_call_hook invoked twice per request: once by the proxy pre-call loop and again by async_pre_call_deployment_hook after the router spreads the model-level guardrails into the top-level request kwargs. Record in request metadata that the proxy pre-call loop already ran a given guardrail, and have the deployment hook skip it when the marker is present. Direct-SDK usage never runs the proxy loop, so the deployment hook stays the sole invocation there and still fires exactly once. The marker key is stripped from untrusted caller metadata so a request body cannot suppress a model-only guardrail by pre-seeding it. * fix(guardrails): mark pre_call dedup on the post-hook request data Record the exactly-once marker after async_pre_call_hook runs, on the data object that flows downstream, rather than before it. A guardrail whose hook returns a brand-new request dict (instead of mutating or spreading the one it received) would otherwise discard the marker, letting the deployment hook re-run the guardrail a second time. (cherry picked from commit 4faeabc)
…0542) * fix(guardrails): stop re-initializing DB guardrails on every poll InMemoryGuardrailHandler._has_guardrail_params_changed compared the in-memory LitellmParams against the raw dict loaded from the DB. The in-memory side carries every field default and coerces enums via model_dump(), while the DB side only holds the keys originally stored, so the two shapes never compared equal and the guardrail was rebuilt on every poll cycle. Each rebuild created a fresh instance, but delete_in_memory_guardrail only removed the old callback from litellm.callbacks. Request handling promotes guardrail callbacks into the success/failure/async lists, so the previous instance stayed referenced there and instances accumulated. Normalize both sides through LitellmParams(...).model_dump() before diffing, and purge the callback from every callback list on delete. * refactor(guardrails): narrow params-normalization fallback to ValidationError The comparison normalizer caught a bare Exception and silently fell back to the raw dict, which hid the cause and quietly degraded the affected guardrail back to re-initializing on every poll. Catch only the ValidationError that LitellmParams construction can raise, log a warning so the offending row is diagnosable, and let any other error surface instead of being swallowed. * refactor(callbacks): add remove_callback_from_all_lists helper to manager Move the knowledge of which callback lists a callback can be promoted into out of the guardrail registry and into LoggingCallbackManager, where the rest of the callback-list bookkeeping already lives. delete_in_memory_guardrail now delegates to the new helper instead of iterating the lists itself. (cherry picked from commit 9fa74ad)
* fix(guardrails): return 400 not 500 when AIM blocks a request AIM guardrail blocks raised a bare HTTPException whose type and param serialized as the literal string "None", which broke OpenAI-SDK error parsing for downstream consumers. Switching AIM to raise a ProxyException surfaced a second bug: the shared error funnel re-derived the HTTP status from a nonexistent status_code attribute and downgraded the 400 to a 500. The funnel now honors an already-normalized ProxyException rather than rebuilding it, and ProxyException is excluded from llm_exceptions alerting so a content-policy block no longer pages on-call as an LLM API failure Resolves LIT-3751 * fix(guardrails): route all AIM rejection paths through ProxyException The block-action fix left two AIM rejection paths raising a bare HTTPException: the multimodal anonymize rejection and the output-side block. Both serialized type and param as the literal string "None", the same malformed shape the block fix removed. Funnel all three through a shared _rejection helper so they return a conformant OpenAI error body. The output block carries content_policy_violation; the multimodal rejection stays a plain invalid_request_error because it is a usage error, not a policy violation Resolves LIT-3751 * fix(guardrails): record AIM ProxyException blocks in failure logs Switching AIM blocks from HTTPException to ProxyException made _is_proxy_only_llm_api_error return False for them, so _handle_logging_proxy_only_error was skipped and the blocked prompt was dropped from the configured failure loggers. Classify ProxyException as a proxy-only error alongside HTTPException so guardrail blocks are recorded again, matching the prior behavior. The llm_exceptions alert suppression is a separate check and stays in place Resolves LIT-3751 * style(guardrails): use str | None over Optional[str] in AIM _rejection * style(guardrails): collapse AIM _rejection signature per black (cherry picked from commit b5fcd85)
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis backport cherry-picks eight discrete bug fixes from
Confidence Score: 4/5Safe to merge; all eight fixes are narrowly scoped to their own call paths with no schema or auth changes, and each carries dedicated test coverage that verifies both the fixed and previously broken behavior. The duplicate-logging refactor touches multiple interacting async paths (streaming handler, pass-through endpoints, deferred stream closure, dispatch_success_handlers) and introduces a new has_dispatched_final_stream_success dedup guard whose correctness depends on _is_assembled_stream_success returning True at the right moment. The logic is well-reasoned and backed by tests, but this class of change is subtle enough that an edge case could silently drop a success log. The guardrail and provider fixes are more self-contained and lower risk. litellm/litellm_core_utils/litellm_logging.py and litellm/proxy/common_request_processing.py for the streaming-deduplication paths; litellm/proxy/guardrails/guardrail_registry.py for the normalization and callback-list cleanup logic.
|
| Filename | Overview |
|---|---|
| litellm/integrations/anthropic_cache_control_hook.py | Refactors cache_control injection to cap at 4 blocks (Anthropic's limit); fixes a pre-existing bug where role-based injection silently failed to update the message list; correctly preserves client-supplied TTLs and accounts for tool_config reservations. |
| litellm/integrations/custom_guardrail.py | Adds per-process token-tagged pre_call marker to prevent double execution of model-level guardrails; uses a secrets.token_hex secret so callers cannot forge the marker to suppress guardrail checks. |
| litellm/integrations/datadog/datadog.py | Replaces simple 413 re-queue with binary-splitting _send_with_413_split to avoid permanent queue wedging; adds _resolve_dd_batch_size for env-var tuning; lone oversized events are dropped with an error log rather than looping forever. |
| litellm/litellm_core_utils/litellm_logging.py | Adds dispatch_success_handlers and _is_assembled_stream_success to deduplicate final-stream logging; refactors _is_sync_litellm_request into a reusable static method; guards async_success_handler so assembled-stream callbacks always fire exactly once. |
| litellm/proxy/guardrails/guardrail_registry.py | Fixes guardrail re-initialization storm by normalizing LitellmParams vs raw-dict comparison; removes callback from all callback lists via remove_callback_from_all_lists to prevent stale instance accumulation. |
| litellm/proxy/guardrails/guardrail_hooks/aim/aim.py | Replaces fastapi.HTTPException raises with ProxyException(code=400) so AIM blocks return structured 400 responses instead of 500s; removes fastapi import from the proxy sub-package guardrail. |
| litellm/proxy/_types.py | Adds super().init(self.message) to ProxyException so str(exc) returns the message string; HTTP wire body (to_dict()) is unchanged. |
| litellm/proxy/utils.py | Treats ProxyException alongside HTTPException for alerting and user-error classification; marks guardrail's pre_call hook as ran after successful proxy pre_call_hook execution. |
| litellm/proxy/common_request_processing.py | Replaces async+sync dual-logging calls with dispatch_success_handlers(prefer_async_handlers=True) across the deferred stream and orphaned stream paths; removes duplicate executor.submit calls. |
| litellm/types/utils.py | Adds use_chat_completions_api to all_litellm_params so it is stripped before the provider request body is built, preventing the flag from leaking to the provider. |
Reviews (1): Last reviewed commit: "chore: refresh uv.lock for 1.87.4" | Re-trigger Greptile
Relevant issues
Backports eight already-merged fixes onto
stable/1.87.xand cuts1.87.4. The set is guardrail reliability (#30573 AIM blocks return 400 instead of 500, #30543 model-level guardrail pre_call hook runs once, #30542 DB guardrails stop re-initializing on every poll), integration correctness (#30480 caps Anthropic cache_control injection at four blocks, #29444 splits oversized Datadog batches on 413 instead of re-queueing forever), and logging/passthrough correctness (#29598 removes duplicate passthrough cost logs, #29311 removes duplicate claude-code traces, #29447 stops the use_chat_completions_api flag leaking into the provider request body). Every pick is absent on the line today, every target file is present, and each pick's own tests pass on 1.87.4Linear ticket
LIT-3751 (the #30573 thread); the rest are reliability fixes without separate tickets
What is included
Picks in merge order (oldest first, which satisfies the one dependency: #29598 builds on #29311's stream dedup guard):
Then the release commits:
bump: version 1.87.3 -> 1.87.4andchore: refresh uv.lock for 1.87.4Provenance: #30573, #30543, #30542 and #30480 cherry-pick directly from their squash commits on the internal staging branch. #29311, #29444, #29447 and #29598 reached internal staging only inside a single large aggregator squash, so they are cherry-picked from their discrete per-PR commits (the same commits that shipped to v1.88.0 / v1.89.0) and content-verified against the aggregator's hunks on staging; every
-xfooter points at the real per-PR source commitAdaptation notes
Two picks needed minor adaptation because 1.87.x predates some surrounding code. In both the actual fix is byte-identical; only context shifted.
#30543: on staging the exactly-once marker call sits just before an
except SensitiveDataRouteExceptionblock that 1.87.x does not have, so on this line the samecallback.mark_pre_call_hook_ran(data)call sits before the genericexcept Exceptioninstead. The new constants and methods are unchanged#30573: the new
ProxyExceptionearly-raise in the error funnel is byte-identical, placed where 1.87.x's funnel sits (1.87.x has no_apply_router_cooldown_retry_aftercall there, so that context line is correctly not introduced). The new regression tests are unchanged and attach to 1.87.x's existing test helpers. This pick also carries one extra line,super().__init__(self.message)inProxyException.__init__: 1.87.x'sProxyExceptionnever forwarded its message toException.args, sostr(exc)was empty and #30573's own block-message assertions could not pass. That single line is what 1.88+ already does; the HTTP wire body usesto_dict()and is unchanged, so this only makesstr(ProxyException)return its message. It exists on staging only inside the same aggregator squash and was content-verified as the exact one-line deltaKnown noise on this line
Judged against a baseline captured on the 1.87.x tip before any pick:
tests/test_litellm/proxy/test_proxy_utils.py::test_get_custom_urlfails on the bare line and after the picks alike; it is an environment assertion (0.0.0.0vslocalhost) unrelated to anything heretest_proxy_utils.pyalready carries an unusedjsonimport, and several touched files already differ from the current ruff formatter, on the bare 1.87.x tip. The backport does not touch either, to keep the diff minimalTargeted test delta: baseline 471 passed / 1 failed (the env case above); after the picks 545 passed / 1 failed (same case). Zero new failures, plus 74 new passing tests that are the picks' own coverage
Screenshots / Proof of Fix
Live proxy on the line with all eight picks applied, against the real Anthropic API.
#30480, cap Anthropic cache_control at 4 blocks. First, the raw provider limit (the "no cap" case), five explicit cache_control breakpoints straight to Anthropic:
Then a model configured with
cache_control_injection_pointsfor every user message, driven with six user turns (which would inject six breakpoints without the cap):The hook capped injection at four, so Anthropic accepted the request it would otherwise reject.
#29447, the use_chat_completions_api flag must not leak into the provider body:
The flag is recognized as a litellm param and stripped, so no provider error.
Sanity on the line with the picks applied: health green, a real claude-haiku completion returns, key generation and a scoped key call both succeed.
Behavioral verification: a universal-direction gauntlet over the worktree found the backport faithfully reproduces each pick's behavior on this line, with symbol resolution clean on the touched modules and no existing caller of a modified function regressing
Type
🐛 Bug Fix
Changes
Eight cherry-picks plus the version bump to 1.87.4 and a uv.lock refresh. No schema, dependency, or auth changes. No production UI source changed, so no dashboard rebuild