Add support for expires after param

This commit is contained in:
Sameer Kankute
2025-12-12 10:01:18 +05:30
parent bdb8c169be
commit 49e0f8e95f
5 changed files with 353 additions and 13 deletions
+19 -7
View File
@@ -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="",
+4 -3
View File
@@ -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(
@@ -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(
+17 -1
View File
@@ -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]
@@ -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"