diff --git a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py index 64a8c81624c..8ed54443b7e 100644 --- a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py +++ b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py @@ -55,7 +55,26 @@ class JsonPathExtractor: if not expr or not data: return None - parts: Final = expr.replace("[*]", ".[*]").split(".") + # Normalize "key[*]" into "key" + "[*]" segments. Idempotent: the + # recursion below passes already-normalized paths (e.g. + # "content.[*].text") back through here, and re-running + # expr.replace("[*]", ".[*]") on those would inject empty segments + # ("content..[*].text") and silently drop every nested-wildcard + # expression, skipping the guardrail (#46302). + segments: list[str] = [] + for segment in expr.split("."): + if segment == "[*]": + segments.append("[*]") + continue + # Peel trailing wildcards one at a time so adjacent wildcards + # ("a[*][*]") and already-normalized paths both split cleanly. + while segment.endswith("[*]"): + segments.append(segment[:-3]) + segments.append("[*]") + segment = "" + if segment: + segments.append(segment) + parts: Final = segments current: Any = data for i, part in enumerate(parts): diff --git a/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py b/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py index 40403de846f..56525e3cf72 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py +++ b/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py @@ -155,6 +155,26 @@ class TestJsonPathExtractorEvaluate: result = JsonPathExtractor.evaluate(data, "messages[*].content") assert result == ["You are helpful", "Hello"] + def test_nested_array_wildcards(self): + """Should extract values through two array wildcards. + + Regression test for #46302: the recursive wildcard branch passes the + already-normalized remaining path (e.g. "content.[*].text") back + through evaluate, where the old replace-based normalization injected + an empty segment ("content..[*].text") and silently dropped every + nested-wildcard expression - so a pass-through guardrail whose only + request_fields entry is messages[*].content[*].text never saw any + text and skipped the check entirely. + """ + data = { + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "item1"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "item2"}]}, + ] + } + assert JsonPathExtractor.evaluate(data, "messages[*].content[*].text") == ["item1", "item2"] + assert JsonPathExtractor.extract_fields(data, ["messages[*].content[*].text"]) == "item1" + chr(10) + "item2" + def test_missing_key_returns_none(self): """Should return None for missing key.""" data = {"query": "test"}