From 4505b53c6f2197d3ce5aa86be13edf33c4f39b10 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:11:37 +0000 Subject: [PATCH] fix(guardrails): snapshot model_call_details before serializing noma v2 scan payload --- .../guardrail_hooks/noma/noma_v2.py | 17 ++++++-- .../guardrail_hooks/test_noma_v2.py | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py b/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py index 9cf0986c122..4e2839b9d40 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py @@ -131,9 +131,18 @@ class NomaV2Guardrail(CustomGuardrail): logging_obj: Optional["LiteLLMLoggingObj"], application_id: Optional[str], ) -> dict: - payload_request_data = self._sanitize_payload_for_transport(request_data) - if logging_obj is not None: - payload_request_data["litellm_logging_obj"] = getattr(logging_obj, "model_call_details", None) + model_call_details = getattr(logging_obj, "model_call_details", None) if logging_obj is not None else None + request_data_to_send = ( + { + **request_data, + "litellm_logging_obj": dict(model_call_details) + if isinstance(model_call_details, dict) + else model_call_details, + } + if logging_obj is not None + else request_data + ) + payload_request_data = self._sanitize_payload_for_transport(request_data_to_send) payload: dict[str, Any] = { "inputs": inputs, @@ -157,7 +166,7 @@ class NomaV2Guardrail(CustomGuardrail): try: json_str = json.dumps(payload, default=_default) - except (ValueError, TypeError): + except (ValueError, TypeError, RuntimeError): json_str = safe_dumps(payload) safe_payload = safe_json_loads(json_str, default={}) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py index b6445a7c90d..558cd4c1f82 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py @@ -236,6 +236,46 @@ class TestNomaV2Configuration: assert "logging_obj" not in payload assert request_data["litellm_logging_obj"] == "" + def test_build_scan_payload_snapshots_live_model_call_details( + self, noma_v2_guardrail + ): + """Regression test: model_call_details is mutated by logging handlers on other + threads, so serializing it by reference raises RuntimeError('dictionary changed + size during iteration').""" + + class _MutatingOnSerialize: + def __init__(self, target: dict) -> None: + self._target = target + + def model_dump(self) -> dict: + self._target[f"late_key_{len(self._target)}"] = "written_by_logging" + return {"serialized": True} + + model_call_details: dict = { + "model": "gpt-4.1-mini", + "call_type": "acompletion", + } + model_call_details["standard_callback_dynamic_params"] = _MutatingOnSerialize( + model_call_details + ) + + class _LoggingObj: + def __init__(self) -> None: + self.model_call_details = model_call_details + + payload = noma_v2_guardrail._build_scan_payload( + inputs={"texts": ["hello"]}, + request_data={"messages": [{"role": "user", "content": "hello"}]}, + input_type="request", + logging_obj=_LoggingObj(), + application_id="test-app", + ) + + sent = payload["request_data"]["litellm_logging_obj"] + assert sent["model"] == "gpt-4.1-mini" + assert sent["standard_callback_dynamic_params"] == {"serialized": True} + assert not any(key.startswith("late_key_") for key in sent) + @pytest.mark.asyncio async def test_call_noma_scan_sanitizes_response_model_dump_object( self, noma_v2_guardrail