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:
Greyson LaLonde
2026-01-23 01:57:29 -05:00
parent 7c9ce9ccd8
commit 4ab53c0726
14 changed files with 833 additions and 44 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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:

View File

@@ -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) "