fix(guardrails): keep the response scan when the request copy fails for explicit both scope
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-10-05 10:23:00 +00:00
parent 733c2fcfdc
commit 9d1bba72b9
2 changed files with 29 additions and 10 deletions

View file

@ -1008,16 +1008,23 @@ class CustomGuardrail(CustomLogger):
def _copy_scratch_request_fields(
self,
kwargs: Mapping[str, object],
) -> tuple[object, object]:
) -> tuple[object, object] | None:
optional_params: Final = kwargs.get("optional_params")
try:
return (
copy.deepcopy(kwargs.get("messages") or kwargs.get("input")),
copy.deepcopy(optional_params.get("tools") if isinstance(optional_params, Mapping) else None),
)
except Exception:
except Exception as e:
if self.logging_only_scope == "output":
return None, None
return None
if self.logging_only_scope == "both":
verbose_logger.warning(
"Guardrail %s: logging_only request copy failed, skipping request scan: %s",
self.guardrail_name,
e,
)
return None
raise
async def _scan_logged_call(
@ -1028,7 +1035,8 @@ class CustomGuardrail(CustomLogger):
output_translation: "BaseTranslation",
scratch_metadata: dict, # mutable-ok: apply_guardrail records its verdict into request metadata
) -> None:
scratch_input, scratch_tools = self._copy_scratch_request_fields(kwargs)
scratch_fields: Final = self._copy_scratch_request_fields(kwargs)
scratch_input, scratch_tools = scratch_fields or (None, None)
scratch_request: Final = {
"model": kwargs.get("model"),
"messages": scratch_input,
@ -1037,13 +1045,8 @@ class CustomGuardrail(CustomLogger):
"litellm_call_id": kwargs.get("litellm_call_id"),
"metadata": scratch_metadata,
}
if self.logging_only_scope != "output":
if self.logging_only_scope != "output" and scratch_fields is not None:
if self.logging_only_scope == "both":
# An explicitly configured "both" observer asked for a verdict on
# each direction, so a failed request scan must not silently drop
# the response verdict. The implicit default (logging_only_scope
# None) keeps the abort semantics of a logging_only hook whose
# scan raised.
try:
await translation.process_input_messages(data=scratch_request, guardrail_to_apply=self)
except Exception as e: # noqa: BLE001 # one direction's scan failure must not drop the other direction's verdict

View file

@ -2647,6 +2647,22 @@ class TestLoggingOnlyApplyGuardrail:
assert [entry["guardrail_name"] for entry in entries] == ["apply-only-observer"]
assert [entry["guardrail_status"] for entry in entries] == ["success"]
@pytest.mark.asyncio
async def test_request_copy_failure_does_not_drop_the_response_scan_for_explicit_both_scope(self):
import threading
guardrail: Final = _ApplyOnlyObserver()
guardrail.logging_only_scope = "both"
call: Final = _logged_call([{"role": "user", "content": "hello there", "lock": threading.Lock()}])
kwargs: Final = call[0]
response: Final = call[1]
out_kwargs, _ = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
assert guardrail.calls == [("response", ["general kenobi"])]
entries: Final = out_kwargs["standard_logging_object"]["guardrail_information"]
assert [entry["guardrail_status"] for entry in entries] == ["success"]
@pytest.mark.asyncio
async def test_block_verdict_is_recorded_without_raising(self):
guardrail = _ApplyOnlyObserver(block=True)