feat: Add nested field removal support to additional_drop_params using JSONPath
This commit is contained in:
parent
5b926ae2c6
commit
da2aa2ba8d
@ -1,7 +1,25 @@
|
||||
"""
|
||||
This file contains the logic for dot notation indexing.
|
||||
Path-based navigation utilities for nested dictionaries.
|
||||
|
||||
Used by JWT Auth to get the user role from the token.
|
||||
This module provides utilities for reading and deleting values in nested
|
||||
dictionaries using dot notation and JSONPath array syntax.
|
||||
|
||||
Uses jsonpath-ng library for standard JSONPath parsing and navigation.
|
||||
|
||||
Supported syntax:
|
||||
- "field" - top-level field
|
||||
- "parent.child" - nested field
|
||||
- "array[*]" - all array elements (wildcard)
|
||||
- "array[0]" - specific array element (index)
|
||||
- "array[*].field" - field in all array elements
|
||||
|
||||
Examples:
|
||||
>>> data = {"tools": [{"name": "t1", "input_examples": ["ex"]}]}
|
||||
>>> delete_nested_value(data, "tools[*].input_examples")
|
||||
{"tools": [{"name": "t1"}]}
|
||||
|
||||
Used by JWT Auth to get the user role from the token, and by
|
||||
additional_drop_params to remove nested fields from optional parameters.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, TypeVar
|
||||
@ -57,3 +75,78 @@ def get_nested_value(
|
||||
|
||||
# Otherwise, ensure the type matches the default
|
||||
return current if isinstance(current, type(default)) else default
|
||||
|
||||
|
||||
def delete_nested_value(
|
||||
data: Dict[str, Any],
|
||||
path: str,
|
||||
depth: int = 0,
|
||||
max_depth: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a field from nested data using JSONPath notation.
|
||||
|
||||
Uses jsonpath-ng library for standard JSONPath parsing.
|
||||
|
||||
Supports:
|
||||
- "field" - top-level field
|
||||
- "parent.child" - nested field
|
||||
- "array[*]" - all array elements
|
||||
- "array[0]" - specific array element
|
||||
- "array[*].field" - field in all array elements
|
||||
|
||||
Args:
|
||||
data: Dictionary to modify (creates deep copy)
|
||||
path: JSONPath-like path string
|
||||
depth: Current recursion depth (kept for API compatibility)
|
||||
max_depth: Maximum recursion depth (kept for API compatibility)
|
||||
|
||||
Returns:
|
||||
New dictionary with field removed at path
|
||||
|
||||
Example:
|
||||
>>> data = {"tools": [{"name": "t1", "input_examples": ["ex"]}]}
|
||||
>>> delete_nested_value(data, "tools[*].input_examples")
|
||||
{"tools": [{"name": "t1"}]}
|
||||
"""
|
||||
import copy
|
||||
|
||||
from jsonpath_ng import parse
|
||||
|
||||
result = copy.deepcopy(data)
|
||||
|
||||
# Add $ prefix required by jsonpath-ng
|
||||
if not path.startswith("$"):
|
||||
path = f"$.{path}"
|
||||
|
||||
try:
|
||||
expr = parse(path)
|
||||
matches = expr.find(result)
|
||||
|
||||
# Process matches in reverse to handle array deletions correctly
|
||||
for match in reversed(matches):
|
||||
parent = match.context.value if match.context else result
|
||||
|
||||
if isinstance(parent, list):
|
||||
if hasattr(match.path, "index"):
|
||||
idx = match.path.index
|
||||
if 0 <= idx < len(parent):
|
||||
parent.pop(idx)
|
||||
elif isinstance(parent, dict):
|
||||
if hasattr(match.path, "fields") and match.path.fields:
|
||||
field = match.path.fields[0]
|
||||
parent.pop(field, None)
|
||||
except Exception:
|
||||
# Invalid path or parsing error - silently skip
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def is_nested_path(path: str) -> bool:
|
||||
"""
|
||||
Check if path requires nested handling.
|
||||
|
||||
Returns True if path contains '.' or '[' (array notation).
|
||||
"""
|
||||
return "." in path or "[" in path
|
||||
|
||||
@ -1843,6 +1843,21 @@ class BaseLLMHTTPHandler:
|
||||
},
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
# Apply additional_drop_params for nested field removal
|
||||
additional_drop_params = litellm_params.additional_drop_params
|
||||
if additional_drop_params:
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import (
|
||||
delete_nested_value,
|
||||
is_nested_path,
|
||||
)
|
||||
|
||||
nested_paths = [p for p in additional_drop_params if is_nested_path(p)]
|
||||
for path in nested_paths:
|
||||
anthropic_messages_optional_request_params = delete_nested_value(
|
||||
anthropic_messages_optional_request_params, path
|
||||
)
|
||||
|
||||
# Prepare request body
|
||||
request_body = anthropic_messages_provider_config.transform_anthropic_messages_request(
|
||||
model=model,
|
||||
|
||||
@ -138,6 +138,10 @@ from litellm.litellm_core_utils.redact_messages import (
|
||||
LiteLLMLoggingObject,
|
||||
redact_message_input_output_from_logging,
|
||||
)
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import (
|
||||
delete_nested_value,
|
||||
is_nested_path,
|
||||
)
|
||||
from litellm.litellm_core_utils.rules import Rules
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.litellm_core_utils.token_counter import get_modified_max_tokens
|
||||
@ -4148,6 +4152,13 @@ def get_optional_params( # noqa: PLR0915
|
||||
non_default_params=non_default_params,
|
||||
allowed_openai_params=allowed_openai_params,
|
||||
)
|
||||
|
||||
# Apply nested drops from additional_drop_params
|
||||
if additional_drop_params:
|
||||
nested_paths = [p for p in additional_drop_params if is_nested_path(p)]
|
||||
for path in nested_paths:
|
||||
optional_params = delete_nested_value(optional_params, path)
|
||||
|
||||
return optional_params
|
||||
|
||||
|
||||
|
||||
4844
poetry.lock
generated
4844
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -68,6 +68,7 @@ semantic-router = {version = ">=0.1.12", optional = true, python = ">=3.9,<3.14"
|
||||
mlflow = {version = ">3.1.4", optional = true, python = ">=3.10"}
|
||||
soundfile = {version = "^0.12.1", optional = true}
|
||||
grpcio = ">=1.62.3,<1.68.0" # Constrain to < 1.68.0 to avoid resource exhausted bug (https://github.com/grpc/grpc/issues/38290). Minimum 1.62.3 required by grpcio-status.
|
||||
jsonpath-ng = "^1.7.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
proxy = [
|
||||
|
||||
234
tests/test_litellm/test_nested_drop_params.py
Normal file
234
tests/test_litellm/test_nested_drop_params.py
Normal file
@ -0,0 +1,234 @@
|
||||
"""
|
||||
Test nested path support in additional_drop_params.
|
||||
|
||||
This tests the new JSONPath-like syntax for removing nested fields.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import (
|
||||
delete_nested_value,
|
||||
is_nested_path,
|
||||
)
|
||||
|
||||
|
||||
class TestIsNestedPath:
|
||||
"""Test path detection."""
|
||||
|
||||
def test_top_level_path(self):
|
||||
"""Top-level paths should return False."""
|
||||
assert is_nested_path("temperature") is False
|
||||
assert is_nested_path("response_format") is False
|
||||
|
||||
def test_nested_path_with_dot(self):
|
||||
"""Paths with dots are nested."""
|
||||
assert is_nested_path("parent.child") is True
|
||||
|
||||
def test_nested_path_with_array(self):
|
||||
"""Paths with array notation are nested."""
|
||||
assert is_nested_path("tools[*].input_examples") is True
|
||||
assert is_nested_path("tools[0].field") is True
|
||||
|
||||
|
||||
class TestDeleteNestedValue:
|
||||
"""Test the core deletion logic."""
|
||||
|
||||
def test_array_wildcard_removes_field_from_all_elements(self):
|
||||
"""Test removing a field from all array elements."""
|
||||
data = {
|
||||
"tools": [
|
||||
{"name": "tool1", "input_examples": ["ex1"]},
|
||||
{"name": "tool2", "input_examples": ["ex2"]},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
result = delete_nested_value(data, "tools[*].input_examples")
|
||||
|
||||
# Verify structure preserved
|
||||
assert len(result["tools"]) == 2
|
||||
assert result["tools"][0]["name"] == "tool1"
|
||||
assert result["tools"][1]["name"] == "tool2"
|
||||
assert result["temperature"] == 0.7
|
||||
|
||||
# Verify input_examples removed
|
||||
assert "input_examples" not in result["tools"][0]
|
||||
assert "input_examples" not in result["tools"][1]
|
||||
|
||||
# Verify original unchanged (deep copy)
|
||||
assert "input_examples" in data["tools"][0]
|
||||
|
||||
|
||||
class TestComplexNestedPatterns:
|
||||
"""Test complex nested patterns with multiple wildcards and deep nesting."""
|
||||
|
||||
def test_multiple_jsonpath_patterns_in_list(self):
|
||||
"""Test processing multiple JSONPath patterns sequentially."""
|
||||
data = {
|
||||
"tools": [
|
||||
{
|
||||
"name": "tool1",
|
||||
"input_examples": ["ex1"],
|
||||
"some_arr": [
|
||||
{
|
||||
"some_struct": {
|
||||
"remove_this_field": "val1",
|
||||
"keep_this": "val2",
|
||||
}
|
||||
},
|
||||
{
|
||||
"some_struct": {
|
||||
"remove_this_field": "val3",
|
||||
"keep_this": "val4",
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "tool2",
|
||||
"input_examples": ["ex2"],
|
||||
"some_arr": [
|
||||
{
|
||||
"some_struct": {
|
||||
"remove_this_field": "val5",
|
||||
"keep_this": "val6",
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
# Simulate multiple paths being processed (as in utils.py:4134-4137)
|
||||
paths = [
|
||||
"tools[*].input_examples",
|
||||
"tools[*].some_arr[*].some_struct.remove_this_field",
|
||||
]
|
||||
|
||||
result = data
|
||||
for path in paths:
|
||||
result = delete_nested_value(result, path)
|
||||
|
||||
# Verify input_examples removed from all tools
|
||||
assert "input_examples" not in result["tools"][0]
|
||||
assert "input_examples" not in result["tools"][1]
|
||||
|
||||
# Verify deeply nested field removed from all array elements
|
||||
assert (
|
||||
"remove_this_field"
|
||||
not in result["tools"][0]["some_arr"][0]["some_struct"]
|
||||
)
|
||||
assert (
|
||||
"remove_this_field"
|
||||
not in result["tools"][0]["some_arr"][1]["some_struct"]
|
||||
)
|
||||
assert (
|
||||
"remove_this_field"
|
||||
not in result["tools"][1]["some_arr"][0]["some_struct"]
|
||||
)
|
||||
|
||||
# Verify other fields preserved
|
||||
assert result["tools"][0]["some_arr"][0]["some_struct"]["keep_this"] == "val2"
|
||||
assert result["tools"][1]["some_arr"][0]["some_struct"]["keep_this"] == "val6"
|
||||
assert result["temperature"] == 0.7
|
||||
|
||||
def test_remove_entire_nested_array_field(self):
|
||||
"""Test removing entire array fields (not just array elements)."""
|
||||
data = {
|
||||
"tools": [
|
||||
{"name": "t1", "some_arr": [1, 2, 3], "other_field": "keep"},
|
||||
{"name": "t2", "some_arr": [4, 5, 6], "other_field": "keep"},
|
||||
]
|
||||
}
|
||||
|
||||
result = delete_nested_value(data, "tools[*].some_arr")
|
||||
|
||||
# Verify entire array field removed (not individual elements)
|
||||
assert "some_arr" not in result["tools"][0]
|
||||
assert "some_arr" not in result["tools"][1]
|
||||
|
||||
# Verify other fields preserved
|
||||
assert result["tools"][0]["name"] == "t1"
|
||||
assert result["tools"][0]["other_field"] == "keep"
|
||||
assert result["tools"][1]["name"] == "t2"
|
||||
assert result["tools"][1]["other_field"] == "keep"
|
||||
|
||||
def test_triple_nested_wildcards(self):
|
||||
"""Test extreme nesting: tools[*].arr1[*].arr2[*].field."""
|
||||
data = {
|
||||
"tools": [
|
||||
{
|
||||
"name": "t1",
|
||||
"arr1": [
|
||||
{
|
||||
"arr2": [
|
||||
{"field": "remove1", "keep": "yes1"},
|
||||
{"field": "remove2", "keep": "yes2"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"arr2": [
|
||||
{"field": "remove3", "keep": "yes3"},
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = delete_nested_value(data, "tools[*].arr1[*].arr2[*].field")
|
||||
|
||||
# Verify deeply nested field removed from all levels
|
||||
assert "field" not in result["tools"][0]["arr1"][0]["arr2"][0]
|
||||
assert "field" not in result["tools"][0]["arr1"][0]["arr2"][1]
|
||||
assert "field" not in result["tools"][0]["arr1"][1]["arr2"][0]
|
||||
|
||||
# Verify keep field preserved at all levels
|
||||
assert result["tools"][0]["arr1"][0]["arr2"][0]["keep"] == "yes1"
|
||||
assert result["tools"][0]["arr1"][0]["arr2"][1]["keep"] == "yes2"
|
||||
assert result["tools"][0]["arr1"][1]["arr2"][0]["keep"] == "yes3"
|
||||
|
||||
def test_combination_of_simple_and_complex_paths(self):
|
||||
"""Test mixing simple nested paths with complex multi-wildcard paths."""
|
||||
data = {
|
||||
"tools": [
|
||||
{
|
||||
"name": "t1",
|
||||
"simple_nested": {"remove": "val1", "keep": "val2"},
|
||||
"complex": [{"nested": {"remove": "val3", "keep": "val4"}}],
|
||||
}
|
||||
],
|
||||
"top_level_remove": "should_go",
|
||||
"top_level_keep": "should_stay",
|
||||
}
|
||||
|
||||
# Process multiple different types of paths
|
||||
paths = [
|
||||
"tools[*].simple_nested.remove",
|
||||
"tools[*].complex[*].nested.remove",
|
||||
]
|
||||
|
||||
result = data
|
||||
for path in paths:
|
||||
result = delete_nested_value(result, path)
|
||||
|
||||
# Verify simple nested removal
|
||||
assert "remove" not in result["tools"][0]["simple_nested"]
|
||||
assert result["tools"][0]["simple_nested"]["keep"] == "val2"
|
||||
|
||||
# Verify complex nested removal
|
||||
assert "remove" not in result["tools"][0]["complex"][0]["nested"]
|
||||
assert result["tools"][0]["complex"][0]["nested"]["keep"] == "val4"
|
||||
|
||||
# Verify top-level fields unchanged
|
||||
assert result["top_level_remove"] == "should_go"
|
||||
assert result["top_level_keep"] == "should_stay"
|
||||
|
||||
|
||||
# Phase 1 tests - validates core functionality and complex patterns
|
||||
Loading…
Reference in New Issue
Block a user