feat(proxy): add require_managed_files setting for file uploads (#30186)

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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.

* fix(proxy): reject model param to close require_managed_files bypass

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Sameer Kankute 2026-06-12 19:55:28 +05:30 • committed by GitHub
parent 680f3ff810
commit 41801d65bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 398 additions and 1 deletions

View file

@ -362,6 +362,9 @@ enable_gemini_default_thinking_level_low: 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'
)

View file

@ -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.

View file

@ -45,6 +45,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
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:

View file

@ -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 @@ def test_get_file_content_non_openai_provider_skips_streaming_handler(
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"]