diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 1d94b10f6a4..e1bca6e905f 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -298,6 +298,103 @@ async def get_request_body(request: Request) -> Dict[str, Any]: return {} +def extract_nested_form_metadata( + form_data: Dict[str, Any], + prefix: str = "litellm_metadata[" +) -> Dict[str, Any]: + """ + Extract nested metadata from form data with bracket notation. + + Handles form data that uses bracket notation to represent nested dictionaries, + such as litellm_metadata[spend_logs_metadata][owner] = "value". + + This is commonly encountered when SDKs or clients send form data with nested + structures using bracket notation instead of JSON. + + Args: + form_data: Dictionary containing form data (from request.form()) + prefix: The prefix to look for in form keys (default: "litellm_metadata[") + + Returns: + Dictionary with nested structure reconstructed from bracket notation + + Example: + Input form_data: + { + "litellm_metadata[spend_logs_metadata][owner]": "john", + "litellm_metadata[spend_logs_metadata][team]": "engineering", + "litellm_metadata[tags]": "production", + "other_field": "value" + } + + Output: + { + "spend_logs_metadata": { + "owner": "john", + "team": "engineering" + }, + "tags": "production" + } + """ + if not form_data: + return {} + + metadata: Dict[str, Any] = {} + + for key, value in form_data.items(): + # Skip keys that don't start with the prefix + if not isinstance(key, str) or not key.startswith(prefix): + continue + + # Skip UploadFile objects - they should not be in metadata + if isinstance(value, UploadFile): + verbose_proxy_logger.warning( + f"Skipping UploadFile in metadata extraction for key: {key}" + ) + continue + + # Extract the nested path from bracket notation + # Example: "litellm_metadata[spend_logs_metadata][owner]" -> ["spend_logs_metadata", "owner"] + try: + # Remove the prefix and strip trailing ']' + path_string = key.replace(prefix, "").rstrip("]") + + # Split by "][" to get individual path parts + parts = path_string.split("][") + + if not parts or not parts[0]: + verbose_proxy_logger.warning( + f"Invalid metadata key format (empty path): {key}" + ) + continue + + # Navigate/create nested dictionary structure + current = metadata + for part in parts[:-1]: + if not isinstance(current, dict): + verbose_proxy_logger.warning( + f"Cannot create nested path - intermediate value is not a dict at: {part}" + ) + break + current = current.setdefault(part, {}) + else: + # Set the final value (only if we didn't break out of the loop) + if isinstance(current, dict): + current[parts[-1]] = value + else: + verbose_proxy_logger.warning( + f"Cannot set value - parent is not a dict for key: {key}" + ) + + except Exception as e: + verbose_proxy_logger.error( + f"Error parsing metadata key '{key}': {str(e)}" + ) + continue + + return metadata + + def get_tags_from_request_body(request_body: dict) -> List[str]: """ Extract tags from request body metadata. diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 2c6b378ae38..ddce41b037a 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -29,7 +29,10 @@ from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing -from litellm.proxy.common_utils.http_parsing_utils import _read_request_body +from litellm.proxy.common_utils.http_parsing_utils import ( + _read_request_body, + extract_nested_form_metadata, +) from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_body, get_custom_llm_provider_from_request_headers, @@ -354,16 +357,20 @@ async def create_file( # noqa: PLR0915 data = {} + # Parse expires_after if provided + expires_after = None + form_data = await request.form() + litellm_metadata = extract_nested_form_metadata( + form_data=form_data, + prefix="litellm_metadata[" + ) + expires_after_anchor = form_data.get("expires_after[anchor]") + expires_after_seconds_str = form_data.get("expires_after[seconds]") + # Add litellm_metadata to data if provided (from form field) if litellm_metadata is not None: data["litellm_metadata"] = litellm_metadata - # Parse expires_after if provided - expires_after = None - form_data = await request.form() - expires_after_anchor = form_data.get("expires_after[anchor]") - expires_after_seconds_str = form_data.get("expires_after[seconds]") - if expires_after_anchor is not None or expires_after_seconds_str is not None: if expires_after_anchor is None or expires_after_seconds_str is None: raise HTTPException( diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index b86f927ea00..837ae79bffc 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -950,3 +950,181 @@ def test_managed_files_with_loadbalancing(mocker: MockerFixture, monkeypatch, ll assert router_acreate_file_calls[0]["model"] == "azure-gpt-3-5-turbo" assert router_acreate_file_calls[1]["model"] == "gpt-3.5-turbo" assert all(call["via_router"] for call in router_acreate_file_calls), "All calls should go through router" + + +def test_create_file_with_nested_litellm_metadata( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """ + Test that nested litellm_metadata is correctly parsed from form data in bracket notation. + + Regression test for: litellm_metadata[spend_logs_metadata][owner] format should be + correctly parsed into nested dictionary structure. + """ + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.types.llms.openai import OpenAIFileObject + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + + captured_litellm_metadata = {} + + class DummyManagedFiles(BaseFileEndpoints): + async def acreate_file(self, llm_router, create_file_request, target_model_names_list, litellm_parent_otel_span, user_api_key_dict): + # Capture litellm_metadata for verification + if isinstance(create_file_request, dict): + captured_litellm_metadata.update( + create_file_request.get("litellm_metadata", {}) + ) + else: + captured_litellm_metadata.update( + getattr(create_file_request, "litellm_metadata", {}) + ) + + return OpenAIFileObject( + id="file-test-123", + object="file", + bytes=100, + created_at=1234567890, + filename="test.jsonl", + purpose="fine-tune", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError("Not implemented for test") + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_delete(self, file_id, litellm_parent_otel_span, llm_router, **data): + raise NotImplementedError("Not implemented for test") + + async def afile_content(self, file_id, litellm_parent_otel_span, llm_router, **data): + raise NotImplementedError("Not implemented for test") + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("test.jsonl", test_file_content, "application/jsonl") + + # Test with nested litellm_metadata in bracket notation + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "target_model_names": "gpt-3.5-turbo", + "litellm_metadata[spend_logs_metadata][owner]": "john_doe", + "litellm_metadata[spend_logs_metadata][team]": "engineering", + "litellm_metadata[tags]": "production", + "litellm_metadata[environment]": "prod", + }, + headers={"Authorization": "Bearer test-key"}, + ) + + # Verify success + assert response.status_code == 200 + result = response.json() + assert result["id"] == "file-test-123" + + # Verify nested metadata was correctly parsed + assert "spend_logs_metadata" in captured_litellm_metadata + assert captured_litellm_metadata["spend_logs_metadata"]["owner"] == "john_doe" + assert captured_litellm_metadata["spend_logs_metadata"]["team"] == "engineering" + assert captured_litellm_metadata["tags"] == "production" + assert captured_litellm_metadata["environment"] == "prod" + + +def test_create_file_with_deep_nested_litellm_metadata( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """ + Test that deeply nested litellm_metadata is correctly parsed from form data. + + Regression test for: litellm_metadata[a][b][c] format should be correctly parsed. + """ + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.types.llms.openai import OpenAIFileObject + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + + captured_litellm_metadata = {} + + class DummyManagedFiles(BaseFileEndpoints): + async def acreate_file(self, llm_router, create_file_request, target_model_names_list, litellm_parent_otel_span, user_api_key_dict): + if isinstance(create_file_request, dict): + captured_litellm_metadata.update( + create_file_request.get("litellm_metadata", {}) + ) + else: + captured_litellm_metadata.update( + getattr(create_file_request, "litellm_metadata", {}) + ) + + return OpenAIFileObject( + id="file-test-456", + object="file", + bytes=50, + created_at=1234567890, + filename="nested.jsonl", + purpose="batch", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError("Not implemented for test") + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_delete(self, file_id, litellm_parent_otel_span, llm_router, **data): + raise NotImplementedError("Not implemented for test") + + async def afile_content(self, file_id, litellm_parent_otel_span, llm_router, **data): + raise NotImplementedError("Not implemented for test") + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo"}}' + test_file = ("nested.jsonl", test_file_content, "application/jsonl") + + # Test with deeply nested metadata + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + "litellm_metadata[config][database][host]": "localhost", + "litellm_metadata[config][database][port]": "5432", + "litellm_metadata[config][cache][enabled]": "true", + }, + headers={"Authorization": "Bearer test-key"}, + ) + + # Verify success + assert response.status_code == 200 + result = response.json() + assert result["id"] == "file-test-456" + + # Verify deeply nested metadata was correctly parsed + assert "config" in captured_litellm_metadata + assert "database" in captured_litellm_metadata["config"] + assert captured_litellm_metadata["config"]["database"]["host"] == "localhost" + assert captured_litellm_metadata["config"]["database"]["port"] == "5432" + assert "cache" in captured_litellm_metadata["config"] + assert captured_litellm_metadata["config"]["cache"]["enabled"] == "true"