Merge branch 'litellm_passthrough_auth_guardrails' into litellm_lakera_v2_gemini_contents

This commit is contained in:
yucheng 2026-11-02 13:43:45 -08:00
commit f0c416a9df
3 changed files with 28 additions and 13 deletions

View file

@ -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

View file

@ -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)

View file

@ -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