diff --git a/litellm/llms/pass_through/guardrail_translation/README.md b/litellm/llms/pass_through/guardrail_translation/README.md index 8a3501dd972..74bde8e809c 100644 --- a/litellm/llms/pass_through/guardrail_translation/README.md +++ b/litellm/llms/pass_through/guardrail_translation/README.md @@ -24,7 +24,7 @@ litellm/proxy/pass_through_endpoints/ The `PassThroughEndpointHandler` enables guardrails to run on passthrough endpoint requests by: 1. **Field Targeting**: Extracts specific fields from the request/response using JSONPath expressions configured in `request_fields` / `response_fields` -2. **Full Payload Fallback**: If no field targeting is configured, processes the entire request payload. A response is read whole, integers included, except a provider's reply, where the ids, timestamps and usage counts around the text are left out +2. **Full Payload Fallback**: If no field targeting is configured, processes the entire request payload. A response is read whole, field names and integers included, except a provider's reply, where the ids, timestamps and usage counts around the text are left out 3. **Config Access**: Uses `get_passthrough_guardrails_config()` / `set_passthrough_guardrails_config()` helpers to access the passthrough guardrails configuration stored in request metadata ## Example Config diff --git a/litellm/llms/pass_through/guardrail_translation/handler.py b/litellm/llms/pass_through/guardrail_translation/handler.py index 80d03527acb..31e92201a41 100644 --- a/litellm/llms/pass_through/guardrail_translation/handler.py +++ b/litellm/llms/pass_through/guardrail_translation/handler.py @@ -6,7 +6,7 @@ It uses the field targeting configuration from litellm_logging_obj to extract specific fields for guardrail processing. """ -from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Final, Optional, Protocol, runtime_checkable @@ -73,10 +73,21 @@ def _reply_field_position(name: str, skipped: frozenset[str] | None) -> frozense return _REPLY_NESTED_FIELDS.get(name, _NO_FIELDS) +def _reply_entries( + fields: Mapping[str, object], skipped: frozenset[str] | None +) -> Iterator[tuple[object, frozenset[str] | None]]: + for name, item in fields.items(): + if skipped is None: + yield name, None + if skipped is None or name not in skipped: + yield item, _reply_field_position(name, skipped) + + def _response_text(response: Mapping[str, object]) -> str: """The text a guardrail reads from a JSON response. In a provider's reply that is its strings without the ids and encoded data around them, so a timestamp or a usage count doesn't read as a phone or card number. What the model - wrote into a tool call, and any other API's JSON, is read whole, integers included. Floats are scores and vectors.""" + wrote into a tool call, and any other API's JSON, is read whole, field names and integers included. Floats are + scores and vectors.""" top_level: Final = _REPLY_TOP_LEVEL_FIELDS if _is_provider_reply(response) else None pending: Final[list[tuple[object, frozenset[str] | None]]] = [(response, top_level)] # mutable-ok: stack texts: Final[list[str]] = [] # mutable-ok: local to this walk @@ -89,15 +100,7 @@ def _response_text(response: Mapping[str, object]) -> str: elif skipped is None and isinstance(value, int) and not isinstance(value, bool): texts.append(str(value)) elif fields is not None: - pending.extend( - reversed( - [ - (item, _reply_field_position(name, skipped)) - for name, item in fields.items() - if skipped is None or name not in skipped - ] - ) - ) + pending.extend(reversed(tuple(_reply_entries(fields, skipped)))) elif items is not None: pending.extend((item, skipped) for item in reversed(items)) return "\n".join(text for text in texts if text) diff --git a/tests/unit/llms/pass_through/guardrail_translation/test_handler.py b/tests/unit/llms/pass_through/guardrail_translation/test_handler.py index b712f6c471c..e19939ddd3e 100644 --- a/tests/unit/llms/pass_through/guardrail_translation/test_handler.py +++ b/tests/unit/llms/pass_through/guardrail_translation/test_handler.py @@ -289,7 +289,19 @@ async def test_post_call_scan_reads_a_field_named_like_the_envelope_in_what_the_ ], ) async def test_post_call_scan_reads_an_integer_in_what_the_model_or_api_wrote(response, expected): - assert await _scanned_response_texts(response) == (expected,) + assert any(expected in text for text in await _scanned_response_texts(response)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + {"results": {"alice@example.com": "active"}}, + {"candidates": [{"content": {"parts": [{"functionCall": {"args": {"alice@example.com": "active"}}}]}}]}, + ], +) +async def test_post_call_scan_reads_a_field_name_in_what_the_model_or_api_wrote(response): + assert any("alice@example.com" in text for text in await _scanned_response_texts(response)) @pytest.mark.asyncio