참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,186 @@
"""
Unified Batch Processing API for Multiple Providers
This module provides a unified interface for batch processing across OpenAI and Anthropic
providers. The API uses a Maybe/Result-like pattern with custom_id
tracking for type-safe handling of batch results.
Supported Providers:
- OpenAI: 50% cost savings on batch requests
- Anthropic: 50% cost savings on batch requests (Message Batches API)
Features:
- Type-safe Maybe/Result pattern for handling successes and errors
- Custom ID tracking for correlating results to original requests
- Unified interface across all providers
- Helper functions for filtering and extracting results
Example usage:
from instructor.batch import BatchProcessor, filter_successful, extract_results
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
processor = BatchProcessor("openai/gpt-4o-mini", User)
batch_id = processor.submit_batch("requests.jsonl")
# Results are BatchSuccess[T] | BatchError union types
all_results = processor.retrieve_results(batch_id)
successful_results = filter_successful(all_results)
extracted_users = extract_results(all_results)
Documentation:
- OpenAI Batch API: https://platform.openai.com/docs/guides/batch
- Anthropic Message Batches: https://docs.anthropic.com/en/api/creating-message-batches
"""
from typing import Any, Optional
# Import all public symbols from the modules
from .models import (
BatchSuccess,
BatchError,
BatchStatus,
BatchTimestamps,
BatchRequestCounts,
BatchErrorInfo,
BatchFiles,
BatchJobInfo,
BatchResult,
T,
)
from .utils import (
filter_successful,
filter_errors,
extract_results,
get_results_by_custom_id,
)
from .request import (
BatchRequest,
Function,
Tool,
RequestBody,
BatchModel,
)
from .processor import BatchProcessor
class BatchJob:
"""Legacy BatchJob class for backward compatibility"""
@classmethod
def parse_from_file(
cls, file_path: str, response_model: type[T]
) -> tuple[list[T], list[dict[Any, Any]]]:
with open(file_path) as file:
content = file.read()
return cls.parse_from_string(content, response_model)
@classmethod
def parse_from_string(
cls, content: str, response_model: type[T]
) -> tuple[list[T], list[dict[Any, Any]]]:
"""Enhanced parser that works with all providers using JSON schema"""
import json
res: list[T] = []
error_objs: list[dict[Any, Any]] = []
lines = content.strip().split("\n")
for line in lines:
if not line.strip():
continue
try:
data = json.loads(line)
extracted_data = cls._extract_structured_data(data)
if extracted_data:
try:
result = response_model(**extracted_data)
res.append(result)
except Exception:
error_objs.append(data)
else:
error_objs.append(data)
except Exception:
error_objs.append({"error": "Failed to parse JSON", "raw_line": line})
return res, error_objs
@classmethod
def _extract_structured_data(cls, data: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Extract structured data from various provider response formats"""
import json
try:
# Try OpenAI JSON schema format first
if "response" in data and "body" in data["response"]:
choices = data["response"]["body"].get("choices", [])
if choices:
message = choices[0].get("message", {})
# JSON schema response
if "content" in message:
content = message["content"]
if isinstance(content, str):
return json.loads(content)
# Tool calls (legacy)
if "tool_calls" in message:
tool_call = message["tool_calls"][0]
return json.loads(tool_call["function"]["arguments"])
# Try Anthropic format
if "result" in data and "message" in data["result"]:
content = data["result"]["message"]["content"]
if isinstance(content, list) and len(content) > 0:
# Tool use response
for item in content:
if item.get("type") == "tool_use":
return item.get("input", {})
# Text response with JSON
for item in content:
if item.get("type") == "text":
text = item.get("text", "")
return json.loads(text)
except Exception:
pass
return None
# Define what gets exported when someone does "from instructor.batch import *"
__all__ = [
# Core types
"T",
"BatchResult",
# Models
"BatchSuccess",
"BatchError",
"BatchStatus",
"BatchTimestamps",
"BatchRequestCounts",
"BatchErrorInfo",
"BatchFiles",
"BatchJobInfo",
# Utility functions
"filter_successful",
"filter_errors",
"extract_results",
"get_results_by_custom_id",
# Request models
"BatchRequest",
"Function",
"Tool",
"RequestBody",
"BatchModel",
# Main processor
"BatchProcessor",
# Legacy
"BatchJob",
]

View File

@@ -0,0 +1,293 @@
"""
Data models and types for batch processing.
This module contains all the Pydantic models, enums, and type definitions
used throughout the batch processing system.
"""
from __future__ import annotations
from typing import Any, Union, TypeVar, Generic
from typing_extensions import TypeAlias
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime, timezone
from enum import Enum
T = TypeVar("T", bound=BaseModel)
class BatchSuccess(BaseModel, Generic[T]):
"""Successful batch result with custom_id"""
custom_id: str
result: T
success: bool = True
model_config = ConfigDict(arbitrary_types_allowed=True)
class BatchError(BaseModel):
"""Error information for failed batch requests"""
custom_id: str
error_type: str
error_message: str
success: bool = False
raw_data: dict[str, Any] | None = None
class BatchStatus(str, Enum):
"""Normalized batch status across providers"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class BatchTimestamps(BaseModel):
"""Comprehensive timestamp tracking"""
created_at: datetime | None = None
started_at: datetime | None = None # in_progress_at, processing start
completed_at: datetime | None = None # completed_at, ended_at
failed_at: datetime | None = None
cancelled_at: datetime | None = None
expired_at: datetime | None = None
expires_at: datetime | None = None
class BatchRequestCounts(BaseModel):
"""Unified request counts across providers"""
total: int | None = None
# OpenAI fields
completed: int | None = None
failed: int | None = None
# Anthropic fields
processing: int | None = None
succeeded: int | None = None
errored: int | None = None
cancelled: int | None = None
expired: int | None = None
class BatchErrorInfo(BaseModel):
"""Batch-level error information"""
error_type: str | None = None
error_message: str | None = None
error_code: str | None = None
class BatchFiles(BaseModel):
"""File references for batch job"""
input_file_id: str | None = None
output_file_id: str | None = None
error_file_id: str | None = None
results_url: str | None = None # Anthropic
class BatchJobInfo(BaseModel):
"""Enhanced unified batch job information with comprehensive provider support"""
# Core identifiers
id: str
provider: str
# Status information
status: BatchStatus
raw_status: str # Original provider status
# Timing information
timestamps: BatchTimestamps
# Request tracking
request_counts: BatchRequestCounts
# File references
files: BatchFiles
# Error information
error: BatchErrorInfo | None = None
# Provider-specific data
metadata: dict[str, Any] = Field(default_factory=dict)
raw_data: dict[str, Any] | None = None
# Additional fields
model: str | None = None
endpoint: str | None = None
completion_window: str | None = None
@classmethod
def from_openai(cls, batch_data: dict[str, Any]) -> BatchJobInfo:
"""Create from OpenAI batch response"""
# Normalize status
status_map = {
"validating": BatchStatus.PENDING,
"in_progress": BatchStatus.PROCESSING,
"finalizing": BatchStatus.PROCESSING,
"completed": BatchStatus.COMPLETED,
"failed": BatchStatus.FAILED,
"expired": BatchStatus.EXPIRED,
"cancelled": BatchStatus.CANCELLED,
"cancelling": BatchStatus.CANCELLED,
}
# Parse timestamps
timestamps = BatchTimestamps(
created_at=(
datetime.fromtimestamp(batch_data["created_at"], tz=timezone.utc)
if batch_data.get("created_at")
else None
),
started_at=(
datetime.fromtimestamp(batch_data["in_progress_at"], tz=timezone.utc)
if batch_data.get("in_progress_at")
else None
),
completed_at=(
datetime.fromtimestamp(batch_data["completed_at"], tz=timezone.utc)
if batch_data.get("completed_at")
else None
),
failed_at=(
datetime.fromtimestamp(batch_data["failed_at"], tz=timezone.utc)
if batch_data.get("failed_at")
else None
),
cancelled_at=(
datetime.fromtimestamp(batch_data["cancelled_at"], tz=timezone.utc)
if batch_data.get("cancelled_at")
else None
),
expired_at=(
datetime.fromtimestamp(batch_data["expired_at"], tz=timezone.utc)
if batch_data.get("expired_at")
else None
),
expires_at=(
datetime.fromtimestamp(batch_data["expires_at"], tz=timezone.utc)
if batch_data.get("expires_at")
else None
),
)
# Parse request counts
request_counts_data = batch_data.get("request_counts", {})
request_counts = BatchRequestCounts(
total=request_counts_data.get("total"),
completed=request_counts_data.get("completed"),
failed=request_counts_data.get("failed"),
)
# Parse files
files = BatchFiles(
input_file_id=batch_data.get("input_file_id"),
output_file_id=batch_data.get("output_file_id"),
error_file_id=batch_data.get("error_file_id"),
)
# Parse error information
error = None
if batch_data.get("errors"):
error_data = batch_data["errors"]
error = BatchErrorInfo(
error_type=error_data.get("type"),
error_message=error_data.get("message"),
error_code=error_data.get("code"),
)
return cls(
id=batch_data["id"],
provider="openai",
status=status_map.get(batch_data["status"], BatchStatus.PENDING),
raw_status=batch_data["status"],
timestamps=timestamps,
request_counts=request_counts,
files=files,
error=error,
metadata=batch_data.get("metadata", {}),
raw_data=batch_data,
endpoint=batch_data.get("endpoint"),
completion_window=batch_data.get("completion_window"),
)
@classmethod
def from_anthropic(cls, batch_data: dict[str, Any]) -> BatchJobInfo:
"""Create from Anthropic batch response"""
# Normalize status
status_map = {
"in_progress": BatchStatus.PROCESSING,
"ended": BatchStatus.COMPLETED,
"failed": BatchStatus.FAILED,
"cancelled": BatchStatus.CANCELLED,
"expired": BatchStatus.EXPIRED,
}
# Parse timestamps
def parse_iso_timestamp(timestamp_value):
if not timestamp_value:
return None
try:
# Handle different timestamp format variations
if isinstance(timestamp_value, datetime):
return timestamp_value
elif isinstance(timestamp_value, str):
return datetime.fromisoformat(
timestamp_value.replace("Z", "+00:00")
)
else:
return None
except (ValueError, AttributeError):
return None
timestamps = BatchTimestamps(
created_at=parse_iso_timestamp(batch_data.get("created_at")),
started_at=parse_iso_timestamp(
batch_data.get("created_at")
), # Anthropic doesn't provide started_at, use created_at
cancelled_at=parse_iso_timestamp(batch_data.get("cancel_initiated_at")),
completed_at=parse_iso_timestamp(batch_data.get("ended_at")),
expires_at=parse_iso_timestamp(batch_data.get("expires_at")),
)
# Parse request counts
request_counts_data = batch_data.get("request_counts", {})
request_counts = BatchRequestCounts(
processing=request_counts_data.get("processing"),
succeeded=request_counts_data.get("succeeded"),
errored=request_counts_data.get("errored"),
cancelled=request_counts_data.get(
"canceled"
), # Note: Anthropic uses "canceled"
expired=request_counts_data.get("expired"),
total=request_counts_data.get("processing", 0)
+ request_counts_data.get("succeeded", 0)
+ request_counts_data.get("errored", 0),
)
# Parse files
files = BatchFiles(
results_url=batch_data.get("results_url"),
)
return cls(
id=batch_data["id"],
provider="anthropic",
status=status_map.get(batch_data["processing_status"], BatchStatus.PENDING),
raw_status=batch_data["processing_status"],
timestamps=timestamps,
request_counts=request_counts,
files=files,
raw_data=batch_data,
)
# Union type for batch results - like a Maybe/Result type
BatchResult: TypeAlias = Union[BatchSuccess[T], BatchError] # type: ignore

View File

@@ -0,0 +1,292 @@
"""
Batch processor for unified batch processing across providers.
This module contains the BatchProcessor class that provides a unified interface
for batch processing across different LLM providers.
"""
from __future__ import annotations
from typing import Any, Generic
import json
import os
import io
from .models import BatchResult, BatchSuccess, BatchError, BatchJobInfo, T
from .request import BatchRequest
from .providers import get_provider
class BatchProcessor(Generic[T]):
"""Unified batch processor that works across all providers"""
def __init__(self, model: str, response_model: type[T]):
self.model = model
self.response_model = response_model
# Parse provider from model string
try:
self.provider_name, self.model_name = model.split("/", 1)
except ValueError as err:
raise ValueError(
'Model string must be in format "provider/model-name" '
'(e.g. "openai/gpt-4" or "anthropic/claude-3-sonnet")'
) from err
# Get the batch provider instance
self.provider = get_provider(self.provider_name)
def create_batch_from_messages(
self,
messages_list: list[list[dict[str, Any]]],
file_path: str | None = None,
max_tokens: int | None = 1000,
temperature: float | None = 0.1,
) -> str | io.BytesIO:
"""Create batch file from list of message conversations
Args:
messages_list: List of message conversations, each as a list of message dicts
file_path: Path to save the batch request file. If None, returns BytesIO buffer
max_tokens: Maximum tokens per request
temperature: Temperature for generation
Returns:
The file path where the batch was saved, or BytesIO buffer if file_path is None
"""
if file_path is not None:
if os.path.exists(file_path):
os.remove(file_path)
batch_requests = []
for i, messages in enumerate(messages_list):
batch_request = BatchRequest[self.response_model](
custom_id=f"request-{i}",
messages=messages,
response_model=self.response_model,
model=self.model_name,
max_tokens=max_tokens,
temperature=temperature,
)
batch_request.save_to_file(file_path, self.provider_name)
batch_requests.append(batch_request)
print(f"Created batch file {file_path} with {len(batch_requests)} requests")
return file_path
else:
# Create BytesIO buffer - caller is responsible for cleanup
buffer = io.BytesIO()
batch_requests = []
for i, messages in enumerate(messages_list):
batch_request = BatchRequest[self.response_model](
custom_id=f"request-{i}",
messages=messages,
response_model=self.response_model,
model=self.model_name,
max_tokens=max_tokens,
temperature=temperature,
)
batch_request.save_to_file(buffer, self.provider_name)
batch_requests.append(batch_request)
print(f"Created batch buffer with {len(batch_requests)} requests")
buffer.seek(0) # Reset buffer position for reading
return buffer
def submit_batch(
self,
file_path_or_buffer: str | io.BytesIO,
metadata: dict[str, Any] | None = None,
**kwargs,
) -> str:
"""Submit batch job to the provider and return job ID
Args:
file_path_or_buffer: Path to the batch request file or BytesIO buffer
metadata: Optional metadata to attach to the batch job
**kwargs: Additional provider-specific arguments
"""
if metadata is None:
metadata = {"description": "Instructor batch job"}
return self.provider.submit_batch(
file_path_or_buffer, metadata=metadata, **kwargs
)
def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get batch job status from the provider"""
return self.provider.get_status(batch_id)
def retrieve_results(self, batch_id: str) -> list[BatchResult]:
"""Retrieve and parse batch results from the provider"""
results_content = self.provider.retrieve_results(batch_id)
return self.parse_results(results_content)
def list_batches(self, limit: int = 10) -> list[BatchJobInfo]:
"""List batch jobs for the current provider
Args:
limit: Maximum number of batch jobs to return
Returns:
List of BatchJobInfo objects with normalized batch information
"""
return self.provider.list_batches(limit)
def get_results(
self, batch_id: str, file_path: str | None = None
) -> list[BatchResult]:
"""Get batch results, optionally saving raw results to a file
Args:
batch_id: The batch job ID
file_path: Optional file path to save raw results. If provided,
raw results will be saved to this file. If not provided,
results are only kept in memory.
Returns:
List of BatchResult objects (BatchSuccess[T] or BatchError)
"""
# Retrieve results directly to memory
results_content = self.retrieve_results(batch_id)
# If file path is provided, save raw results to file
if file_path is not None:
self.provider.download_results(batch_id, file_path)
return results_content
def cancel_batch(self, batch_id: str) -> dict[str, Any]:
"""Cancel a batch job
Args:
batch_id: The batch job ID to cancel
Returns:
Dict containing the cancelled batch information
"""
return self.provider.cancel_batch(batch_id)
def delete_batch(self, batch_id: str) -> dict[str, Any]:
"""Delete a batch job (only available for completed batches)
Args:
batch_id: The batch job ID to delete
Returns:
Dict containing the deletion confirmation
"""
return self.provider.delete_batch(batch_id)
def parse_results(self, results_content: str) -> list[BatchResult]:
"""Parse batch results from content string into Maybe-like results with custom_id tracking"""
results: list[BatchResult] = []
lines = results_content.strip().split("\n")
for line in lines:
if not line.strip():
continue
try:
data = json.loads(line)
custom_id = data.get("custom_id", "unknown")
extracted_data = self._extract_from_response(data)
if extracted_data:
try:
# Parse into response model
result = self.response_model(**extracted_data)
batch_result = BatchSuccess[T](
custom_id=custom_id, result=result
)
results.append(batch_result)
except Exception as e:
error_result = BatchError(
custom_id=custom_id,
error_type="parsing_error",
error_message=f"Failed to parse into {self.response_model.__name__}: {e}",
raw_data=extracted_data,
)
results.append(error_result)
else:
# Check if this is a provider error response
error_message = "Unknown error"
error_type = "extraction_error"
if self.provider_name == "anthropic" and "result" in data:
result = data["result"]
if result.get("type") == "error":
error_info = result.get("error", {})
if isinstance(error_info, dict) and "error" in error_info:
error_details = error_info["error"]
error_message = error_details.get(
"message", "Unknown Anthropic error"
)
error_type = error_details.get(
"type", "anthropic_error"
)
else:
error_message = str(error_info)
error_type = "anthropic_error"
error_result = BatchError(
custom_id=custom_id,
error_type=error_type,
error_message=error_message,
raw_data=data,
)
results.append(error_result)
except Exception as e:
error_result = BatchError(
custom_id="unknown",
error_type="json_parse_error",
error_message=f"Failed to parse JSON: {e}",
raw_data={"raw_line": line},
)
results.append(error_result)
return results
def _extract_from_response(self, data: dict[str, Any]) -> dict[str, Any] | None:
"""Extract structured data from provider-specific response format"""
try:
if self.provider_name == "openai":
# OpenAI JSON schema response
content = data["response"]["body"]["choices"][0]["message"]["content"]
return json.loads(content)
elif self.provider_name == "anthropic":
# Anthropic batch response format
if "result" not in data:
return None
result = data["result"]
# Check if result is an error
if result.get("type") == "error":
# Return None to indicate error, let caller handle
return None
# Handle successful message result
if result.get("type") == "succeeded" and "message" in result:
content = result["message"]["content"]
if isinstance(content, list) and len(content) > 0:
# Try tool_use first
for item in content:
if item.get("type") == "tool_use":
return item.get("input", {})
# Fallback to text content and parse JSON
for item in content:
if item.get("type") == "text":
text = item.get("text", "")
try:
return json.loads(text)
except json.JSONDecodeError:
continue
return None
except Exception:
return None
return None

View File

@@ -0,0 +1,31 @@
"""
Provider-specific batch processing implementations.
This module contains provider-specific implementations for OpenAI and Anthropic
batch processing APIs.
"""
from .base import BatchProvider
import importlib.util
if importlib.util.find_spec("openai") is not None:
from .openai import OpenAIProvider
if importlib.util.find_spec("anthropic") is not None:
from .anthropic import AnthropicProvider
def get_provider(provider_name: str) -> BatchProvider:
"""Factory function to get the appropriate provider instance"""
if provider_name == "openai":
if OpenAIProvider is None:
raise ValueError("OpenAI is not installed")
return OpenAIProvider()
elif provider_name == "anthropic":
if AnthropicProvider is None:
raise ValueError("Anthropic is not installed")
return AnthropicProvider()
else:
raise ValueError(f"Unsupported provider: {provider_name}")
__all__ = ["BatchProvider", "OpenAIProvider", "AnthropicProvider", "get_provider"]

View File

@@ -0,0 +1,243 @@
"""
Anthropic-specific batch processing implementation.
This module contains the Anthropic batch processing provider class.
"""
import json
from typing import Any, Optional, Union
import io
import logging
from .base import BatchProvider
from ..models import BatchJobInfo
logger = logging.getLogger(__name__)
class AnthropicProvider(BatchProvider):
"""Anthropic batch processing provider"""
def submit_batch(
self,
file_path_or_buffer: Union[str, io.BytesIO],
metadata: Optional[dict[str, Any]] = None,
**kwargs,
) -> str:
"""Submit Anthropic batch job"""
_ = kwargs # Unused but accepted for API consistency
try:
import anthropic
client = anthropic.Anthropic()
# Note: Anthropic doesn't support metadata in batch creation
# but we accept it for API consistency
if metadata:
print(
f"Note: Anthropic batches don't support metadata. Ignoring: {metadata}"
)
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
if isinstance(file_path_or_buffer, str):
with open(file_path_or_buffer) as f:
requests = [json.loads(line) for line in f if line.strip()]
elif isinstance(file_path_or_buffer, io.BytesIO):
file_path_or_buffer.seek(0)
content = file_path_or_buffer.read().decode("utf-8")
requests = [
json.loads(line) for line in content.split("\n") if line.strip()
]
else:
raise ValueError(
f"Unsupported file_path_or_buffer type: {type(file_path_or_buffer)}"
)
batch = batches_client.create(requests=requests)
return batch.id
except (ValueError, TypeError) as e:
# Re-raise validation errors as-is
logger.error(f"Validation error in Anthropic batch submission: {e}")
raise
except Exception as e:
raise RuntimeError(f"Failed to submit Anthropic batch: {e}") from e
def get_status(self, batch_id: str) -> dict[str, Any]:
"""Get Anthropic batch status"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batch = batches_client.retrieve(batch_id)
return {
"id": batch.id,
"status": batch.processing_status,
"created_at": batch.created_at,
"request_counts": getattr(batch, "request_counts", {}),
}
except Exception as e:
raise Exception(f"Failed to get Anthropic batch status: {e}") from e
def retrieve_results(self, batch_id: str) -> str:
"""Retrieve Anthropic batch results"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batch = batches_client.retrieve(batch_id)
# Check for various terminal states
if batch.processing_status in ["failed", "cancelled", "expired"]:
raise Exception(
f"Batch job failed with status: {batch.processing_status}"
)
if batch.processing_status != "ended":
raise Exception(
f"Batch not completed, status: {batch.processing_status}"
)
# Check if all requests failed
request_counts = getattr(batch, "request_counts", None)
if request_counts:
succeeded = getattr(request_counts, "succeeded", 0)
errored = getattr(request_counts, "errored", 0)
total = getattr(request_counts, "total", 0)
if errored > 0 and succeeded == 0:
raise RuntimeError(
f"All {total} batch requests failed. No results will be available."
)
results = batches_client.results(batch_id)
results_lines = []
for result in results:
results_lines.append(result.model_dump_json())
return "\n".join(results_lines)
except Exception as e:
raise Exception(f"Failed to retrieve Anthropic results: {e}") from e
def download_results(self, batch_id: str, file_path: str) -> None:
"""Download Anthropic batch results to a file"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batch = batches_client.retrieve(batch_id)
# Check for various terminal states
if batch.processing_status in ["failed", "cancelled", "expired"]:
raise Exception(
f"Batch job failed with status: {batch.processing_status}"
)
if batch.processing_status != "ended":
raise Exception(
f"Batch not completed, status: {batch.processing_status}"
)
# Check if all requests failed
request_counts = getattr(batch, "request_counts", None)
if request_counts:
succeeded = getattr(request_counts, "succeeded", 0)
errored = getattr(request_counts, "errored", 0)
total = getattr(request_counts, "total", 0)
if errored > 0 and succeeded == 0:
raise RuntimeError(
f"All {total} batch requests failed. No results will be available."
)
results = batches_client.results(batch_id)
with open(file_path, "w") as f:
for result in results:
f.write(result.model_dump_json() + "\n")
except Exception as e:
raise Exception(f"Failed to download Anthropic results: {e}") from e
def cancel_batch(self, batch_id: str) -> dict[str, Any]:
"""Cancel Anthropic batch job"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batch = batches_client.cancel(batch_id)
return batch.model_dump()
except Exception as e:
raise Exception(f"Failed to cancel Anthropic batch: {e}") from e
def delete_batch(self, batch_id: str) -> dict[str, Any]:
"""Delete Anthropic batch job"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batch = batches_client.retrieve(batch_id)
return {
"id": batch.id,
"status": batch.processing_status,
"message": "Anthropic does not support batch deletion",
}
except Exception as e:
raise Exception(f"Failed to delete Anthropic batch: {e}") from e
def list_batches(self, limit: int = 10) -> list[BatchJobInfo]:
"""List Anthropic batch jobs"""
try:
import anthropic
client = anthropic.Anthropic()
# TODO(#batch-api-stable): Remove beta fallback when stable API is available
try:
batches_client = client.messages.batches
except AttributeError:
batches_client = client.beta.messages.batches
batches = batches_client.list(limit=limit)
return [
BatchJobInfo.from_anthropic(batch.model_dump())
for batch in batches.data
]
except Exception as e:
raise Exception(f"Failed to list Anthropic batches: {e}") from e

View File

@@ -0,0 +1,57 @@
"""
Base provider class for batch processing.
This module defines the abstract base class that all batch providers must implement.
"""
from abc import ABC, abstractmethod
from typing import Any, Optional, Union
import io
import logging
from ..models import BatchJobInfo
logger = logging.getLogger(__name__)
class BatchProvider(ABC):
"""Abstract base class for batch processing providers"""
@abstractmethod
def submit_batch(
self,
file_path_or_buffer: Union[str, io.BytesIO],
metadata: Optional[dict[str, Any]] = None,
**kwargs,
) -> str:
"""Submit a batch job and return the job ID"""
pass
@abstractmethod
def get_status(self, batch_id: str) -> dict[str, Any]:
"""Get the status of a batch job"""
pass
@abstractmethod
def retrieve_results(self, batch_id: str) -> str:
"""Retrieve batch results as a string"""
pass
@abstractmethod
def download_results(self, batch_id: str, file_path: str) -> None:
"""Download batch results to a file"""
pass
@abstractmethod
def cancel_batch(self, batch_id: str) -> dict[str, Any]:
"""Cancel a batch job"""
pass
@abstractmethod
def delete_batch(self, batch_id: str) -> dict[str, Any]:
"""Delete a batch job"""
pass
@abstractmethod
def list_batches(self, limit: int = 10) -> list[BatchJobInfo]:
"""List batch jobs"""
pass

View File

@@ -0,0 +1,242 @@
"""
OpenAI-specific batch processing implementation.
This module contains the OpenAI batch processing provider class.
"""
from typing import Any, Optional, Union
import io
import logging
from .base import BatchProvider
from ..models import BatchJobInfo
logger = logging.getLogger(__name__)
class OpenAIProvider(BatchProvider):
"""OpenAI batch processing provider"""
def submit_batch(
self,
file_path_or_buffer: Union[str, io.BytesIO],
metadata: Optional[dict[str, Any]] = None,
**kwargs,
) -> str:
"""Submit OpenAI batch job"""
try:
from openai import OpenAI
client = OpenAI()
if metadata is None:
metadata = {"description": "Instructor batch job"}
logger.debug(f"Submitting batch job with metadata: {metadata}")
if isinstance(file_path_or_buffer, str):
logger.debug(f"Creating batch file from path: {file_path_or_buffer}")
with open(file_path_or_buffer, "rb") as f:
batch_file = client.files.create(file=f, purpose="batch")
elif isinstance(file_path_or_buffer, io.BytesIO):
logger.debug("Creating batch file from BytesIO buffer")
file_path_or_buffer.seek(0)
batch_file = client.files.create(
file=file_path_or_buffer, purpose="batch"
)
else:
raise ValueError(
f"Unsupported file_path_or_buffer type: {type(file_path_or_buffer)}"
)
batch_job = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window=kwargs.get("completion_window", "24h"),
metadata=metadata,
)
logger.info(f"Successfully submitted batch job: {batch_job.id}")
return batch_job.id
except (ValueError, TypeError) as e:
# Re-raise validation errors as-is
logger.error(f"Validation error in OpenAI batch submission: {e}")
raise
except Exception as e:
logger.error(f"Failed to submit OpenAI batch: {e}")
raise RuntimeError(f"Failed to submit OpenAI batch: {e}") from e
def get_status(self, batch_id: str) -> dict[str, Any]:
"""Get OpenAI batch status"""
try:
from openai import OpenAI
client = OpenAI()
batch = client.batches.retrieve(batch_id)
return {
"id": batch.id,
"status": batch.status,
"created_at": batch.created_at,
"request_counts": {
"total": getattr(batch.request_counts, "total", 0),
"completed": getattr(batch.request_counts, "completed", 0),
"failed": getattr(batch.request_counts, "failed", 0),
},
}
except Exception as e:
raise Exception(f"Failed to get OpenAI batch status: {e}") from e
def retrieve_results(self, batch_id: str) -> str:
"""Retrieve OpenAI batch results"""
try:
from openai import OpenAI
import time
client = OpenAI()
batch = client.batches.retrieve(batch_id)
if batch.status != "completed":
raise Exception(f"Batch not completed, status: {batch.status}")
# Check if all requests failed
request_counts = getattr(batch, "request_counts", None)
if request_counts:
completed = getattr(request_counts, "completed", 0)
failed = getattr(request_counts, "failed", 0)
total = getattr(request_counts, "total", 0)
if failed > 0 and completed == 0:
raise RuntimeError(
f"All {total} batch requests failed. No output file will be available. "
)
if not batch.output_file_id:
# Sometimes output file isn't immediately available, wait longer and retry more
max_retries = 10
for attempt in range(max_retries):
wait_time = min(
5 + attempt, 15
) # Progressive backoff: 5s, 6s, 7s... up to 15s
print(
f"Output file not ready, waiting {wait_time}s (attempt {attempt + 1}/{max_retries})..."
)
time.sleep(wait_time)
batch = client.batches.retrieve(batch_id)
if batch.output_file_id:
print(f"Output file now available: {batch.output_file_id}")
break
# Check if batch failed during our wait
if batch.status != "completed":
raise Exception(
f"Batch status changed to {batch.status} while waiting for output file"
)
if attempt == max_retries - 1:
# Final attempt - provide detailed error info
raise RuntimeError(
f"No output file available after {max_retries} retries over {sum(range(5, 5 + max_retries))} seconds. "
f"Batch status: {batch.status}, Request counts: {getattr(batch, 'request_counts', 'unknown')}. "
)
if batch.output_file_id is None:
raise RuntimeError("Batch has no output file ID available")
file_response = client.files.content(batch.output_file_id)
return file_response.text
except Exception as e:
raise Exception(f"Failed to retrieve OpenAI results: {e}") from e
def download_results(self, batch_id: str, file_path: str) -> None:
"""Download OpenAI batch results to a file"""
try:
from openai import OpenAI
import time
client = OpenAI()
batch = client.batches.retrieve(batch_id)
if batch.status != "completed":
raise Exception(f"Batch not completed, status: {batch.status}")
# Check if all requests failed
request_counts = getattr(batch, "request_counts", None)
if request_counts:
completed = getattr(request_counts, "completed", 0)
failed = getattr(request_counts, "failed", 0)
total = getattr(request_counts, "total", 0)
if failed > 0 and completed == 0:
raise RuntimeError(
f"All {total} batch requests failed. No output file will be available."
)
if not batch.output_file_id:
# Sometimes output file isn't immediately available, wait longer and retry more
max_retries = 10
for attempt in range(max_retries):
wait_time = min(
5 + attempt, 15
) # Progressive backoff: 5s, 6s, 7s... up to 15s
print(
f"Output file not ready, waiting {wait_time}s (attempt {attempt + 1}/{max_retries})..."
)
time.sleep(wait_time)
batch = client.batches.retrieve(batch_id)
if batch.output_file_id:
print(f"Output file now available: {batch.output_file_id}")
break
# Check if batch failed during our wait
if batch.status != "completed":
raise Exception(
f"Batch status changed to {batch.status} while waiting for output file"
)
if attempt == max_retries - 1:
# Final attempt - provide detailed error info
raise Exception(
f"No output file available after {max_retries} retries over {sum(range(5, 5 + max_retries))} seconds. "
f"Batch status: {batch.status}, Request counts: {getattr(batch, 'request_counts', 'unknown')}."
)
if batch.output_file_id is None:
raise RuntimeError("Batch has no output file ID available")
file_response = client.files.content(batch.output_file_id)
with open(file_path, "w") as f:
f.write(file_response.text)
except Exception as e:
raise Exception(f"Failed to download OpenAI results: {e}") from e
def cancel_batch(self, batch_id: str) -> dict[str, Any]:
"""Cancel OpenAI batch job"""
try:
from openai import OpenAI
client = OpenAI()
batch = client.batches.cancel(batch_id)
return batch.model_dump()
except Exception as e:
raise Exception(f"Failed to cancel OpenAI batch: {e}") from e
def delete_batch(self, batch_id: str) -> dict[str, Any]:
"""Delete OpenAI batch job"""
try:
from openai import OpenAI
client = OpenAI()
# OpenAI doesn't have a delete endpoint, so we'll return the batch info
batch = client.batches.retrieve(batch_id)
return {
"id": batch.id,
"status": batch.status,
"message": "OpenAI does not support batch deletion",
}
except Exception as e:
raise Exception(f"Failed to delete OpenAI batch: {e}") from e
def list_batches(self, limit: int = 10) -> list[BatchJobInfo]:
"""List OpenAI batch jobs"""
try:
from openai import OpenAI
client = OpenAI()
batches = client.batches.list(limit=limit)
return [
BatchJobInfo.from_openai(batch.model_dump()) for batch in batches.data
]
except Exception as e:
raise Exception(f"Failed to list OpenAI batches: {e}") from e

View File

@@ -0,0 +1,175 @@
"""
Batch request models and schema utilities.
This module contains the BatchRequest class and related models for creating
provider-specific batch requests with JSON schema generation.
"""
from __future__ import annotations
from typing import Any, Generic
from pydantic import BaseModel, Field, ConfigDict
import json
import io
from .models import T
class Function(BaseModel):
name: str
description: str
parameters: Any
class Tool(BaseModel):
type: str
function: Function
class RequestBody(BaseModel):
model: str
messages: list[dict[str, Any]]
max_tokens: int | None = Field(default=1000)
temperature: float | None = Field(default=1.0)
tools: list[Tool] | None
tool_choice: dict[str, Any] | None
class BatchModel(BaseModel):
custom_id: str
body: RequestBody
url: str
method: str
class BatchRequest(BaseModel, Generic[T]):
"""Unified batch request that works across all providers using JSON schema"""
custom_id: str
messages: list[dict[str, Any]]
response_model: type[T]
model: str
max_tokens: int | None = Field(default=1000)
temperature: float | None = Field(default=0.1)
model_config = ConfigDict(arbitrary_types_allowed=True)
def get_json_schema(self) -> dict[str, Any]:
"""Generate JSON schema from response_model"""
return self.response_model.model_json_schema()
def to_openai_format(self) -> dict[str, Any]:
"""Convert to OpenAI batch format with JSON schema"""
schema = self.get_json_schema()
# OpenAI strict mode requires additionalProperties to be false
def make_strict_schema(schema_dict):
"""Recursively add additionalProperties: false for OpenAI strict mode"""
if isinstance(schema_dict, dict):
if "type" in schema_dict:
if schema_dict["type"] == "object":
schema_dict["additionalProperties"] = False
elif schema_dict["type"] == "array" and "items" in schema_dict:
schema_dict["items"] = make_strict_schema(schema_dict["items"])
# Recursively process properties
if "properties" in schema_dict:
for prop_name, prop_schema in schema_dict["properties"].items():
schema_dict["properties"][prop_name] = make_strict_schema(
prop_schema
)
# Process definitions/defs
for key in ["definitions", "$defs"]:
if key in schema_dict:
for def_name, def_schema in schema_dict[key].items():
schema_dict[key][def_name] = make_strict_schema(def_schema)
return schema_dict
strict_schema = make_strict_schema(schema.copy())
return {
"custom_id": self.custom_id,
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": self.model,
"messages": self.messages,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": self.response_model.__name__,
"strict": True,
"schema": strict_schema,
},
},
},
}
def to_anthropic_format(self) -> dict[str, Any]:
"""Convert to Anthropic batch format with JSON schema"""
schema = self.get_json_schema()
# Ensure schema has proper format for Anthropic
if "type" not in schema:
schema["type"] = "object"
if "additionalProperties" not in schema:
schema["additionalProperties"] = False
# Extract system message and convert to system parameter
system_message = None
filtered_messages = []
for message in self.messages:
if message.get("role") == "system":
system_message = message.get("content", "")
else:
filtered_messages.append(message)
params = {
"model": self.model,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"messages": filtered_messages,
"tools": [
{
"name": "extract_data",
"description": f"Extract data matching the {self.response_model.__name__} schema",
"input_schema": schema,
}
],
"tool_choice": {"type": "tool", "name": "extract_data"},
}
# Add system parameter if system message exists
if system_message:
params["system"] = system_message
return {
"custom_id": self.custom_id,
"params": params,
}
def save_to_file(
self, file_path_or_buffer: str | io.BytesIO, provider: str
) -> None:
"""Save batch request to file or BytesIO buffer in provider-specific format"""
if provider == "openai":
data = self.to_openai_format()
elif provider == "anthropic":
data = self.to_anthropic_format()
else:
raise ValueError(f"Unsupported provider: {provider}")
json_line = json.dumps(data) + "\n"
if isinstance(file_path_or_buffer, str):
with open(file_path_or_buffer, "a") as f:
f.write(json_line)
elif isinstance(file_path_or_buffer, io.BytesIO):
file_path_or_buffer.write(json_line.encode("utf-8"))
else:
raise ValueError(
f"Unsupported file_path_or_buffer type: {type(file_path_or_buffer)}"
)

View File

@@ -0,0 +1,28 @@
"""
Utility functions for batch processing.
This module contains helper functions for filtering, extracting, and manipulating
batch results.
"""
from .models import BatchResult, BatchSuccess, BatchError, T
def filter_successful(results: list[BatchResult]) -> list[BatchSuccess[T]]:
"""Filter to only successful results"""
return [r for r in results if r.success]
def filter_errors(results: list[BatchResult]) -> list[BatchError]:
"""Filter to only error results"""
return [r for r in results if not r.success]
def extract_results(results: list[BatchResult]) -> list[T]:
"""Extract just the result objects from successful results"""
return [r.result for r in results if r.success]
def get_results_by_custom_id(results: list[BatchResult]) -> dict[str, BatchResult]:
"""Create a dictionary mapping custom_id to results"""
return {r.custom_id: r for r in results}