diff --git a/litellm/__init__.py b/litellm/__init__.py index e6c30e12286..9ac600adb2b 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -359,6 +359,9 @@ def _dev_env_hot_reload_enabled() -> bool: #################### logging: bool = True enable_loadbalancing_on_batch_endpoints: Optional[bool] = None +require_managed_files: bool = ( + False # proxy only - require target_model_names on POST /v1/files +) enable_caching_on_provider_specific_optional_params: bool = ( False # feature-flag for caching on optional params - e.g. 'top_k' ) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index b2834e52306..2ba1d937c04 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -619,8 +619,10 @@ async def extract_file_creation_params( # Extract target_storage (simplified - just use form parameter) target_storage = _extract_target_storage_simple(target_storage_form) - # Extract target_model_names (simplified - just use form parameter) + # Extract target_model_names from the form field, then fall back to the raw form target_model_names = _extract_target_model_names_simple(target_model_names_form) + if not target_model_names: + target_model_names = await _extract_target_model_names_from_form(request) # Extract model parameter model = _extract_model_param(request, request_body) @@ -667,6 +669,77 @@ def _extract_target_model_names_simple( return [] +def _is_target_model_names_key(key: str) -> bool: + return key == "target_model_names" or ( + key.startswith("target_model_names[") and key.endswith("]") + ) + + +async def _extract_target_model_names_from_form(request: "Request") -> List[str]: + """ + Collect target_model_names from the raw multipart form. + + Reads ``request.form()`` directly instead of the parsed request body, which is + built via ``dict(form_data)`` and keeps only the last value for repeated keys. + The OpenAI SDK sends a list ``extra_body`` as repeated ``target_model_names[]`` + fields, so reading the form preserves every value instead of truncating to one. + Indexed keys like ``target_model_names[0]`` are handled the same way. + """ + form_data = await request.form() + + names: List[str] = [] + for key, value in form_data.multi_items(): + if _is_target_model_names_key(key) and isinstance(value, str): + names.extend(_extract_target_model_names_simple(value)) + + seen = set() + result: List[str] = [] + for name in names: + if name and name not in seen: + seen.add(name) + result.append(name) + return result + + +def validate_managed_files_requirement( + target_model_names: List[str], + model: Optional[str] = None, +) -> None: + """ + Enforce proxy-level managed files when litellm.require_managed_files is enabled. + + Raises: + HTTPException: 400 if the upload would bypass the managed-files flow, i.e. + target_model_names is missing or a model parameter routes the request + through the direct provider path instead of the managed-files hook. + """ + import litellm + from fastapi import HTTPException + + if litellm.require_managed_files is not True: + return + + if not target_model_names: + raise HTTPException( + status_code=400, + detail=( + "target_model_names is required when require_managed_files is enabled " + "in litellm_settings. Provide one or more model aliases via the " + "target_model_names form field (e.g. target_model_names=my-model-alias)." + ), + ) + + if model: + raise HTTPException( + status_code=400, + detail=( + "model is not allowed when require_managed_files is enabled in " + "litellm_settings. Uploads must go through managed files using " + "target_model_names instead of the model parameter." + ), + ) + + def _extract_model_param(request: "Request", request_body: dict) -> Optional[str]: """ Extract model parameter from request. diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 9eef7cd7e8b..3e5873c2655 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -45,6 +45,7 @@ get_credentials_for_model, handle_model_based_routing, prepare_data_with_credentials, + validate_managed_files_requirement, ) from litellm.proxy.utils import ProxyLogging, is_known_model from litellm.repositories.table_repositories import ManagedFileRepository @@ -345,6 +346,11 @@ async def create_file( # noqa: PLR0915 target_storage = file_params.target_storage target_model_names_list = file_params.target_model_names model_param = file_params.model + + validate_managed_files_requirement( + target_model_names=target_model_names_list, model=model_param + ) + # Prepare the data for forwarding # Replace with: 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 5fc36b71f2b..103e05bd3af 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 @@ -1,6 +1,7 @@ import json import os import sys +from typing import List from unittest.mock import ANY, AsyncMock import pytest @@ -1873,3 +1874,317 @@ async def _mock_afile_content(**kwargs): assert "stream" not in captured_kwargs mock_streaming_response.assert_not_awaited() proxy_logging_obj.post_call_failure_hook.assert_not_called() + + +def test_require_managed_files_rejects_missing_target_model_names( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr("litellm.require_managed_files", True) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + + mock_acreate_file = mocker.patch("litellm.acreate_file", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.post( + "/v1/files", + files={"file": ("test.txt", b"abc", "text/plain")}, + data={"purpose": "user_data"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + monkeypatch.setattr("litellm.require_managed_files", False) + + assert response.status_code == 400, response.text + error_message = response.json()["error"]["message"] + assert error_message.startswith("target_model_names is required") + assert not error_message.startswith("{") + mock_acreate_file.assert_not_called() + + +def test_require_managed_files_allows_managed_file_upload( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr("litellm.require_managed_files", True) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + + 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, + ): + return OpenAIFileObject( + id="litellm_managed_file_abc123", + object="file", + bytes=3, + created_at=1234567890, + filename="test.txt", + purpose="user_data", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError + + async def afile_delete( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + async def afile_content( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + + mock_acreate_file = mocker.patch("litellm.acreate_file", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.post( + "/v1/files", + files={"file": ("test.txt", b"abc", "text/plain")}, + data={ + "purpose": "user_data", + "target_model_names": "gpt-3.5-turbo", + }, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + monkeypatch.setattr("litellm.require_managed_files", False) + + assert response.status_code == 200, response.text + assert response.json()["id"] == "litellm_managed_file_abc123" + mock_acreate_file.assert_not_called() + + +def test_require_managed_files_rejects_model_param_bypass( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """ + Supplying model alongside target_model_names must not bypass managed files: + route_create_file would otherwise take the model branch and call + litellm.acreate_file directly instead of the managed-files hook. + """ + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr("litellm.require_managed_files", True) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + + mock_acreate_file = mocker.patch("litellm.acreate_file", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.post( + "/v1/files", + files={"file": ("test.txt", b"abc", "text/plain")}, + data={ + "purpose": "user_data", + "target_model_names": "gpt-3.5-turbo", + "model": "gpt-3.5-turbo", + }, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + monkeypatch.setattr("litellm.require_managed_files", False) + + assert response.status_code == 400, response.text + error_message = response.json()["error"]["message"] + assert error_message.startswith("model is not allowed") + mock_acreate_file.assert_not_called() + + +def test_require_managed_files_accepts_target_model_names_bracket_form( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """ + OpenAI SDK sends list extra_body as target_model_names[] in multipart form. + """ + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr("litellm.require_managed_files", True) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + + 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, + ): + assert target_model_names_list == ["gpt-3.5-turbo"] + return OpenAIFileObject( + id="litellm_managed_file_bracket", + object="file", + bytes=3, + created_at=1234567890, + filename="test.txt", + purpose="user_data", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError + + async def afile_delete( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + async def afile_content( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.post( + "/v1/files", + files={"file": ("test.txt", b"abc", "text/plain")}, + data={ + "purpose": "user_data", + "target_model_names[]": "gpt-3.5-turbo", + }, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + monkeypatch.setattr("litellm.require_managed_files", False) + + assert response.status_code == 200, response.text + assert response.json()["id"] == "litellm_managed_file_bracket" + + +def test_require_managed_files_accepts_repeated_target_model_names_bracket_form( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """ + The OpenAI SDK serialises a list extra_body as repeated target_model_names[] + fields. dict(form_data) keeps only the last one, so every value must survive. + """ + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr("litellm.require_managed_files", True) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + + received_target_model_names: List[str] = [] + + 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, + ): + received_target_model_names.extend(target_model_names_list) + return OpenAIFileObject( + id="litellm_managed_file_repeated", + object="file", + bytes=3, + created_at=1234567890, + filename="test.txt", + purpose="user_data", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError + + async def afile_delete( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + async def afile_content( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.post( + "/v1/files", + files={"file": ("test.txt", b"abc", "text/plain")}, + data={ + "purpose": "user_data", + "target_model_names[]": ["azure-gpt-3-5-turbo", "gpt-3.5-turbo"], + }, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + monkeypatch.setattr("litellm.require_managed_files", False) + + assert response.status_code == 200, response.text + assert response.json()["id"] == "litellm_managed_file_repeated" + assert received_target_model_names == ["azure-gpt-3-5-turbo", "gpt-3.5-turbo"]