Skip to content

chore(release): backport #29311, #29444, #29447, #29598, #30480, #30543, #30542, #30573 to stable/1.87.x and cut 1.87.4#30890

Merged
yuneng-berri merged 10 commits into
stable/1.87.xfrom
litellm_backport_1_87_x_0620
Jun 20, 2026
Merged

chore(release): backport #29311, #29444, #29447, #29598, #30480, #30543, #30542, #30573 to stable/1.87.x and cut 1.87.4#30890
yuneng-berri merged 10 commits into
stable/1.87.xfrom
litellm_backport_1_87_x_0620

Conversation

@yuneng-berri

Copy link
Copy Markdown
Collaborator

Relevant issues

Backports eight already-merged fixes onto stable/1.87.x and cuts 1.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.4

Linear 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.4 and chore: refresh uv.lock for 1.87.4

Provenance: #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 -x footer points at the real per-PR source commit

Adaptation 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 SensitiveDataRouteException block that 1.87.x does not have, so on this line the same callback.mark_pre_call_hook_ran(data) call sits before the generic except Exception instead. The new constants and methods are unchanged

#30573: the new ProxyException early-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_after call 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) in ProxyException.__init__: 1.87.x's ProxyException never forwarded its message to Exception.args, so str(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 uses to_dict() and is unchanged, so this only makes str(ProxyException) return its message. It exists on staging only inside the same aggregator squash and was content-verified as the exact one-line delta

Known 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_url fails on the bare line and after the picks alike; it is an environment assertion (0.0.0.0 vs localhost) unrelated to anything here
  • test_proxy_utils.py already carries an unused json import, 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 minimal

Targeted 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:

$ # 5 cache_control blocks -> Anthropic
HTTP 400 -> A maximum of 4 blocks with cache_control may be provided. Found 5.

Then a model configured with cache_control_injection_points for every user message, driven with six user turns (which would inject six breakpoints without the cap):

$ curl .../v1/chat/completions -d '{"model":"claude-cachecap", "messages":[<6 user turns>], ...}'
HTTP OK -> content: DONE.

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:

$ curl .../v1/chat/completions -d '{"model":"claude-haiku","use_chat_completions_api":true, ...}'
HTTP OK -> content: OK

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

mateo-berri and others added 10 commits June 20, 2026 11:53
…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)
@yuneng-berri yuneng-berri requested a review from a team June 20, 2026 19:41
@CLAassistant

CLAassistant commented Jun 20, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
4 out of 5 committers have signed the CLA.

✅ mateo-berri
✅ ryan-crabbe-berri
✅ yuneng-berri
✅ shivamrawat1
❌ yassin-berriai
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This backport cherry-picks eight discrete bug fixes from main onto stable/1.87.x and cuts version 1.87.4. Every pick targets a narrowly scoped, production-observable defect — no schema, auth, or API-contract changes are included.

Confidence Score: 4/5

Safe 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.

Important Files Changed

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

@yuneng-berri yuneng-berri merged commit faa2f13 into stable/1.87.x Jun 20, 2026
60 of 75 checks passed
@yuneng-berri yuneng-berri deleted the litellm_backport_1_87_x_0620 branch June 20, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants