diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index c986e835e4f..39f8de0b0cc 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -45,6 +45,8 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.openai_files_endpoints.common_utils import ( + FILE_LIST_CONTINUATION_CHUNK_SIZE, + MAX_FILE_LIST_LIMIT, _is_base64_encoded_unified_file_id, apply_unified_file_ids, ensure_batch_response_managed_file_ids, @@ -54,6 +56,8 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( map_raw_file_ids_to_unified, normalize_mime_type_for_provider, resolve_managed_output_file_model_name, + validate_file_list_limit, + validate_file_list_purpose, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import ( request_tags_from_metadata, @@ -63,9 +67,9 @@ from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccess AsyncCursorPage, ChatCompletionFileObject, CreateFileRequest, + FileListPage, FileObject, OpenAIFileObject, - OpenAIFilesPurpose, ResponsesAPIResponse, ) from litellm.types.utils import ( @@ -144,7 +148,14 @@ class _ManagedFileRow(Protocol): class _ManagedFileTableActions(Protocol): async def find_first(self, where: Mapping[str, object]) -> Optional[_ManagedFileRow]: ... - async def find_many(self, where: Mapping[str, object]) -> Sequence[_ManagedFileRow]: ... + async def find_many( + self, + where: Mapping[str, object], + take: int = ..., + order: Union[Mapping[str, str], Sequence[Mapping[str, str]]] = ..., + cursor: Mapping[str, str] = ..., + skip: int = ..., + ) -> Sequence[_ManagedFileRow]: ... async def upsert(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, object]]) -> _ManagedFileRow: ... @@ -1365,12 +1376,76 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): async def afile_list( self, - purpose: Optional[OpenAIFilesPurpose], + purpose: Optional[str], litellm_parent_otel_span: Optional[Span], + user_api_key_dict: UserAPIKeyAuth, + limit: Optional[int] = None, + after: Optional[str] = None, **data: Dict, - ) -> List[OpenAIFileObject]: - """Handled in files_endpoints.py""" - return [] + ) -> FileListPage: + """List the managed files the caller owns, newest first. + + Pagination is keyset based on ``unified_file_id`` so a key that owns + every file on the proxy still reads one bounded page at a time. + ``purpose`` is applied after parsing, because the managed file table + keeps it inside the ``file_object`` blob instead of a column, and rows + whose blob will not parse drop out there too, so a chunk of rows can + yield fewer matches than the page holds. Successive chunks are read + until the page is full or the caller's rows run out, which keeps + ``data`` non-empty while matches remain and its last id usable as the + next cursor. A first chunk that fills the page costs one query; once a + scan has to continue past it, the chunk widens to + ``FILE_LIST_CONTINUATION_CHUNK_SIZE``, so the walk costs one query per + that many rows instead of one per page. That bound is per query, not + per request: the work is still linear in the rows the caller owns, and + a filter matching nothing reads every one of them, with no index + covering either the owner filter or the sort. + """ + validate_file_list_limit(limit) + validate_file_list_purpose(purpose) + + owner_filter: Final = build_owner_filter(user_api_key_dict) + if owner_filter is None: + return FileListPage(**build_list_page([])) + + if after: + cursor_row = await _managed_file_table(self.prisma_client).find_first( + where={**owner_filter, "unified_file_id": after} + ) + if cursor_row is None: + raise ProxyException( + message=f"Invalid 'after' cursor: no file found with id '{after}'.", + type="invalid_request_error", + param="after", + code=400, + openai_code="invalid_value", + ) + + page_size: Final = min(limit or MAX_FILE_LIST_LIMIT, MAX_FILE_LIST_LIMIT) + matches: Final[List[OpenAIFileObject]] = [] + cursor_id = after + chunk_size = page_size + 1 + + while len(matches) <= page_size: + cursor_args: _CursorPageArgs = {"cursor": {"unified_file_id": cursor_id}, "skip": 1} if cursor_id else {} + chunk = await _managed_file_table(self.prisma_client).find_many( + where=owner_filter, + take=chunk_size, + order=[{"created_at": "desc"}, {"unified_file_id": "desc"}], + **cursor_args, + ) + matches.extend( + parsed_file_object.model_copy(update={"id": row.unified_file_id}) + for row in chunk + if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None + and (purpose is None or parsed_file_object.purpose == purpose) + ) + if len(chunk) < chunk_size: + break + cursor_id = chunk[-1].unified_file_id + chunk_size = max(chunk_size, FILE_LIST_CONTINUATION_CHUNK_SIZE) + + return FileListPage(**build_list_page(matches[:page_size], has_more=len(matches) > page_size)) def _is_batch_polling_enabled(self) -> bool: """ diff --git a/litellm/llms/base_llm/files/transformation.py b/litellm/llms/base_llm/files/transformation.py index 174be93448b..b20fe0f1560 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -11,9 +11,9 @@ from litellm.types.llms.openai import ( AllMessageValues, CreateFileRequest, FileContentRequest, + FileListPage, OpenAICreateFileRequestOptionalParams, OpenAIFileObject, - OpenAIFilesPurpose, ) from litellm.types.utils import LlmProviders, ModelResponse @@ -240,10 +240,13 @@ class BaseFileEndpoints(ABC): @abstractmethod async def afile_list( self, - purpose: OpenAIFilesPurpose | None, + purpose: str | None, litellm_parent_otel_span: Span | None, + user_api_key_dict: UserAPIKeyAuth, + limit: int | None = None, + after: str | None = None, **data: dict, - ) -> list[OpenAIFileObject]: + ) -> FileListPage: pass @abstractmethod diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index b9af01e9aea..d4cb8fe2374 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -4,12 +4,14 @@ import re from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, get_args, runtime_checkable +from litellm.proxy._types import ProxyException from litellm.repositories.table_repositories import ( ManagedFileRepository, ManagedObjectRepository, ) +from litellm.types.llms.openai import OpenAIFilesPurpose from litellm.types.utils import SpecialEnums if TYPE_CHECKING: @@ -22,6 +24,50 @@ if TYPE_CHECKING: from litellm.types.utils import LiteLLMBatch +MAX_FILE_LIST_LIMIT: Final = 10000 + +FILE_LIST_CONTINUATION_CHUNK_SIZE: Final = 500 + + +def validate_file_list_limit(limit: int | None) -> None: + """Reject a ``limit`` outside the range OpenAI documents for GET /v1/files.""" + if limit is None or 1 <= limit <= MAX_FILE_LIST_LIMIT: + return + bound, expected, openai_code = ( + ("below minimum", ">= 1", "integer_below_min_value") + if limit < 1 + else ("above maximum", f"<= {MAX_FILE_LIST_LIMIT}", "integer_above_max_value") + ) + raise ProxyException( + message=f"Invalid 'limit': integer {bound} value. Expected a value {expected}, but got {limit} instead.", + type="invalid_request_error", + param="limit", + code=400, + openai_code=openai_code, + ) + + +def validate_file_list_purpose(purpose: str | None) -> None: + """Reject a ``purpose`` filter no upload to this proxy could have stored. + + An unknown purpose matches no file, so filtering on it would report an + empty page for what is really a bad request. Rejecting it keeps a managed + listing consistent with the upload route, which refuses the same values + against this same set. The provider-backed listings do not: they pass + ``purpose`` upstream, so a purpose OpenAI accepts before it is added here + is rejected on the managed path while still working on those. + """ + valid_purposes: Final = get_args(OpenAIFilesPurpose) + if purpose is None or purpose in valid_purposes: + return + raise ProxyException( + message=f"Invalid purpose: {purpose}. Must be one of: {valid_purposes}", + type="invalid_request_error", + param="purpose", + code=400, + ) + + @runtime_checkable class ManagedResourceAccessChecker(Protocol): async def can_user_call_unified_file_id( diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 813ce9630a5..92bbd58ed90 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -65,6 +65,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( get_credentials_for_model, handle_model_based_routing, prepare_data_with_credentials, + validate_file_list_limit, validate_managed_files_requirement, validate_managed_id_requirement, ) @@ -1410,6 +1411,8 @@ async def list_files( provider: str | None = None, target_model_names: str | None = None, purpose: str | None = None, + limit: int | None = None, + after: str | None = None, ): """ Returns information about a specific file. that can be used across - Assistants API, Batch API @@ -1434,6 +1437,8 @@ async def list_files( data: dict = {} try: + validate_file_list_limit(limit) + # Include original request and headers in the data base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data) ( @@ -1500,24 +1505,30 @@ async def list_files( or get_custom_llm_provider_from_request_headers(request=request) or get_custom_llm_provider_from_request_query(request=request) or await get_custom_llm_provider_from_request_body(request=request) - or "openai" ) + managed_files_obj: Final = proxy_logging_obj.get_proxy_hook("managed_files") + if custom_llm_provider is None and isinstance(managed_files_obj, BaseFileEndpoints): + response = await managed_files_obj.afile_list( + purpose=purpose, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + user_api_key_dict=user_api_key_dict, + limit=limit, + after=after, + ) + else: + resolved_custom_llm_provider: Final = custom_llm_provider or "openai" + apply_team_provider_credentials( + data=data, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + custom_llm_provider=resolved_custom_llm_provider, + ) - # No model/target_model_names pinned: resolve upstream credentials from - # the team's deployment for this provider so the call is authenticated - # against the team's own account (e.g. the team's openai deployment). - apply_team_provider_credentials( - data=data, - llm_router=llm_router, - user_api_key_dict=user_api_key_dict, - custom_llm_provider=custom_llm_provider, - ) - - response = await litellm.afile_list( - custom_llm_provider=custom_llm_provider, - purpose=purpose, - **data, - ) + response = await litellm.afile_list( + custom_llm_provider=resolved_custom_llm_provider, + purpose=purpose, + **data, + ) if response is None: raise HTTPException( @@ -1561,6 +1572,8 @@ async def list_files( ) verbose_proxy_logger.error("litellm.proxy.proxy_server.list_files(): Exception occured - %s", e) verbose_proxy_logger.debug(traceback.format_exc()) + if isinstance(e, ProxyException): + raise if isinstance(e, HTTPException): raise ProxyException( message=getattr(e, "message", str(e.detail)), diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 1588c650177..50e47071012 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -65,6 +65,7 @@ from pydantic import ( BaseModel, ConfigDict, Discriminator, + Field, PrivateAttr, field_serializer, field_validator, @@ -275,6 +276,7 @@ OpenAIFilesPurpose = Literal[ "fine-tune-results", "vision", "user_data", + "evals", "messages", ] @@ -381,6 +383,21 @@ class OpenAIFileObject(BaseModel): return self.dict() +class FileListPage(BaseModel): + """A page of files, as `GET /v1/files` returns it. + + Post-call hooks and logging callbacks are handed the listing response, and + the provider SDKs hand them a page object rather than a mapping, so this + exposes the same ``.data`` attribute while serializing to an identical body. + """ + + object: Literal["list"] = "list" + data: list[OpenAIFileObject] = Field(default_factory=list) + first_id: str | None = None + last_id: str | None = None + has_more: bool = False + + CREATE_FILE_REQUESTS_PURPOSE = Literal["assistants", "batch", "fine-tune", "messages"] diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index fcd03e77aa2..eddfc4fbd34 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -14,8 +14,8 @@ import pytest from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch -from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.llms.openai import OpenAIFileObject +from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth +from litellm.types.llms.openai import FileListPage, OpenAIFileObject from litellm.types.utils import LiteLLMBatch @@ -66,6 +66,114 @@ def _make_user_api_key_dict() -> UserAPIKeyAuth: ) +def _make_team_member_api_key_dict() -> UserAPIKeyAuth: + """The shape most real virtual keys carry: a user_id and a team_id.""" + return UserAPIKeyAuth( + api_key="sk-test", + user_id="test-user", + team_id="test-team", + parent_otel_span=None, + ) + + +def _make_service_account_api_key_dict() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="sk-service", + team_id="test-team", + parent_otel_span=None, + ) + + +def _make_admin_api_key_dict() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="sk-admin", + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + parent_otel_span=None, + ) + + +def _make_managed_file_row( + unified_file_id: str, + purpose: str = "batch_output", + created_by: str = "test-user", + team_id: Optional[str] = None, +) -> MagicMock: + file_object = _make_file_object(f"file-provider-{unified_file_id}").model_copy( + update={"purpose": purpose} + ) + return MagicMock( + unified_file_id=unified_file_id, + file_object=file_object.model_dump(), + created_by=created_by, + team_id=team_id, + ) + + +def _make_unparseable_managed_file_row( + unified_file_id: str, + created_by: str = "test-user", + team_id: Optional[str] = None, +) -> MagicMock: + """A row whose stored blob cannot be parsed back into a file object.""" + return MagicMock( + unified_file_id=unified_file_id, + file_object=None, + created_by=created_by, + team_id=team_id, + ) + + +def _row_matches_where(row, where) -> bool: + """Apply the Prisma ``where`` shapes build_owner_filter actually emits: + ``{}``, a single equality, and the ``OR`` of equalities a key carrying + both a user_id and a team_id produces.""" + for field, expected in where.items(): + if field == "OR": + if not any(_row_matches_where(row, clause) for clause in expected): + return False + elif getattr(row, field) != expected: + return False + return True + + +class _FakeManagedFileTable: + """In-memory stand-in for the managed file table, newest row first.""" + + def __init__(self, rows): + self.rows = list(rows) + self.find_many_calls = [] + self.find_first_calls = [] + + def _owned_rows(self, where): + return [row for row in self.rows if _row_matches_where(row, where)] + + async def find_first(self, where): + self.find_first_calls.append(where) + return next(iter(self._owned_rows(where)), None) + + async def find_many(self, where, take=None, order=None, cursor=None, skip=0): + self.find_many_calls.append( + {"where": where, "take": take, "order": order, "cursor": cursor, "skip": skip} + ) + rows = self._owned_rows(where) + if cursor is not None: + start = next( + index + for index, row in enumerate(rows) + if row.unified_file_id == cursor["unified_file_id"] + ) + rows = rows[start + skip :] + return rows if take is None else rows[:take] + + +def _make_managed_files_over_rows(rows): + managed_files = _make_managed_files_instance() + table = _FakeManagedFileTable(rows) + managed_files.prisma_client.db.litellm_managedfiletable = table + return managed_files, table + + def _make_managed_files_instance(): """Create a _PROXY_LiteLLMManagedFiles with storage methods mocked out.""" from litellm_enterprise.proxy.hooks.managed_files import ( @@ -190,6 +298,578 @@ async def test_get_user_created_file_ids_remaps_stored_raw_provider_id_to_unifie assert files[0].purpose == raw_provider_object.purpose +@pytest.mark.asyncio +async def test_afile_list_returns_owner_scoped_managed_files(): + managed_files = _make_managed_files_instance() + managed_files.prisma_client.db.litellm_managedfiletable.find_many = AsyncMock( + return_value=[ + MagicMock( + file_object=_make_file_object("file-provider-id").model_dump(), + unified_file_id="unified-file-id", + ), + MagicMock( + file_object=_make_file_object("file-other-purpose").model_copy( + update={"purpose": "batch"} + ).model_dump(), + unified_file_id="unified-other-purpose", + ), + ] + ) + + response = await managed_files.afile_list( + purpose="batch_output", + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + managed_files.prisma_client.db.litellm_managedfiletable.find_many.assert_awaited_once_with( + where={"created_by": "test-user"}, + take=10001, + order=[{"created_at": "desc"}, {"unified_file_id": "desc"}], + ) + assert [file.id for file in response.data] == ["unified-file-id"] + assert response.first_id == "unified-file-id" + assert response.last_id == "unified-file-id" + assert response.has_more is False + + +@pytest.mark.asyncio +async def test_afile_list_returns_a_page_object_callbacks_can_read(): + """Post-call hooks receive the listing and read ``.data`` off it, the way the + provider SDK's page lets them. The body on the wire stays a plain list page.""" + from fastapi.encoders import jsonable_encoder + + managed_files, _ = _make_managed_files_over_rows([_make_managed_file_row("unified-file-id")]) + + page = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert isinstance(page, FileListPage) + assert [file.id for file in page.data] == ["unified-file-id"] + + body = jsonable_encoder(page) + assert list(body) == ["object", "data", "first_id", "last_id", "has_more"] + assert body["object"] == "list" + assert [file["id"] for file in body["data"]] == ["unified-file-id"] + assert body["first_id"] == "unified-file-id" + assert body["last_id"] == "unified-file-id" + assert body["has_more"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("purpose", ["nonexistent_purpose", "EVALS", "batch "]) +async def test_afile_list_rejects_a_purpose_the_files_api_never_accepts(purpose): + """No stored file can carry an undocumented purpose, so filtering on one is a + bad request rather than a legitimately empty page.""" + managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-file-id")]) + + with pytest.raises(ProxyException) as exc_info: + await managed_files.afile_list( + purpose=purpose, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.param == "purpose" + assert table.find_many_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("purpose", ["batch", "assistants", "fine-tune", "evals", None]) +async def test_afile_list_accepts_every_documented_purpose(purpose): + managed_files, _ = _make_managed_files_over_rows([_make_managed_file_row("unified-file-id")]) + + page = await managed_files.afile_list( + purpose=purpose, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert isinstance(page, FileListPage) + + +@pytest.mark.asyncio +async def test_afile_list_does_not_leak_another_callers_files(): + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-mine-2"), + _make_managed_file_row("unified-theirs", created_by="other-user"), + _make_managed_file_row("unified-mine-1"), + ] + ) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert [file.id for file in response.data] == ["unified-mine-2", "unified-mine-1"] + assert table.find_many_calls[0]["where"] == {"created_by": "test-user"} + + +@pytest.mark.asyncio +async def test_afile_list_returns_own_and_team_files_for_a_key_carrying_both_ids(): + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-mine"), + _make_managed_file_row("unified-teammates", created_by="other-user", team_id="test-team"), + _make_managed_file_row("unified-outsiders", created_by="outsider", team_id="other-team"), + ] + ) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_team_member_api_key_dict(), + ) + + assert [file.id for file in response.data] == ["unified-mine", "unified-teammates"] + assert table.find_many_calls[0]["where"] == { + "OR": [{"created_by": "test-user"}, {"team_id": "test-team"}] + } + + +@pytest.mark.asyncio +async def test_afile_list_scopes_a_service_account_key_to_its_team(): + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-teams", created_by="other-user", team_id="test-team"), + _make_managed_file_row("unified-outsiders", created_by="outsider", team_id="other-team"), + ] + ) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_service_account_api_key_dict(), + ) + + assert [file.id for file in response.data] == ["unified-teams"] + assert table.find_many_calls[0]["where"] == {"team_id": "test-team"} + + +@pytest.mark.asyncio +async def test_afile_list_returns_every_callers_files_for_a_proxy_admin(): + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-mine"), + _make_managed_file_row("unified-theirs", created_by="other-user", team_id="other-team"), + ] + ) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_admin_api_key_dict(), + ) + + assert [file.id for file in response.data] == ["unified-mine", "unified-theirs"] + assert table.find_many_calls[0]["where"] == {} + + +@pytest.mark.asyncio +async def test_afile_list_pages_a_team_key_across_both_halves_of_its_filter(): + """Keyset pagination has to walk an OR filter as one ordered set, without + repeating a row across pages or dropping one between them.""" + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-0"), + _make_managed_file_row("unified-1", created_by="other-user", team_id="test-team"), + _make_managed_file_row("unified-2"), + _make_managed_file_row("unified-3", created_by="outsider", team_id="other-team"), + _make_managed_file_row("unified-4", created_by="other-user", team_id="test-team"), + ] + ) + user_api_key_dict = _make_team_member_api_key_dict() + + seen = [] + cursor = None + for _ in range(4): + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=2, + after=cursor, + ) + seen.extend(file.id for file in response.data) + if not response.has_more: + break + cursor = response.last_id + + assert seen == ["unified-0", "unified-1", "unified-2", "unified-4"] + assert all( + call["where"] == {"OR": [{"created_by": "test-user"}, {"team_id": "test-team"}]} + for call in table.find_many_calls + ) + + +@pytest.mark.asyncio +async def test_afile_list_orders_newest_first_and_breaks_ties_on_the_cursor_column(): + managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-mine")]) + + await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert table.find_many_calls[0]["order"] == [ + {"created_at": "desc"}, + {"unified_file_id": "desc"}, + ] + + +@pytest.mark.asyncio +async def test_afile_list_denies_a_caller_without_a_user_or_team(): + managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-mine")]) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", parent_otel_span=None), + ) + + assert response.data == [] + assert response.has_more is False + assert table.find_many_calls == [] + + +@pytest.mark.asyncio +async def test_afile_list_filters_by_purpose(): + managed_files, _ = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-batch-output"), + _make_managed_file_row("unified-batch", purpose="batch"), + ] + ) + + response = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + ) + + assert [file.id for file in response.data] == ["unified-batch"] + + +async def _walk_afile_list(managed_files, user_api_key_dict, purpose, limit): + """Page through the listing the way the official SDK does, off ``data[-1].id``.""" + seen = [] + after = None + while True: + page = await managed_files.afile_list( + purpose=purpose, + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=limit, + after=after, + ) + page_ids = [file.id for file in page.data] + assert not set(page_ids) & set(seen) + seen.extend(page_ids) + if not page.has_more: + return seen + assert page_ids, "an SDK stops paging on an empty page, so has_more must never ride one" + after = page_ids[-1] + + +@pytest.mark.asyncio +async def test_afile_list_fills_a_page_past_rows_the_purpose_filter_drops(): + """The newest rows do not match, so the page must reach past them rather than come back empty.""" + managed_files, _ = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-0"), + _make_managed_file_row("unified-1"), + _make_managed_file_row("unified-2", purpose="batch"), + _make_managed_file_row("unified-3"), + _make_managed_file_row("unified-4", purpose="batch"), + ] + ) + user_api_key_dict = _make_user_api_key_dict() + + first_page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=1, + ) + + assert [file.id for file in first_page.data] == ["unified-2"] + assert first_page.has_more is True + assert first_page.last_id == "unified-2" + + second_page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=1, + after=first_page.last_id, + ) + + assert [file.id for file in second_page.data] == ["unified-4"] + assert second_page.has_more is False + + +@pytest.mark.parametrize("limit", [1, 2, 3]) +@pytest.mark.asyncio +async def test_afile_list_walks_every_purpose_match_at_any_limit(limit): + managed_files, _ = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-0"), + _make_managed_file_row("unified-1"), + _make_managed_file_row("unified-2", purpose="batch"), + _make_managed_file_row("unified-3"), + _make_managed_file_row("unified-4", purpose="batch"), + _make_managed_file_row("unified-5", purpose="batch"), + _make_managed_file_row("unified-6"), + ] + ) + + seen = await _walk_afile_list(managed_files, _make_user_api_key_dict(), "batch", limit) + + assert seen == ["unified-2", "unified-4", "unified-5"] + + +@pytest.mark.asyncio +async def test_afile_list_fills_a_page_past_rows_that_do_not_parse(): + managed_files, _ = _make_managed_files_over_rows( + [ + _make_unparseable_managed_file_row("unified-0"), + _make_unparseable_managed_file_row("unified-1"), + _make_managed_file_row("unified-2"), + ] + ) + + page = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=1, + ) + + assert [file.id for file in page.data] == ["unified-2"] + assert page.has_more is False + + +_DEEP_SCAN_ROW_COUNT = 2000 +_DEEP_SCAN_QUERY_BUDGET = 10 + + +@pytest.mark.asyncio +async def test_afile_list_bounds_the_queries_a_deep_purpose_match_costs(): + """A tiny limit over rows the filter drops must not turn one request into thousands of queries.""" + managed_files, table = _make_managed_files_over_rows( + [_make_managed_file_row(f"unified-{index:05d}") for index in range(_DEEP_SCAN_ROW_COUNT)] + + [_make_managed_file_row("unified-match", purpose="batch")] + ) + + page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=1, + ) + + assert [file.id for file in page.data] == ["unified-match"] + assert page.has_more is False + assert len(table.find_many_calls) <= _DEEP_SCAN_QUERY_BUDGET + + +@pytest.mark.asyncio +async def test_afile_list_bounds_the_queries_a_deep_unparseable_run_costs(): + """Rows that will not parse drop out like a filter does, so they get the same bound.""" + managed_files, table = _make_managed_files_over_rows( + [_make_unparseable_managed_file_row(f"unified-{index:05d}") for index in range(_DEEP_SCAN_ROW_COUNT)] + + [_make_managed_file_row("unified-parses")] + ) + + page = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=1, + ) + + assert [file.id for file in page.data] == ["unified-parses"] + assert page.has_more is False + assert len(table.find_many_calls) <= _DEEP_SCAN_QUERY_BUDGET + + +@pytest.mark.asyncio +async def test_afile_list_reads_one_chunk_when_the_first_one_fills_the_page(): + """The widened chunk must stay off the common path, where the newest rows already fill the page.""" + managed_files, table = _make_managed_files_over_rows( + [_make_managed_file_row(f"unified-{index:05d}") for index in range(_DEEP_SCAN_ROW_COUNT)] + ) + + page = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=2, + ) + + assert [file.id for file in page.data] == ["unified-00000", "unified-00001"] + assert page.has_more is True + assert [call["take"] for call in table.find_many_calls] == [3] + + +@pytest.mark.asyncio +async def test_afile_list_reports_no_more_pages_when_nothing_matches(): + managed_files, _ = _make_managed_files_over_rows( + [_make_managed_file_row(f"unified-{index}") for index in range(5)] + ) + + page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=2, + ) + + assert page.data == [] + assert page.has_more is False + assert page.first_id is None + assert page.last_id is None + + +@pytest.mark.asyncio +async def test_afile_list_honors_limit_and_reports_more_pages(): + managed_files, table = _make_managed_files_over_rows( + [_make_managed_file_row(f"unified-{index}") for index in range(5)] + ) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=2, + ) + + assert [file.id for file in response.data] == ["unified-0", "unified-1"] + assert response.has_more is True + assert table.find_many_calls[0]["take"] == 3 + + +@pytest.mark.asyncio +async def test_afile_list_pages_through_every_file_without_overlap(): + managed_files, table = _make_managed_files_over_rows( + [_make_managed_file_row(f"unified-{index}") for index in range(5)] + ) + user_api_key_dict = _make_user_api_key_dict() + + seen = [] + after = None + while True: + page = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=2, + after=after, + ) + page_ids = [file.id for file in page.data] + assert not set(page_ids) & set(seen) + seen.extend(page_ids) + if not page.has_more: + break + after = page.last_id + + assert seen == [f"unified-{index}" for index in range(5)] + assert table.find_many_calls[1]["cursor"] == {"unified_file_id": "unified-1"} + assert table.find_many_calls[1]["skip"] == 1 + + +@pytest.mark.parametrize( + "unknown_cursor", + ["unified-theirs", "unified-nowhere"], + ids=["another-users-file", "no-such-file"], +) +@pytest.mark.asyncio +async def test_afile_list_rejects_an_after_cursor_outside_the_callers_files(unknown_cursor): + from litellm.proxy._types import ProxyException + + managed_files, table = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-mine"), + _make_managed_file_row("unified-theirs", created_by="other-user"), + ] + ) + + with pytest.raises(ProxyException) as exc_info: + await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + after=unknown_cursor, + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.param == "after" + assert exc_info.value.message == f"Invalid 'after' cursor: no file found with id '{unknown_cursor}'." + assert table.find_first_calls[0] == { + "created_by": "test-user", + "unified_file_id": unknown_cursor, + } + assert table.find_many_calls == [] + + +@pytest.mark.parametrize( + "limit, bound, expected_range", + [ + (0, "below minimum", ">= 1"), + (-1, "below minimum", ">= 1"), + (10001, "above maximum", "<= 10000"), + ], +) +@pytest.mark.asyncio +async def test_afile_list_rejects_a_limit_outside_the_openai_range(limit, bound, expected_range): + from litellm.proxy._types import ProxyException + + managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-mine")]) + + with pytest.raises(ProxyException) as exc_info: + await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=limit, + ) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.param == "limit" + assert exc_info.value.message == ( + f"Invalid 'limit': integer {bound} value. Expected a value {expected_range}, but got {limit} instead." + ) + assert table.find_many_calls == [] + + +@pytest.mark.parametrize("limit", [1, 10000]) +@pytest.mark.asyncio +async def test_afile_list_accepts_the_ends_of_the_openai_limit_range(limit): + managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-mine")]) + + response = await managed_files.afile_list( + purpose=None, + litellm_parent_otel_span=None, + user_api_key_dict=_make_user_api_key_dict(), + limit=limit, + ) + + assert [file.id for file in response.data] == ["unified-mine"] + assert response.has_more is False + assert table.find_many_calls[0]["take"] == limit + 1 + + @pytest.mark.asyncio async def test_parse_managed_file_object_warning_omits_rejected_values(caplog): from litellm_enterprise.proxy.hooks.managed_files import ( 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 237a3092035..c15ba5bcedb 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 @@ -1,5 +1,5 @@ import json -from typing import List +from typing import Final, List from unittest.mock import ANY, AsyncMock import pytest @@ -19,7 +19,11 @@ from litellm.proxy.openai_files_endpoints.file_content_streaming_handler import FileContentStreamingHandler, ) from litellm.proxy.proxy_server import app -from litellm.types.llms.openai import HttpxBinaryResponseContent, OpenAIFileObject +from litellm.types.llms.openai import ( + FileListPage, + HttpxBinaryResponseContent, + OpenAIFileObject, +) client = TestClient(app) from litellm.caching.caching import DualCache @@ -325,7 +329,15 @@ def test_mock_create_audio_file(mocker: MockerFixture, monkeypatch, llm_router: async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -899,7 +911,15 @@ def test_create_file_with_expires_after( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1062,7 +1082,15 @@ def test_create_file_with_expires_after_valid_values( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1150,7 +1178,15 @@ def test_create_file_without_expires_after( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1247,7 +1283,15 @@ def test_managed_files_with_loadbalancing( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1364,7 +1408,15 @@ def test_create_file_with_nested_litellm_metadata( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1468,7 +1520,15 @@ def test_create_file_with_deep_nested_litellm_metadata( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError("Not implemented for test") - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError("Not implemented for test") async def afile_delete( @@ -1564,7 +1624,15 @@ def _make_capturing_managed_files(): async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError async def afile_delete( @@ -2047,7 +2115,15 @@ def test_require_managed_files_allows_managed_file_upload( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError async def afile_delete( @@ -2171,7 +2247,15 @@ def test_require_managed_files_accepts_target_model_names_bracket_form( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError async def afile_delete( @@ -2251,7 +2335,15 @@ def test_require_managed_files_accepts_repeated_target_model_names_bracket_form( async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): raise NotImplementedError - async def afile_list(self, purpose, litellm_parent_otel_span): + async def afile_list( + self, + purpose, + litellm_parent_otel_span, + user_api_key_dict, + limit=None, + after=None, + **data, + ): raise NotImplementedError async def afile_delete( @@ -2463,6 +2555,403 @@ def test_list_files_without_target_model_names_uses_team_openai_deployment( proxy_logging_obj.post_call_failure_hook.assert_not_called() +def test_unscoped_list_files_uses_managed_file_store( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + managed_file = OpenAIFileObject( + id="unified-file-id", + object="file", + bytes=100, + created_at=1700000000, + filename="output.jsonl", + purpose="batch_output", + status="processed", + ) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + managed_files = mocker.MagicMock(spec=BaseFileEndpoints) + managed_files.afile_list = mocker.AsyncMock( + return_value={ + "object": "list", + "data": [managed_file], + "first_id": managed_file.id, + "last_id": managed_file.id, + "has_more": False, + } + ) + proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files + 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() + 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) + provider_list = mocker.patch.object(litellm, "afile_list", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + ) + + try: + response = client.get( + "/v1/files", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert response.json()["data"][0]["id"] == "unified-file-id" + managed_files.afile_list.assert_awaited_once() + assert managed_files.afile_list.await_args.kwargs["user_api_key_dict"].user_id == "test-user" + assert managed_files.afile_list.await_args.kwargs["limit"] is None + assert managed_files.afile_list.await_args.kwargs["after"] is None + provider_list.assert_not_awaited() + proxy_logging_obj.post_call_failure_hook.assert_not_called() + + +def test_unscoped_list_files_forwards_limit_and_after_to_the_managed_file_store( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + second_page_file = OpenAIFileObject( + id="unified-file-id-2", + object="file", + bytes=100, + created_at=1700000000, + filename="output.jsonl", + purpose="batch", + status="processed", + ) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + managed_files = mocker.MagicMock(spec=BaseFileEndpoints) + managed_files.afile_list = mocker.AsyncMock( + return_value={ + "object": "list", + "data": [second_page_file], + "first_id": second_page_file.id, + "last_id": second_page_file.id, + "has_more": True, + } + ) + proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files + 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() + 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) + provider_list = mocker.patch.object(litellm, "afile_list", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + ) + + try: + response = client.get( + "/v1/files?limit=2&after=unified-file-id-1&purpose=batch", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert response.json()["data"][0]["id"] == "unified-file-id-2" + assert response.json()["has_more"] is True + call_kwargs = managed_files.afile_list.await_args.kwargs + assert call_kwargs["limit"] == 2 + assert call_kwargs["after"] == "unified-file-id-1" + assert call_kwargs["purpose"] == "batch" + provider_list.assert_not_awaited() + proxy_logging_obj.post_call_failure_hook.assert_not_called() + + +def _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router: Router, afile_list): + """Wire GET /v1/files to the managed file store, with afile_list as the store.""" + import litellm.proxy.proxy_server as ps + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.proxy._types import LitellmUserRoles + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + managed_files = mocker.MagicMock(spec=BaseFileEndpoints) + managed_files.afile_list = mocker.AsyncMock(side_effect=afile_list) + proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files + 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() + 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) + mocker.patch.object(litellm, "afile_list", new=mocker.AsyncMock()) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + ) + return managed_files + + +def _get_list_files(path: str): + try: + return client.get(path, headers={"Authorization": "Bearer test-key"}) + finally: + import litellm.proxy.proxy_server as ps + + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def _get_unscoped_list_files(query: str): + return _get_list_files(f"/v1/files{query}") + + +_EMPTY_FILE_LIST_PAGE: Final = { + "object": "list", + "data": [], + "first_id": None, + "last_id": None, + "has_more": False, +} + + +async def _validating_afile_list(**kwargs): + """Stand in for the managed file store, applying the real request validation.""" + from litellm.proxy.openai_files_endpoints.common_utils import ( + validate_file_list_limit, + validate_file_list_purpose, + ) + + validate_file_list_limit(kwargs.get("limit")) + validate_file_list_purpose(kwargs.get("purpose")) + return FileListPage(**_EMPTY_FILE_LIST_PAGE) + + +async def _permissive_afile_list(**kwargs): + """Stand in for a file store that validates nothing, so only the route can reject.""" + return FileListPage(**_EMPTY_FILE_LIST_PAGE) + + +@pytest.mark.parametrize( + "limit, bound, expected_range", + [ + (0, "below minimum", ">= 1"), + (-1, "below minimum", ">= 1"), + (10001, "above maximum", "<= 10000"), + ], +) +def test_unscoped_list_files_returns_400_for_a_limit_outside_the_openai_range( + mocker: MockerFixture, monkeypatch, llm_router: Router, limit, bound, expected_range +): + """An out-of-range limit is the caller's mistake, so it must not read as a 500 the SDK retries.""" + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _validating_afile_list) + + response = _get_unscoped_list_files(f"?limit={limit}") + + assert response.status_code == 400, response.text + assert response.json() == { + "error": { + "message": ( + f"Invalid 'limit': integer {bound} value. " + f"Expected a value {expected_range}, but got {limit} instead." + ), + "type": "invalid_request_error", + "param": "limit", + "code": "400", + } + } + + +@pytest.mark.parametrize("limit", [1, 10000]) +def test_unscoped_list_files_accepts_the_ends_of_the_openai_limit_range( + mocker: MockerFixture, monkeypatch, llm_router: Router, limit +): + managed_files = _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _validating_afile_list) + + response = _get_unscoped_list_files(f"?limit={limit}") + + assert response.status_code == 200, response.text + assert response.json()["data"] == [] + assert managed_files.afile_list.await_args.kwargs["limit"] == limit + + +@pytest.mark.parametrize( + "path", + [ + "/v1/files?limit=0", + "/v1/files?limit=0&target_model_names=gpt-3.5-turbo", + "/openai/v1/files?limit=0", + ], + ids=["managed-file-store", "target-model-names", "provider-route"], +) +def test_list_files_validates_the_limit_on_every_branch( + mocker: MockerFixture, monkeypatch, llm_router: Router, path +): + """The limit is a route-level contract, so the scoped and provider branches reject it too.""" + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _permissive_afile_list) + + response = _get_list_files(path) + + assert response.status_code == 400, response.text + assert response.json()["error"]["param"] == "limit" + assert response.json()["error"]["message"] == ( + "Invalid 'limit': integer below minimum value. Expected a value >= 1, but got 0 instead." + ) + + +def test_unscoped_list_files_returns_400_for_an_unknown_after_cursor( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + from litellm.proxy._types import ProxyException + + async def _unknown_cursor(**kwargs): + raise ProxyException( + message=f"Invalid 'after' cursor: no file found with id '{kwargs['after']}'.", + type="invalid_request_error", + param="after", + code=400, + openai_code="invalid_value", + ) + + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _unknown_cursor) + + response = _get_unscoped_list_files("?after=file-does-not-exist-xyz") + + assert response.status_code == 400, response.text + assert response.json() == { + "error": { + "message": "Invalid 'after' cursor: no file found with id 'file-does-not-exist-xyz'.", + "type": "invalid_request_error", + "param": "after", + "code": "400", + } + } + + +def _managed_file(file_id: str) -> OpenAIFileObject: + return OpenAIFileObject( + id=file_id, + bytes=17, + created_at=1700000000, + filename="batch_input.jsonl", + object="file", + purpose="batch", + status="uploaded", + ) + + +def test_unscoped_list_files_hands_post_call_hooks_a_page_object( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Logging callbacks read ``response.data`` off a listing, so the managed + branch has to hand them the same page shape the provider branch does. A bare + mapping turns every registered callback into a 500 on this route.""" + import litellm.proxy.proxy_server as ps + + seen_by_callback: list[list[str]] = [] + + async def _reads_response_data(data, user_api_key_dict, response): + seen_by_callback.append([file.id for file in response.data]) + return None + + async def _one_managed_file(**kwargs): + return FileListPage( + data=[_managed_file("unified-file-id")], + first_id="unified-file-id", + last_id="unified-file-id", + ) + + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _one_managed_file) + ps.proxy_logging_obj.post_call_success_hook = _reads_response_data + + response = _get_unscoped_list_files("") + + assert response.status_code == 200, response.text + assert seen_by_callback == [["unified-file-id"]] + body = response.json() + assert list(body) == ["object", "data", "first_id", "last_id", "has_more"] + assert body["object"] == "list" + assert [file["id"] for file in body["data"]] == ["unified-file-id"] + assert body["has_more"] is False + + +@pytest.mark.parametrize("purpose", ["nonexistent_purpose", "EVALS", "batch "]) +def test_unscoped_list_files_returns_400_for_a_purpose_the_api_never_accepts( + mocker: MockerFixture, monkeypatch, llm_router: Router, purpose +): + """An unknown purpose matches nothing, so reporting an empty page would dress + a bad request up as a successful one. The provider-backed branches reject the + same values, and so does the upload route.""" + from urllib.parse import quote + + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _validating_afile_list) + + response = _get_list_files(f"/v1/files?purpose={quote(purpose)}") + + assert response.status_code == 400, response.text + assert response.json()["error"]["param"] == "purpose" + assert response.json()["error"]["type"] == "invalid_request_error" + assert response.json()["error"]["message"].startswith(f"Invalid purpose: {purpose}. Must be one of: ") + + +@pytest.mark.parametrize("purpose", ["batch", "assistants", "fine-tune"]) +def test_unscoped_list_files_accepts_every_documented_purpose( + mocker: MockerFixture, monkeypatch, llm_router: Router, purpose +): + managed_files = _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _validating_afile_list) + + response = _get_list_files(f"/v1/files?purpose={purpose}") + + assert response.status_code == 200, response.text + assert managed_files.afile_list.await_args.kwargs["purpose"] == purpose + + +def test_list_files_reports_a_bad_target_model_names_as_a_400( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """The exception tail reports an HTTPException with its own status and error + type rather than relabelling it, so a client that branches on either keeps + reading the same thing off a bad request.""" + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _permissive_afile_list) + + response = _get_list_files("/v1/files?target_model_names=gpt-3.5-turbo,gpt-4o") + + assert response.status_code == 400, response.text + assert response.json() == { + "error": { + "message": "target_model_names on list files must be a list of one model name. Example: ['gpt-4o']", + "type": "None", + "param": "None", + "code": "400", + } + } + + +def test_list_files_reports_an_unexpected_file_store_error_as_a_500( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + async def _blows_up(**kwargs): + raise RuntimeError("managed file table is unreachable") + + _setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _blows_up) + + response = _get_unscoped_list_files("") + + assert response.status_code == 500, response.text + assert response.json()["error"]["message"] == "managed file table is unreachable" + + def test_list_files_restricted_team_does_not_leak_global_openai_credentials( mocker: MockerFixture, monkeypatch ): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index cf55dc69e86..d311bfa3cbc 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -43144,6 +43144,8 @@ export interface operations { provider?: string | null; target_model_names?: string | null; purpose?: string | null; + limit?: number | null; + after?: string | null; }; header?: never; path?: never; @@ -58015,6 +58017,8 @@ export interface operations { provider?: string | null; target_model_names?: string | null; purpose?: string | null; + limit?: number | null; + after?: string | null; }; header?: never; path?: never; @@ -64268,6 +64272,8 @@ export interface operations { query?: { target_model_names?: string | null; purpose?: string | null; + limit?: number | null; + after?: string | null; }; header?: never; path: {