mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Add litellm metadata correctly for file create
This commit is contained in:
parent
f2d2ed5a0d
commit
4b385e5b32
3 changed files with 289 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue