mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge pull request #37855 from rimysore/litellm_fix_unscoped_managed_files
fix(files): list and page unscoped managed files locally
This commit is contained in:
commit
076eebb520
8 changed files with 1370 additions and 41 deletions
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue