From 7fbea550b05f770a2a1e0d81c9584bd1d50f80fc Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 00:43:10 +0500 Subject: [PATCH 1/7] fix(proxy/guardrails): prevent presidio logging_only hook from mutating client response (#47605) --- .../guardrails/guardrail_hooks/presidio.py | 13 +++++ .../guardrail_hooks/test_presidio.py | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index cadc9ca689b..a3024c1ea64 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1,3 +1,4 @@ +import copy # +-----------------------------------------------+ # | | # | PII Masking | @@ -1192,6 +1193,18 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): Masks the input and output before logging to langfuse, datadog, etc. """ if call_type == "completion" or call_type == "acompletion": # /chat/completions requests + # Operates on defensive copies so the client-facing request and response are not modified in-place + try: + kwargs = copy.deepcopy(kwargs) + except Exception: + kwargs = dict(kwargs) + if "messages" in kwargs and isinstance(kwargs["messages"], list): + kwargs["messages"] = copy.deepcopy(kwargs["messages"]) + try: + result = copy.deepcopy(result) + except Exception: + pass + messages: Final[list | None] = kwargs.get("messages", None) tasks: Final = [] task_mappings: list[tuple[int, int | None]] = [] # Track (message_index, content_index) for each task diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index bd1e9ae4068..e85f3f72bc7 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -633,6 +633,56 @@ async def test_logging_hook_masks_the_response_too(presidio_guardrail): assert "[CREDIT_CARD]" in result_response.choices[0].message.content assert "4111-1111-1111-1111" not in result_response.choices[0].message.content +@pytest.mark.asyncio +async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_guardrail): + """ + Issue #47605: Presidio guardrail in logging_only mode must not mutate the client-facing + request kwargs or response object in place. Logging redaction must operate on defensive copies + so the live response and request are returned unmasked to the client. + """ + async def mock_check_pii(text, output_parse_pii, presidio_config, request_data): + return text.replace("4111-1111-1111-1111", "[CREDIT_CARD]").replace("user@example.com", "[EMAIL]") + + presidio_guardrail.check_pii = mock_check_pii + + test_kwargs = { + "messages": [{"role": "user", "content": "My email is user@example.com"}], + "model": "gpt-4", + } + response = ModelResponse( + id="1", + object="chat.completion", + created=0, + model="gpt-test", + choices=[ + Choices( + message=Message( + role="assistant", + content="Sure, your card is 4111-1111-1111-1111", + ), + index=0, + finish_reason="stop", + ) + ], + ) + + result_kwargs, result_response = await presidio_guardrail.async_logging_hook( + kwargs=test_kwargs, + result=response, + call_type="completion", + ) + + # 1. The returned logging objects MUST be redacted + assert "[EMAIL]" in result_kwargs["messages"][0]["content"] + assert "[CREDIT_CARD]" in result_response.choices[0].message.content + + # 2. The original client-facing request kwargs and response MUST NOT be mutated in-place + assert "user@example.com" in test_kwargs["messages"][0]["content"] + assert "[EMAIL]" not in test_kwargs["messages"][0]["content"] + assert "4111-1111-1111-1111" in response.choices[0].message.content + assert "[CREDIT_CARD]" not in response.choices[0].message.content + + @pytest.mark.asyncio async def test_logging_only_does_not_mask_pre_call_request(mock_user_api_key, mock_cache): From f35275946c99aa3e8d8881c98a242d5c4fb016f8 Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 01:13:16 +0500 Subject: [PATCH 2/7] fix(proxy/guardrails): adopt safe_deep_copy for presidio logging_only hook --- .../guardrails/guardrail_hooks/presidio.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index a3024c1ea64..47ff438f14b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1,4 +1,3 @@ -import copy # +-----------------------------------------------+ # | | # | PII Masking | @@ -32,7 +31,11 @@ from litellm.constants import ( PRESIDIO_ANALYZE_CHUNK_CONCURRENCY, PRESIDIO_ANALYZE_CHUNK_OVERLAP_CHARS, ) -from litellm.litellm_core_utils.core_helpers import attribute_items, attribute_or_none +from litellm.litellm_core_utils.core_helpers import ( + attribute_items, + attribute_or_none, + safe_deep_copy, +) from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: @@ -1194,16 +1197,8 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): """ if call_type == "completion" or call_type == "acompletion": # /chat/completions requests # Operates on defensive copies so the client-facing request and response are not modified in-place - try: - kwargs = copy.deepcopy(kwargs) - except Exception: - kwargs = dict(kwargs) - if "messages" in kwargs and isinstance(kwargs["messages"], list): - kwargs["messages"] = copy.deepcopy(kwargs["messages"]) - try: - result = copy.deepcopy(result) - except Exception: - pass + kwargs = safe_deep_copy(kwargs) + result = safe_deep_copy(result) messages: Final[list | None] = kwargs.get("messages", None) tasks: Final = [] From 681db1f7105256fd4817fe80795b2ec0707cb61d Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 01:34:07 +0500 Subject: [PATCH 3/7] fix(proxy/guardrails): eliminate parameter rebinding in async_logging_hook for type discipline --- .../guardrails/guardrail_hooks/presidio.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 47ff438f14b..d8bfeed26c9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1197,17 +1197,17 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): """ if call_type == "completion" or call_type == "acompletion": # /chat/completions requests # Operates on defensive copies so the client-facing request and response are not modified in-place - kwargs = safe_deep_copy(kwargs) - result = safe_deep_copy(result) + kwargs_copy: Final = safe_deep_copy(kwargs) + result_copy: Final = safe_deep_copy(result) - messages: Final[list | None] = kwargs.get("messages", None) + messages: Final[list | None] = kwargs_copy.get("messages", None) tasks: Final = [] task_mappings: list[tuple[int, int | None]] = [] # Track (message_index, content_index) for each task if messages is None: - return kwargs, result + return kwargs_copy, result_copy - presidio_config: Final = self.get_presidio_settings_from_request_data(kwargs) + presidio_config: Final = self.get_presidio_settings_from_request_data(kwargs_copy) for msg_idx, m in enumerate(messages): content = m.get("content", None) @@ -1219,7 +1219,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): text=content, output_parse_pii=False, presidio_config=presidio_config, - request_data=kwargs, + request_data=kwargs_copy, ) ) # need to pass separately b/c presidio has context window limits task_mappings.append((msg_idx, None)) # None indicates string content @@ -1233,7 +1233,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): text=text_str, output_parse_pii=False, presidio_config=presidio_config, - request_data=kwargs, + request_data=kwargs_copy, ) ) task_mappings.append((msg_idx, int(content_idx))) @@ -1254,21 +1254,23 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): messages[msg_idx]["content"][content_idx_optional]["text"] = r verbose_proxy_logger.debug("Presidio PII Masking: Redacted pii message: %s", messages) - kwargs["messages"] = messages + kwargs_copy["messages"] = messages if ( - isinstance(result, ModelResponse) - and result.choices - and not isinstance(result.choices[0], StreamingChoices) + isinstance(result_copy, ModelResponse) + and result_copy.choices + and not isinstance(result_copy.choices[0], StreamingChoices) ): - await self._process_response_for_pii(response=result, request_data=kwargs, mode="mask") - elif isinstance(result, dict) and self._is_anthropic_message_response(result): + await self._process_response_for_pii(response=result_copy, request_data=kwargs_copy, mode="mask") + elif isinstance(result_copy, dict) and self._is_anthropic_message_response(result_copy): await self._process_anthropic_response_for_pii( - response=result, - request_data=kwargs, + response=result_copy, + request_data=kwargs_copy, mode="mask", ) + return kwargs_copy, result_copy + return kwargs, result async def async_post_call_success_hook( From d5d5e51323259afdf68d4485c3d93467467e844d Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 01:54:09 +0500 Subject: [PATCH 4/7] fix(proxy/guardrails): type annotate copies and synchronize logging input representation --- litellm/proxy/guardrails/guardrail_hooks/presidio.py | 9 +++++++-- .../proxy/guardrails/guardrail_hooks/test_presidio.py | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index d8bfeed26c9..3e3c89463e4 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1197,8 +1197,8 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): """ if call_type == "completion" or call_type == "acompletion": # /chat/completions requests # Operates on defensive copies so the client-facing request and response are not modified in-place - kwargs_copy: Final = safe_deep_copy(kwargs) - result_copy: Final = safe_deep_copy(result) + kwargs_copy: Final[dict] = safe_deep_copy(kwargs) # mutable-ok: defensive clone of request kwargs + result_copy: Final[object] = safe_deep_copy(result) messages: Final[list | None] = kwargs_copy.get("messages", None) tasks: Final = [] @@ -1255,6 +1255,11 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): verbose_proxy_logger.debug("Presidio PII Masking: Redacted pii message: %s", messages) kwargs_copy["messages"] = messages + if "input" in kwargs_copy and ( + kwargs.get("input") is kwargs.get("messages") + or kwargs_copy.get("input") == kwargs.get("messages") + ): + kwargs_copy["input"] = messages if ( isinstance(result_copy, ModelResponse) diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index e85f3f72bc7..f0f6ed3e640 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -645,8 +645,10 @@ async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_ presidio_guardrail.check_pii = mock_check_pii + shared_messages = [{"role": "user", "content": "My email is user@example.com"}] test_kwargs = { - "messages": [{"role": "user", "content": "My email is user@example.com"}], + "messages": shared_messages, + "input": shared_messages, "model": "gpt-4", } response = ModelResponse( @@ -672,13 +674,16 @@ async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_ call_type="completion", ) - # 1. The returned logging objects MUST be redacted + # 1. The returned logging objects MUST be redacted (both messages and input representation) assert "[EMAIL]" in result_kwargs["messages"][0]["content"] + assert "[EMAIL]" in result_kwargs["input"][0]["content"] assert "[CREDIT_CARD]" in result_response.choices[0].message.content # 2. The original client-facing request kwargs and response MUST NOT be mutated in-place assert "user@example.com" in test_kwargs["messages"][0]["content"] assert "[EMAIL]" not in test_kwargs["messages"][0]["content"] + assert "user@example.com" in test_kwargs["input"][0]["content"] + assert "[EMAIL]" not in test_kwargs["input"][0]["content"] assert "4111-1111-1111-1111" in response.choices[0].message.content assert "[CREDIT_CARD]" not in response.choices[0].message.content From e5d090c4674646e13248e3b92240363c11fe8682 Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 02:05:10 +0500 Subject: [PATCH 5/7] style(proxy/guardrails): format ruff line and add generic dict types --- litellm/proxy/guardrails/guardrail_hooks/presidio.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 3e3c89463e4..f73e909aa7c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1197,7 +1197,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): """ if call_type == "completion" or call_type == "acompletion": # /chat/completions requests # Operates on defensive copies so the client-facing request and response are not modified in-place - kwargs_copy: Final[dict] = safe_deep_copy(kwargs) # mutable-ok: defensive clone of request kwargs + kwargs_copy: Final[dict[str, object]] = safe_deep_copy(kwargs) # mutable-ok: defensive clone result_copy: Final[object] = safe_deep_copy(result) messages: Final[list | None] = kwargs_copy.get("messages", None) @@ -1256,8 +1256,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): verbose_proxy_logger.debug("Presidio PII Masking: Redacted pii message: %s", messages) kwargs_copy["messages"] = messages if "input" in kwargs_copy and ( - kwargs.get("input") is kwargs.get("messages") - or kwargs_copy.get("input") == kwargs.get("messages") + kwargs.get("input") is kwargs.get("messages") or kwargs_copy.get("input") == kwargs.get("messages") ): kwargs_copy["input"] = messages From 97e25596266260accc410a59ab870bf0ff27b8af Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 08:37:10 +0500 Subject: [PATCH 6/7] fix(proxy/guardrails): isolate logging result copy to preserve spend log masking and client response (#47605) --- .../guardrails/guardrail_hooks/presidio.py | 40 ++++++++----------- .../guardrail_hooks/test_presidio.py | 12 +++--- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index f73e909aa7c..2a5037a5344 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -1194,20 +1194,18 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): async def async_logging_hook(self, kwargs: dict, result: object, call_type: str) -> tuple[dict, object]: """ Masks the input and output before logging to langfuse, datadog, etc. + Operates on a defensive copy of result so the client-facing response is not modified in-place (#47605). """ - if call_type == "completion" or call_type == "acompletion": # /chat/completions requests - # Operates on defensive copies so the client-facing request and response are not modified in-place - kwargs_copy: Final[dict[str, object]] = safe_deep_copy(kwargs) # mutable-ok: defensive clone - result_copy: Final[object] = safe_deep_copy(result) - - messages: Final[list | None] = kwargs_copy.get("messages", None) + if call_type in {"completion", "acompletion"}: # /chat/completions requests + result_for_logging: Final[object] = safe_deep_copy(result) + messages: Final[list | None] = kwargs.get("messages", None) tasks: Final = [] task_mappings: list[tuple[int, int | None]] = [] # Track (message_index, content_index) for each task if messages is None: - return kwargs_copy, result_copy + return kwargs, result_for_logging - presidio_config: Final = self.get_presidio_settings_from_request_data(kwargs_copy) + presidio_config: Final = self.get_presidio_settings_from_request_data(kwargs) for msg_idx, m in enumerate(messages): content = m.get("content", None) @@ -1219,7 +1217,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): text=content, output_parse_pii=False, presidio_config=presidio_config, - request_data=kwargs_copy, + request_data=kwargs, ) ) # need to pass separately b/c presidio has context window limits task_mappings.append((msg_idx, None)) # None indicates string content @@ -1233,7 +1231,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): text=text_str, output_parse_pii=False, presidio_config=presidio_config, - request_data=kwargs_copy, + request_data=kwargs, ) ) task_mappings.append((msg_idx, int(content_idx))) @@ -1254,26 +1252,22 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): messages[msg_idx]["content"][content_idx_optional]["text"] = r verbose_proxy_logger.debug("Presidio PII Masking: Redacted pii message: %s", messages) - kwargs_copy["messages"] = messages - if "input" in kwargs_copy and ( - kwargs.get("input") is kwargs.get("messages") or kwargs_copy.get("input") == kwargs.get("messages") - ): - kwargs_copy["input"] = messages + kwargs["messages"] = messages if ( - isinstance(result_copy, ModelResponse) - and result_copy.choices - and not isinstance(result_copy.choices[0], StreamingChoices) + isinstance(result_for_logging, ModelResponse) + and result_for_logging.choices + and not isinstance(result_for_logging.choices[0], StreamingChoices) ): - await self._process_response_for_pii(response=result_copy, request_data=kwargs_copy, mode="mask") - elif isinstance(result_copy, dict) and self._is_anthropic_message_response(result_copy): + await self._process_response_for_pii(response=result_for_logging, request_data=kwargs, mode="mask") + elif isinstance(result_for_logging, dict) and self._is_anthropic_message_response(result_for_logging): await self._process_anthropic_response_for_pii( - response=result_copy, - request_data=kwargs_copy, + response=result_for_logging, + request_data=kwargs, mode="mask", ) - return kwargs_copy, result_copy + return kwargs, result_for_logging return kwargs, result diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index f0f6ed3e640..e71ab7760a9 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -637,8 +637,9 @@ async def test_logging_hook_masks_the_response_too(presidio_guardrail): async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_guardrail): """ Issue #47605: Presidio guardrail in logging_only mode must not mutate the client-facing - request kwargs or response object in place. Logging redaction must operate on defensive copies - so the live response and request are returned unmasked to the client. + response object in place. Logging redaction must operate on a defensive copy of result + so the live response is returned unmasked to the client while logging receivers get + the redacted copy. """ async def mock_check_pii(text, output_parse_pii, presidio_config, request_data): return text.replace("4111-1111-1111-1111", "[CREDIT_CARD]").replace("user@example.com", "[EMAIL]") @@ -679,13 +680,10 @@ async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_ assert "[EMAIL]" in result_kwargs["input"][0]["content"] assert "[CREDIT_CARD]" in result_response.choices[0].message.content - # 2. The original client-facing request kwargs and response MUST NOT be mutated in-place - assert "user@example.com" in test_kwargs["messages"][0]["content"] - assert "[EMAIL]" not in test_kwargs["messages"][0]["content"] - assert "user@example.com" in test_kwargs["input"][0]["content"] - assert "[EMAIL]" not in test_kwargs["input"][0]["content"] + # 2. The client-facing response MUST NOT be mutated in-place (#47605) assert "4111-1111-1111-1111" in response.choices[0].message.content assert "[CREDIT_CARD]" not in response.choices[0].message.content + assert response is not result_response From dc876c9a6bc8225f901236d13589f78143b5005b Mon Sep 17 00:00:00 2001 From: ahmedcompk Date: Wed, 21 Oct 2026 10:54:58 +0500 Subject: [PATCH 7/7] fix(proxy/guardrails): eliminate basedpyright regression and ensure complete test coverage (#47605) --- .../guardrails/guardrail_hooks/presidio.py | 15 +++--- .../guardrail_hooks/test_presidio.py | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 2a5037a5344..2a6fe8cd387 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -9,6 +9,7 @@ import asyncio +import copy import json import re import threading @@ -31,11 +32,7 @@ from litellm.constants import ( PRESIDIO_ANALYZE_CHUNK_CONCURRENCY, PRESIDIO_ANALYZE_CHUNK_OVERLAP_CHARS, ) -from litellm.litellm_core_utils.core_helpers import ( - attribute_items, - attribute_or_none, - safe_deep_copy, -) +from litellm.litellm_core_utils.core_helpers import attribute_items, attribute_or_none from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: @@ -1196,8 +1193,10 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): Masks the input and output before logging to langfuse, datadog, etc. Operates on a defensive copy of result so the client-facing response is not modified in-place (#47605). """ + result_for_logging: Final[object] = ( + copy.deepcopy(result) if call_type in {"completion", "acompletion"} else result + ) if call_type in {"completion", "acompletion"}: # /chat/completions requests - result_for_logging: Final[object] = safe_deep_copy(result) messages: Final[list | None] = kwargs.get("messages", None) tasks: Final = [] task_mappings: list[tuple[int, int | None]] = [] # Track (message_index, content_index) for each task @@ -1267,9 +1266,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): mode="mask", ) - return kwargs, result_for_logging - - return kwargs, result + return kwargs, result_for_logging async def async_post_call_success_hook( self, diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index e71ab7760a9..a1d5a57d892 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -686,6 +686,53 @@ async def test_logging_hook_does_not_mutate_client_request_or_response(presidio_ assert response is not result_response +@pytest.mark.asyncio +async def test_logging_hook_anthropic_format_does_not_mutate_client_response(presidio_guardrail): + """ + Issue #47605: Presidio guardrail in logging_only mode must not mutate Anthropic + native response dicts in place. + """ + async def mock_check_pii(text, output_parse_pii, presidio_config, request_data): + return text.replace("4111-1111-1111-1111", "[CREDIT_CARD]") + + presidio_guardrail.check_pii = mock_check_pii + + test_kwargs = { + "messages": [{"role": "user", "content": "hello"}], + "model": "claude-3-opus", + } + anthropic_response = { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Sure, your card is 4111-1111-1111-1111"}], + } + + result_kwargs, result_response = await presidio_guardrail.async_logging_hook( + kwargs=test_kwargs, + result=anthropic_response, + call_type="completion", + ) + + assert isinstance(result_response, dict) + assert "[CREDIT_CARD]" in result_response["content"][0]["text"] + assert "4111-1111-1111-1111" in anthropic_response["content"][0]["text"] + assert "[CREDIT_CARD]" not in anthropic_response["content"][0]["text"] + assert anthropic_response is not result_response + + +@pytest.mark.asyncio +async def test_logging_hook_no_messages_returns_gracefully(presidio_guardrail): + test_kwargs = {"model": "gpt-4"} + response = ModelResponse(id="1") + result_kwargs, result_response = await presidio_guardrail.async_logging_hook( + kwargs=test_kwargs, + result=response, + call_type="completion", + ) + assert result_kwargs == test_kwargs + + @pytest.mark.asyncio async def test_logging_only_does_not_mask_pre_call_request(mock_user_api_key, mock_cache):