mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-03 16:22:49 +00:00
feat(files): add file_id upload support and text file handling
- Add VCR patch for binary request bodies (base64 encoding fallback) - Add generate_filename() utility for UUID-based filenames with extension - Add OpenAIResponsesFormatter for Responses API (input_image, input_file) - Fix OpenAI uploader to use 'vision' purpose for images - Fix Anthropic uploader to use tuple format (filename, content, content_type) - Add TextConstraints and text support for Gemini - Add file_id upload integration tests for Anthropic and OpenAI Responses API
This commit is contained in:
@@ -54,7 +54,7 @@ class FileOperationMetrics:
|
||||
}
|
||||
|
||||
if self.filename:
|
||||
result["filename"] = self.filename
|
||||
result["file_name"] = self.filename
|
||||
if self.provider:
|
||||
result["provider"] = self.provider
|
||||
if self.size_bytes is not None:
|
||||
|
||||
@@ -64,6 +64,21 @@ def _fallback_content_type(filename: str | None) -> str:
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def generate_filename(content_type: str) -> str:
|
||||
"""Generate a UUID-based filename with extension from content type.
|
||||
|
||||
Args:
|
||||
content_type: MIME type to derive extension from.
|
||||
|
||||
Returns:
|
||||
Filename in format "{uuid}{ext}" where ext includes the dot.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
ext = mimetypes.guess_extension(content_type) or ""
|
||||
return f"{uuid.uuid4()}{ext}"
|
||||
|
||||
|
||||
def detect_content_type(data: bytes, filename: str | None = None) -> str:
|
||||
"""Detect MIME type from file content.
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ from crewai_files.formatting.api import (
|
||||
aformat_multimodal_content,
|
||||
format_multimodal_content,
|
||||
)
|
||||
from crewai_files.formatting.openai import OpenAIResponsesFormatter
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OpenAIResponsesFormatter",
|
||||
"aformat_multimodal_content",
|
||||
"format_multimodal_content",
|
||||
]
|
||||
|
||||
@@ -186,6 +186,11 @@ def _get_supported_types(
|
||||
supported.append("audio/")
|
||||
if constraints.video is not None:
|
||||
supported.append("video/")
|
||||
if constraints.text is not None:
|
||||
supported.append("text/")
|
||||
supported.append("application/json")
|
||||
supported.append("application/xml")
|
||||
supported.append("application/x-yaml")
|
||||
return supported
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,95 @@ from crewai_files.core.resolved import (
|
||||
)
|
||||
|
||||
|
||||
class OpenAIResponsesFormatter:
|
||||
"""Formats resolved files into OpenAI Responses API content blocks.
|
||||
|
||||
The Responses API uses a different format than Chat Completions:
|
||||
- Images use `type: "input_image"` with `file_id` or `image_url`
|
||||
- PDFs use `type: "input_file"` with `file_id`, `file_url`, or `file_data`
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def format_block(resolved: ResolvedFileType, content_type: str) -> dict[str, Any]:
|
||||
"""Format a resolved file into an OpenAI Responses API content block.
|
||||
|
||||
Args:
|
||||
resolved: Resolved file.
|
||||
content_type: MIME type of the file.
|
||||
|
||||
Returns:
|
||||
Content block dict.
|
||||
|
||||
Raises:
|
||||
TypeError: If resolved type is not supported.
|
||||
"""
|
||||
is_image = content_type.startswith("image/")
|
||||
is_pdf = content_type == "application/pdf"
|
||||
|
||||
if isinstance(resolved, FileReference):
|
||||
if is_image:
|
||||
return {
|
||||
"type": "input_image",
|
||||
"file_id": resolved.file_id,
|
||||
}
|
||||
if is_pdf:
|
||||
return {
|
||||
"type": "input_file",
|
||||
"file_id": resolved.file_id,
|
||||
}
|
||||
raise TypeError(
|
||||
f"Unsupported content type for Responses API: {content_type}"
|
||||
)
|
||||
|
||||
if isinstance(resolved, UrlReference):
|
||||
if is_image:
|
||||
return {
|
||||
"type": "input_image",
|
||||
"image_url": resolved.url,
|
||||
}
|
||||
if is_pdf:
|
||||
return {
|
||||
"type": "input_file",
|
||||
"file_url": resolved.url,
|
||||
}
|
||||
raise TypeError(
|
||||
f"Unsupported content type for Responses API: {content_type}"
|
||||
)
|
||||
|
||||
if isinstance(resolved, InlineBase64):
|
||||
if is_image:
|
||||
return {
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{resolved.content_type};base64,{resolved.data}",
|
||||
}
|
||||
if is_pdf:
|
||||
return {
|
||||
"type": "input_file",
|
||||
"file_data": f"data:{resolved.content_type};base64,{resolved.data}",
|
||||
}
|
||||
raise TypeError(
|
||||
f"Unsupported content type for Responses API: {content_type}"
|
||||
)
|
||||
|
||||
if isinstance(resolved, InlineBytes):
|
||||
data = base64.b64encode(resolved.data).decode("ascii")
|
||||
if is_image:
|
||||
return {
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{resolved.content_type};base64,{data}",
|
||||
}
|
||||
if is_pdf:
|
||||
return {
|
||||
"type": "input_file",
|
||||
"file_data": f"data:{resolved.content_type};base64,{data}",
|
||||
}
|
||||
raise TypeError(
|
||||
f"Unsupported content type for Responses API: {content_type}"
|
||||
)
|
||||
|
||||
raise TypeError(f"Unexpected resolved type: {type(resolved).__name__}")
|
||||
|
||||
|
||||
class OpenAIFormatter:
|
||||
"""Formats resolved files into OpenAI content blocks."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Literal
|
||||
from crewai_files.core.types import (
|
||||
AudioMimeType,
|
||||
ImageMimeType,
|
||||
TextContentType,
|
||||
VideoMimeType,
|
||||
)
|
||||
|
||||
@@ -72,6 +73,27 @@ GEMINI_VIDEO_FORMATS: tuple[VideoMimeType, ...] = (
|
||||
"video/x-flv",
|
||||
)
|
||||
|
||||
DEFAULT_TEXT_FORMATS: tuple[TextContentType, ...] = (
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"text/xml",
|
||||
"text/html",
|
||||
)
|
||||
|
||||
GEMINI_TEXT_FORMATS: tuple[TextContentType, ...] = (
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"application/x-yaml",
|
||||
"text/yaml",
|
||||
"text/html",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageConstraints:
|
||||
@@ -135,6 +157,19 @@ class VideoConstraints:
|
||||
supported_formats: tuple[VideoMimeType, ...] = DEFAULT_VIDEO_FORMATS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextConstraints:
|
||||
"""Constraints for text files.
|
||||
|
||||
Attributes:
|
||||
max_size_bytes: Maximum file size in bytes.
|
||||
supported_formats: Supported text MIME types.
|
||||
"""
|
||||
|
||||
max_size_bytes: int
|
||||
supported_formats: tuple[TextContentType, ...] = DEFAULT_TEXT_FORMATS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConstraints:
|
||||
"""Complete set of constraints for a provider.
|
||||
@@ -145,6 +180,7 @@ class ProviderConstraints:
|
||||
pdf: PDF file constraints.
|
||||
audio: Audio file constraints.
|
||||
video: Video file constraints.
|
||||
text: Text file constraints.
|
||||
general_max_size_bytes: Maximum size for any file type.
|
||||
supports_file_upload: Whether the provider supports file upload APIs.
|
||||
file_upload_threshold_bytes: Size threshold above which to use file upload.
|
||||
@@ -156,6 +192,7 @@ class ProviderConstraints:
|
||||
pdf: PDFConstraints | None = None
|
||||
audio: AudioConstraints | None = None
|
||||
video: VideoConstraints | None = None
|
||||
text: TextConstraints | None = None
|
||||
general_max_size_bytes: int | None = None
|
||||
supports_file_upload: bool = False
|
||||
file_upload_threshold_bytes: int | None = None
|
||||
@@ -213,6 +250,10 @@ GEMINI_CONSTRAINTS = ProviderConstraints(
|
||||
max_duration_seconds=3600, # 1 hour at default resolution
|
||||
supported_formats=GEMINI_VIDEO_FORMATS,
|
||||
),
|
||||
text=TextConstraints(
|
||||
max_size_bytes=104_857_600,
|
||||
supported_formats=GEMINI_TEXT_FORMATS,
|
||||
),
|
||||
supports_file_upload=True,
|
||||
file_upload_threshold_bytes=20_971_520,
|
||||
supports_url_references=True,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from crewai_files.core.sources import generate_filename
|
||||
from crewai_files.core.types import FileInput
|
||||
from crewai_files.processing.exceptions import classify_upload_error
|
||||
from crewai_files.uploaders.base import FileUploader, UploadResult
|
||||
@@ -91,17 +91,14 @@ class AnthropicFileUploader(FileUploader):
|
||||
client = self._get_client()
|
||||
|
||||
content = file.read()
|
||||
file_purpose = purpose or "user_upload"
|
||||
|
||||
file_data = io.BytesIO(content)
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{file.filename}' to Anthropic ({len(content)} bytes)"
|
||||
)
|
||||
|
||||
uploaded_file = client.files.create(
|
||||
file=(file.filename, file_data, file.content_type),
|
||||
purpose=file_purpose,
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
uploaded_file = client.beta.files.upload(
|
||||
file=(filename, content, file.content_type),
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded to Anthropic: {uploaded_file.id}")
|
||||
@@ -129,7 +126,7 @@ class AnthropicFileUploader(FileUploader):
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
client.files.delete(file_id=file_id)
|
||||
client.beta.files.delete(file_id=file_id)
|
||||
logger.info(f"Deleted Anthropic file: {file_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -147,7 +144,7 @@ class AnthropicFileUploader(FileUploader):
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
file_info = client.files.retrieve(file_id=file_id)
|
||||
file_info = client.beta.files.retrieve(file_id=file_id)
|
||||
return {
|
||||
"id": file_info.id,
|
||||
"filename": file_info.filename,
|
||||
@@ -167,7 +164,7 @@ class AnthropicFileUploader(FileUploader):
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
files = client.files.list()
|
||||
files = client.beta.files.list()
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
@@ -202,17 +199,14 @@ class AnthropicFileUploader(FileUploader):
|
||||
client = self._get_async_client()
|
||||
|
||||
content = await file.aread()
|
||||
file_purpose = purpose or "user_upload"
|
||||
|
||||
file_data = io.BytesIO(content)
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{file.filename}' to Anthropic ({len(content)} bytes)"
|
||||
)
|
||||
|
||||
uploaded_file = await client.files.create(
|
||||
file=(file.filename, file_data, file.content_type),
|
||||
purpose=file_purpose,
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
uploaded_file = await client.beta.files.upload(
|
||||
file=(filename, content, file.content_type),
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded to Anthropic: {uploaded_file.id}")
|
||||
@@ -240,7 +234,7 @@ class AnthropicFileUploader(FileUploader):
|
||||
"""
|
||||
try:
|
||||
client = self._get_async_client()
|
||||
await client.files.delete(file_id=file_id)
|
||||
await client.beta.files.delete(file_id=file_id)
|
||||
logger.info(f"Deleted Anthropic file: {file_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
from typing import Any
|
||||
|
||||
from crewai_files.core.constants import DEFAULT_UPLOAD_CHUNK_SIZE, FILES_API_MAX_SIZE
|
||||
from crewai_files.core.sources import FileBytes, FilePath, FileStream
|
||||
from crewai_files.core.sources import FileBytes, FilePath, FileStream, generate_filename
|
||||
from crewai_files.core.types import FileInput
|
||||
from crewai_files.processing.exceptions import (
|
||||
PermanentUploadError,
|
||||
@@ -22,6 +22,27 @@ from crewai_files.uploaders.base import FileUploader, UploadResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_purpose_for_content_type(content_type: str, purpose: str | None) -> str:
|
||||
"""Get the appropriate purpose for a file based on content type.
|
||||
|
||||
OpenAI Files API requires different purposes for different file types:
|
||||
- Images (for Responses API vision): "vision"
|
||||
- PDFs and other documents: "user_data"
|
||||
|
||||
Args:
|
||||
content_type: MIME type of the file.
|
||||
purpose: Optional explicit purpose override.
|
||||
|
||||
Returns:
|
||||
The purpose string to use for upload.
|
||||
"""
|
||||
if purpose is not None:
|
||||
return purpose
|
||||
if content_type.startswith("image/"):
|
||||
return "vision"
|
||||
return "user_data"
|
||||
|
||||
|
||||
def _get_file_size(file: FileInput) -> int | None:
|
||||
"""Get file size without reading content if possible.
|
||||
|
||||
@@ -219,13 +240,14 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
|
||||
file_data = io.BytesIO(content)
|
||||
file_data.name = file.filename or "file"
|
||||
file_data.name = filename
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{file.filename}' to OpenAI Files API ({len(content)} bytes)"
|
||||
f"Uploading file '{filename}' to OpenAI Files API ({len(content)} bytes)"
|
||||
)
|
||||
|
||||
uploaded_file = client.files.create(
|
||||
@@ -254,8 +276,8 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
filename = file.filename or "file"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
file_size = len(content)
|
||||
|
||||
logger.info(
|
||||
@@ -329,8 +351,8 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
filename = file.filename or "file"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{filename}' to OpenAI Uploads API (streaming) "
|
||||
@@ -496,10 +518,10 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_async_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
|
||||
file_data = io.BytesIO(content)
|
||||
file_data.name = file.filename or "file"
|
||||
file_data.name = file.filename or generate_filename(file.content_type)
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{file.filename}' to OpenAI Files API ({len(content)} bytes)"
|
||||
@@ -531,8 +553,8 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_async_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
filename = file.filename or "file"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
file_size = len(content)
|
||||
|
||||
logger.info(
|
||||
@@ -606,8 +628,8 @@ class OpenAIFileUploader(FileUploader):
|
||||
UploadResult with the file ID and metadata.
|
||||
"""
|
||||
client = self._get_async_client()
|
||||
file_purpose = purpose or "user_data"
|
||||
filename = file.filename or "file"
|
||||
file_purpose = _get_purpose_for_content_type(file.content_type, purpose)
|
||||
filename = file.filename or generate_filename(file.content_type)
|
||||
|
||||
logger.info(
|
||||
f"Uploading file '{filename}' to OpenAI Uploads API (streaming) "
|
||||
|
||||
Reference in New Issue
Block a user