Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion litellm/proxy/openai_files_endpoints/files_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
)
from litellm.proxy.utils import ProxyLogging, is_known_model
from litellm.router import Router
from openai.pagination import AsyncCursorPage

from litellm.types.llms.openai import (
CREATE_FILE_REQUESTS_PURPOSE,
FileExpiresAfter,
Expand Down Expand Up @@ -1377,7 +1379,16 @@ async def list_files(
_response = await proxy_logging_obj.post_call_success_hook(
data=data, user_api_key_dict=user_api_key_dict, response=response
)
if _response is not None and isinstance(_response, OpenAIFileObject):
# NOTE: the managed-files hook returns AsyncCursorPage for the list-files
# response (see enterprise/litellm_enterprise/proxy/hooks/managed_files.py:
# async_post_call_success_hook). Without AsyncCursorPage in this isinstance
# tuple the hook return value is discarded, and the unfiltered raw provider
# listing leaks back to the caller. The hook also mutates response.data in
# place which partially masks this, but breaks the moment any future hook
# returns a freshly-constructed page object.
if _response is not None and isinstance(
_response, (OpenAIFileObject, AsyncCursorPage)
):
response = _response

### ALERTING ###
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1873,3 +1873,94 @@ async def _mock_afile_content(**kwargs):
assert "stream" not in captured_kwargs
mock_streaming_response.assert_not_awaited()
proxy_logging_obj.post_call_failure_hook.assert_not_called()


@pytest.mark.asyncio
async def test_list_files_uses_async_cursor_page_returned_by_post_call_hook(
monkeypatch, llm_router: Router
):
"""
Regression for LIT-3386 / GH #28294.

The managed-files hook (`async_post_call_success_hook`) returns an
`AsyncCursorPage` for GET /v1/files responses. Prior to the fix the
list_files endpoint only re-assigned `response` when the hook returned an
`OpenAIFileObject`, so any freshly-constructed page from the hook was
silently dropped and the unfiltered raw provider listing was returned to
the caller. This test asserts the hook-returned page reaches the client.
"""
from openai.pagination import AsyncCursorPage
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles

setup_proxy_logging_object(monkeypatch, llm_router)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)

raw_provider_page = AsyncCursorPage(
data=[
OpenAIFileObject(
id="file-leaked-raw",
bytes=10,
created_at=1,
filename="raw.jsonl",
object="file",
purpose="batch",
status="processed",
),
OpenAIFileObject(
id="file-owned-by-user",
bytes=10,
created_at=1,
filename="owned.jsonl",
object="file",
purpose="batch",
status="processed",
),
],
)

filtered_page = AsyncCursorPage(
data=[
OpenAIFileObject(
id="file-owned-by-user",
bytes=10,
created_at=1,
filename="owned.jsonl",
object="file",
purpose="batch",
status="processed",
),
],
)

monkeypatch.setattr(
litellm,
"afile_list",
AsyncMock(return_value=raw_provider_page),
)
monkeypatch.setattr(
ps.proxy_logging_obj,
"post_call_success_hook",
AsyncMock(return_value=filtered_page),
)

app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="test-user",
)

try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)

assert response.status_code == 200, response.text
body = response.json()
returned_ids = [item["id"] for item in body["data"]]
assert returned_ids == ["file-owned-by-user"], (
f"expected only owned file id, got {returned_ids}"
)
Loading