mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-10-04 04:04:14 +00:00
feat(files, s3, expiration): add expires_after support to S3 files provider (#3283)
This commit is contained in:
parent
78a78264a7
commit
3370d8e557
9 changed files with 372 additions and 81 deletions
|
@ -8,6 +8,7 @@ from io import BytesIO
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from llama_stack.core.datatypes import User
|
||||
|
||||
|
@ -79,6 +80,88 @@ def test_openai_client_basic_operations(openai_client):
|
|||
pass # ignore 404
|
||||
|
||||
|
||||
@pytest.mark.xfail(message="expires_after not available on all providers")
|
||||
def test_expires_after(openai_client):
|
||||
"""Test uploading a file with expires_after parameter."""
|
||||
client = openai_client
|
||||
|
||||
uploaded_file = None
|
||||
try:
|
||||
with BytesIO(b"expires_after test") as file_buffer:
|
||||
file_buffer.name = "expires_after.txt"
|
||||
uploaded_file = client.files.create(
|
||||
file=file_buffer,
|
||||
purpose="assistants",
|
||||
expires_after={"anchor": "created_at", "seconds": 4545},
|
||||
)
|
||||
|
||||
assert uploaded_file.expires_at is not None
|
||||
assert uploaded_file.expires_at == uploaded_file.created_at + 4545
|
||||
|
||||
listed = client.files.list()
|
||||
ids = [f.id for f in listed.data]
|
||||
assert uploaded_file.id in ids
|
||||
|
||||
retrieved = client.files.retrieve(uploaded_file.id)
|
||||
assert retrieved.id == uploaded_file.id
|
||||
|
||||
finally:
|
||||
if uploaded_file is not None:
|
||||
try:
|
||||
client.files.delete(uploaded_file.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.xfail(message="expires_after not available on all providers")
|
||||
def test_expires_after_requests(openai_client):
|
||||
"""Upload a file using requests multipart/form-data and bracketed expires_after fields.
|
||||
|
||||
This ensures clients that send form fields like `expires_after[anchor]` and
|
||||
`expires_after[seconds]` are handled by the server.
|
||||
"""
|
||||
base_url = f"{openai_client.base_url}files"
|
||||
|
||||
uploaded_id = None
|
||||
try:
|
||||
files = {"file": ("expires_after_with_requests.txt", BytesIO(b"expires_after via requests"))}
|
||||
data = {
|
||||
"purpose": "assistants",
|
||||
"expires_after[anchor]": "created_at",
|
||||
"expires_after[seconds]": "4545",
|
||||
}
|
||||
|
||||
session = requests.Session()
|
||||
request = requests.Request("POST", base_url, files=files, data=data)
|
||||
prepared = session.prepare_request(request)
|
||||
resp = session.send(prepared, timeout=30)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
assert result.get("id", "").startswith("file-")
|
||||
uploaded_id = result["id"]
|
||||
assert result.get("created_at") is not None
|
||||
assert result.get("expires_at") == result["created_at"] + 4545
|
||||
|
||||
list_resp = requests.get(base_url, timeout=30)
|
||||
list_resp.raise_for_status()
|
||||
listed = list_resp.json()
|
||||
ids = [f["id"] for f in listed.get("data", [])]
|
||||
assert uploaded_id in ids
|
||||
|
||||
retrieve_resp = requests.get(f"{base_url}/{uploaded_id}", timeout=30)
|
||||
retrieve_resp.raise_for_status()
|
||||
retrieved = retrieve_resp.json()
|
||||
assert retrieved["id"] == uploaded_id
|
||||
|
||||
finally:
|
||||
if uploaded_id:
|
||||
try:
|
||||
requests.delete(f"{base_url}/{uploaded_id}", timeout=30)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.xfail(message="User isolation broken for current providers, must be fixed.")
|
||||
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
|
||||
def test_files_authentication_isolation(mock_get_authenticated_user, llama_stack_client):
|
||||
|
|
|
@ -197,3 +197,104 @@ class TestS3FilesImpl:
|
|||
|
||||
files_list = await s3_provider.openai_list_files()
|
||||
assert len(files_list.data) == 0, "No file metadata should remain after failed upload"
|
||||
|
||||
@pytest.mark.parametrize("purpose", [p for p in OpenAIFilePurpose if p != OpenAIFilePurpose.BATCH])
|
||||
async def test_default_no_expiration(self, s3_provider, sample_text_file, purpose):
|
||||
"""Test that by default files have no expiration."""
|
||||
sample_text_file.filename = "test_default_no_expiration"
|
||||
uploaded = await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=purpose,
|
||||
)
|
||||
assert uploaded.expires_at is None, "By default files should have no expiration"
|
||||
|
||||
async def test_default_batch_expiration(self, s3_provider, sample_text_file):
|
||||
"""Test that by default batch files have an expiration."""
|
||||
sample_text_file.filename = "test_default_batch_an_expiration"
|
||||
uploaded = await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.BATCH,
|
||||
)
|
||||
assert uploaded.expires_at is not None, "By default batch files should have an expiration"
|
||||
thirty_days_seconds = 30 * 24 * 3600
|
||||
assert uploaded.expires_at == uploaded.created_at + thirty_days_seconds, (
|
||||
"Batch default expiration should be 30 days"
|
||||
)
|
||||
|
||||
async def test_expired_file_is_unavailable(self, s3_provider, sample_text_file, s3_config, s3_client):
|
||||
"""Uploaded file that has expired should not be listed or retrievable/deletable."""
|
||||
with patch.object(s3_provider, "_now") as mock_now: # control time
|
||||
two_hours = 2 * 60 * 60
|
||||
|
||||
mock_now.return_value = 0
|
||||
|
||||
sample_text_file.filename = "test_expired_file"
|
||||
uploaded = await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.ASSISTANTS,
|
||||
expires_after_anchor="created_at",
|
||||
expires_after_seconds=two_hours,
|
||||
)
|
||||
|
||||
mock_now.return_value = two_hours * 2 # fast forward 4 hours
|
||||
|
||||
listed = await s3_provider.openai_list_files()
|
||||
assert uploaded.id not in [f.id for f in listed.data]
|
||||
|
||||
with pytest.raises(ResourceNotFoundError, match="not found"):
|
||||
await s3_provider.openai_retrieve_file(uploaded.id)
|
||||
|
||||
with pytest.raises(ResourceNotFoundError, match="not found"):
|
||||
await s3_provider.openai_retrieve_file_content(uploaded.id)
|
||||
|
||||
with pytest.raises(ResourceNotFoundError, match="not found"):
|
||||
await s3_provider.openai_delete_file(uploaded.id)
|
||||
|
||||
with pytest.raises(ClientError) as exc_info:
|
||||
s3_client.head_object(Bucket=s3_config.bucket_name, Key=uploaded.id)
|
||||
assert exc_info.value.response["Error"]["Code"] == "404"
|
||||
|
||||
with pytest.raises(ResourceNotFoundError, match="not found"):
|
||||
await s3_provider._get_file(uploaded.id, return_expired=True)
|
||||
|
||||
async def test_unsupported_expires_after_anchor(self, s3_provider, sample_text_file):
|
||||
"""Unsupported anchor value should raise ValueError."""
|
||||
sample_text_file.filename = "test_unsupported_expires_after_anchor"
|
||||
|
||||
with pytest.raises(ValueError, match="Input should be 'created_at'"):
|
||||
await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.ASSISTANTS,
|
||||
expires_after_anchor="now",
|
||||
expires_after_seconds=3600,
|
||||
)
|
||||
|
||||
async def test_nonint_expires_after_seconds(self, s3_provider, sample_text_file):
|
||||
"""Non-integer seconds in expires_after should raise ValueError."""
|
||||
sample_text_file.filename = "test_nonint_expires_after_seconds"
|
||||
|
||||
with pytest.raises(ValueError, match="should be a valid integer"):
|
||||
await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.ASSISTANTS,
|
||||
expires_after_anchor="created_at",
|
||||
expires_after_seconds="many",
|
||||
)
|
||||
|
||||
async def test_expires_after_seconds_out_of_bounds(self, s3_provider, sample_text_file):
|
||||
"""Seconds outside allowed range should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="greater than or equal to 3600"):
|
||||
await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.ASSISTANTS,
|
||||
expires_after_anchor="created_at",
|
||||
expires_after_seconds=3599,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="less than or equal to 2592000"):
|
||||
await s3_provider.openai_upload_file(
|
||||
file=sample_text_file,
|
||||
purpose=OpenAIFilePurpose.ASSISTANTS,
|
||||
expires_after_anchor="created_at",
|
||||
expires_after_seconds=2592001,
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue