Merge pull request #17758 from BerriAI/litellm_managed_files_target_storage

Add v0 support for target storage
This commit is contained in:
Sameer Kankute
2025-12-12 22:26:34 +05:30
committed by GitHub
11 changed files with 1300 additions and 20 deletions
@@ -22,7 +22,6 @@ from litellm.proxy._types import (
)
from litellm.proxy.openai_files_endpoints.common_utils import (
_is_base64_encoded_unified_file_id,
convert_b64_uid_to_unified_uid,
get_batch_id_from_unified_batch_id,
get_model_id_from_unified_batch_id,
)
@@ -42,6 +41,10 @@ from litellm.types.utils import (
LLMResponseTypes,
SpecialEnums,
)
from litellm.proxy.openai_files_endpoints.common_utils import (
get_content_type_from_file_object,
normalize_mime_type_for_provider,
)
if TYPE_CHECKING:
from litellm.types.llms.openai import HttpxBinaryResponseContent
@@ -108,6 +111,17 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if file_object is not None:
db_data["file_object"] = file_object.model_dump_json()
# Extract storage metadata from hidden params if present
hidden_params = getattr(file_object, "_hidden_params", {}) or {}
if "storage_backend" in hidden_params:
db_data["storage_backend"] = hidden_params["storage_backend"]
if "storage_url" in hidden_params:
db_data["storage_url"] = hidden_params["storage_url"]
verbose_logger.debug(
f"Storage metadata: storage_backend={db_data.get('storage_backend')}, "
f"storage_url={db_data.get('storage_url')}"
)
result = await self.prisma_client.db.litellm_managedfiletable.create(
data=db_data
@@ -268,7 +282,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
return False
async def async_pre_call_hook(
async def async_pre_call_hook( # noqa: PLR0915
self,
user_api_key_dict: UserAPIKeyAuth,
cache: DualCache,
@@ -287,15 +301,31 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
await self.check_managed_file_id_access(data, user_api_key_dict)
### HANDLE TRANSFORMATIONS ###
if call_type == CallTypes.completion.value:
# Check both completion and acompletion call types
is_completion_call = (
call_type == CallTypes.completion.value
or call_type == CallTypes.acompletion.value
)
if is_completion_call:
messages = data.get("messages")
model = data.get("model", "")
if messages:
file_ids = self.get_file_ids_from_messages(messages)
if file_ids:
# Check if any files are stored in storage backends and need base64 conversion
# This is needed for Vertex AI/Gemini which requires base64 content
is_vertex_ai = model and ("vertex_ai" in model or "gemini" in model.lower())
if is_vertex_ai:
await self._convert_storage_files_to_base64(
messages=messages,
file_ids=file_ids,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
)
model_file_id_mapping = await self.get_model_file_id_mapping(
file_ids, user_api_key_dict.parent_otel_span
)
data["model_file_id_mapping"] = model_file_id_mapping
elif call_type == CallTypes.aresponses.value or call_type == CallTypes.responses.value:
# Handle managed files in responses API input
@@ -865,3 +895,124 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
else:
raise Exception(f"LiteLLM Managed File object with id={file_id} not found")
async def _convert_storage_files_to_base64(
self,
messages: List[AllMessageValues],
file_ids: List[str],
litellm_parent_otel_span: Optional[Span],
) -> None:
"""
Convert files stored in storage backends to base64 format for Vertex AI/Gemini.
This method checks if any managed files are stored in storage backends,
downloads them, and converts them to base64 format in the messages.
"""
# Check each file_id to see if it's stored in a storage backend
for file_id in file_ids:
# Check if this is a base64 encoded unified file ID
decoded_unified_file_id = _is_base64_encoded_unified_file_id(file_id)
if not decoded_unified_file_id:
continue
# Check database for storage backend info
# IMPORTANT: The database stores the base64 encoded unified_file_id (not the decoded version)
# So we query with the original file_id (which is base64 encoded)
db_file = await self.prisma_client.db.litellm_managedfiletable.find_first(
where={"unified_file_id": file_id}
)
if not db_file or not db_file.storage_backend or not db_file.storage_url:
continue
# File is stored in a storage backend, download and convert to base64
try:
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
storage_backend_name = db_file.storage_backend
storage_url = db_file.storage_url
# Get storage backend (uses same env vars as callback)
try:
storage_backend = get_storage_backend(storage_backend_name)
except ValueError as e:
verbose_logger.warning(
f"Storage backend '{storage_backend_name}' error for file {file_id}: {str(e)}"
)
continue
file_content = await storage_backend.download_file(storage_url)
# Determine content type from file object
content_type = self._get_content_type_from_file_object(db_file.file_object)
# Convert to base64
base64_data = base64.b64encode(file_content).decode("utf-8")
base64_data_uri = f"data:{content_type};base64,{base64_data}"
# Update messages to use base64 instead of file_id
self._update_messages_with_base64_data(messages, file_id, base64_data_uri, content_type)
except Exception as e:
verbose_logger.exception(
f"Error converting file {file_id} from storage backend to base64: {str(e)}"
)
# Continue with other files even if one fails
continue
def _get_content_type_from_file_object(self, file_object: Optional[Any]) -> str:
"""
Determine content type from file object.
Uses the MIME type utility for consistent detection and normalization.
Args:
file_object: The file object from the database (can be dict, JSON string, or None)
Returns:
str: MIME type (defaults to "application/octet-stream" if cannot be determined)
"""
# Use utility function for detection
content_type = get_content_type_from_file_object(file_object)
# Normalize for Gemini/Vertex AI (requires image/jpeg, not image/jpg)
content_type = normalize_mime_type_for_provider(content_type, provider="gemini")
return content_type
def _update_messages_with_base64_data(
self,
messages: List[AllMessageValues],
file_id: str,
base64_data_uri: str,
content_type: str,
) -> None:
"""
Update messages to replace file_id with base64 data URI.
Args:
messages: List of messages to update
file_id: The file ID to replace
base64_data_uri: The base64 data URI to use as replacement
content_type: The MIME type of the file (e.g., "image/jpeg", "application/pdf")
"""
for message in messages:
if message.get("role") == "user":
content = message.get("content")
if content and isinstance(content, list):
for element in content:
if element.get("type") == "file":
file_element = cast(ChatCompletionFileObject, element)
file_element_file = file_element.get("file", {})
if file_element_file.get("file_id") == file_id:
# Replace file_id with base64 data
file_element_file["file_data"] = base64_data_uri
# Set format to help Gemini determine mime type
file_element_file["format"] = content_type
# Remove file_id to ensure only file_data is used
file_element_file.pop("file_id", None)
verbose_logger.debug(
f"Converted file {file_id} from storage backend to base64 with format {content_type}"
)
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "LiteLLM_ManagedFileTable" ADD COLUMN IF NOT EXISTS "storage_backend" TEXT;
ALTER TABLE "LiteLLM_ManagedFileTable" ADD COLUMN IF NOT EXISTS "storage_url" TEXT;
@@ -0,0 +1,312 @@
"""
Azure Blob Storage backend implementation for file storage.
This module implements the Azure Blob Storage backend for storing files
in Azure Data Lake Storage Gen2. It inherits from AzureBlobStorageLogger
to reuse all authentication and Azure Storage operations.
"""
import time
from typing import Optional
from urllib.parse import quote
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from .storage_backend import BaseFileStorageBackend
from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger
class AzureBlobStorageBackend(BaseFileStorageBackend, AzureBlobStorageLogger):
"""
Azure Blob Storage backend implementation.
Inherits from AzureBlobStorageLogger to reuse:
- Authentication (account key and Azure AD)
- Service client management
- Token management
- All Azure Storage helper methods
Reads configuration from the same environment variables as AzureBlobStorageLogger.
"""
def __init__(self, **kwargs):
"""
Initialize Azure Blob Storage backend.
Inherits all functionality from AzureBlobStorageLogger which handles:
- Reading environment variables
- Authentication (account key and Azure AD)
- Service client management
- Token management
Environment variables (same as AzureBlobStorageLogger):
- AZURE_STORAGE_ACCOUNT_NAME (required)
- AZURE_STORAGE_FILE_SYSTEM (required)
- AZURE_STORAGE_ACCOUNT_KEY (optional, if using account key auth)
- AZURE_STORAGE_TENANT_ID (optional, if using Azure AD)
- AZURE_STORAGE_CLIENT_ID (optional, if using Azure AD)
- AZURE_STORAGE_CLIENT_SECRET (optional, if using Azure AD)
Note: We skip periodic_flush since we're not using this as a logger.
"""
# Initialize AzureBlobStorageLogger (handles all auth and config)
AzureBlobStorageLogger.__init__(self, **kwargs)
# Disable logging functionality - we're only using this for file storage
# The periodic_flush task will be created but will do nothing since we override it
async def periodic_flush(self):
"""
Override to do nothing - we're not using this as a logger.
This prevents the periodic flush task from doing any work.
"""
# Do nothing - this class is used for file storage, not logging
return
async def async_log_success_event(self, *args, **kwargs):
"""
Override to do nothing - we're not using this as a logger.
"""
# Do nothing - this class is used for file storage, not logging
pass
async def async_log_failure_event(self, *args, **kwargs):
"""
Override to do nothing - we're not using this as a logger.
"""
# Do nothing - this class is used for file storage, not logging
pass
def _generate_file_name(
self, original_filename: str, file_naming_strategy: str
) -> str:
"""Generate file name based on naming strategy."""
if file_naming_strategy == "original_filename":
# Use original filename, but sanitize it
return quote(original_filename, safe="")
elif file_naming_strategy == "timestamp":
# Use timestamp
extension = original_filename.split(".")[-1] if "." in original_filename else ""
timestamp = int(time.time() * 1000) # milliseconds
return f"{timestamp}.{extension}" if extension else str(timestamp)
else: # default to "uuid"
# Use UUID
extension = original_filename.split(".")[-1] if "." in original_filename else ""
file_uuid = str(uuid.uuid4())
return f"{file_uuid}.{extension}" if extension else file_uuid
async def upload_file(
self,
file_content: bytes,
filename: str,
content_type: str,
path_prefix: Optional[str] = None,
file_naming_strategy: str = "uuid",
) -> str:
"""
Upload a file to Azure Blob Storage.
Returns the blob URL in format: https://{account}.blob.core.windows.net/{container}/{path}
"""
try:
# Generate file name
file_name = self._generate_file_name(filename, file_naming_strategy)
# Build full path
if path_prefix:
# Remove leading/trailing slashes and normalize
prefix = path_prefix.strip("/")
full_path = f"{prefix}/{file_name}"
else:
full_path = file_name
if self.azure_storage_account_key:
# Use Azure SDK with account key (reuse logger's method)
storage_url = await self._upload_file_with_account_key(
file_content=file_content,
full_path=full_path,
)
else:
# Use REST API with Azure AD token (reuse logger's methods)
storage_url = await self._upload_file_with_azure_ad(
file_content=file_content,
full_path=full_path,
)
verbose_logger.debug(
f"Successfully uploaded file to Azure Blob Storage: {storage_url}"
)
return storage_url
except Exception as e:
verbose_logger.exception(f"Error uploading file to Azure Blob Storage: {str(e)}")
raise
async def _upload_file_with_account_key(
self, file_content: bytes, full_path: str
) -> str:
"""Upload file using Azure SDK with account key authentication."""
# Reuse the logger's service client method
service_client = await self.get_service_client()
file_system_client = service_client.get_file_system_client(
file_system=self.azure_storage_file_system
)
# Create filesystem (container) if it doesn't exist
if not await file_system_client.exists():
await file_system_client.create_file_system()
verbose_logger.debug(f"Created filesystem: {self.azure_storage_file_system}")
# Extract directory and filename (similar to logger's pattern)
path_parts = full_path.split("/")
if len(path_parts) > 1:
directory_path = "/".join(path_parts[:-1])
file_name = path_parts[-1]
# Create directory if needed (like logger does)
directory_client = file_system_client.get_directory_client(directory_path)
if not await directory_client.exists():
await directory_client.create_directory()
verbose_logger.debug(f"Created directory: {directory_path}")
# Get file client from directory (same pattern as logger)
file_client = directory_client.get_file_client(file_name)
else:
# No directory, create file directly in root
file_client = file_system_client.get_file_client(full_path)
# Create, append, and flush (same pattern as logger's upload_to_azure_data_lake_with_azure_account_key)
await file_client.create_file()
await file_client.append_data(data=file_content, offset=0, length=len(file_content))
await file_client.flush_data(position=len(file_content), offset=0)
# Return blob URL (not DFS URL)
blob_url = f"https://{self.azure_storage_account_name}.blob.core.windows.net/{self.azure_storage_file_system}/{full_path}"
return blob_url
async def _upload_file_with_azure_ad(
self, file_content: bytes, full_path: str
) -> str:
"""Upload file using REST API with Azure AD authentication."""
# Reuse the logger's token management
await self.set_valid_azure_ad_token()
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
async_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback
)
# Use DFS endpoint for upload
base_url = f"https://{self.azure_storage_account_name}.dfs.core.windows.net/{self.azure_storage_file_system}/{full_path}"
# Execute 3-step upload process: create, append, flush
# Reuse the logger's helper methods
await self._create_file(async_client, base_url)
# Append data - logger's _append_data expects string, so we create our own for bytes
await self._append_data_bytes(async_client, base_url, file_content)
await self._flush_data(async_client, base_url, len(file_content))
# Return blob URL (not DFS URL)
blob_url = f"https://{self.azure_storage_account_name}.blob.core.windows.net/{self.azure_storage_file_system}/{full_path}"
return blob_url
async def _append_data_bytes(
self, client, base_url: str, file_content: bytes
):
"""Append binary data to file using REST API."""
from litellm.constants import AZURE_STORAGE_MSFT_VERSION
headers = {
"x-ms-version": AZURE_STORAGE_MSFT_VERSION,
"Content-Type": "application/octet-stream",
"Authorization": f"Bearer {self.azure_auth_token}",
}
response = await client.patch(
f"{base_url}?action=append&position=0",
headers=headers,
content=file_content,
)
response.raise_for_status()
async def download_file(self, storage_url: str) -> bytes:
"""
Download a file from Azure Blob Storage.
Args:
storage_url: Blob URL in format: https://{account}.blob.core.windows.net/{container}/{path}
Returns:
bytes: File content
"""
try:
# Parse blob URL to extract path
# URL format: https://{account}.blob.core.windows.net/{container}/{path}
if ".blob.core.windows.net/" not in storage_url:
raise ValueError(f"Invalid Azure Blob Storage URL: {storage_url}")
# Extract path after container name
container_and_path = storage_url.split(".blob.core.windows.net/", 1)[1]
path_parts = container_and_path.split("/", 1)
if len(path_parts) < 2:
raise ValueError(f"Invalid Azure Blob Storage URL format: {storage_url}")
file_path = path_parts[1] # Path after container name
if self.azure_storage_account_key:
# Use Azure SDK (reuse logger's service client)
return await self._download_file_with_account_key(file_path)
else:
# Use REST API (reuse logger's token management)
return await self._download_file_with_azure_ad(file_path)
except Exception as e:
verbose_logger.exception(f"Error downloading file from Azure Blob Storage: {str(e)}")
raise
async def _download_file_with_account_key(self, file_path: str) -> bytes:
"""Download file using Azure SDK with account key."""
# Reuse the logger's service client method
service_client = await self.get_service_client()
file_system_client = service_client.get_file_system_client(
file_system=self.azure_storage_file_system
)
# Ensure filesystem exists (should already exist, but check for safety)
if not await file_system_client.exists():
raise ValueError(f"Filesystem {self.azure_storage_file_system} does not exist")
file_client = file_system_client.get_file_client(file_path)
# Download file
download_response = await file_client.download_file()
file_content = await download_response.readall()
return file_content
async def _download_file_with_azure_ad(self, file_path: str) -> bytes:
"""Download file using REST API with Azure AD token."""
# Reuse the logger's token management
await self.set_valid_azure_ad_token()
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.constants import AZURE_STORAGE_MSFT_VERSION
async_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback
)
# Use blob endpoint for download (simpler than DFS)
blob_url = f"https://{self.azure_storage_account_name}.blob.core.windows.net/{self.azure_storage_file_system}/{file_path}"
headers = {
"x-ms-version": AZURE_STORAGE_MSFT_VERSION,
"Authorization": f"Bearer {self.azure_auth_token}",
}
response = await async_client.get(blob_url, headers=headers)
response.raise_for_status()
return response.content
@@ -0,0 +1,79 @@
"""
Base storage backend interface for file storage backends.
This module defines the abstract base class that all file storage backends
(e.g., Azure Blob Storage, S3, GCS) must implement.
"""
from abc import ABC, abstractmethod
from typing import Optional
class BaseFileStorageBackend(ABC):
"""
Abstract base class for file storage backends.
All storage backends (Azure Blob Storage, S3, GCS, etc.) must implement
these methods to provide a consistent interface for file operations.
"""
@abstractmethod
async def upload_file(
self,
file_content: bytes,
filename: str,
content_type: str,
path_prefix: Optional[str] = None,
file_naming_strategy: str = "uuid",
) -> str:
"""
Upload a file to the storage backend.
Args:
file_content: The file content as bytes
filename: Original filename (may be used for naming strategy)
content_type: MIME type of the file
path_prefix: Optional path prefix for organizing files
file_naming_strategy: Strategy for naming files ("uuid", "timestamp", "original_filename")
Returns:
str: The storage URL where the file can be accessed/downloaded
Raises:
Exception: If upload fails
"""
pass
@abstractmethod
async def download_file(self, storage_url: str) -> bytes:
"""
Download a file from the storage backend.
Args:
storage_url: The storage URL returned from upload_file
Returns:
bytes: The file content
Raises:
Exception: If download fails
"""
pass
async def delete_file(self, storage_url: str) -> None:
"""
Delete a file from the storage backend.
This is optional and can be overridden by backends that support deletion.
Default implementation does nothing.
Args:
storage_url: The storage URL of the file to delete
Raises:
Exception: If deletion fails
"""
# Default implementation: no-op
# Backends can override if they support deletion
pass
@@ -0,0 +1,41 @@
"""
Factory for creating storage backend instances.
This module provides a factory function to instantiate the correct storage backend
based on the backend type. Backends use the same configuration as their corresponding
callbacks (e.g., azure_storage uses the same env vars as AzureBlobStorageLogger).
"""
from litellm._logging import verbose_logger
from .azure_blob_storage_backend import AzureBlobStorageBackend
from .storage_backend import BaseFileStorageBackend
def get_storage_backend(backend_type: str) -> BaseFileStorageBackend:
"""
Factory function to create a storage backend instance.
Backends are configured using the same environment variables as their
corresponding callbacks. For example, "azure_storage" uses the same
env vars as AzureBlobStorageLogger.
Args:
backend_type: Backend type identifier (e.g., "azure_storage")
Returns:
BaseFileStorageBackend: Instance of the appropriate storage backend
Raises:
ValueError: If backend_type is not supported
"""
verbose_logger.debug(f"Creating storage backend: type={backend_type}")
if backend_type == "azure_storage":
return AzureBlobStorageBackend()
else:
raise ValueError(
f"Unsupported storage backend type: {backend_type}. "
f"Supported types: azure_storage"
)
+2
View File
@@ -3685,6 +3685,8 @@ class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase):
flat_model_file_ids: List[str]
created_by: Optional[str]
updated_by: Optional[str]
storage_backend: Optional[str] = None
storage_url: Optional[str] = None
class LiteLLM_ManagedObjectTable(LiteLLMPydanticObjectBase):
@@ -1,7 +1,12 @@
import base64
import mimetypes
import re
from dataclasses import dataclass, field
from typing import List, Literal, Optional, Union
from fastapi import Request
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.types.utils import SpecialEnums
@@ -339,3 +344,294 @@ def handle_model_based_routing(
# No model-based routing needed
return False, None, None, None
# ============================================================================
# MIME TYPE DETECTION AND NORMALIZATION
# ============================================================================
# Gemini-supported image MIME types
GEMINI_SUPPORTED_IMAGE_TYPES = {
"image/png",
"image/jpeg",
"image/webp",
}
# Gemini-supported video MIME types
GEMINI_SUPPORTED_VIDEO_TYPES = {
"video/3gpp",
"video/wmv",
"video/webm",
"video/mp4",
"video/mpg",
"video/mpegps",
"video/mpeg",
"video/quicktime",
"video/x-flv",
}
# Gemini-supported audio MIME types
GEMINI_SUPPORTED_AUDIO_TYPES = {
"audio/webm",
"audio/wav",
"audio/pcm",
"audio/opus",
"audio/mp4",
"audio/mpga",
"audio/mpeg",
"audio/m4a",
"audio/mp3",
"audio/flac",
"audio/aac",
}
# Gemini-supported document MIME types
GEMINI_SUPPORTED_DOCUMENT_TYPES = {
"text/plain",
"application/pdf",
}
# Mapping of common file extensions to MIME types
# This extends Python's mimetypes with custom mappings
EXTENSION_TO_MIME_TYPE = {
".jpg": "image/jpeg", # Normalize jpg to jpeg
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".pdf": "application/pdf",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
}
def detect_content_type_from_filename(filename: str) -> str:
"""
Detect content type from filename using extension.
Uses Python's mimetypes module with custom overrides for common cases.
Normalizes jpg to jpeg for consistency.
"""
if not filename:
return "application/octet-stream"
# Try custom mapping first
filename_lower = filename.lower()
for ext, mime_type in EXTENSION_TO_MIME_TYPE.items():
if filename_lower.endswith(ext):
return mime_type
# Fall back to Python's mimetypes
mime_type_guess, _ = mimetypes.guess_type(filename)
if mime_type_guess is not None:
return mime_type_guess
return "application/octet-stream"
def normalize_mime_type_for_provider(
mime_type: str, provider: Optional[str] = None
) -> str:
"""
Normalize MIME type for specific provider requirements.
Currently handles:
- Gemini: Normalizes image/jpg to image/jpeg
Args:
mime_type: Original MIME type
provider: Provider name (e.g., "gemini", "vertex_ai")
Returns:
str: Normalized MIME type
"""
normalized = mime_type.lower().strip()
# Gemini/Vertex AI requires image/jpeg, not image/jpg
if provider and ("gemini" in provider.lower() or "vertex_ai" in provider.lower()):
if normalized == "image/jpg":
normalized = "image/jpeg"
# General normalization: always normalize jpg to jpeg
if normalized == "image/jpg":
normalized = "image/jpeg"
return normalized
def is_gemini_supported_mime_type(mime_type: str) -> bool:
"""
Check if a MIME type is supported by Gemini multimodal models.
Supported categories:
- Images: image/png, image/jpeg, image/webp
- Video: 3gpp, wmv, webm, mp4, mpg, mpegps, mpeg, quicktime, x-flv
- Audio: webm, wav, pcm, opus, mp4, mpga, mpeg, m4a, mp3, flac, aac
- Documents: text/plain, application/pdf
Args:
mime_type: MIME type to check
Returns:
bool: True if supported, False otherwise
"""
normalized = normalize_mime_type_for_provider(mime_type, provider="gemini")
return normalized in (
GEMINI_SUPPORTED_IMAGE_TYPES
| GEMINI_SUPPORTED_VIDEO_TYPES
| GEMINI_SUPPORTED_AUDIO_TYPES
| GEMINI_SUPPORTED_DOCUMENT_TYPES
)
def get_content_type_from_file_object(file_object: Optional[dict]) -> str:
"""
Determine content type from file object (from database or API response).
Extracts filename from file object and uses detect_content_type_from_filename.
Falls back to default if file object is invalid or filename not found.
Args:
file_object: File object dictionary (can be None)
Returns:
str: MIME type (defaults to "application/octet-stream" if cannot be determined)
"""
if not file_object:
return "application/octet-stream"
# Handle JSON string
if isinstance(file_object, str):
import json
try:
file_object = json.loads(file_object)
except json.JSONDecodeError:
return "application/octet-stream"
if not isinstance(file_object, dict):
return "application/octet-stream"
# Try to get filename
filename = file_object.get("filename", "")
if filename:
return detect_content_type_from_filename(filename)
return "application/octet-stream"
# ============================================================================
# REQUEST PARAMETER EXTRACTION
# ============================================================================
@dataclass
class FileCreationParams:
"""
Structured parameters extracted from file creation requests.
Attributes:
target_storage: Storage backend name (e.g., "azure_storage", "default")
target_model_names: List of model names for managed files
model: Model parameter for multi-account routing
"""
target_storage: str = "default"
target_model_names: List[str] = field(default_factory=list)
model: Optional[str] = None
def __post_init__(self):
"""Normalize and validate parameters after initialization."""
if self.target_model_names is None:
self.target_model_names = []
# Normalize target_storage
if not self.target_storage:
self.target_storage = "default"
# Strip whitespace from model names
self.target_model_names = [name.strip() for name in self.target_model_names if name.strip()]
async def extract_file_creation_params(
request: Request,
request_body: Optional[dict] = None,
target_model_names_form: Optional[str] = None,
target_storage_form: Optional[str] = None,
) -> FileCreationParams:
"""
Extract file creation parameters from request.
Args:
request: FastAPI request object
request_body: Optional pre-parsed request body
target_model_names_form: target_model_names from form field (comma-separated string)
target_storage_form: target_storage from form field (defaults to "default")
Returns:
FileCreationParams: Structured parameters extracted from the request
"""
if request_body is None:
request_body = await _read_request_body(request=request) or {}
# Extract target_storage (simplified - just use form parameter)
target_storage = _extract_target_storage_simple(target_storage_form)
# Extract target_model_names (simplified - just use form parameter)
target_model_names = _extract_target_model_names_simple(target_model_names_form)
# Extract model parameter
model = _extract_model_param(request, request_body)
return FileCreationParams(
target_storage=target_storage,
target_model_names=target_model_names,
model=model,
)
def _extract_target_storage_simple(target_storage_form: Optional[str] = None) -> str:
"""
Extract target_storage parameter from form field.
Args:
target_storage_form: target_storage from form field
Returns:
str: Target storage backend name, or "default"
"""
if target_storage_form:
return target_storage_form.strip()
return "default"
def _extract_target_model_names_simple(target_model_names_form: Optional[str] = None) -> List[str]:
"""
Extract target_model_names parameter from form field.
"""
if not target_model_names_form:
return []
# Parse comma-separated string into list
if isinstance(target_model_names_form, str):
return [name.strip() for name in target_model_names_form.split(",") if name.strip()]
elif isinstance(target_model_names_form, list):
return [str(name).strip() for name in target_model_names_form if name]
return []
def _extract_model_param(request: Request, request_body: dict) -> Optional[str]:
"""
Extract model parameter from request.
Priority:
1. request_body.model
2. Query parameter (?model=)
3. Header (x-litellm-model)
"""
return (
request_body.get("model")
or request.query_params.get("model")
or request.headers.get("x-litellm-model")
)
@@ -7,7 +7,7 @@
import asyncio
import traceback
from typing import Optional, cast, get_args
from typing import Any, Optional, cast, get_args
import httpx
from fastapi import (
@@ -47,10 +47,12 @@ from litellm.types.llms.openai import (
from .common_utils import (
_is_base64_encoded_unified_file_id,
encode_file_id_with_model,
extract_file_creation_params,
get_credentials_for_model,
handle_model_based_routing,
prepare_data_with_credentials,
)
from .storage_backend_service import StorageBackendFileService
router = APIRouter()
@@ -136,17 +138,38 @@ async def route_create_file(
router_model: Optional[str],
custom_llm_provider: str,
model: Optional[str] = None,
target_storage: Optional[str] = "default",
) -> OpenAIFileObject:
"""
Route file creation request to the appropriate provider.
Priority:
1. If model parameter provided -> use model credentials and encode ID
2. If enable_loadbalancing_on_batch_endpoints -> deprecated loadbalancing
3. If target_model_names_list -> managed files (requires DB)
4. Else -> use custom_llm_provider with files_settings
1. If target_storage is specified and not "default" -> use storage backend
2. If model parameter provided -> use model credentials and encode ID
3. If enable_loadbalancing_on_batch_endpoints -> deprecated loadbalancing
4. If target_model_names_list -> managed files (requires DB)
5. Else -> use custom_llm_provider with files_settings
"""
# Handle custom storage backend
if target_storage and target_storage != "default":
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
# Extract file data
file_data = extract_file_data(cast(Any, _create_file_request.get("file")))
# Use storage backend service to handle upload
file_object = await StorageBackendFileService.upload_file_to_storage_backend(
file_data=file_data,
target_storage=target_storage,
target_model_names=target_model_names_list,
purpose=purpose,
proxy_logging_obj=proxy_logging_obj,
user_api_key_dict=user_api_key_dict,
)
return file_object
# NEW: Handle model-based routing (no DB required)
if model is not None:
# Get credentials from model_list via router
@@ -255,6 +278,7 @@ async def create_file( # noqa: PLR0915
fastapi_response: Response,
purpose: str = Form(...),
target_model_names: str = Form(default=""),
target_storage: str = Form(default="default"),
provider: Optional[str] = None,
custom_llm_provider: str = Form(default="openai"),
file: UploadFile = File(...),
@@ -299,18 +323,18 @@ async def create_file( # noqa: PLR0915
or "openai"
)
# NEW: Extract model parameter for multi-account routing
# Extract file creation parameters using utility function
request_body = await _read_request_body(request=request) or {}
model_param = (
request_body.get("model")
or request.query_params.get("model")
or request.headers.get("x-litellm-model")
file_params = await extract_file_creation_params(
request=request,
request_body=request_body,
target_model_names_form=target_model_names,
target_storage_form=target_storage,
)
target_model_names_list = (
target_model_names.split(",") if target_model_names else []
)
target_model_names_list = [model.strip() for model in target_model_names_list]
target_storage = file_params.target_storage
target_model_names_list = file_params.target_model_names
model_param = file_params.model
# Prepare the data for forwarding
# Replace with:
@@ -392,6 +416,7 @@ async def create_file( # noqa: PLR0915
router_model=router_model,
custom_llm_provider=custom_llm_provider,
model=model_param,
target_storage=target_storage,
)
if response is None:
@@ -471,7 +496,7 @@ async def create_file( # noqa: PLR0915
dependencies=[Depends(user_api_key_auth)],
tags=["files"],
)
async def get_file_content(
async def get_file_content( # noqa: PLR0915
request: Request,
fastapi_response: Response,
file_id: str,
@@ -549,6 +574,38 @@ async def get_file_content(
param="None",
code=500,
)
# Check if file is stored in a storage backend (check DB)
if hasattr(managed_files_obj, "prisma_client") and managed_files_obj.prisma_client:
db_file = await managed_files_obj.prisma_client.db.litellm_managedfiletable.find_first(
where={"unified_file_id": file_id}
)
if db_file and db_file.storage_backend and db_file.storage_url:
# File is stored in a storage backend, download it
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
storage_backend_name = db_file.storage_backend
storage_url = db_file.storage_url
try:
# Get storage backend (uses same env vars as callback)
storage_backend = get_storage_backend(storage_backend_name)
file_content = await storage_backend.download_file(storage_url)
# Return file content
from fastapi.responses import Response as FastAPIResponse
return FastAPIResponse(
content=file_content,
media_type="application/octet-stream",
)
except ValueError as e:
raise ProxyException(
message=f"Storage backend error: {str(e)}",
type="invalid_request_error",
param="file_id",
code=400,
)
model = cast(Optional[str], data.get("model"))
if model:
response = await llm_router.afile_content(
@@ -0,0 +1,244 @@
"""
Storage backend service for file upload operations.
This module provides a service class for handling file uploads to custom
storage backends (e.g., Azure Blob Storage) and managing associated metadata.
"""
import base64
import time
from typing import Any, List, Mapping, cast
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid as uuid_module
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.proxy.utils import ProxyLogging
from litellm.types.llms.openai import OpenAIFileObject
from litellm.types.utils import SpecialEnums
class StorageBackendFileService:
"""
Service for handling file uploads to storage backends.
This service encapsulates the logic for:
- Uploading files to storage backends
- Creating file objects with storage metadata
- Generating unified file IDs for managed files
- Storing files in the managed files system
"""
@staticmethod
async def upload_file_to_storage_backend(
file_data: Mapping[str, Any],
target_storage: str,
target_model_names: List[str],
purpose: str,
proxy_logging_obj: ProxyLogging,
user_api_key_dict: UserAPIKeyAuth,
) -> OpenAIFileObject:
"""
Upload a file to a storage backend and create a file object.
Args:
file_data: File data dictionary from extract_file_data()
target_storage: Storage backend name (e.g., "azure_storage")
target_model_names: List of model names for managed files
purpose: File purpose (e.g., "user_data", "batch")
proxy_logging_obj: Proxy logging object for accessing hooks
user_api_key_dict: User API key authentication data
Returns:
OpenAIFileObject: Created file object with storage metadata
Raises:
ProxyException: If storage backend is invalid or upload fails
"""
# Get storage backend instance
try:
storage_backend = get_storage_backend(target_storage)
except ValueError as e:
raise ProxyException(
message=str(e),
type="invalid_request_error",
param="target_storage",
code=400,
)
# Extract file information
file_content = file_data["content"]
filename = file_data.get("filename", "file")
content_type = file_data.get("content_type", "application/octet-stream")
# Upload to storage backend
storage_url = await storage_backend.upload_file(
file_content=file_content,
filename=filename,
content_type=content_type,
path_prefix="",
file_naming_strategy="uuid",
)
verbose_proxy_logger.debug(
f"Storage backend upload complete: backend={target_storage}, url={storage_url}"
)
# Create file object with storage metadata
file_object = StorageBackendFileService._create_file_object_with_storage_metadata(
file_content=file_content,
filename=filename,
purpose=purpose,
target_storage=target_storage,
storage_url=storage_url,
)
# Store in managed files if target_model_names provided
if target_model_names:
await StorageBackendFileService._store_in_managed_files(
file_object=file_object,
file_data=file_data,
target_model_names=target_model_names,
target_storage=target_storage,
storage_url=storage_url,
proxy_logging_obj=proxy_logging_obj,
user_api_key_dict=user_api_key_dict,
)
return file_object
@staticmethod
def _create_file_object_with_storage_metadata(
file_content: bytes,
filename: str,
purpose: str,
target_storage: str,
storage_url: str,
) -> OpenAIFileObject:
"""
Create an OpenAIFileObject with storage backend metadata.
Args:
file_content: File content bytes
filename: Original filename
purpose: File purpose
target_storage: Storage backend name
storage_url: URL where file is stored
Returns:
OpenAIFileObject: File object with storage metadata in _hidden_params
"""
file_id = f"file-{uuid_module.uuid4().hex[:24]}"
file_object = OpenAIFileObject(
id=file_id,
object="file",
purpose=purpose,
created_at=int(time.time()),
bytes=len(file_content),
filename=filename,
status="uploaded",
)
# Store storage metadata in hidden params
if not hasattr(file_object, "_hidden_params") or file_object._hidden_params is None:
file_object._hidden_params = {}
file_object._hidden_params.update({
"storage_backend": target_storage,
"storage_url": storage_url,
})
return file_object
@staticmethod
def _create_unified_file_id(
file_type: str,
target_model_names: List[str],
file_id: str,
) -> str:
"""
Create a base64-encoded unified file ID for managed files.
Args:
file_type: MIME type of the file
target_model_names: List of model names
file_id: Original file ID
Returns:
str: Base64-encoded unified file ID
"""
unified_file_id_str = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format(
file_type,
str(uuid_module.uuid4()),
",".join(target_model_names),
file_id,
None,
)
base64_unified_file_id = (
base64.urlsafe_b64encode(unified_file_id_str.encode()).decode().rstrip("=")
)
return base64_unified_file_id
@staticmethod
async def _store_in_managed_files(
file_object: OpenAIFileObject,
file_data: Mapping[str, Any],
target_model_names: List[str],
target_storage: str,
storage_url: str,
proxy_logging_obj: ProxyLogging,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
Store file in managed files system with unified file ID.
Args:
file_object: File object to store
file_data: File data dictionary
target_model_names: List of model names
target_storage: Storage backend name
storage_url: URL where file is stored
proxy_logging_obj: Proxy logging object
user_api_key_dict: User API key authentication data
"""
managed_files_obj = proxy_logging_obj.get_proxy_hook("managed_files")
if not managed_files_obj or not isinstance(managed_files_obj, BaseFileEndpoints):
verbose_proxy_logger.warning(
"Managed files hook not available, skipping managed files storage"
)
return
managed_files_obj = cast(Any, managed_files_obj)
# Create model mappings using storage URL
model_mappings = {
model_name: storage_url
for model_name in target_model_names
}
# Create unified file ID
file_type = file_data.get("content_type", "application/octet-stream")
base64_unified_file_id = StorageBackendFileService._create_unified_file_id(
file_type=file_type,
target_model_names=target_model_names,
file_id=file_object.id,
)
# Update file object ID to unified ID
file_object.id = base64_unified_file_id
verbose_proxy_logger.debug(
f"Storing file in managed files: unified_id={base64_unified_file_id}, "
f"storage_backend={target_storage}, storage_url={storage_url}"
)
# Store in managed files
await managed_files_obj.store_unified_file_id(
file_id=base64_unified_file_id,
file_object=file_object,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
model_mappings=model_mappings,
user_api_key_dict=user_api_key_dict,
)
+2
View File
@@ -602,6 +602,8 @@ model LiteLLM_ManagedFileTable {
file_object Json? // Stores the OpenAIFileObject
model_mappings Json
flat_model_file_ids String[] @default([]) // Flat list of model file id's - for faster querying of model id -> unified file id
storage_backend String? // Storage backend name (e.g., "azure_storage", "gcs", "default")
storage_url String? // The actual storage URL where the file is stored
created_at DateTime @default(now())
created_by String?
updated_at DateTime @updatedAt
@@ -18,6 +18,7 @@ from litellm.proxy._types import LiteLLM_UserTableFiltered, UserAPIKeyAuth
from litellm.proxy.hooks import get_proxy_hook
from litellm.proxy.management_endpoints.internal_user_endpoints import ui_view_users
from litellm.proxy.proxy_server import app
from litellm.types.llms.openai import OpenAIFileObject
client = TestClient(app)
from litellm.caching.caching import DualCache
@@ -225,6 +226,97 @@ def test_mock_create_audio_file(mocker: MockerFixture, monkeypatch, llm_router:
assert openai_call_found, "OpenAI call not found with expected parameters"
def test_target_storage_invokes_storage_backend(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""
Ensure target_storage is parsed and invokes the storage backend service.
"""
setup_proxy_logging_object(monkeypatch, llm_router)
async_mock = mocker.AsyncMock(
return_value=OpenAIFileObject(
id="file-test",
object="file",
purpose="user_data",
created_at=0,
bytes=3,
filename="abc.txt",
status="uploaded",
)
)
mocker.patch(
"litellm.proxy.openai_files_endpoints.files_endpoints.StorageBackendFileService.upload_file_to_storage_backend",
new=async_mock,
)
test_file_content = b"abc"
test_file = ("abc.txt", test_file_content, "text/plain")
response = client.post(
"/v1/files",
files={"file": test_file},
data={
"purpose": "user_data",
"target_storage": "azure_storage",
},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200
async_mock.assert_awaited_once()
called_kwargs = async_mock.call_args.kwargs
assert called_kwargs["target_storage"] == "azure_storage"
assert called_kwargs["target_model_names"] == []
assert called_kwargs["purpose"] == "user_data"
def test_target_storage_with_target_models(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""
Ensure target_storage and target_model_names are parsed and passed through.
"""
setup_proxy_logging_object(monkeypatch, llm_router)
async_mock = mocker.AsyncMock(
return_value=OpenAIFileObject(
id="file-test",
object="file",
purpose="user_data",
created_at=0,
bytes=3,
filename="abc.txt",
status="uploaded",
)
)
mocker.patch(
"litellm.proxy.openai_files_endpoints.files_endpoints.StorageBackendFileService.upload_file_to_storage_backend",
new=async_mock,
)
test_file_content = b"abc"
test_file = ("abc.txt", test_file_content, "text/plain")
response = client.post(
"/v1/files",
files={"file": test_file},
data={
"purpose": "user_data",
"target_storage": "azure_storage",
"target_model_names": "gemini-2.0-flash",
},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200
async_mock.assert_awaited_once()
called_kwargs = async_mock.call_args.kwargs
assert called_kwargs["target_storage"] == "azure_storage"
assert called_kwargs["target_model_names"] == ["gemini-2.0-flash"]
assert called_kwargs["purpose"] == "user_data"
@pytest.mark.skip(reason="mock respx fails on ci/cd - unclear why")
def test_create_file_and_call_chat_completion_e2e(
mocker: MockerFixture, monkeypatch, llm_router: Router