From f198efee32f459946377fb1183aac969291e7904 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:00:12 +0000 Subject: [PATCH] fix(proxy): trigger async_pre_call_hook on POST /v1/files uploads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../openai_files_endpoints/files_endpoints.py | 12 +++ litellm/types/utils.py | 2 + .../test_files_endpoint.py | 79 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 92bbd58ed90..59bbc182087 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -526,6 +526,18 @@ async def create_file( proxy_config=proxy_config, ) + hook_data: Final[dict] = { + **data, + "purpose": purpose, + "file": {"filename": file.filename, "content_type": file.content_type, "size": file.size}, + } + hooked_data: Final[dict] = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, + data=hook_data, + call_type="acreate_file", + ) + data = {k: v for k, v in hooked_data.items() if k not in ("purpose", "file")} + # /v1/files stores its proxy metadata under litellm_metadata, not metadata request_metadata: Final = data.get("metadata") or data.get("litellm_metadata") or EMPTY_MAPPING scan_result: Final = await _scan_batch_upload( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 95429e899c9..a4bac719442 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -543,6 +543,8 @@ CallTypesLiteral = Literal[ "_arealtime", "create_batch", "acreate_batch", + "create_file", + "acreate_file", "pass_through_endpoint", "allm_passthrough_route", "anthropic_messages", 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 23552f2fa31..37c0382222f 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 @@ -4476,3 +4476,82 @@ def test_scoped_list_files_still_resolves_deployment_credentials( provider_list.assert_awaited_once() assert provider_list.await_args.kwargs["custom_llm_provider"] == "openai" assert provider_list.await_args.kwargs["api_key"] == "openai_api_key" + + +def _post_user_data_file() -> httpx.Response: + return client.post( + "/v1/files", + files={"file": ("labels.jsonl", b'{"label": "restricted"}', "application/json")}, + data={"purpose": "user_data"}, + headers={"Authorization": "Bearer test-key"}, + ) + + +def _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, hook): + setup_proxy_logging_object(monkeypatch, llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr(litellm, "callbacks", [hook]) + monkeypatch.setattr( + "litellm.proxy.openai_files_endpoints.files_endpoints.files_config", + [{"custom_llm_provider": "openai", "api_key": "sk-test"}], + ) + return mocker.patch( + "litellm.proxy.openai_files_endpoints.files_endpoints.litellm.acreate_file", + new=mocker.AsyncMock( + return_value=OpenAIFileObject( + id="file-hooked", + object="file", + bytes=23, + created_at=1234567890, + filename="labels.jsonl", + purpose="user_data", + status="uploaded", + ) + ), + ) + + +def test_create_file_triggers_async_pre_call_hook(mocker: MockerFixture, monkeypatch, llm_router: Router): + """`POST /v1/files` must run `async_pre_call_hook` so a hook can inspect the upload + before it reaches the provider (LIT-5916).""" + from litellm.integrations.custom_logger import CustomLogger + + recorded: dict = {} + + class RecordingHook(CustomLogger): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + recorded["call_type"] = call_type + recorded["purpose"] = data.get("purpose") + recorded["file"] = data.get("file") + + provider_create = _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, RecordingHook()) + + response = _post_user_data_file() + + assert response.status_code == 200, response.text + assert recorded["call_type"] == "acreate_file" + assert recorded["purpose"] == "user_data" + assert recorded["file"]["filename"] == "labels.jsonl" + provider_create.assert_awaited_once() + forwarded = provider_create.await_args.kwargs + assert forwarded["purpose"] == "user_data" + assert forwarded["file"][0] == "labels.jsonl" + + +def test_create_file_async_pre_call_hook_rejection_blocks_upload( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """A hook rejecting the upload must 400 before the file reaches the provider.""" + from litellm.integrations.custom_logger import CustomLogger + + class RejectingHook(CustomLogger): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + return "file upload not allowed" + + provider_create = _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, RejectingHook()) + + response = _post_user_data_file() + + assert response.status_code == 400, response.text + assert "file upload not allowed" in response.text + provider_create.assert_not_awaited()