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 1/4] 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() From dc18aaf13d17bfbd509ea29e1bc59101366cea48 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:23:59 +0000 Subject: [PATCH 2/4] test: mock provider files API at the HTTP boundary with respx Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_files_endpoint.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) 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 37c0382222f..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 @@ -4487,7 +4487,7 @@ def _post_user_data_file() -> httpx.Response: ) -def _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, hook): +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]) @@ -4495,23 +4495,24 @@ def _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, hook) "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", - ) - ), + 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", + }, + ) ) -def test_create_file_triggers_async_pre_call_hook(mocker: MockerFixture, monkeypatch, llm_router: Router): +@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 @@ -4524,7 +4525,7 @@ def test_create_file_triggers_async_pre_call_hook(mocker: MockerFixture, monkeyp recorded["purpose"] = data.get("purpose") recorded["file"] = data.get("file") - provider_create = _setup_create_file_over_pre_call_hook(mocker, monkeypatch, llm_router, RecordingHook()) + provider_route = _setup_create_file_over_pre_call_hook(monkeypatch, llm_router, RecordingHook()) response = _post_user_data_file() @@ -4532,15 +4533,14 @@ def test_create_file_triggers_async_pre_call_hook(mocker: MockerFixture, monkeyp 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" + 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 -def test_create_file_async_pre_call_hook_rejection_blocks_upload( - mocker: MockerFixture, monkeypatch, llm_router: Router -): +@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 @@ -4548,10 +4548,10 @@ def test_create_file_async_pre_call_hook_rejection_blocks_upload( 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()) + 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 - provider_create.assert_not_awaited() + assert provider_route.call_count == 0 From 44cfb2537952be397191ec1a22055a9445019311 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:30:17 +0000 Subject: [PATCH 3/4] fix(proxy): expose upload info to file pre-call hooks without new mutable builds Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../openai_files_endpoints/files_endpoints.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 59bbc182087..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,17 +534,21 @@ 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}, + uploaded_file_info: Final[UploadedFileInfo] = { + "filename": file.filename, + "content_type": file.content_type, + "size": file.size, } - hooked_data: Final[dict] = await proxy_logging_obj.pre_call_hook( + 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=hook_data, + data=data, call_type="acreate_file", ) - data = {k: v for k, v in hooked_data.items() if k not in ("purpose", "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 From f1bebb6fbd5ab67a569aca616f62cd5604d768a1 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 28 Aug 2026 16:25:41 -0700 Subject: [PATCH 4/4] chore(deps): raise RestrictedPython floor to 8.5 RestrictedPython 8.3 extended its protected-name validation to cover positional-only parameters, so sandboxed source can no longer bind a local named _getattr_, _getitem_, _write_ or _print_ that takes precedence over the hooks the custom-code guardrail sandbox installs. 8.4 and 8.5 continue in the same direction with safer_getattr and the Python 3.15 syntax audit. The floor moves rather than the lock alone so downstream installs of litellm[proxy] pick up the same behaviour. --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eba9e5afc98..a0db4d49467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ proxy = [ "mcp>=1.28.1,<2.0", "litellm-proxy-extras==0.4.90", "litellm-enterprise==0.1.61", - "RestrictedPython>=8.1,<9.0", + "RestrictedPython>=8.5,<9.0", "rich>=13.9.4,<14.0", "InquirerPy>=0.3.4,<1.0", "polars>=1.38.1,<2.0", diff --git a/uv.lock b/uv.lock index f42c67079e6..019c5a70f9e 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-24T20:19:42.376246Z" +exclude-newer = "2026-08-25T23:16:47.126855Z" exclude-newer-span = "P3D" [manifest] @@ -4558,7 +4558,7 @@ requires-dist = [ { name = "redisvl", marker = "extra == 'extra-proxy'", specifier = ">=0.4.1,<1.0" }, { name = "requests", marker = "extra == 'cli'", specifier = ">=2.32.0,<3.0" }, { name = "resend", marker = "extra == 'extra-proxy'", specifier = ">=2.23.0,<3.0" }, - { name = "restrictedpython", marker = "extra == 'proxy'", specifier = ">=8.1,<9.0" }, + { name = "restrictedpython", marker = "extra == 'proxy'", specifier = ">=8.5,<9.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.4,<14.0" }, { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, @@ -8265,11 +8265,11 @@ wheels = [ [[package]] name = "restrictedpython" -version = "8.1" +version = "8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/1c/aec08bcb4ab14a1521579fbe21ceff2a634bb1f737f11cf7f9c8bb96e680/restrictedpython-8.1.tar.gz", hash = "sha256:4a69304aceacf6bee74bdf153c728221d4e3109b39acbfe00b3494927080d898", size = 838331, upload-time = "2025-10-19T14:11:32.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/3b/8e41f7cfabbb30b1013ebc7484303d6c87da2906ec432d69dea11d2f7d75/restrictedpython-8.5.tar.gz", hash = "sha256:4ed1269dbe3caa88db650d1af325198a952aeb1451eca05df0cfa65db4466215", size = 455879, upload-time = "2026-08-19T07:02:10.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/c0/3848f4006f7e164ee20833ca984067e4b3fc99fe7f1dfa88b4927e681299/restrictedpython-8.1-py3-none-any.whl", hash = "sha256:4769449c6cdb10f2071649ba386902befff0eff2a8fd6217989fa7b16aeae926", size = 27651, upload-time = "2025-10-19T14:11:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/16ce3c721f5a33317e4110575d5c9976c0c45f7fd96ca2e0adeab06e6026/restrictedpython-8.5-py3-none-any.whl", hash = "sha256:6c70e0a3af13e830d37225788cdc8ab5804a8df4b500c135086eaef34b5c01e0", size = 30962, upload-time = "2026-08-19T07:02:09.553Z" }, ] [[package]]