fix(files): keep the SDK listing a list and build the OpenAI page at the proxy route

This commit is contained in:
mateo-berri 2026-09-07 21:17:37 -07:00
parent 91391c1360
commit 5f4d667365
4 changed files with 82 additions and 27 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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
):