From 5f4d667365c4ce99574bc9a36877a623154ebffe Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:17:37 -0700 Subject: [PATCH] fix(files): keep the SDK listing a list and build the OpenAI page at the proxy route --- litellm/llms/custom_httpx/llm_http_handler.py | 12 ++-- .../openai_files_endpoints/files_endpoints.py | 10 ++++ .../test_bedrock_files_transformation.py | 30 ++++------ .../test_files_endpoint.py | 57 +++++++++++++++++++ 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b29185fec2e..0c9c7ad2b7f 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -59,7 +59,6 @@ from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) -from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig @@ -121,7 +120,6 @@ from litellm.types.llms.openai import ( CreateBatchRequest, CreateFileRequest, FileContentRequest, - FileListPage, HttpxBinaryResponseContent, OpenAIFileObject, ResponseInputParam, @@ -4901,7 +4899,7 @@ class BaseLLMHTTPHandler: _is_async: bool = False, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> FileListPage | Coroutine[object, object, FileListPage]: + ) -> list[OpenAIFileObject] | Coroutine[object, object, list[OpenAIFileObject]]: """ List all files """ @@ -4956,10 +4954,9 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_listing_page( response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout ) - listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns + return [ # mutable-ok: the files contract returns the listing as a list listed_file for page_files in files_per_page for listed_file in page_files ] - return FileListPage(**build_list_page(listed_files)) async def async_list_files( self, @@ -4970,7 +4967,7 @@ class BaseLLMHTTPHandler: logging_obj: LiteLLMLoggingObj, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> FileListPage: + ) -> list[OpenAIFileObject]: """ Async list all files """ @@ -5014,10 +5011,9 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_async_listing_page( response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout ) - listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns + return [ # mutable-ok: the files contract returns the listing as a list listed_file async for page_files in files_per_page for listed_file in page_files ] - return FileListPage(**build_list_page(listed_files)) def _files_per_listing_page( self, diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 00acc524eb8..28455270c17 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -33,6 +33,7 @@ from litellm.litellm_core_utils.cloud_storage_security import ( ) from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket from litellm.llms.base_llm.files.transformation import BaseFileEndpoints +from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing @@ -85,6 +86,7 @@ from litellm.router import Router from litellm.types.llms.openai import ( CREATE_FILE_REQUESTS_PURPOSE, FileExpiresAfter, + FileListPage, OpenAIFileObject, OpenAIFilesPurpose, ) @@ -92,6 +94,7 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) +_LISTED_FILES_ADAPTER: Final = TypeAdapter(list[OpenAIFileObject]) class UploadedFileInfo(TypedDict): @@ -1441,6 +1444,12 @@ async def delete_file( ) +def _as_file_list_page(response: object) -> object: + if not isinstance(response, list): + return response + return FileListPage(**build_list_page(_LISTED_FILES_ADAPTER.validate_python(response))) + + @router.get( "/{provider}/v1/files", dependencies=[Depends(user_api_key_auth)], @@ -1587,6 +1596,7 @@ async def list_files( status_code=500, detail="Either 'provider' or 'target_model_names' must be provided e.g. `?target_model_names=gpt-4o`", ) + response = _as_file_list_page(response) # rebind-ok: each dispatch branch above binds response ## POST CALL HOOKS ### _response: Final = await proxy_logging_obj.post_call_success_hook( diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index f3506dc4045..55f4b75ba02 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -3024,20 +3024,13 @@ class TestBedrockFileListTransformation: return_value=httpx.Response(200, content=self.LISTING) ) - page = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) + files = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) assert route.called request = route.calls[0].request assert request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in page.data] == list(self.BATCH_IDS) - assert (page.object, page.first_id, page.last_id, page.has_more) == ( - "list", - self.BATCH_IDS[0], - self.BATCH_IDS[-1], - False, - ) - assert page.model_dump()["object"] == "list" + assert [file.id for file in files] == list(self.BATCH_IDS) def test_file_list_uses_trusted_snapshot_bucket_without_env(self, monkeypatch): import httpx @@ -3058,7 +3051,7 @@ class TestBedrockFileListTransformation: ) assert route.called - assert [file.id for file in files.data] == [*self.BATCH_IDS, self.OUTPUT_ID] + assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID] @pytest.mark.asyncio async def test_afile_list_end_to_end_sends_signed_listing(self, monkeypatch): @@ -3083,8 +3076,7 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files.data] == [self.OUTPUT_ID] - assert (files.object, files.first_id, files.last_id, files.has_more) == ("list", self.OUTPUT_ID, self.OUTPUT_ID, False) + assert [file.id for file in files] == [self.OUTPUT_ID] def test_transform_list_files_request_narrows_prefix_to_requested_purpose(self, monkeypatch): from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3185,11 +3177,11 @@ class TestBedrockFileListTransformation: return_value=httpx.Response(200, content=self.EMPTY_LISTING) ) - page = litellm.file_list(custom_llm_provider="bedrock", purpose="user_data", **_bedrock_s3_params()) + files = litellm.file_list(custom_llm_provider="bedrock", purpose="user_data", **_bedrock_s3_params()) assert route.call_count == 1 assert "prefix" not in route.calls[0].request.url.params - assert (page.data, page.has_more) == ([], False) + assert files == [] def test_transform_list_files_request_lists_the_output_bucket_without_an_input_bucket(self, monkeypatch): from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3253,7 +3245,7 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files.data] == [self.OUTPUT_BUCKET_ID] + assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] def test_transform_list_files_next_request_signs_the_continuation_page(self, monkeypatch): import httpx @@ -3350,7 +3342,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files.data) + self._assert_paged_listing(first_page, last_page, files) @pytest.mark.asyncio async def test_afile_list_follows_continuation_tokens_across_pages(self, monkeypatch): @@ -3372,7 +3364,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files.data) + self._assert_paged_listing(first_page, last_page, files) OVERSIZED_PAGE_SIZE = 3000 OVERSIZED_PAGE_COUNT = 6 @@ -3429,7 +3421,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files.data) + self._assert_capped_listing(route, files) @pytest.mark.asyncio async def test_afile_list_stops_at_the_openai_listing_ceiling(self, monkeypatch): @@ -3450,7 +3442,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files.data) + self._assert_capped_listing(route, files) def test_file_list_end_to_end_surfaces_the_s3_error_body(self, monkeypatch): import httpx 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 824170e6b3d..3b700d80539 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 @@ -2441,6 +2441,63 @@ def test_list_files_resolves_wildcard_deployment_credentials( proxy_logging_obj.post_call_failure_hook.assert_not_called() +def test_list_files_by_model_returns_an_openai_page_for_a_provider_listing( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + from litellm.types.llms.openai import FileListPage + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + 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.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=None) + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + listed_files = [ + OpenAIFileObject( + id=f"file-{index}", + bytes=index, + created_at=index, + filename=f"{index}.jsonl", + object="file", + purpose="batch", + status="uploaded", + ) + for index in (1, 2) + ] + + async def _mock_afile_list(**kwargs): + return list(listed_files) + + monkeypatch.setattr(litellm, "afile_list", _mock_afile_list) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="test-user", + ) + + try: + response = client.get( + "/v1/files?target_model_names=gpt-3.5-turbo", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + body = response.json() + assert body["object"] == "list" + assert [listed["id"] for listed in body["data"]] == ["file-1", "file-2"] + assert (body["first_id"], body["last_id"], body["has_more"]) == ("file-1", "file-2", False) + hook_response = proxy_logging_obj.post_call_success_hook.call_args.kwargs["response"] + assert isinstance(hook_response, FileListPage) + assert [listed.id for listed in hook_response.data] == ["file-1", "file-2"] + + def test_list_files_model_routing_does_not_forward_custom_llm_provider_twice( mocker: MockerFixture, monkeypatch, llm_router: Router ):