mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
680f3ff810
commit
41801d65bd
4 changed files with 398 additions and 1 deletions
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue