use crate::error::{Error, Result};
use crate::types::{MessagesRequest, MessagesResponse};
use futures::Stream;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
use std::time::Duration;
use tracing::{debug, info};
const BATCH_API_URL: &str = "https://api.anthropic.com/v1/messages/batches";
const API_VERSION: &str = "2023-06-01";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRequest {
pub custom_id: String,
pub params: MessagesRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageBatch {
pub id: String,
#[serde(rename = "type")]
pub batch_type: String,
pub processing_status: BatchProcessingStatus,
pub request_counts: RequestCounts,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<String>,
pub created_at: String,
pub expires_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cancel_initiated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub results_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BatchProcessingStatus {
InProgress,
Canceling,
Ended,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RequestCounts {
pub processing: u32,
pub succeeded: u32,
pub errored: u32,
pub canceled: u32,
pub expired: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchResult {
pub custom_id: String,
pub result: BatchResultType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[allow(clippy::large_enum_variant)]
pub enum BatchResultType {
Succeeded { message: MessagesResponse },
Errored { error: BatchError },
Canceled,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchError {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
}
pub struct BatchClient {
http: Client,
api_key: String,
api_version: String,
}
impl BatchClient {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
http: Client::new(),
api_key: api_key.into(),
api_version: API_VERSION.to_string(),
}
}
pub async fn create(&self, requests: Vec<BatchRequest>) -> Result<MessageBatch> {
debug!("Creating batch with {} requests", requests.len());
#[derive(Serialize)]
struct CreateRequest {
requests: Vec<BatchRequest>,
}
let response = self
.http
.post(BATCH_API_URL)
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.header("content-type", "application/json")
.json(&CreateRequest { requests })
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
message: error_text,
error_type: None,
});
}
let batch: MessageBatch = response.json().await?;
Ok(batch)
}
pub async fn retrieve(&self, batch_id: &str) -> Result<MessageBatch> {
debug!("Retrieving batch: {}", batch_id);
let url = format!("{}/{}", BATCH_API_URL, batch_id);
let response = self
.http
.get(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
message: error_text,
error_type: None,
});
}
let batch: MessageBatch = response.json().await?;
Ok(batch)
}
pub async fn list(&self, limit: Option<u32>) -> Result<Vec<MessageBatch>> {
debug!("Listing batches");
let mut url = BATCH_API_URL.to_string();
if let Some(lim) = limit {
url.push_str(&format!("?limit={}", lim));
}
let response = self
.http
.get(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
message: error_text,
error_type: None,
});
}
#[derive(Deserialize)]
struct ListResponse {
data: Vec<MessageBatch>,
}
let list_response: ListResponse = response.json().await?;
Ok(list_response.data)
}
pub async fn cancel(&self, batch_id: &str) -> Result<MessageBatch> {
info!("Canceling batch: {}", batch_id);
let url = format!("{}/{}/cancel", BATCH_API_URL, batch_id);
let response = self
.http
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
message: error_text,
error_type: None,
});
}
let batch: MessageBatch = response.json().await?;
Ok(batch)
}
pub async fn wait_for_completion(&self, batch_id: &str) -> Result<MessageBatch> {
info!("Waiting for batch {} to complete", batch_id);
loop {
let batch = self.retrieve(batch_id).await?;
if batch.processing_status == BatchProcessingStatus::Ended {
return Ok(batch);
}
debug!(
"Batch {} still processing (status: {:?})",
batch_id, batch.processing_status
);
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
pub async fn results(
&self,
batch_id: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<BatchResult>> + Send>>> {
let batch = self.retrieve(batch_id).await?;
let results_url = batch
.results_url
.ok_or_else(|| Error::InvalidRequest("Batch has no results yet".into()))?;
debug!("Streaming results from: {}", results_url);
let response = self
.http
.get(&results_url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
message: error_text,
error_type: None,
});
}
let byte_stream = response.bytes_stream();
let stream = async_stream::stream! {
use futures::StreamExt;
let mut byte_stream = byte_stream;
let mut buffer = Vec::new();
while let Some(chunk_result) = byte_stream.next().await {
let chunk = chunk_result.map_err(|e| Error::Network(format!("Stream error: {}", e)))?;
buffer.extend_from_slice(&chunk);
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = buffer.drain(..=newline_pos).collect();
let line = String::from_utf8_lossy(&line_bytes).trim().to_string();
if !line.is_empty() {
let result: BatchResult = serde_json::from_str(&line)
.map_err(Error::Json)?;
yield Ok(result);
}
}
}
if !buffer.is_empty() {
let line = String::from_utf8_lossy(&buffer).trim().to_string();
if !line.is_empty() {
let result: BatchResult = serde_json::from_str(&line)
.map_err(Error::Json)?;
yield Ok(result);
}
}
};
Ok(Box::pin(stream))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_batch_client_creation() {
let client = BatchClient::new("test-key");
assert_eq!(client.api_key, "test-key");
}
#[test]
fn test_batch_processing_status() {
assert_eq!(
serde_json::to_string(&BatchProcessingStatus::InProgress).unwrap(),
r#""in_progress""#
);
assert_eq!(
serde_json::to_string(&BatchProcessingStatus::Ended).unwrap(),
r#""ended""#
);
}
#[tokio::test]
#[ignore]
async fn test_create_and_retrieve_batch() {
let api_key = std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY required");
let client = BatchClient::new(api_key);
let requests = vec![BatchRequest {
custom_id: "test-1".into(),
params: MessagesRequest::new(
"claude-sonnet-4-5-20250929",
100,
vec![crate::types::Message::user("Hello!")],
),
}];
let batch = client.create(requests).await;
match batch {
Ok(b) => {
println!("Created batch: {}", b.id);
assert_eq!(b.processing_status, BatchProcessingStatus::InProgress);
}
Err(e) => println!("Test skipped (expected without real API): {}", e),
}
}
}