From 49e0f8e95f7b00df1ef2888ea1cdf1fdfd12ef3a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 12 Dec 2025 10:01:18 +0530 Subject: [PATCH] Add support for expires after param --- litellm/files/main.py | 26 +- litellm/llms/openai/openai.py | 7 +- .../openai_files_endpoints/files_endpoints.py | 28 +- litellm/types/llms/openai.py | 18 +- .../test_files_endpoint.py | 287 ++++++++++++++++++ 5 files changed, 353 insertions(+), 13 deletions(-) diff --git a/litellm/files/main.py b/litellm/files/main.py index acf545e431..a7c82290c2 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -27,6 +27,7 @@ from litellm.llms.vertex_ai.files.handler import VertexAIFilesHandler from litellm.types.llms.openai import ( CreateFileRequest, FileContentRequest, + FileExpiresAfter, FileTypes, HttpxBinaryResponseContent, OpenAIFileObject, @@ -58,6 +59,7 @@ anthropic_files_instance = AnthropicFilesHandler() async def acreate_file( file: FileTypes, purpose: Literal["assistants", "batch", "fine-tune"], + expires_after: Optional[FileExpiresAfter] = None, custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock", "hosted_vllm"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, @@ -75,6 +77,7 @@ async def acreate_file( call_args = { "file": file, "purpose": purpose, + "expires_after": expires_after, "custom_llm_provider": custom_llm_provider, "extra_headers": extra_headers, "extra_body": extra_body, @@ -83,7 +86,6 @@ async def acreate_file( # Use a partial function to pass your keyword arguments func = partial(create_file, **call_args) - # Add the context to the function ctx = contextvars.copy_context() func_with_context = partial(ctx.run, func) @@ -102,6 +104,7 @@ async def acreate_file( def create_file( file: FileTypes, purpose: Literal["assistants", "batch", "fine-tune"], + expires_after: Optional[FileExpiresAfter] = None, custom_llm_provider: Optional[Literal["openai", "azure", "vertex_ai", "bedrock", "hosted_vllm"]] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, @@ -141,12 +144,21 @@ def create_file( elif timeout is None: timeout = 600.0 - _create_file_request = CreateFileRequest( - file=file, - purpose=purpose, - extra_headers=extra_headers, - extra_body=extra_body, - ) + if expires_after is not None: + _create_file_request = CreateFileRequest( + file=file, + purpose=purpose, + expires_after=expires_after, + extra_headers=extra_headers, + extra_body=extra_body, + ) + else: + _create_file_request = CreateFileRequest( + file=file, + purpose=purpose, + extra_headers=extra_headers, + extra_body=extra_body, + ) provider_config = ProviderConfigManager.get_provider_files_config( model="", diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index bb9225fc79..e04def0d9c 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1,6 +1,7 @@ import time import types from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Callable, @@ -10,7 +11,6 @@ from typing import ( List, Literal, Optional, - TYPE_CHECKING, Union, cast, ) @@ -20,6 +20,7 @@ import httpx if TYPE_CHECKING: from aiohttp import ClientSession + import openai from openai import AsyncOpenAI, OpenAI from openai.types.beta.assistant_deleted import AssistantDeleted @@ -1549,7 +1550,7 @@ class OpenAIFilesAPI(BaseLLM): create_file_data: CreateFileRequest, openai_client: AsyncOpenAI, ) -> OpenAIFileObject: - response = await openai_client.files.create(**create_file_data) + response = await openai_client.files.create(**create_file_data) # type: ignore[arg-type] return OpenAIFileObject(**response.model_dump()) def create_file( @@ -1585,7 +1586,7 @@ class OpenAIFilesAPI(BaseLLM): return self.acreate_file( # type: ignore create_file_data=create_file_data, openai_client=openai_client ) - response = cast(OpenAI, openai_client).files.create(**create_file_data) + response = cast(OpenAI, openai_client).files.create(**create_file_data) # type: ignore[arg-type] return OpenAIFileObject(**response.model_dump()) async def afile_content( diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 3f08a4ec36..f2be1a5294 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -39,6 +39,7 @@ from litellm.proxy.utils import ProxyLogging, is_known_model from litellm.router import Router from litellm.types.llms.openai import ( CREATE_FILE_REQUESTS_PURPOSE, + FileExpiresAfter, OpenAIFileObject, OpenAIFilesPurpose, ) @@ -272,7 +273,8 @@ async def create_file( -H "Authorization: Bearer sk-1234" \ -F purpose="batch" \ -F file="@mydata.jsonl" - + -F expires_after[anchor]="created_at" \ + -F expires_after[seconds]=2592000 ``` """ from litellm.proxy.proxy_server import ( @@ -329,6 +331,25 @@ async def create_file( if litellm_metadata is not None: data["litellm_metadata"] = litellm_metadata + # Parse expires_after if provided + expires_after = None + form_data = await request.form() + expires_after_anchor = form_data.get("expires_after[anchor]") + expires_after_seconds_str = form_data.get("expires_after[seconds]") + + if expires_after_anchor is not None or expires_after_seconds_str is not None: + if expires_after_anchor is None or expires_after_seconds_str is None: + raise HTTPException( + status_code=400, + detail={ + "error": "Both expires_after[anchor] and expires_after[seconds] must be provided if expires_after is specified", + }, + ) + expires_after = FileExpiresAfter( + anchor=expires_after_anchor, + seconds=int(expires_after_seconds_str), + ) + # Include original request and headers in the data data = await add_litellm_data_to_request( data=data, @@ -354,7 +375,10 @@ async def create_file( ) _create_file_request = CreateFileRequest( - file=file_data, purpose=cast(CREATE_FILE_REQUESTS_PURPOSE, purpose), **data + file=file_data, + purpose=cast(CREATE_FILE_REQUESTS_PURPOSE, purpose), + expires_after=expires_after, + **data ) response = await route_create_file( diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index d0e4bbf4a4..fc2bbb37ad 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -14,6 +14,7 @@ from typing import ( ) import httpx +from openai import Omit from openai._legacy_response import ( HttpxBinaryResponseContent as _HttpxBinaryResponseContent, ) @@ -69,7 +70,7 @@ from openai.types.responses.response_create_params import ( ToolParam, ) from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall -from pydantic import BaseModel, ConfigDict, Discriminator, Field, PrivateAttr +from pydantic import BaseModel, ConfigDict, Discriminator, PrivateAttr from typing_extensions import Annotated, Dict, Required, TypedDict, override from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject @@ -346,6 +347,19 @@ class OpenAIFileObject(BaseModel): CREATE_FILE_REQUESTS_PURPOSE = Literal["assistants", "batch", "fine-tune"] +# File expiration policy +class FileExpiresAfter(TypedDict): + """ + File expiration policy + + Properties: + anchor: Anchor timestamp after which the expiration policy applies. Supported anchors: created_at. + seconds: The number of seconds after the anchor time that the file will expire. Must be between 3600 (1 hour) and 2592000 (30 days). + """ + anchor: Required[Literal["created_at"]] + seconds: Required[int] + + # OpenAI Files Types class CreateFileRequest(TypedDict, total=False): """ @@ -357,6 +371,7 @@ class CreateFileRequest(TypedDict, total=False): purpose: Literal['assistants', 'batch', 'fine-tune'] Optional Params: + expires_after: Optional[FileExpiresAfter] - The expiration policy for a file extra_headers: Optional[Dict[str, str]] extra_body: Optional[Dict[str, str]] = None timeout: Optional[float] = None @@ -364,6 +379,7 @@ class CreateFileRequest(TypedDict, total=False): file: Required[FileTypes] purpose: Required[CREATE_FILE_REQUESTS_PURPOSE] + expires_after: Optional[FileExpiresAfter] extra_headers: Optional[Dict[str, str]] extra_body: Optional[Dict[str, str]] timeout: Optional[float] 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 521faae3ca..732d4784a5 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 @@ -477,3 +477,290 @@ def test_create_file_for_each_model( openai_call_found = True break assert openai_call_found, "OpenAI call not found with expected parameters" + + +def test_create_file_with_expires_after(mocker: MockerFixture, monkeypatch, llm_router: Router): + """ + Test that expires_after is properly parsed and passed through when creating a file + """ + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.types.llms.openai import OpenAIFileObject + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + + class DummyManagedFiles(BaseFileEndpoints): + async def acreate_file(self, llm_router, create_file_request, target_model_names_list, litellm_parent_otel_span, user_api_key_dict): + # Verify expires_after is in the request + if isinstance(create_file_request, dict): + expires_after = create_file_request.get("expires_after") + else: + expires_after = getattr(create_file_request, "expires_after", None) + + # Verify expires_after was passed correctly + assert expires_after is not None, "expires_after should be in the request" + assert expires_after["anchor"] == "created_at" + assert expires_after["seconds"] == 2592000 + + # Return a dummy response + return OpenAIFileObject( + id="file-abc123", + object="file", + bytes=100, + created_at=1234567890, + filename="mydata.jsonl", + purpose="fine-tune", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_delete(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_content(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + # Create test file content + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("mydata.jsonl", test_file_content, "application/json") + + # Test with expires_after + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "target_model_names": "gpt-3.5-turbo", + "expires_after[anchor]": "created_at", + "expires_after[seconds]": "2592000", # 30 days + }, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["id"] == "file-abc123" + assert result["purpose"] == "fine-tune" + + +def test_create_file_with_expires_after_missing_anchor(mocker: MockerFixture, monkeypatch, llm_router: Router): + """ + Test that an error is returned when expires_after[anchor] is missing + """ + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("mydata.jsonl", test_file_content, "application/json") + + # Test with only expires_after[seconds], missing anchor + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "expires_after[seconds]": "2592000", + }, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 400 + error_detail = response.json() + assert "expires_after" in error_detail["error"]["message"].lower() or "both" in error_detail["error"]["message"].lower() + + +def test_create_file_with_expires_after_missing_seconds(mocker: MockerFixture, monkeypatch, llm_router: Router): + """ + Test that an error is returned when expires_after[seconds] is missing + """ + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("mydata.jsonl", test_file_content, "application/json") + + # Test with only expires_after[anchor], missing seconds + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "expires_after[anchor]": "created_at", + }, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 400 + error_detail = response.json() + assert "expires_after" in error_detail["error"]["message"].lower() or "both" in error_detail["error"]["message"].lower() + + +def test_create_file_with_expires_after_valid_values(mocker: MockerFixture, monkeypatch, llm_router: Router): + """ + Test that expires_after works with valid anchor and seconds values + """ + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.types.llms.openai import OpenAIFileObject + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + + class DummyManagedFiles(BaseFileEndpoints): + async def acreate_file(self, llm_router, create_file_request, target_model_names_list, litellm_parent_otel_span, user_api_key_dict): + # Verify expires_after is in the request + if isinstance(create_file_request, dict): + expires_after = create_file_request.get("expires_after") + else: + expires_after = getattr(create_file_request, "expires_after", None) + + # Verify expires_after was passed correctly + assert expires_after is not None, "expires_after should be in the request" + assert expires_after["anchor"] == "created_at" + assert expires_after["seconds"] == 3600 + + return OpenAIFileObject( + id="file-abc123", + object="file", + bytes=100, + created_at=1234567890, + filename="mydata.jsonl", + purpose="fine-tune", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_delete(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_content(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("mydata.jsonl", test_file_content, "application/json") + + # Test with valid expires_after values + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "target_model_names": "gpt-3.5-turbo", + "expires_after[anchor]": "created_at", + "expires_after[seconds]": "3600", # Minimum valid value (1 hour) + }, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["id"] == "file-abc123" + assert result["purpose"] == "fine-tune" + + +def test_create_file_without_expires_after(mocker: MockerFixture, monkeypatch, llm_router: Router): + """ + Test that file creation works normally without expires_after + """ + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + from litellm.types.llms.openai import OpenAIFileObject + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + proxy_logging_obj._add_proxy_hooks(llm_router) + + class DummyManagedFiles(BaseFileEndpoints): + async def acreate_file(self, llm_router, create_file_request, target_model_names_list, litellm_parent_otel_span, user_api_key_dict): + # Verify expires_after is None when not provided + if isinstance(create_file_request, dict): + expires_after = create_file_request.get("expires_after") + else: + expires_after = getattr(create_file_request, "expires_after", None) + + # expires_after should be None when not provided + assert expires_after is None, "expires_after should be None when not provided" + + return OpenAIFileObject( + id="file-abc123", + object="file", + bytes=100, + created_at=1234567890, + filename="mydata.jsonl", + purpose="fine-tune", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_delete(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + async def afile_content(self, file_id, litellm_parent_otel_span): + raise NotImplementedError("Not implemented for test") + + proxy_logging_obj.proxy_hook_mapping["managed_files"] = DummyManagedFiles() + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + test_file_content = b'{"prompt": "Hello", "completion": "Hi"}' + test_file = ("mydata.jsonl", test_file_content, "application/json") + + # Test without expires_after + response = client.post( + "/v1/files", + files={"file": test_file}, + data={ + "purpose": "fine-tune", + "target_model_names": "gpt-3.5-turbo", + }, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["id"] == "file-abc123" + assert result["purpose"] == "fine-tune"