chore(release): backport #30543, #30542 to stable/1.88.x and cut 1.88.3 - #30680
Conversation
…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)
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis backport cherry-picks two guardrail reliability fixes onto
Confidence Score: 5/5Straightforward backport with surgical, well-tested changes to the guardrail subsystem; no regressions to existing behavior expected. Both fixes are well-isolated: the double-execution fix is gated by isinstance(callback, CustomGuardrail) in the pipeline path, _process_guardrail_callback in utils.py only receives CustomGuardrail objects, and the forge-protection via the per-process token is verified by a dedicated test. The poll-fix normalization handles the ValidationError fallback gracefully. Tests are purely mocked, additive, and cover the main edge cases. No auth, schema, or dependency changes. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/integrations/custom_guardrail.py | Adds per-process token, mark_pre_call_hook_ran, and _pre_call_hook_already_ran to CustomGuardrail; early-returns in async_pre_call_deployment_hook when the proxy loop already ran the hook. Logic is sound, token forge protection is present, both metadata buckets are read correctly. |
| litellm/proxy/utils.py | Adds callback.mark_pre_call_hook_ran(data) to _process_guardrail_callback after the response is merged into data, ensuring the marker travels on the final dict that flows downstream to the deployment hook. |
| litellm/proxy/policy_engine/pipeline_executor.py | Marks both data and response after the hook in the pipeline path; when the hook returns a fresh dict that becomes modified_data, the marker on response ensures it carries through to the caller. |
| litellm/proxy/guardrails/guardrail_registry.py | Adds _normalize_litellm_params_for_comparison to canonicalize both sides through LitellmParams.model_dump() before diffing, eliminating the false-change on every poll. delete_in_memory_guardrail now uses remove_callback_from_all_lists to purge stale instances from all five lists. |
| litellm/litellm_core_utils/logging_callback_manager.py | Adds remove_callback_from_all_lists helper that fans out to all five callback lists. The underlying remove_callback_from_list_by_object handles duplicates correctly. |
| litellm/proxy/litellm_pre_call_utils.py | Adds PRE_CALL_EXECUTED_GUARDRAILS_KEY to _UNTRUSTED_METADATA_CONTROL_FIELDS, preventing callers from pre-seeding the marker via request metadata. |
| litellm/proxy/common_utils/callback_utils.py | Adds PRE_CALL_EXECUTED_GUARDRAILS_KEY to LITELLM_PROXY_INTERNAL_METADATA_KEYS so the key is not forwarded to backends. |
| litellm/constants.py | Adds PRE_CALL_EXECUTED_GUARDRAILS_KEY = '_pre_call_executed_guardrails' constant, correctly placed per the sentinel-in-constants rule. |
| tests/test_litellm/proxy/test_model_level_guardrails.py | Adds four integration-level tests covering: exact-once execution via proxy->deployment path, fresh-dict response, direct-SDK path, and forge-protection. All use mocks, no real network calls. |
| tests/test_litellm/integrations/test_custom_guardrail.py | Adds four unit tests for the new marker methods (skip when marked, run when unmarked, litellm_metadata bucket, and forge test). Purely additive; existing tests unchanged. |
| tests/test_litellm/proxy/guardrails/test_guardrail_registry.py | Adds seven new tests covering: unchanged params no longer register as changed, genuine changes still detected, malformed params fail-safe, delete purges all lists, and end-to-end accumulation regression. All mock-only. |
| tests/litellm_utils_tests/test_logging_callback_manager.py | Adds test_remove_callback_from_all_lists verifying all five lists are cleared in one call. Purely additive. |
| pyproject.toml | Version bump 1.88.2 -> 1.88.3 in both [project] and [tool.commitizen] sections. |
Reviews (1): Last reviewed commit: "chore: refresh uv.lock for 1.88.3" | Re-trigger Greptile
Relevant issues
Backports two already-merged guardrail reliability fixes from
litellm_internal_stagingontostable/1.88.xand cuts 1.88.3. Both target the guardrail subsystem and neither has shipped on any release line yet.#30543 fixes a model-level CustomGuardrail (attached via
litellm_params.guardrails) having itsasync_pre_call_hookinvoked twice per request; once by the proxy pre-call loop and again byasync_pre_call_deployment_hookafter the router spreads the model-level guardrails into the top-level kwargs. The proxy loop now records, on the request data that flows downstream, that it already ran a given guardrail, and the deployment hook skips it when that 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 is tagged with a per-process token and the marker key is stripped from untrusted caller metadata, so a request body cannot pre-seed the marker to suppress a model-level guardrail.#30542 fixes
InMemoryGuardrailHandlerre-initializing DB-backed guardrails on every poll cycle. The change compared an in-memoryLitellmParams(whosemodel_dump()carries every field default and coerces enums) against the raw sparse dict loaded from the DB, so the two shapes never compared equal and the guardrail was rebuilt every poll; each rebuild left the prior callback instance stranded in the success/failure/async callback lists. Both sides are now normalized throughLitellmParams(...).model_dump()before diffing, and a deleted guardrail's callback is purged from every callback list rather than onlylitellm.callbacks.Linear ticket
N/A
Pre-Submission checklist
What is included
In cherry-pick (staging merge) order, each carrying its
-xprovenance footer:Then the version bump 1.88.2 -> 1.88.3 and a
uv.lockrefresh. The lock diff is the litellm self-version and the relativeexclude-newer = "3 days"snapshot moving forward; no dependency version changed.#30274 (populate access_via_team_ids on /v1/model/info) was requested but is already present on this line; it was backported earlier as commit 80f0c38 via #30408, so it is not re-picked here.
Adaptation notes
#30542 is a verbatim cherry-pick (patch-id identical to staging). #30543 is adapted in one file; the fix's own added code is preserved byte-for-byte.
litellm/proxy/utils.py: staging addedcallback.mark_pre_call_hook_ran(data)inside the guardrail-execution block ofProxyLogging.pre_call_hook. On stable/1.88.x that block lives in a dedicated method,ProxyLogging._process_guardrail_callback, which the pre-call loop routes every CustomGuardrail through, and which has noexcept SensitiveDataRouteExceptionbranch (that branch is pre-existing staging code unrelated to this fix). The marker line is placed at the structurally equivalent location, after the response-processing block and before the method'sexcept Exceptionhandler; staging's unrelatedexcept SensitiveDataRouteExceptionblock was deliberately not imported. A second, purely cosmetic divergence: inlitellm/integrations/custom_guardrail.pythe new per-process token and helper methods are byte-identical to staging, but git anchored the insertion at a different surrounding context because the nearby imports differ on this line.Known noise on this line
The targeted test set (the four test files these PRs add to or modify) ran 73 passed, 0 failures on the line tip before any pick, so there is no pre-existing red to discount in this set. Separately,
tests/.../test_guardrail_coverage.pyin the broader suite fails locally on a missing optionaldetect_secretsdependency in enterprise code untouched by these picks; that is an environment gap, not introduced here.Screenshots / Proof of Fix
Live proxy on the backport branch (real OpenAI API). The picks edit the pre-call serving path, so the first signal is that normal request flow through
_process_guardrail_callbackis intact.Each pick delivers its claim on this line:
tests/test_litellm/proxy/test_model_level_guardrails.py::test_pre_call_hook_runs_once_with_model_level_guardrailsdrives the realProxyLogging.pre_call_hook(through the adapted_process_guardrail_callback) and thenasync_pre_call_deployment_hook, asserting the guardrail's hook ran exactly once. It would fail if the marker were misplaced.test_deployment_hook_ignores_forged_caller_markerconfirms a forged marker cannot suppress a guardrailTargeted test delta: 73 passed before the picks, 86 passed after, 0 new failures. A deep adversarial gauntlet run (universal direction, 7 lenses including two independent live-proxy reproductions) returned SURVIVED with all three sub-claims (symbol resolution, each pick delivers its claim, no broken existing caller) holding and zero verified regressions.
Type
🐛 Bug Fix
Changes
Two guardrail reliability backports onto stable/1.88.x with the 1.88.3 version cut. No schema, dependency, or auth-default changes.