From 9a99e317ec53cd3f9c191a0f69db3496705df6a2 Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Wed, 14 Oct 2026 06:27:12 +0800 Subject: [PATCH 1/4] fix(guardrails): extract nested-wildcard request_fields so the check runs JsonPathExtractor.evaluate normalized an expression by replacing [*] with .[*]. and splitting on dots, then recursed into wildcard items passing the ALREADY-normalized remaining path. Re-running the replace on that path injected an empty segment (content..[*].text), so every expression with two array wildcards - e.g. messages[*].content[*].text, the natural request_fields for chat-shaped pass-through payloads - evaluated to nothing. extract_fields returned an empty string and the pass-through handler skipped the guardrail entirely ('No text to check'), sending the request upstream unchecked while still recording the guardrail as applied. The normalization now splits each dot segment by peeling trailing wildcards one at a time, which is idempotent for the paths recursion feeds back through, and adjacent wildcards keep working. Fixes #46302 --- .../jsonpath_extractor.py | 21 ++++++++++++++++++- .../test_passthrough_guardrails.py | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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"} From 056b6a1af26f7951c31fcc0322f3f3907425554a Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Wed, 14 Oct 2026 17:01:13 +0800 Subject: [PATCH 2/4] refactor: extract the wildcard normalization into a helper evaluate() crossed the C901 ceiling (16 > 15); the normalization loop moves out unchanged as _normalize_wildcard_segments. --- .../jsonpath_extractor.py | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py index 8ed54443b7e..ba2e5837847 100644 --- a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py +++ b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py @@ -9,6 +9,31 @@ from typing import Any, Final from litellm._logging import verbose_proxy_logger +def _normalize_wildcard_segments(expr: str) -> list[str]: + """Normalize ``key[*]`` into ``key`` + ``[*]`` segments. + + Idempotent: the recursion in :meth:`JsonPathExtractor.evaluate` 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) + return segments + + class JsonPathExtractor: """Extracts field values from data using JSONPath-like expressions.""" @@ -55,26 +80,7 @@ class JsonPathExtractor: if not expr or not data: return None - # 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 + parts: Final = _normalize_wildcard_segments(expr) current: Any = data for i, part in enumerate(parts): From 982d11b7c02aca79d367cab2fdbc8ba31a6fda23 Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Wed, 14 Oct 2026 17:23:24 +0800 Subject: [PATCH 3/4] refactor: build the wildcard normalization functionally The loop-and-append helper tripped the type-discipline gate (mutable list annotations, non-Final accumulation). Replace + filter + tuple keeps the same segment output for raw, adjacent and already-normalized wildcards with read-only types throughout. --- .../jsonpath_extractor.py | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py index ba2e5837847..bc01fa8f85f 100644 --- a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py +++ b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py @@ -9,28 +9,21 @@ from typing import Any, Final from litellm._logging import verbose_proxy_logger -def _normalize_wildcard_segments(expr: str) -> list[str]: +def _normalize_wildcard_segments(expr: str) -> tuple[str, ...]: """Normalize ``key[*]`` into ``key`` + ``[*]`` segments. Idempotent: the recursion in :meth:`JsonPathExtractor.evaluate` 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). + already-normalized paths (e.g. ``content.[*].text``) back through here. + Re-running ``expr.replace("[*]", ".[*]")`` on those injects empty + segments (``content..[*].text``); dropping empty segments afterwards + keeps the normalization stable for raw (``a[*].b``), adjacent + (``a[*][*].b``) and already-normalized inputs alike, so no + nested-wildcard expression is silently dropped and skips 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) + segments: Final = tuple( + segment for segment in expr.replace("[*]", ".[*]").split(".") if segment + ) return segments From 6de98912d325cacfe40db8396cf28c4a36bb8a72 Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Wed, 14 Oct 2026 17:23:48 +0800 Subject: [PATCH 4/4] style: ruff format the normalization helper --- litellm/proxy/pass_through_endpoints/jsonpath_extractor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py index bc01fa8f85f..6136c05cec6 100644 --- a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py +++ b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py @@ -21,9 +21,7 @@ def _normalize_wildcard_segments(expr: str) -> tuple[str, ...]: nested-wildcard expression is silently dropped and skips the guardrail (#46302). """ - segments: Final = tuple( - segment for segment in expr.replace("[*]", ".[*]").split(".") if segment - ) + segments: Final = tuple(segment for segment in expr.replace("[*]", ".[*]").split(".") if segment) return segments