From 4625ccbaa2531778599524cbd4da49d6a37fe0ec Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Wed, 18 Mar 2026 01:08:01 +0100 Subject: [PATCH] fix(proxy): anchor metadata dict in _process_response/_process_error so pop() mutates the real dict --- .../guardrail_hooks/openai/moderations.py | 16 ++-- .../openai/test_moderations.py | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py index 6da8f9acbf4..4ddeac9a208 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py +++ b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py @@ -255,9 +255,11 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): Follows the Model Armor pattern (model_armor.py:325-360). """ - metadata = ( - (request_data.get("metadata") or {}) if isinstance(request_data, dict) else {} - ) + if isinstance(request_data, dict): + metadata = request_data.get("metadata") or {} + request_data["metadata"] = metadata # anchor so pop() mutates the real dict + else: + metadata = {} # .pop() cleans up the internal key so it doesn't leak to downstream # loggers. Falls back to "allow" when no moderation call was made @@ -294,9 +296,11 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): else "guardrail_failed_to_respond" ) - metadata = ( - (request_data.get("metadata") or {}) if isinstance(request_data, dict) else {} - ) + if isinstance(request_data, dict): + metadata = request_data.get("metadata") or {} + request_data["metadata"] = metadata # anchor so pop() mutates the real dict + else: + metadata = {} # Use the stashed moderation response if available, fall back to exception guardrail_response: Union[dict, Exception, str] = metadata.pop( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py index bb6d7865d48..5c19e7189e0 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py @@ -730,3 +730,88 @@ async def test_openai_moderation_post_call_request_data_passthrough(): assert guardrail_info_list is not None assert isinstance(guardrail_info_list[0]["guardrail_response"], dict) assert "results" in guardrail_info_list[0]["guardrail_response"] + + +def test_openai_moderation_process_response_metadata_none_edge_case(): + """ + Test that _process_response anchors the metadata dict back into + request_data when metadata is None, so pop() doesn't operate on a + temporary and the moderation response is correctly logged. + """ + with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}): + guardrail = OpenAIModerationGuardrail( + guardrail_name="test-openai-moderation", + event_hook="post_call", + ) + + mod_dict = {"id": "modr-test", "model": "omni-moderation-latest", "results": []} + + # Simulate apply_guardrail having stashed the response but metadata + # was None initially — apply_guardrail anchors it, so metadata is a + # real dict with the stashed key by the time _process_response runs. + request_data = {"metadata": {"_openai_moderation_response": mod_dict}} + + guardrail._process_response( + response={"inputs": {}}, + request_data=request_data, + ) + + # Full moderation dict should be logged, not "allow" + info_list = request_data["metadata"].get( + "standard_logging_guardrail_information" + ) + assert info_list is not None + assert info_list[0]["guardrail_response"] == mod_dict + + # Internal key should have been cleaned up by pop() + assert "_openai_moderation_response" not in request_data["metadata"] + + +def test_openai_moderation_process_error_metadata_none_edge_case(): + """ + Test that _process_error anchors the metadata dict back into + request_data when metadata starts as None (or {}), so pop() doesn't + operate on a temporary. + """ + from fastapi import HTTPException + + with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}): + guardrail = OpenAIModerationGuardrail( + guardrail_name="test-openai-moderation", + event_hook="post_call", + ) + + mod_dict = { + "id": "modr-test", + "model": "omni-moderation-latest", + "results": [{"flagged": True, "categories": {"hate": True}}], + } + + # metadata is None — exercises the or {} anchor + request_data: dict = {"metadata": None} + + # Simulate stashing the response then calling _process_error + # (normally apply_guardrail stashes, then the decorator calls + # _process_error on HTTPException) + # First anchor metadata like apply_guardrail does: + metadata = request_data.get("metadata") or {} + request_data["metadata"] = metadata + metadata["_openai_moderation_response"] = mod_dict + + exc = HTTPException(status_code=400, detail="Violated policy") + with pytest.raises(HTTPException): + guardrail._process_error( + e=exc, + request_data=request_data, + ) + + # Full moderation dict should be logged, not the exception + info_list = request_data["metadata"].get( + "standard_logging_guardrail_information" + ) + assert info_list is not None + assert info_list[0]["guardrail_response"] == mod_dict + assert info_list[0]["guardrail_status"] == "guardrail_intervened" + + # Internal key cleaned up + assert "_openai_moderation_response" not in request_data["metadata"]