From 9eff611b1a6e1ba34934ace87aa11108769afe43 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 11 Mar 2026 12:45:19 -0300 Subject: [PATCH 1/5] feat(anthropic): add Files API support for SDK Implement Anthropic Files API (upload, retrieve, list, delete, content) using the BaseFilesConfig provider pattern. Adds multipart form-data support to BaseLLMHTTPHandler for file uploads. --- docs/my-website/docs/files_endpoints.md | 6 + docs/my-website/docs/providers/anthropic.md | 92 +++++ litellm/files/main.py | 70 +++- .../prompt_templates/common_utils.py | 5 + litellm/llms/anthropic/files/__init__.py | 3 +- .../llms/anthropic/files/transformation.py | 315 +++++++++++++++ litellm/llms/custom_httpx/llm_http_handler.py | 18 + litellm/types/llms/openai.py | 7 +- litellm/utils.py | 6 + ruff.toml | 1 + .../llms/anthropic/files/__init__.py | 0 .../test_anthropic_files_transformation.py | 379 ++++++++++++++++++ 12 files changed, 876 insertions(+), 26 deletions(-) create mode 100644 litellm/llms/anthropic/files/transformation.py create mode 100644 tests/test_litellm/llms/anthropic/files/__init__.py create mode 100644 tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py diff --git a/docs/my-website/docs/files_endpoints.md b/docs/my-website/docs/files_endpoints.md index 30677c748a9..deb17931638 100644 --- a/docs/my-website/docs/files_endpoints.md +++ b/docs/my-website/docs/files_endpoints.md @@ -326,4 +326,10 @@ print("file content=", content.text) ### [Bedrock](./providers/bedrock_batches#4-retrieve-batch-results) +### [Anthropic](./providers/anthropic#files-api) + +:::note +Anthropic Files API has a different purpose than OpenAI's. It's **not** for Batches or Fine-tuning—it's for uploading files once and referencing them by `file_id` in multiple messages, avoiding re-uploads. File API operations are free — file content used in Messages requests is priced as input tokens. +::: + ## [Swagger API Reference](https://litellm-api.up.railway.app/#/files) diff --git a/docs/my-website/docs/providers/anthropic.md b/docs/my-website/docs/providers/anthropic.md index aa77ee7c268..50b964bd936 100644 --- a/docs/my-website/docs/providers/anthropic.md +++ b/docs/my-website/docs/providers/anthropic.md @@ -1965,6 +1965,98 @@ curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ +## Files API + +Upload files once and reference them by `file_id` in multiple requests—no need to re-upload content each time. + +:::info +The `file_id` obtained from Anthropic only works with Anthropic Claude models. You cannot use it with other providers (OpenAI, Bedrock, etc.). +::: + +- **Max file size:** 500 MB | **Total storage:** 100 GB per org +- **Pricing:** File API operations are free. File content used in Messages requests is priced as input tokens. + +**Supported models by file type:** +- **Images:** All Claude 3+ models +- **PDFs:** All Claude 3.5+ models +- **Other file types** (for code execution): Claude 3.5 Haiku + all Claude 3.7+ models + +### Quick Start + +```python +import litellm +import os + +os.environ["ANTHROPIC_API_KEY"] = "sk-ant-..." + +# 1. Upload a file once +file = litellm.create_file( + file=open("document.pdf", "rb"), + purpose="messages", + custom_llm_provider="anthropic", +) + +# 2. Use file_id in messages (no re-upload needed) +response = litellm.completion( + model="anthropic/claude-sonnet-4-5-20250929", + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": "Summarize this document"}, + {"type": "file", "file": {"file_id": file.id, "format": "application/pdf"}} + ] + }] +) +``` + +### File Operations + +| Operation | Function | +|-----------|----------| +| Upload | `litellm.create_file(file, purpose="messages", custom_llm_provider="anthropic")` | +| List | `litellm.file_list(custom_llm_provider="anthropic")` | +| Retrieve | `litellm.file_retrieve(file_id, custom_llm_provider="anthropic")` | +| Delete | `litellm.file_delete(file_id, custom_llm_provider="anthropic")` | +| Download | `litellm.file_content(file_id, custom_llm_provider="anthropic")` | + +:::note +Download only works for files created by the [code execution tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/code-execution-tool), not uploaded files. +::: + +### Supported Formats + +| File Type | Format Value | +|-----------|-------------| +| PDF | `application/pdf` | +| Plain text | `text/plain` | +| JPEG | `image/jpeg` | +| PNG | `image/png` | +| GIF | `image/gif` | +| WebP | `image/webp` | + +### Using Images + +```python +# Upload image +image = litellm.create_file( + file=open("photo.jpg", "rb"), + purpose="messages", + custom_llm_provider="anthropic", +) + +# Use in message +response = litellm.completion( + model="anthropic/claude-sonnet-4-5-20250929", + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "file", "file": {"file_id": image.id, "format": "image/jpeg"}} + ] + }] +) +``` + ## Usage - passing 'user_id' to Anthropic LiteLLM translates the OpenAI `user` param to Anthropic's `metadata[user_id]` param. diff --git a/litellm/files/main.py b/litellm/files/main.py index 2a10789e741..863ea222151 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -61,9 +61,9 @@ anthropic_files_instance = AnthropicFilesHandler() @client async def acreate_file( file: FileTypes, - purpose: Literal["assistants", "batch", "fine-tune"], + purpose: Literal["assistants", "batch", "fine-tune", "messages"], expires_after: Optional[FileExpiresAfter] = None, - custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "bedrock", "hosted_vllm", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "bedrock", "hosted_vllm", "manus", "anthropic"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -106,9 +106,9 @@ async def acreate_file( @client def create_file( file: FileTypes, - purpose: Literal["assistants", "batch", "fine-tune"], + purpose: Literal["assistants", "batch", "fine-tune", "messages"], expires_after: Optional[FileExpiresAfter] = None, - custom_llm_provider: Optional[Literal["openai", "azure", "gemini", "vertex_ai", "bedrock", "hosted_vllm", "manus"]] = None, + custom_llm_provider: Optional[Literal["openai", "azure", "gemini", "vertex_ai", "bedrock", "hosted_vllm", "manus", "anthropic"]] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -218,7 +218,7 @@ def create_file( ) else: raise litellm.exceptions.BadRequestError( - message="LiteLLM doesn't support {} for 'create_file'. Only ['openai', 'azure', 'vertex_ai', 'manus'] are supported.".format( + message="LiteLLM doesn't support {} for 'create_file'. Only ['openai', 'azure', 'vertex_ai', 'manus', 'anthropic'] are supported.".format( custom_llm_provider ), model="n/a", @@ -237,7 +237,7 @@ def create_file( @client async def afile_retrieve( file_id: str, - custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "manus", "anthropic"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -278,7 +278,7 @@ async def afile_retrieve( @client def file_retrieve( file_id: str, - custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "manus", "anthropic"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -382,7 +382,7 @@ def file_retrieve( ) else: raise litellm.exceptions.BadRequestError( - message="LiteLLM doesn't support {} for 'file_retrieve'. Only 'openai', 'azure', and 'manus' are supported.".format( + message="LiteLLM doesn't support {} for 'file_retrieve'. Only 'openai', 'azure', 'manus', and 'anthropic' are supported.".format( custom_llm_provider ), model="n/a", @@ -403,7 +403,7 @@ def file_retrieve( @client async def afile_delete( file_id: str, - custom_llm_provider: Literal["openai", "azure", "gemini", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "gemini", "manus", "anthropic"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -447,7 +447,7 @@ async def afile_delete( def file_delete( file_id: str, model: Optional[str] = None, - custom_llm_provider: Union[Literal["openai", "azure", "gemini", "manus"], str] = "openai", + custom_llm_provider: Union[Literal["openai", "azure", "gemini", "manus", "anthropic"], str] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, @@ -558,7 +558,7 @@ def file_delete( ) else: raise litellm.exceptions.BadRequestError( - message="LiteLLM doesn't support {} for 'file_delete'. Only 'openai', 'azure', 'gemini', and 'manus' are supported.".format( + message="LiteLLM doesn't support {} for 'file_delete'. Only 'openai', 'azure', 'gemini', 'manus', and 'anthropic' are supported.".format( custom_llm_provider ), model="n/a", @@ -577,7 +577,7 @@ def file_delete( # List files @client async def afile_list( - custom_llm_provider: Literal["openai", "azure", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "manus", "anthropic"] = "openai", purpose: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, @@ -618,7 +618,7 @@ async def afile_list( @client def file_list( - custom_llm_provider: Literal["openai", "azure", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "manus", "anthropic"] = "openai", purpose: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, @@ -723,7 +723,7 @@ def file_list( ) else: raise litellm.exceptions.BadRequestError( - message="LiteLLM doesn't support {} for 'file_list'. Only 'openai', 'azure', and 'manus' are supported.".format( + message="LiteLLM doesn't support {} for 'file_list'. Only 'openai', 'azure', 'manus', and 'anthropic' are supported.".format( custom_llm_provider ), model="n/a", @@ -834,15 +834,41 @@ def file_content( _is_async = kwargs.pop("afile_content", False) is True - # Check if this is an Anthropic batch results request - if custom_llm_provider == "anthropic": - response = anthropic_files_instance.file_content( - _is_async=_is_async, + # Check if provider has a custom files config (e.g., Anthropic, Manus) + provider_config = ProviderConfigManager.get_provider_files_config( + model="", + provider=LlmProviders(custom_llm_provider), + ) + if provider_config is not None: + litellm_params_dict["api_key"] = optional_params.api_key + litellm_params_dict["api_base"] = optional_params.api_base + + logging_obj = kwargs.get("litellm_logging_obj") + if logging_obj is None: + logging_obj = LiteLLMLoggingObj( + model="", + messages=[], + stream=False, + call_type="afile_content" if _is_async else "file_content", + start_time=time.time(), + litellm_call_id=kwargs.get("litellm_call_id", str(uuid_module.uuid4())), + function_id=str(kwargs.get("id") or ""), + ) + + response = base_llm_http_handler.retrieve_file_content( file_content_request=_file_content_request, - api_base=optional_params.api_base, - api_key=optional_params.api_key, + provider_config=provider_config, + litellm_params=litellm_params_dict, + headers=extra_headers or {}, + logging_obj=logging_obj, + _is_async=_is_async, + client=( + client + if client is not None + and isinstance(client, (HTTPHandler, AsyncHTTPHandler)) + else None + ), timeout=timeout, - max_retries=optional_params.max_retries, ) return response @@ -915,7 +941,7 @@ def file_content( ) else: raise litellm.exceptions.BadRequestError( - message="LiteLLM doesn't support {} for 'file_content'. Supported providers are 'openai', 'azure', 'vertex_ai', 'bedrock', 'manus'.".format( + message="LiteLLM doesn't support {} for 'file_content'. Supported providers are 'openai', 'azure', 'vertex_ai', 'bedrock', 'manus', 'anthropic'.".format( custom_llm_provider ), model="n/a", diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index d59b8d88714..12447b39e1e 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -5,6 +5,7 @@ Common utility functions used for translating messages across providers import io import mimetypes import re +import os from os import PathLike from pathlib import Path from typing import ( @@ -644,6 +645,10 @@ def extract_file_data(file_data: FileTypes) -> ExtractedFileData: content = f.read() elif isinstance(file_content, io.IOBase): # If it's a file-like object + # Try to get filename from file handle if not already set + if not filename and hasattr(file_content, 'name'): + filename = os.path.basename(file_content.name) + content = file_content.read() if isinstance(content, str): diff --git a/litellm/llms/anthropic/files/__init__.py b/litellm/llms/anthropic/files/__init__.py index b8b538ffb62..50fac0c4bdb 100644 --- a/litellm/llms/anthropic/files/__init__.py +++ b/litellm/llms/anthropic/files/__init__.py @@ -1,4 +1,5 @@ from .handler import AnthropicFilesHandler +from .transformation import AnthropicFilesConfig -__all__ = ["AnthropicFilesHandler"] +__all__ = ["AnthropicFilesHandler", "AnthropicFilesConfig"] diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py new file mode 100644 index 00000000000..09433909442 --- /dev/null +++ b/litellm/llms/anthropic/files/transformation.py @@ -0,0 +1,315 @@ +""" +Anthropic Files API transformation config. + +Implements BaseFilesConfig for Anthropic's Files API (beta). +Reference: https://docs.anthropic.com/en/docs/build-with-claude/files + +Anthropic Files API endpoints: +- POST /v1/files - Upload a file +- GET /v1/files - List files +- GET /v1/files/{file_id} - Retrieve file metadata +- DELETE /v1/files/{file_id} - Delete a file +- GET /v1/files/{file_id}/content - Download file content +""" + +import time +from typing import Any, Dict, List, Optional, Union + +import httpx +from openai.types.file_deleted import FileDeleted + +from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.files.transformation import ( + BaseFilesConfig, + LiteLLMLoggingObj, +) +from litellm.types.llms.openai import ( + CreateFileRequest, + FileContentRequest, + HttpxBinaryResponseContent, + OpenAICreateFileRequestOptionalParams, + OpenAIFileObject, +) +from litellm.types.utils import LlmProviders + +from ..common_utils import AnthropicModelInfo + +ANTHROPIC_FILES_API_BASE = "https://api.anthropic.com" +ANTHROPIC_FILES_BETA_HEADER = "files-api-2025-04-14" + + +class AnthropicError(BaseLLMException): + def __init__( + self, + status_code: int, + message: str, + headers: Union[dict, httpx.Headers] = {}, + ): + self.status_code = status_code + self.message = message + self.headers = headers + super().__init__(status_code=status_code, message=message, headers=headers) + + +class AnthropicFilesConfig(BaseFilesConfig): + """ + Transformation config for Anthropic Files API. + + Anthropic uses: + - x-api-key header for authentication + - anthropic-beta: files-api-2025-04-14 header + - multipart/form-data for file uploads + - purpose="messages" (Anthropic-specific, not for batches/fine-tuning) + """ + + def __init__(self): + pass + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.ANTHROPIC + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + api_base = AnthropicModelInfo.get_api_base(api_base) or ANTHROPIC_FILES_API_BASE + return f"{api_base.rstrip('/')}/v1/files" + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: Union[dict, httpx.Headers], + ) -> BaseLLMException: + return AnthropicError( + status_code=status_code, + message=error_message, + headers=headers, + ) + + def validate_environment( + self, + headers: dict, + model: str, + messages: list, + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + api_key = AnthropicModelInfo.get_api_key(api_key) + if not api_key: + raise ValueError( + "Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or pass api_key parameter." + ) + headers.update( + { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "anthropic-beta": ANTHROPIC_FILES_BETA_HEADER, + } + ) + return headers + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAICreateFileRequestOptionalParams]: + return ["purpose"] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + return optional_params + + def transform_create_file_request( + self, + model: str, + create_file_data: CreateFileRequest, + optional_params: dict, + litellm_params: dict, + ) -> dict: + """ + Transform to multipart form data for Anthropic file upload. + + Anthropic expects: POST /v1/files with multipart form-data + - file: the file content + - purpose: "messages" (required) + """ + file_data = create_file_data.get("file") + if file_data is None: + raise ValueError("File data is required") + + extracted = extract_file_data(file_data) + filename = extracted["filename"] or f"file_{int(time.time())}" + content = extracted["content"] + content_type = extracted.get("content_type", "application/octet-stream") + + purpose = create_file_data.get("purpose", "messages") + + return { + "file": (filename, content, content_type), + "purpose": (None, purpose), + } + + def transform_create_file_response( + self, + model: Optional[str], + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> OpenAIFileObject: + """ + Transform Anthropic file response to OpenAI format. + + Anthropic response: + { + "id": "file-xxx", + "type": "file", + "filename": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 12345, + "created_at": "2025-01-01T00:00:00Z" + } + """ + response_json = raw_response.json() + return self._parse_anthropic_file(response_json) + + def transform_retrieve_file_request( + self, + file_id: str, + optional_params: dict, + litellm_params: dict, + ) -> tuple[str, dict]: + api_base = AnthropicModelInfo.get_api_base( + litellm_params.get("api_base") + ) or ANTHROPIC_FILES_API_BASE + return f"{api_base.rstrip('/')}/v1/files/{file_id}", {} + + def transform_retrieve_file_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> OpenAIFileObject: + response_json = raw_response.json() + return self._parse_anthropic_file(response_json) + + def transform_delete_file_request( + self, + file_id: str, + optional_params: dict, + litellm_params: dict, + ) -> tuple[str, dict]: + api_base = AnthropicModelInfo.get_api_base( + litellm_params.get("api_base") + ) or ANTHROPIC_FILES_API_BASE + return f"{api_base.rstrip('/')}/v1/files/{file_id}", {} + + def transform_delete_file_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> FileDeleted: + response_json = raw_response.json() + file_id = response_json.get("id", "") + return FileDeleted( + id=file_id, + deleted=True, + object="file", + ) + + def transform_list_files_request( + self, + purpose: Optional[str], + optional_params: dict, + litellm_params: dict, + ) -> tuple[str, dict]: + api_base = AnthropicModelInfo.get_api_base( + litellm_params.get("api_base") + ) or ANTHROPIC_FILES_API_BASE + url = f"{api_base.rstrip('/')}/v1/files" + params: Dict[str, Any] = {} + if purpose: + params["purpose"] = purpose + return url, params + + def transform_list_files_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> List[OpenAIFileObject]: + """ + Anthropic list response: + { + "data": [...], + "has_more": false, + "first_id": "...", + "last_id": "..." + } + """ + response_json = raw_response.json() + files_data = response_json.get("data", []) + return [self._parse_anthropic_file(f) for f in files_data] + + def transform_file_content_request( + self, + file_content_request: FileContentRequest, + optional_params: dict, + litellm_params: dict, + ) -> tuple[str, dict]: + file_id = file_content_request.get("file_id") + api_base = AnthropicModelInfo.get_api_base( + litellm_params.get("api_base") + ) or ANTHROPIC_FILES_API_BASE + return f"{api_base.rstrip('/')}/v1/files/{file_id}/content", {} + + def transform_file_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> HttpxBinaryResponseContent: + return HttpxBinaryResponseContent(response=raw_response) + + @staticmethod + def _parse_anthropic_file(file_data: dict) -> OpenAIFileObject: + """Parse Anthropic file object into OpenAI format.""" + created_at_str = file_data.get("created_at", "") + if created_at_str: + try: + created_at = int( + time.mktime( + time.strptime( + created_at_str.replace("Z", "+00:00")[:19], + "%Y-%m-%dT%H:%M:%S", + ) + ) + ) + except (ValueError, TypeError): + created_at = int(time.time()) + else: + created_at = int(time.time()) + + return OpenAIFileObject( + id=file_data.get("id", ""), + bytes=file_data.get("size_bytes", file_data.get("bytes", 0)), + created_at=created_at, + filename=file_data.get("filename", ""), + object="file", + purpose=file_data.get("purpose", "messages"), + status="uploaded", + status_details=None, + ) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 1cef3e9ce15..43162b428df 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -3012,6 +3012,15 @@ class BaseLLMHTTPHandler: data=transformed_request, timeout=timeout, ) + elif isinstance(transformed_request, dict) and "file" in transformed_request: + # Handle multipart form-data uploads (e.g., Anthropic Files API) + # The dict contains tuples suitable for httpx's `files` parameter + upload_response = sync_httpx_client.post( + url=api_base, + headers=headers, + files=transformed_request, + timeout=timeout, + ) else: raise ValueError(f"Unsupported transformed_request type: {type(transformed_request)}") @@ -3140,6 +3149,15 @@ class BaseLLMHTTPHandler: data=transformed_request, timeout=timeout, ) + elif isinstance(transformed_request, dict) and "file" in transformed_request: + # Handle multipart form-data uploads (e.g., Anthropic Files API) + # The dict contains tuples suitable for httpx's `files` parameter + upload_response = await async_httpx_client.post( + url=api_base, + headers=headers, + files=transformed_request, + timeout=timeout, + ) else: raise ValueError(f"Unsupported transformed_request type: {type(transformed_request)}") diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 8a2c5efc268..4c2d2dd8a96 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -287,6 +287,7 @@ OpenAIFilesPurpose = Literal[ "fine-tune-results", "vision", "user_data", + "messages", ] @@ -352,7 +353,7 @@ class OpenAIFileObject(BaseModel): return self.dict() -CREATE_FILE_REQUESTS_PURPOSE = Literal["assistants", "batch", "fine-tune"] +CREATE_FILE_REQUESTS_PURPOSE = Literal["assistants", "batch", "fine-tune", "messages"] # File expiration policy @@ -373,11 +374,11 @@ class FileExpiresAfter(TypedDict): class CreateFileRequest(TypedDict, total=False): """ CreateFileRequest - Used by Assistants API, Batches API, and Fine-Tunes API + Used by Assistants API, Batches API, Fine-Tunes API, and Anthropic Files API Required Params: file: FileTypes - purpose: Literal['assistants', 'batch', 'fine-tune'] + purpose: Literal['assistants', 'batch', 'fine-tune', 'messages'] Optional Params: expires_after: Optional[FileExpiresAfter] - The expiration policy for a file diff --git a/litellm/utils.py b/litellm/utils.py index 4367ec789b3..ab1df26a8a4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8529,6 +8529,12 @@ class ProviderConfigManager: from litellm.llms.manus.files.transformation import ManusFilesConfig return ManusFilesConfig() + elif LlmProviders.ANTHROPIC == provider: + from litellm.llms.anthropic.files.transformation import ( + AnthropicFilesConfig, + ) + + return AnthropicFilesConfig() return None @staticmethod diff --git a/ruff.toml b/ruff.toml index 76acb5dc936..55d008a7dd6 100644 --- a/ruff.toml +++ b/ruff.toml @@ -17,3 +17,4 @@ exclude = ["litellm/types/*", "litellm/__init__.py", "litellm/proxy/example_conf "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py" = ["PLR0915"] "litellm/proxy/guardrails/guardrail_hooks/guardrail_benchmarks/test_eval.py" = ["PLR0915"] "litellm/responses/streaming_iterator.py" = ["PLR0915"] +"litellm/files/main.py" = ["PLR0915"] diff --git a/tests/test_litellm/llms/anthropic/files/__init__.py b/tests/test_litellm/llms/anthropic/files/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py b/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py new file mode 100644 index 00000000000..e9509be9e11 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py @@ -0,0 +1,379 @@ +""" +Test Anthropic Files API transformation functionality. + +Tests the AnthropicFilesConfig class which transforms between +OpenAI-compatible file operations and Anthropic's Files API format. +""" + +import io +import time + +import httpx +import pytest +from unittest.mock import Mock, patch + +from litellm.llms.anthropic.files.transformation import ( + AnthropicFilesConfig, + ANTHROPIC_FILES_API_BASE, + ANTHROPIC_FILES_BETA_HEADER, +) +from litellm.types.llms.openai import OpenAIFileObject +from litellm.types.utils import LlmProviders + + +class TestAnthropicFilesConfig: + """Test AnthropicFilesConfig transformation methods.""" + + def setup_method(self): + self.config = AnthropicFilesConfig() + + def test_custom_llm_provider(self): + assert self.config.custom_llm_provider == LlmProviders.ANTHROPIC + + def test_get_complete_url_default(self): + url = self.config.get_complete_url( + api_base=None, + api_key="test-key", + model="", + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files" + + def test_get_complete_url_custom_base(self): + url = self.config.get_complete_url( + api_base="https://custom.api.com", + api_key="test-key", + model="", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.api.com/v1/files" + + def test_get_complete_url_strips_trailing_slash(self): + url = self.config.get_complete_url( + api_base="https://custom.api.com/", + api_key="test-key", + model="", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.api.com/v1/files" + + def test_validate_environment_sets_headers(self): + headers = {} + result = self.config.validate_environment( + headers=headers, + model="", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-ant-test-key", + ) + assert result["x-api-key"] == "sk-ant-test-key" + assert result["anthropic-version"] == "2023-06-01" + assert result["anthropic-beta"] == ANTHROPIC_FILES_BETA_HEADER + + @patch.dict("os.environ", {}, clear=True) + @patch( + "litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key", + return_value=None, + ) + def test_validate_environment_missing_api_key(self, mock_get_key): + with pytest.raises(ValueError, match="Anthropic API key is required"): + self.config.validate_environment( + headers={}, + model="", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + def test_get_supported_openai_params(self): + params = self.config.get_supported_openai_params(model="") + assert "purpose" in params + + def test_transform_create_file_request(self): + file_content = b"test file content" + file_tuple = ("test.txt", file_content, "text/plain") + + result = self.config.transform_create_file_request( + model="", + create_file_data={ + "file": file_tuple, + "purpose": "messages", + }, + optional_params={}, + litellm_params={}, + ) + + assert "file" in result + assert "purpose" in result + # file should be a tuple (filename, content, content_type) + assert result["file"][0] == "test.txt" + assert result["file"][1] == file_content + assert result["file"][2] == "text/plain" + # purpose should be (None, value) for multipart form field + assert result["purpose"] == (None, "messages") + + def test_transform_create_file_request_missing_file(self): + with pytest.raises(ValueError, match="File data is required"): + self.config.transform_create_file_request( + model="", + create_file_data={"purpose": "messages"}, + optional_params={}, + litellm_params={}, + ) + + def test_transform_create_file_request_default_purpose(self): + file_tuple = ("test.txt", b"content", "text/plain") + result = self.config.transform_create_file_request( + model="", + create_file_data={"file": file_tuple}, + optional_params={}, + litellm_params={}, + ) + assert result["purpose"] == (None, "messages") + + def test_transform_create_file_response(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "file-abc123", + "type": "file", + "filename": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 12345, + "created_at": "2025-01-15T10:30:00Z", + } + + result = self.config.transform_create_file_response( + model=None, + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + + assert isinstance(result, OpenAIFileObject) + assert result.id == "file-abc123" + assert result.filename == "document.pdf" + assert result.bytes == 12345 + assert result.object == "file" + assert result.purpose == "messages" + assert result.status == "uploaded" + + def test_transform_retrieve_file_request(self): + url, params = self.config.transform_retrieve_file_request( + file_id="file-abc123", + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files/file-abc123" + assert params == {} + + def test_transform_retrieve_file_request_custom_base(self): + url, params = self.config.transform_retrieve_file_request( + file_id="file-abc123", + optional_params={}, + litellm_params={"api_base": "https://custom.api.com"}, + ) + assert url == "https://custom.api.com/v1/files/file-abc123" + assert params == {} + + def test_transform_retrieve_file_response(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "file-abc123", + "type": "file", + "filename": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 5000, + "created_at": "2025-06-01T12:00:00Z", + } + + result = self.config.transform_retrieve_file_response( + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + + assert isinstance(result, OpenAIFileObject) + assert result.id == "file-abc123" + assert result.bytes == 5000 + + def test_transform_delete_file_request(self): + url, params = self.config.transform_delete_file_request( + file_id="file-abc123", + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files/file-abc123" + assert params == {} + + def test_transform_delete_file_response(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "file-abc123", + "type": "file_deleted", + } + + result = self.config.transform_delete_file_response( + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + + assert result.id == "file-abc123" + assert result.deleted is True + assert result.object == "file" + + def test_transform_list_files_request(self): + url, params = self.config.transform_list_files_request( + purpose=None, + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files" + assert params == {} + + def test_transform_list_files_request_with_purpose(self): + url, params = self.config.transform_list_files_request( + purpose="messages", + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files" + assert params == {"purpose": "messages"} + + def test_transform_list_files_response(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "data": [ + { + "id": "file-1", + "filename": "a.txt", + "size_bytes": 100, + "created_at": "2025-01-01T00:00:00Z", + }, + { + "id": "file-2", + "filename": "b.txt", + "size_bytes": 200, + "created_at": "2025-01-02T00:00:00Z", + }, + ], + "has_more": False, + } + + result = self.config.transform_list_files_response( + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + + assert len(result) == 2 + assert result[0].id == "file-1" + assert result[0].filename == "a.txt" + assert result[1].id == "file-2" + + def test_transform_list_files_response_empty(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"data": [], "has_more": False} + + result = self.config.transform_list_files_response( + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + assert result == [] + + def test_transform_file_content_request(self): + url, params = self.config.transform_file_content_request( + file_content_request={"file_id": "file-abc123"}, + optional_params={}, + litellm_params={}, + ) + assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files/file-abc123/content" + assert params == {} + + def test_transform_file_content_response(self): + mock_response = Mock(spec=httpx.Response) + result = self.config.transform_file_content_response( + raw_response=mock_response, + logging_obj=Mock(), + litellm_params={}, + ) + assert result.response == mock_response + + def test_parse_anthropic_file_with_size_bytes(self): + """Test that size_bytes is correctly mapped to bytes field.""" + result = AnthropicFilesConfig._parse_anthropic_file( + { + "id": "file-test", + "filename": "test.pdf", + "size_bytes": 9999, + "created_at": "2025-03-01T00:00:00Z", + } + ) + assert result.bytes == 9999 + + def test_parse_anthropic_file_fallback_bytes_field(self): + """Test fallback to 'bytes' field when 'size_bytes' is missing.""" + result = AnthropicFilesConfig._parse_anthropic_file( + { + "id": "file-test", + "filename": "test.pdf", + "bytes": 7777, + "created_at": "2025-03-01T00:00:00Z", + } + ) + assert result.bytes == 7777 + + def test_parse_anthropic_file_invalid_timestamp(self): + """Test that invalid timestamps fall back to current time.""" + result = AnthropicFilesConfig._parse_anthropic_file( + { + "id": "file-test", + "filename": "test.pdf", + "size_bytes": 100, + "created_at": "not-a-date", + } + ) + # Should not raise, should use current time + assert isinstance(result.created_at, int) + assert result.created_at > 0 + + def test_parse_anthropic_file_missing_timestamp(self): + """Test that missing timestamps fall back to current time.""" + result = AnthropicFilesConfig._parse_anthropic_file( + { + "id": "file-test", + "filename": "test.pdf", + "size_bytes": 100, + } + ) + assert isinstance(result.created_at, int) + assert result.created_at > 0 + + def test_get_error_class(self): + error = self.config.get_error_class( + error_message="Not found", + status_code=404, + headers={}, + ) + assert error.status_code == 404 + assert error.message == "Not found" + + +class TestProviderConfigRegistration: + """Test that AnthropicFilesConfig is properly registered.""" + + def test_provider_config_returns_anthropic_files_config(self): + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_files_config( + model="", + provider=LlmProviders.ANTHROPIC, + ) + assert config is not None + assert isinstance(config, AnthropicFilesConfig) From b601b531e7f4e83ce2fb035a9a27370f9dfb81f3 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 11 Mar 2026 13:03:49 -0300 Subject: [PATCH 2/5] fix: use calendar.timegm for UTC timestamps and reuse AnthropicError Address Greptile review feedback: - Use calendar.timegm() instead of time.mktime() for correct UTC parsing - Import AnthropicError from common_utils instead of redefining it --- litellm/llms/anthropic/files/transformation.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index 09433909442..d8edd51488b 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -12,6 +12,7 @@ Anthropic Files API endpoints: - GET /v1/files/{file_id}/content - Download file content """ +import calendar import time from typing import Any, Dict, List, Optional, Union @@ -33,25 +34,12 @@ from litellm.types.llms.openai import ( ) from litellm.types.utils import LlmProviders -from ..common_utils import AnthropicModelInfo +from ..common_utils import AnthropicError, AnthropicModelInfo ANTHROPIC_FILES_API_BASE = "https://api.anthropic.com" ANTHROPIC_FILES_BETA_HEADER = "files-api-2025-04-14" -class AnthropicError(BaseLLMException): - def __init__( - self, - status_code: int, - message: str, - headers: Union[dict, httpx.Headers] = {}, - ): - self.status_code = status_code - self.message = message - self.headers = headers - super().__init__(status_code=status_code, message=message, headers=headers) - - class AnthropicFilesConfig(BaseFilesConfig): """ Transformation config for Anthropic Files API. @@ -291,7 +279,7 @@ class AnthropicFilesConfig(BaseFilesConfig): if created_at_str: try: created_at = int( - time.mktime( + calendar.timegm( time.strptime( created_at_str.replace("Z", "+00:00")[:19], "%Y-%m-%dT%H:%M:%S", From 093cca9f570417efe16889397a5e6ec86ed4a3dd Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 11 Mar 2026 13:12:40 -0300 Subject: [PATCH 3/5] chore: remove unused AnthropicFilesHandler import and instance --- litellm/files/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/litellm/files/main.py b/litellm/files/main.py index 863ea222151..7905998c2f8 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -18,7 +18,6 @@ import litellm from litellm import get_secret_str from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.llms.anthropic.files.handler import AnthropicFilesHandler from litellm.llms.azure.common_utils import get_azure_credentials from litellm.llms.azure.files.handler import AzureOpenAIFilesAPI from litellm.llms.bedrock.files.handler import BedrockFilesHandler @@ -54,7 +53,6 @@ openai_files_instance = OpenAIFilesAPI() azure_files_instance = AzureOpenAIFilesAPI() vertex_ai_files_instance = VertexAIFilesHandler() bedrock_files_instance = BedrockFilesHandler() -anthropic_files_instance = AnthropicFilesHandler() ################################################# From abe0c40c1aabc18c4740844213a2e35aff517c14 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 11 Mar 2026 13:22:39 -0300 Subject: [PATCH 4/5] fix: clarify purpose field docstring in AnthropicFilesConfig --- litellm/llms/anthropic/files/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index d8edd51488b..cf157a90f86 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -132,7 +132,7 @@ class AnthropicFilesConfig(BaseFilesConfig): Anthropic expects: POST /v1/files with multipart form-data - file: the file content - - purpose: "messages" (required) + - purpose: "messages" (defaults to "messages" if not provided) """ file_data = create_file_data.get("file") if file_data is None: From d7c2532137023e4674701382f511622ccc28ef08 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 11 Mar 2026 13:37:52 -0300 Subject: [PATCH 5/5] fix: remove redundant import os, use Path().name instead --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 12447b39e1e..3e639e79b6d 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -5,7 +5,6 @@ Common utility functions used for translating messages across providers import io import mimetypes import re -import os from os import PathLike from pathlib import Path from typing import ( @@ -647,7 +646,7 @@ def extract_file_data(file_data: FileTypes) -> ExtractedFileData: # If it's a file-like object # Try to get filename from file handle if not already set if not filename and hasattr(file_content, 'name'): - filename = os.path.basename(file_content.name) + filename = Path(file_content.name).name content = file_content.read()