From f9f8320972f6589dd5aac0877a1bc89c4f200028 Mon Sep 17 00:00:00 2001 From: Rithvik Mysore Suresh Date: Fri, 21 Aug 2026 11:47:04 -0400 Subject: [PATCH] fix(files): list unscoped managed files Read owner-scoped managed rows directly when no provider or model is supplied, avoiding an unauthenticated OpenAI fallback. Refs #35362 --- .../proxy/hooks/managed_files.py | 19 ++++-- litellm/llms/base_llm/files/transformation.py | 6 +- .../openai_files_endpoints/files_endpoints.py | 36 ++++++----- .../proxy/test_managed_files_hook.py | 33 +++++++++++ .../test_files_endpoint.py | 59 +++++++++++++++++++ 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index c986e835e4f..9b62284072d 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -1365,12 +1365,23 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): async def afile_list( self, - purpose: Optional[OpenAIFilesPurpose], + purpose: str | None, litellm_parent_otel_span: Optional[Span], + user_api_key_dict: UserAPIKeyAuth, **data: Dict, - ) -> List[OpenAIFileObject]: - """Handled in files_endpoints.py""" - return [] + ) -> Dict[str, object]: + owner_filter: Final = build_owner_filter(user_api_key_dict) + if owner_filter is None: + return build_list_page([]) + + rows: Final = await _managed_file_table(self.prisma_client).find_many(where=owner_filter) + files: Final = [ + parsed_file_object.model_copy(update={"id": row.unified_file_id}) + for row in rows + 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) + ] + return build_list_page(files) 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..7c19326b627 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -13,7 +13,6 @@ from litellm.types.llms.openai import ( FileContentRequest, OpenAICreateFileRequestOptionalParams, OpenAIFileObject, - OpenAIFilesPurpose, ) from litellm.types.utils import LlmProviders, ModelResponse @@ -240,10 +239,11 @@ 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, **data: dict, - ) -> list[OpenAIFileObject]: + ) -> dict[str, object]: pass @abstractmethod diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 37cfd9d073d..a482cc54748 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -1488,24 +1488,28 @@ 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, + ) + 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( 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..b39d2ef8559 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -190,6 +190,39 @@ 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"} + ) + 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_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 bf9323cdc6a..e6101d3edd8 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 @@ -2468,6 +2468,65 @@ 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" + provider_list.assert_not_awaited() + proxy_logging_obj.post_call_failure_hook.assert_not_called() + + def test_list_files_restricted_team_does_not_leak_global_openai_credentials( mocker: MockerFixture, monkeypatch ):