From a2ed121444d76c6768bf72bbf3a59b6439cb5cf2 Mon Sep 17 00:00:00 2001 From: D41910 <158550716+D41910@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:27:11 +0800 Subject: [PATCH 1/2] fix(jwt): read a genuinely nested metadata claim without changing precedence --- litellm/constants.py | 4 ++ .../dot_notation_indexing.py | 45 +++++++++++----- .../test_dot_notation_indexing.py | 54 +++++++++++++++++++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 938a3c85c29..b7549a0252a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -2252,3 +2252,7 @@ HARNESS_SNAPSHOT_SKIP_DIRS: Final = frozenset( ) DEFAULT_TOOL_LOOP_MAX_ROUNDS: Final = 20 + +# Sentinel for "this lookup path did not resolve", distinct from any caller-supplied +# default, which may itself be None. +NESTED_VALUE_MISSING_SENTINEL: Final = object() diff --git a/litellm/litellm_core_utils/dot_notation_indexing.py b/litellm/litellm_core_utils/dot_notation_indexing.py index 1dac67ecbf6..63872046879 100644 --- a/litellm/litellm_core_utils/dot_notation_indexing.py +++ b/litellm/litellm_core_utils/dot_notation_indexing.py @@ -26,6 +26,8 @@ additional_drop_params to remove nested fields from optional parameters. from collections.abc import Mapping from typing import Any, Final, TypeVar +from litellm.constants import NESTED_VALUE_MISSING_SENTINEL + T = TypeVar("T") @@ -54,29 +56,44 @@ def get_nested_value(data: Mapping[str, object], key_path: str, default: T | Non if not key_path: return default - # Remove metadata. prefix if it exists - key_path = key_path.replace("metadata.", "", 1) if key_path.startswith("metadata.") else key_path - # Split the key path into parts, respecting escaped dots (\.) # Use a temporary placeholder, split on unescaped dots, then restore placeholder: Final = "\x00" - parts = key_path.replace("\\.", placeholder).split(".") - parts = [p.replace(placeholder, ".") for p in parts] - # Traverse through the dictionary - current: Any = data - for part in parts: - try: - current = current[part] - except (KeyError, TypeError): - return default + def _resolve(path: str) -> Any: + parts = path.replace("\\.", placeholder).split(".") + current: Any = data + for part in parts: + part = part.replace(placeholder, ".") + try: + current = current[part] + except (KeyError, TypeError): + return NESTED_VALUE_MISSING_SENTINEL + return current + + # A leading "metadata." has always been stripped before resolving, so + # `roles_jwt_field: "metadata.roles"` reads a top-level `roles` claim. Keep + # that spelling authoritative so no token changes team, and fall back to the + # path as written only when the legacy one is absent, which is what makes a + # genuinely nested `metadata` object readable instead of silently falling back + # to the defaults JWT auth applies when a claim is missing. + resolved: Any = NESTED_VALUE_MISSING_SENTINEL + if key_path.startswith("metadata."): + without_prefix: Final = key_path[len("metadata.") :] + if without_prefix: + resolved = _resolve(without_prefix) + if resolved is NESTED_VALUE_MISSING_SENTINEL: + resolved = _resolve(key_path) + + if resolved is NESTED_VALUE_MISSING_SENTINEL: + return default # If default is None, we can return any type if default is None: - return current + return resolved # Otherwise, ensure the type matches the default - return current if isinstance(current, type(default)) else default + return resolved if isinstance(resolved, type(default)) else default def _parse_path_segments(path: str) -> list: diff --git a/tests/unit/litellm_core_utils/test_dot_notation_indexing.py b/tests/unit/litellm_core_utils/test_dot_notation_indexing.py index ae529a71009..a19b128c1a3 100644 --- a/tests/unit/litellm_core_utils/test_dot_notation_indexing.py +++ b/tests/unit/litellm_core_utils/test_dot_notation_indexing.py @@ -23,6 +23,60 @@ class TestGetNestedValue: data = {"a": {"b": {"c": "value"}}} assert get_nested_value(data, "a.b.c") == "value" + def test_nested_metadata_object_is_reachable(self): + """A token that really nests claims under `metadata` resolves. + + The prefix used to be stripped before traversal, so `metadata.roles` was + looked up as `roles` and a genuinely nested claim read as missing. JWT auth + then fell back to its defaults, which for get_team_id means `team_id_default` + rather than the team the token named. + """ + assert get_nested_value({"metadata": {"roles": ["admin"]}}, "metadata.roles") == ["admin"] + + def test_top_level_claim_keeps_precedence_over_nested_metadata(self): + """A token carrying both shapes must resolve to the same team it always did. + + Stripping the prefix before traversal meant the top-level claim won, so + that stays authoritative. Reading the nested value first would silently + move an existing deployment onto a different team. + """ + data = {"metadata": {"roles": ["nested"]}, "roles": ["top-level"]} + assert get_nested_value(data, "metadata.roles") == ["top-level"] + + def test_metadata_prefix_still_falls_back_to_top_level(self): + """The legacy spelling keeps working when there is no nested object.""" + assert get_nested_value({"roles": ["admin"]}, "metadata.roles") == ["admin"] + + def test_metadata_prefix_falls_back_to_a_default(self): + """With neither shape present the caller's default comes back unchanged.""" + assert get_nested_value({"other": 1}, "metadata.roles", "fallback") == "fallback" + + def test_metadata_prefix_applies_only_once(self): + """Only a leading `metadata.` is treated as a prefix. + + `metadata.metadata.x` strips one segment, so a key literally named + `metadata` inside a `metadata` object is still reachable. + """ + data = {"metadata": {"metadata": {"team_id": "team-1"}}} + assert get_nested_value(data, "metadata.metadata.team_id") == "team-1" + + def test_nested_metadata_respects_the_default_type(self): + """The caller's default still governs type coercion on the nested read.""" + data = {"metadata": {"team_id": "team-1"}} + assert get_nested_value(data, "metadata.team_id", "fallback") == "team-1" + assert get_nested_value(data, "metadata.team_id", 1234) == 1234, ( + "a default of another type must make the nested value unusable here, " + "exactly as for a non-prefixed path" + ) + + def test_bare_metadata_key_is_untouched(self): + """A top-level key named `metadata` resolves as written.""" + assert get_nested_value({"metadata": "value"}, "metadata") == "value" + + def test_non_dict_metadata_is_not_read_as_a_nested_claim(self): + """A string `metadata` claim cannot hold a nested object, so the read falls through.""" + assert get_nested_value({"metadata": "not-a-dict"}, "metadata.roles", "fallback") == "fallback" + def test_missing_key_returns_default(self): """Test that missing keys return the default value.""" data = {"a": {"b": "value"}} From bb2ec095082d2ec2fa2025c3e8cf056f8f83dc16 Mon Sep 17 00:00:00 2001 From: D41910 <158550716+D41910@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:22:22 +0800 Subject: [PATCH 2/2] fix: address review and validation findings for PR 44365 --- litellm/litellm_core_utils/dot_notation_indexing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/dot_notation_indexing.py b/litellm/litellm_core_utils/dot_notation_indexing.py index 63872046879..b5aec25800c 100644 --- a/litellm/litellm_core_utils/dot_notation_indexing.py +++ b/litellm/litellm_core_utils/dot_notation_indexing.py @@ -60,7 +60,7 @@ def get_nested_value(data: Mapping[str, object], key_path: str, default: T | Non # Use a temporary placeholder, split on unescaped dots, then restore placeholder: Final = "\x00" - def _resolve(path: str) -> Any: + def _resolve(path: str) -> object: parts = path.replace("\\.", placeholder).split(".") current: Any = data for part in parts: