diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 92bbd58ed90..9bc90260de1 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -8,7 +8,7 @@ import asyncio import traceback from collections.abc import Mapping -from typing import Any, BinaryIO, Final, cast, get_args +from typing import Any, BinaryIO, Final, TypedDict, cast, get_args import httpx from fastapi import ( @@ -23,6 +23,7 @@ from fastapi import ( status, ) from pydantic import TypeAdapter +from typing_extensions import ReadOnly import litellm from litellm import CreateFileRequest, get_secret_str @@ -83,6 +84,13 @@ router: Final = APIRouter() _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) + +class UploadedFileInfo(TypedDict): + filename: ReadOnly[str | None] + content_type: ReadOnly[str | None] + size: ReadOnly[int | None] + + files_config = None @@ -526,6 +534,22 @@ async def create_file( proxy_config=proxy_config, ) + uploaded_file_info: Final[UploadedFileInfo] = { + "filename": file.filename, + "content_type": file.content_type, + "size": file.size, + } + data["purpose"] = purpose + data["file"] = uploaded_file_info + hooked_data: Final = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, + data=data, + call_type="acreate_file", + ) + data = hooked_data if hooked_data is not None else data + data.pop("purpose", None) + data.pop("file", None) + # /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 737361e5413..f0319a7c664 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -544,6 +544,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..87e0319f6a1 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(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 respx.post("https://api.openai.com/v1/files").mock( + return_value=respx.MockResponse( + status_code=200, + json={ + "id": "file-hooked", + "object": "file", + "bytes": 23, + "created_at": 1234567890, + "filename": "labels.jsonl", + "purpose": "user_data", + "status": "uploaded", + }, + ) + ) + + +@respx.mock +def test_create_file_triggers_async_pre_call_hook(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_route = _setup_create_file_over_pre_call_hook(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" + assert provider_route.call_count == 1 + forwarded_body = provider_route.calls.last.request.content + assert b"user_data" in forwarded_body + assert b"labels.jsonl" in forwarded_body + + +@respx.mock +def test_create_file_async_pre_call_hook_rejection_blocks_upload(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_route = _setup_create_file_over_pre_call_hook(monkeypatch, llm_router, RejectingHook()) + + response = _post_user_data_file() + + assert response.status_code == 400, response.text + assert "file upload not allowed" in response.text + assert provider_route.call_count == 0