diff --git a/docs/my-website/docs/proxy/litellm_managed_files.md b/docs/my-website/docs/proxy/litellm_managed_files.md index ab0e4b3a75..c63b121889 100644 --- a/docs/my-website/docs/proxy/litellm_managed_files.md +++ b/docs/my-website/docs/proxy/litellm_managed_files.md @@ -21,7 +21,7 @@ Available via the `litellm[proxy]` package or any `litellm` docker image. | Proxy | ✅ | | | SDK | ❌ | Requires postgres DB for storing file ids. | | Available across all providers | ✅ | | -| Supported endpoints | `/chat/completions`, `/batch`, `/fine_tuning` | | +| Supported endpoints | `/chat/completions`, `/batch`, `/fine_tuning`, `/responses` | | ## Usage diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 80cc77883f..20b850191d 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -296,6 +296,16 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): file_ids, user_api_key_dict.parent_otel_span ) + data["model_file_id_mapping"] = model_file_id_mapping + elif call_type == CallTypes.aresponses.value or call_type == CallTypes.responses.value: + # Handle managed files in responses API input + input_data = data.get("input") + if input_data: + file_ids = self.get_file_ids_from_responses_input(input_data) + if file_ids: + model_file_id_mapping = await self.get_model_file_id_mapping( + file_ids, user_api_key_dict.parent_otel_span + ) data["model_file_id_mapping"] = model_file_id_mapping elif call_type == CallTypes.afile_content.value: retrieve_file_id = cast(Optional[str], data.get("file_id")) @@ -453,6 +463,47 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): file_ids.append(file_id) return file_ids + def get_file_ids_from_responses_input( + self, input: Union[str, List[Dict[str, Any]]] + ) -> List[str]: + """ + Gets file ids from responses API input. + + The input can be: + - A string (no files) + - A list of input items, where each item can have: + - type: "input_file" with file_id + - content: a list that can contain items with type: "input_file" and file_id + """ + file_ids: List[str] = [] + + if isinstance(input, str): + return file_ids + + if not isinstance(input, list): + return file_ids + + for item in input: + if not isinstance(item, dict): + continue + + # Check for direct input_file type + if item.get("type") == "input_file": + file_id = item.get("file_id") + if file_id: + file_ids.append(file_id) + + # Check for input_file in content array + content = item.get("content") + if isinstance(content, list): + for content_item in content: + if isinstance(content_item, dict) and content_item.get("type") == "input_file": + file_id = content_item.get("file_id") + if file_id: + file_ids.append(file_id) + + return file_ids + async def get_model_file_id_mapping( self, file_ids: List[str], litellm_parent_otel_span: Span ) -> dict: diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 69e3cc4332..c50ceeabdb 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -437,6 +437,66 @@ def update_messages_with_model_file_ids( return messages +def update_responses_input_with_model_file_ids( + input: Any, +) -> Union[str, List[Dict[str, Any]]]: + """ + Updates responses API input with provider-specific file IDs. + File IDs are always inside the content array, not as direct input_file items. + + For managed files (unified file IDs), decodes the base64-encoded unified file ID + and extracts the llm_output_file_id directly. + """ + from litellm.proxy.openai_files_endpoints.common_utils import ( + _is_base64_encoded_unified_file_id, + convert_b64_uid_to_unified_uid, + ) + + if isinstance(input, str): + return input + + if not isinstance(input, list): + return input + + updated_input = [] + for item in input: + if not isinstance(item, dict): + updated_input.append(item) + continue + + updated_item = item.copy() + content = item.get("content") + if isinstance(content, list): + updated_content = [] + for content_item in content: + if isinstance(content_item, dict) and content_item.get("type") == "input_file": + file_id = content_item.get("file_id") + if file_id: + # Check if this is a managed file ID (base64-encoded unified file ID) + is_unified_file_id = _is_base64_encoded_unified_file_id(file_id) + if is_unified_file_id: + unified_file_id = convert_b64_uid_to_unified_uid(file_id) + if "llm_output_file_id," in unified_file_id: + provider_file_id = unified_file_id.split("llm_output_file_id,")[1].split(";")[0] + else: + # Fallback: keep original if we can't extract + provider_file_id = file_id + updated_content_item = content_item.copy() + updated_content_item["file_id"] = provider_file_id + updated_content.append(updated_content_item) + else: + updated_content.append(content_item) + else: + updated_content.append(content_item) + else: + updated_content.append(content_item) + updated_item["content"] = updated_content + + updated_input.append(updated_item) + + return updated_input + + def extract_file_data(file_data: FileTypes) -> ExtractedFileData: """ Extracts and processes file data from various input formats. diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 9cb1691c46..013b50aa8a 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -12,6 +12,7 @@ from typing import ( Optional, Type, Union, + cast, ) import httpx @@ -37,6 +38,9 @@ from litellm.types.llms.openai import ( ToolChoice, ToolParam, ) +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + update_responses_input_with_model_file_ids, +) # Handle ResponseText import with fallback if TYPE_CHECKING: @@ -555,6 +559,13 @@ def responses( api_base=litellm_params.api_base, api_key=litellm_params.api_key, ) + + ######################################################### + # Update input with provider-specific file IDs if managed files are used + ######################################################### + input = cast(Union[str, ResponseInputParam], update_responses_input_with_model_file_ids(input=input)) + local_vars["input"] = input + ######################################################### # Native MCP Responses API ######################################################### diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 9d419c76f1..9bef6ebef7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -390,6 +390,8 @@ CallTypesLiteral = Literal[ "vector_store_file_delete", "avector_store_file_delete", "call_mcp_tool", + "aresponses", + "responses", ] diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index 032bd03a54..5f66b03aad 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -453,3 +453,140 @@ async def test_async_post_call_success_hook_twice_assert_no_unique_violation(): await asyncio.gather(*tasks, return_exceptions=True) for task in tasks: assert task.exception() is None, f"Error: {task.exception()}" + + +def test_update_responses_input_with_unified_file_id(): + """ + Test that update_responses_input_with_model_file_ids correctly decodes + unified file IDs and extracts llm_output_file_id from responses API input. + """ + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + update_responses_input_with_model_file_ids, + ) + + # Create a base64-encoded unified file ID + # This decodes to: litellm_proxy:application/pdf;unified_id,6c0b5890-8914-48e0-b8f4-0ae5ed3c14a5;target_model_names,gpt-4o;llm_output_file_id,file-ECBPW7ML9g7XHdwGgUPZaM;llm_output_file_model_id,e26453f9e76e7993680d0068d98c1f4cc205bbad0967a33c664893568ca743c2 + unified_file_id = "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9wZGY7dW5pZmllZF9pZCw2YzBiNTg5MC04OTE0LTQ4ZTAtYjhmNC0wYWU1ZWQzYzE0YTU7dGFyZ2V0X21vZGVsX25hbWVzLGdwdC00bztsbG1fb3V0cHV0X2ZpbGVfaWQsZmlsZS1FQ0JQVzdNTDlnN1hIZHdHZ1VQWmFNO2xsbV9vdXRwdXRfZmlsZV9tb2RlbF9pZCxlMjY0NTNmOWU3NmU3OTkzNjgwZDAwNjhkOThjMWY0Y2MyMDViYmFkMDk2N2EzM2M2NjQ4OTM1NjhjYTc0M2My" + + # Test input with unified file ID in content array + input_data = [ + { + "role": "user", + "content": [ + { + "type": "input_file", + "file_id": unified_file_id, + }, + { + "type": "input_text", + "text": "What is the first dragon in the book?", + }, + ], + } + ] + + # Update the input + updated_input = update_responses_input_with_model_file_ids(input=input_data) + + # Verify the file_id was updated to the provider-specific file ID + assert updated_input[0]["content"][0]["type"] == "input_file" + assert updated_input[0]["content"][0]["file_id"] == "file-ECBPW7ML9g7XHdwGgUPZaM" + assert updated_input[0]["content"][1]["type"] == "input_text" + assert updated_input[0]["content"][1]["text"] == "What is the first dragon in the book?" + + +def test_update_responses_input_with_regular_file_id(): + """ + Test that update_responses_input_with_model_file_ids keeps regular + OpenAI file IDs unchanged. + """ + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + update_responses_input_with_model_file_ids, + ) + + # Regular OpenAI file ID (not a unified file ID) + regular_file_id = "file-abc123xyz" + + input_data = [ + { + "role": "user", + "content": [ + { + "type": "input_file", + "file_id": regular_file_id, + }, + { + "type": "input_text", + "text": "What is this file?", + }, + ], + } + ] + + # Update the input + updated_input = update_responses_input_with_model_file_ids(input=input_data) + + # Verify the file_id was kept unchanged (regular OpenAI file ID) + assert updated_input[0]["content"][0]["type"] == "input_file" + assert updated_input[0]["content"][0]["file_id"] == regular_file_id + assert updated_input[0]["content"][1]["type"] == "input_text" + + +def test_update_responses_input_with_string_input(): + """ + Test that update_responses_input_with_model_file_ids returns string input unchanged. + """ + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + update_responses_input_with_model_file_ids, + ) + + input_data = "What is AI?" + + updated_input = update_responses_input_with_model_file_ids(input=input_data) + + assert updated_input == input_data + assert isinstance(updated_input, str) + + +def test_update_responses_input_with_multiple_file_ids(): + """ + Test that update_responses_input_with_model_file_ids handles multiple file IDs + (both unified and regular) in the same input. + """ + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + update_responses_input_with_model_file_ids, + ) + + # Unified file ID + unified_file_id = "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9wZGY7dW5pZmllZF9pZCw2YzBiNTg5MC04OTE0LTQ4ZTAtYjhmNC0wYWU1ZWQzYzE0YTU7dGFyZ2V0X21vZGVsX25hbWVzLGdwdC00bztsbG1fb3V0cHV0X2ZpbGVfaWQsZmlsZS1FQ0JQVzdNTDlnN1hIZHdHZ1VQWmFNO2xsbV9vdXRwdXRfZmlsZV9tb2RlbF9pZCxlMjY0NTNmOWU3NmU3OTkzNjgwZDAwNjhkOThjMWY0Y2MyMDViYmFkMDk2N2EzM2M2NjQ4OTM1NjhjYTc0M2My" + # Regular OpenAI file ID + regular_file_id = "file-regular123" + + input_data = [ + { + "role": "user", + "content": [ + { + "type": "input_file", + "file_id": unified_file_id, + }, + { + "type": "input_text", + "text": "Compare these files", + }, + { + "type": "input_file", + "file_id": regular_file_id, + }, + ], + } + ] + + updated_input = update_responses_input_with_model_file_ids(input=input_data) + + # Verify unified file ID was updated + assert updated_input[0]["content"][0]["file_id"] == "file-ECBPW7ML9g7XHdwGgUPZaM" + # Verify regular file ID was kept unchanged + assert updated_input[0]["content"][2]["file_id"] == regular_file_id + # Verify text content was preserved + assert updated_input[0]["content"][1]["text"] == "Compare these files"