diff --git a/docs/my-website/docs/completion/drop_params.md b/docs/my-website/docs/completion/drop_params.md index a81fd897b4e..cc32d3bbd32 100644 --- a/docs/my-website/docs/completion/drop_params.md +++ b/docs/my-website/docs/completion/drop_params.md @@ -117,6 +117,56 @@ response = litellm.completion( **additional_drop_params**: List or null - Is a list of openai params you want to drop when making a call to the model. +### Nested Field Removal + +Drop nested fields within complex objects using JSONPath-like notation: + + + + +```python +import litellm + +response = litellm.completion( + model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0", + messages=[{"role": "user", "content": "Hello"}], + tools=[{ + "name": "search", + "description": "Search files", + "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}, + "input_examples": [{"query": "test"}] # Will be removed + }], + additional_drop_params=["tools[*].input_examples"] # Remove from all tools +) +``` + + + + +```yaml +model_list: + - model_name: my-bedrock-model + litellm_params: + model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0 + additional_drop_params: ["tools[*].input_examples"] # Remove from all tools +``` + + + + +**Supported syntax:** +- `field` - Top-level field +- `parent.child` - Nested object field +- `array[*]` - All array elements +- `array[0]` - Specific array index +- `tools[*].input_examples` - Field in all array elements +- `tools[0].metadata.field` - Specific index + nested field + +**Example use cases:** +- Remove `input_examples` from tool definitions (Claude Code + AWS Bedrock) +- Drop provider-specific fields from nested structures +- Clean up nested parameters before sending to LLM + ## Specify allowed openai params in a request Tell litellm to allow specific openai params in a request. Use this if you get a `litellm.UnsupportedParamsError` and want to allow a param. LiteLLM will pass the param as is to the model. diff --git a/litellm/litellm_core_utils/dot_notation_indexing.py b/litellm/litellm_core_utils/dot_notation_indexing.py index fda37f65007..6e293a4cb77 100644 --- a/litellm/litellm_core_utils/dot_notation_indexing.py +++ b/litellm/litellm_core_utils/dot_notation_indexing.py @@ -1,10 +1,28 @@ """ -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-like array syntax. + +Custom implementation with zero external dependencies. + +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 +from typing import Any, Dict, List, Optional, TypeVar, Union T = TypeVar("T") @@ -57,3 +75,164 @@ def get_nested_value( # Otherwise, ensure the type matches the default return current if isinstance(current, type(default)) else default + + +def _parse_path_segments(path: str) -> list: + """ + Parse a JSONPath-like string into segments using regex. + + Handles: + - Dot notation: "a.b.c" → ["a", "b", "c"] + - Array wildcards: "a[*].b" → ["a", "[*]", "b"] + - Array indices: "a[0].b" → ["a", "[0]", "b"] + + Args: + path: JSONPath-like path string + + Returns: + List of path segments + + Example: + >>> _parse_path_segments("tools[*].arr[0].field") + ["tools", "[*]", "arr", "[0]", "field"] + """ + import re + + # Match field names OR bracket expressions + # Pattern: field_name (anything except . or [) | [anything_in_brackets] + pattern = r'[^\.\[]+|\[[^\]]*\]' + segments = re.findall(pattern, path) + return segments + + +def _delete_nested_value_custom( + data: Union[Dict[str, Any], List[Any]], + segments: list, + segment_index: int = 0, +) -> None: + """ + Recursively delete a field from nested data using parsed segments. + + Modifies data in-place (caller must deep copy first). + + Args: + data: Dictionary or list to modify + segments: Parsed path segments + segment_index: Current position in segments list + """ + if segment_index >= len(segments): + return + + segment = segments[segment_index] + is_last = segment_index == len(segments) - 1 + + # Handle array wildcard: [*] + if segment == "[*]": + if isinstance(data, list): + for item in data: + if is_last: + # Can't delete array elements themselves, skip + pass + else: + # Only recurse if item is a dict or list (nested structure) + if isinstance(item, (dict, list)): + _delete_nested_value_custom(item, segments, segment_index + 1) + return + + # Handle array index: [0], [1], [2], etc. + if segment.startswith("[") and segment.endswith("]"): + try: + index = int(segment[1:-1]) + if isinstance(data, list) and 0 <= index < len(data): + if is_last: + # Can't delete array elements themselves, skip + pass + else: + # Only recurse if element is a dict or list (nested structure) + element = data[index] + if isinstance(element, (dict, list)): + _delete_nested_value_custom(element, segments, segment_index + 1) + except (ValueError, IndexError): + # Invalid index, skip + pass + return + + # Handle regular field navigation + if isinstance(data, dict): + if is_last: + # Delete the field + data.pop(segment, None) + else: + # Navigate deeper + if segment in data: + next_segment = segments[segment_index + 1] if segment_index + 1 < len(segments) else None + + # If next segment is array notation, current field should be list + if next_segment and (next_segment.startswith("[")): + if isinstance(data[segment], list): + _delete_nested_value_custom(data[segment], segments, segment_index + 1) + # Otherwise navigate into dict + elif isinstance(data[segment], dict): + _delete_nested_value_custom(data[segment], segments, segment_index + 1) + + +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. + + Custom implementation - no external dependencies. + + Supports: + - "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 + + 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 + + result = copy.deepcopy(data) + + try: + # Parse path into segments + segments = _parse_path_segments(path) + + if not segments: + return result + + # Delete using custom recursive implementation + _delete_nested_value_custom(result, segments, 0) + + 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 diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 3ea85ecee5e..72fe0ac7ec1 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1844,6 +1844,21 @@ class BaseLLMHTTPHandler: }, custom_llm_provider=custom_llm_provider, ) + + # Apply additional_drop_params for nested field removal + additional_drop_params = litellm_params.get("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, diff --git a/litellm/utils.py b/litellm/utils.py index 284007328ff..b92af35e70d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index c26d1669a81..1a3a3260f78 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -35,6 +35,7 @@ IGNORE_FUNCTIONS = [ "_fix_enum_types", # max depth set. "_collect_argument_paths", # max depth set. "_split_text", # max depth set. + "_delete_nested_value_custom", # max depth set (bounded by number of path segments). ] diff --git a/tests/test_litellm/test_nested_drop_params.py b/tests/test_litellm/test_nested_drop_params.py new file mode 100644 index 00000000000..d90b435419b --- /dev/null +++ b/tests/test_litellm/test_nested_drop_params.py @@ -0,0 +1,354 @@ +""" +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] + + def test_specific_array_index_removes_field_from_single_element(self): + """Test removing a field from specific array elements using [n] syntax.""" + # Test data with multiple array elements + data = { + "tools": [ + {"name": "t0", "input_examples": ["ex0"], "keep": "val0"}, + {"name": "t1", "input_examples": ["ex1"], "keep": "val1"}, + {"name": "t2", "input_examples": ["ex2"], "keep": "val2"}, + {"name": "t3", "input_examples": ["ex3"], "keep": "val3"}, + {"name": "t4", "input_examples": ["ex4"], "keep": "val4"}, + {"name": "t5", "input_examples": ["ex5"], "keep": "val5"}, + ] + } + + # Test [0] - first element + result = delete_nested_value(data, "tools[0].input_examples") + assert "input_examples" not in result["tools"][0] + assert "input_examples" in result["tools"][1] + assert "input_examples" in result["tools"][2] + + # Test [1] - second element + result = delete_nested_value(data, "tools[1].input_examples") + assert "input_examples" in result["tools"][0] + assert "input_examples" not in result["tools"][1] + assert "input_examples" in result["tools"][2] + + # Test [2] - middle element + result = delete_nested_value(data, "tools[2].input_examples") + assert "input_examples" in result["tools"][0] + assert "input_examples" not in result["tools"][2] + assert "input_examples" in result["tools"][3] + + # Test [5] - last element + result = delete_nested_value(data, "tools[5].input_examples") + assert "input_examples" in result["tools"][0] + assert "input_examples" not in result["tools"][5] + + # Verify other fields preserved in all cases + assert result["tools"][0]["keep"] == "val0" + assert result["tools"][5]["keep"] == "val5" + + # 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" + + def test_mixed_wildcards_and_indices_with_deep_nesting(self): + """Test combining [*] wildcards, [n] indices, and deep nesting in complex patterns.""" + data = { + "tools": [ + { + "name": "t0", + "configs": [ + {"id": "c0", "remove_me": "val1", "keep": "yes1"}, + {"id": "c1", "remove_me": "val2", "keep": "yes2"}, + ], + "metadata": {"drop_this": "meta1", "preserve": "preserve1"}, + }, + { + "name": "t1", + "configs": [ + {"id": "c0", "remove_me": "val3", "keep": "yes3"}, + {"id": "c1", "remove_me": "val4", "keep": "yes4"}, + ], + "metadata": {"drop_this": "meta2", "preserve": "preserve2"}, + }, + { + "name": "t2", + "configs": [ + {"id": "c0", "remove_me": "val5", "keep": "yes5"}, + ], + "metadata": {"drop_this": "meta3", "preserve": "preserve3"}, + }, + ] + } + + # Simulate processing multiple complex paths + paths = [ + "tools[*].configs[1].remove_me", # Wildcard + specific index [1] + nested + "tools[1].metadata.drop_this", # Specific index + nested + "tools[*].configs[*].id", # Double wildcard + nested + ] + + result = data + for path in paths: + result = delete_nested_value(result, path) + + # Verify: tools[*].configs[1].remove_me removed from second config of all tools (that have one) + assert "remove_me" in result["tools"][0]["configs"][0] # First config untouched + assert ( + "remove_me" not in result["tools"][0]["configs"][1] + ) # Second config removed + assert "remove_me" in result["tools"][1]["configs"][0] # First config untouched + assert ( + "remove_me" not in result["tools"][1]["configs"][1] + ) # Second config removed + assert ( + "remove_me" in result["tools"][2]["configs"][0] + ) # Only has [0], unaffected + + # Verify: tools[1].metadata.drop_this removed only from second tool + assert "drop_this" in result["tools"][0]["metadata"] + assert "drop_this" not in result["tools"][1]["metadata"] + assert "drop_this" in result["tools"][2]["metadata"] + + # Verify: tools[*].configs[*].id removed from all configs in all tools + assert "id" not in result["tools"][0]["configs"][0] + assert "id" not in result["tools"][0]["configs"][1] + assert "id" not in result["tools"][1]["configs"][0] + assert "id" not in result["tools"][1]["configs"][1] + assert "id" not in result["tools"][2]["configs"][0] + + # Verify: other fields preserved + assert result["tools"][0]["configs"][0]["keep"] == "yes1" + assert result["tools"][1]["configs"][1]["keep"] == "yes4" + assert result["tools"][0]["metadata"]["preserve"] == "preserve1" + assert result["tools"][1]["metadata"]["preserve"] == "preserve2" + assert result["tools"][2]["name"] == "t2" + + # Verify original unchanged + assert "remove_me" in data["tools"][0]["configs"][1] + + +# Phase 1 tests - validates core functionality and complex patterns