From 776c00d2ebaa6d2f43642d4241ab06934dbc79d6 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 11 Jun 2026 10:12:26 +0530 Subject: [PATCH 1/4] feat(proxy): add require_managed_files setting for file uploads Add an opt-in litellm_settings flag that rejects POST /v1/files without target_model_names, and parse target_model_names[] from OpenAI SDK list extra_body. Co-authored-by: Cursor --- litellm/__init__.py | 3 + .../openai_files_endpoints/common_utils.py | 72 ++++++- .../openai_files_endpoints/files_endpoints.py | 4 + .../test_files_endpoint.py | 187 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) 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..25234461e18 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -619,8 +619,11 @@ 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 form field, then fall back to request body + # (OpenAI SDK sends list extra_body as target_model_names[] in multipart form) target_model_names = _extract_target_model_names_simple(target_model_names_form) + if not target_model_names and request_body: + target_model_names = _extract_target_model_names_from_request_body(request_body) # Extract model parameter model = _extract_model_param(request, request_body) @@ -667,6 +670,73 @@ def _extract_target_model_names_simple( return [] +def _parse_target_model_names_value(value) -> List[str]: + """Parse a target_model_names value from form or JSON body.""" + from fastapi import UploadFile + + if value is None or isinstance(value, UploadFile): + return [] + if isinstance(value, list): + return [str(name).strip() for name in value if str(name).strip()] + if isinstance(value, str): + return _extract_target_model_names_simple(value) + return [str(value).strip()] if str(value).strip() else [] + + +def _extract_target_model_names_from_request_body(request_body: dict) -> List[str]: + """ + Extract target_model_names from parsed request body. + + Supports: + - target_model_names (string or list) + - target_model_names[] (OpenAI SDK list extra_body in multipart form) + - target_model_names[0], target_model_names[1], ... + """ + names: List[str] = [] + + for key in ("target_model_names", "target_model_names[]"): + if key in request_body: + names.extend(_parse_target_model_names_value(request_body[key])) + + for key, value in request_body.items(): + if key.startswith("target_model_names[") and key not in { + "target_model_names[]", + }: + names.extend(_parse_target_model_names_value(value)) + + # Preserve order while deduplicating + 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]) -> None: + """ + Enforce proxy-level managed files when litellm.require_managed_files is enabled. + + Raises: + HTTPException: 400 if target_model_names is missing while enforcement is on. + """ + import litellm + from fastapi import HTTPException + + if litellm.require_managed_files is True and not target_model_names: + raise HTTPException( + status_code=400, + detail={ + "error": ( + "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)." + ), + }, + ) + + 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..1c53b2bcde0 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,9 @@ 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) + # 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..372b1db62a4 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 @@ -1873,3 +1873,190 @@ 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 + assert "target_model_names is required" in response.json()["error"]["message"] + 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_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" From 3640b72578f9c3d1a6b0381e1653e7e69b663e2c Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 11 Jun 2026 10:21:35 +0530 Subject: [PATCH 2/4] fix(proxy): return clean require_managed_files error message Use a plain HTTPException detail string so create_file does not stringify a dict, and import UploadFile at module scope. Co-authored-by: Cursor --- .../proxy/openai_files_endpoints/common_utils.py | 16 +++++++--------- .../openai_files_endpoint/test_files_endpoint.py | 4 +++- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 25234461e18..77cf48bba81 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -5,6 +5,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING, List, Literal, Optional, Union +from starlette.datastructures import UploadFile + from litellm.repositories.table_repositories import ( ManagedFileRepository, ManagedObjectRepository, @@ -672,8 +674,6 @@ def _extract_target_model_names_simple( def _parse_target_model_names_value(value) -> List[str]: """Parse a target_model_names value from form or JSON body.""" - from fastapi import UploadFile - if value is None or isinstance(value, UploadFile): return [] if isinstance(value, list): @@ -727,13 +727,11 @@ def validate_managed_files_requirement(target_model_names: List[str]) -> None: if litellm.require_managed_files is True and not target_model_names: raise HTTPException( status_code=400, - detail={ - "error": ( - "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)." - ), - }, + 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)." + ), ) 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 372b1db62a4..11b39999eab 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 @@ -1905,7 +1905,9 @@ def test_require_managed_files_rejects_missing_target_model_names( monkeypatch.setattr("litellm.require_managed_files", False) assert response.status_code == 400, response.text - assert "target_model_names is required" in response.json()["error"]["message"] + 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() From 74a458f21279d56e580b79b0473d4a6593c21fd3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:03:03 +0000 Subject: [PATCH 3/4] fix(proxy): preserve repeated target_model_names[] form fields Read target_model_names from the raw multipart form instead of the dict(form_data)-collapsed request body so repeated target_model_names[] fields (how the OpenAI SDK serialises a list extra_body) keep every value rather than truncating to the last one. Drops the now-unreachable list branch in the value parser. --- .../openai_files_endpoints/common_utils.py | 51 +++++------- .../test_files_endpoint.py | 82 +++++++++++++++++++ 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 77cf48bba81..0388ab02e45 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -5,8 +5,6 @@ from types import MappingProxyType from typing import TYPE_CHECKING, List, Literal, Optional, Union -from starlette.datastructures import UploadFile - from litellm.repositories.table_repositories import ( ManagedFileRepository, ManagedObjectRepository, @@ -621,11 +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 from form field, then fall back to request body - # (OpenAI SDK sends list extra_body as target_model_names[] in multipart form) + # 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 and request_body: - target_model_names = _extract_target_model_names_from_request_body(request_body) + 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) @@ -672,39 +669,29 @@ def _extract_target_model_names_simple( return [] -def _parse_target_model_names_value(value) -> List[str]: - """Parse a target_model_names value from form or JSON body.""" - if value is None or isinstance(value, UploadFile): - return [] - if isinstance(value, list): - return [str(name).strip() for name in value if str(name).strip()] - if isinstance(value, str): - return _extract_target_model_names_simple(value) - return [str(value).strip()] if str(value).strip() else [] +def _is_target_model_names_key(key: str) -> bool: + return key == "target_model_names" or ( + key.startswith("target_model_names[") and key.endswith("]") + ) -def _extract_target_model_names_from_request_body(request_body: dict) -> List[str]: +async def _extract_target_model_names_from_form(request: "Request") -> List[str]: """ - Extract target_model_names from parsed request body. + Collect target_model_names from the raw multipart form. - Supports: - - target_model_names (string or list) - - target_model_names[] (OpenAI SDK list extra_body in multipart form) - - target_model_names[0], target_model_names[1], ... + 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. """ - names: List[str] = [] - - for key in ("target_model_names", "target_model_names[]"): - if key in request_body: - names.extend(_parse_target_model_names_value(request_body[key])) + form_data = await request.form() - for key, value in request_body.items(): - if key.startswith("target_model_names[") and key not in { - "target_model_names[]", - }: - names.extend(_parse_target_model_names_value(value)) + 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)) - # Preserve order while deduplicating seen = set() result: List[str] = [] for name in names: 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 11b39999eab..c3965c0b9c2 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 @@ -2062,3 +2063,84 @@ async def afile_content( 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"] From 305f5958aee5354492e2f0f10999fc02df482e20 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:20:05 +0000 Subject: [PATCH 4/4] fix(proxy): reject model param to close require_managed_files bypass --- .../openai_files_endpoints/common_utils.py | 24 ++++++++-- .../openai_files_endpoints/files_endpoints.py | 4 +- .../test_files_endpoint.py | 44 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 0388ab02e45..2ba1d937c04 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -701,17 +701,25 @@ async def _extract_target_model_names_from_form(request: "Request") -> List[str] return result -def validate_managed_files_requirement(target_model_names: List[str]) -> None: +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 target_model_names is missing while enforcement is on. + 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 True and not target_model_names: + if litellm.require_managed_files is not True: + return + + if not target_model_names: raise HTTPException( status_code=400, detail=( @@ -721,6 +729,16 @@ def validate_managed_files_requirement(target_model_names: List[str]) -> None: ), ) + 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]: """ diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 1c53b2bcde0..3e5873c2655 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -347,7 +347,9 @@ async def create_file( # noqa: PLR0915 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) + validate_managed_files_requirement( + target_model_names=target_model_names_list, model=model_param + ) # Prepare the data for forwarding 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 c3965c0b9c2..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 @@ -1988,6 +1988,50 @@ async def afile_content( 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 ):