use std::hash::{BuildHasher, Hasher};
use http::{
header::{HeaderMap, HeaderValue, ACCEPT},
Method,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{Client, DecisionRequest, Error};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileObject {
pub id: String,
pub object: String,
pub bytes: u64,
pub created_at: i64,
pub expires_at: Option<i64>,
pub filename: String,
pub purpose: String,
pub status: String,
pub status_details: Option<Value>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeletedFile {
pub id: String,
pub object: String,
pub deleted: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchPage<T> {
pub object: String,
pub data: Vec<T>,
pub first_id: Option<String>,
pub last_id: Option<String>,
pub has_more: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Default)]
pub struct BatchListOptions<'a> {
pub after: Option<&'a str>,
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Default)]
pub struct FileUploadOptions {
pub expires_after_seconds: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchExpiresAfter {
pub anchor: String,
pub seconds: u32,
}
impl BatchExpiresAfter {
pub fn created_at(seconds: u32) -> Self {
Self { anchor: "created_at".into(), seconds }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateBatchRequest {
pub input_file_id: String,
pub endpoint: String,
pub completion_window: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_expires_after: Option<BatchExpiresAfter>,
}
impl CreateBatchRequest {
pub fn new(input_file_id: impl Into<String>) -> Self {
Self {
input_file_id: input_file_id.into(),
endpoint: "/v1/basal".into(),
completion_window: "24h".into(),
metadata: None,
output_expires_after: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchRequestCounts {
pub total: u64,
pub completed: u64,
pub failed: u64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchErrorEntry {
pub code: String,
pub message: String,
pub param: Option<String>,
pub line: Option<u64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchErrors {
pub object: String,
pub data: Vec<BatchErrorEntry>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Batch {
pub id: String,
pub object: String,
pub endpoint: String,
pub model: Option<String>,
pub errors: Option<BatchErrors>,
pub input_file_id: String,
pub completion_window: String,
pub status: String,
pub output_file_id: Option<String>,
pub error_file_id: Option<String>,
pub created_at: i64,
pub in_progress_at: Option<i64>,
pub expires_at: i64,
pub finalizing_at: Option<i64>,
pub completed_at: Option<i64>,
pub failed_at: Option<i64>,
pub expired_at: Option<i64>,
pub cancelling_at: Option<i64>,
pub cancelled_at: Option<i64>,
pub request_counts: BatchRequestCounts,
pub metadata: Option<std::collections::BTreeMap<String, String>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchInputLine {
pub custom_id: String,
pub method: String,
pub url: String,
pub body: DecisionRequest,
}
impl BatchInputLine {
pub fn new(custom_id: impl Into<String>, body: DecisionRequest) -> Self {
Self { custom_id: custom_id.into(), method: "POST".into(), url: "/v1/basal".into(), body }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchResultLine {
pub id: String,
pub custom_id: String,
pub response: Option<BatchResultResponse>,
pub error: Option<BatchResultError>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchResultResponse {
pub status_code: u16,
pub request_id: String,
pub body: Value,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchResultError {
pub code: String,
pub message: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
fn id_component(id: &str, prefix: &str) -> Result<(), Error> {
let suffix = id.strip_prefix(prefix).ok_or(Error::InvalidRequest("invalid resource id"))?;
if suffix.len() != 32 || !suffix.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
return Err(Error::InvalidRequest("invalid resource id"));
}
Ok(())
}
fn list_path(base: &str, options: &BatchListOptions<'_>, prefix: &str, purpose: Option<&str>) -> Result<String, Error> {
let mut params = Vec::new();
if let Some(after) = options.after {
id_component(after, prefix)?;
params.push(format!("after={after}"));
}
if let Some(limit) = options.limit {
if !(1..=100).contains(&limit) {
return Err(Error::InvalidRequest("list limit must be 1-100"));
}
params.push(format!("limit={limit}"));
}
if let Some(purpose) = purpose {
if !matches!(purpose, "batch" | "batch_output") {
return Err(Error::InvalidRequest("invalid file purpose"));
}
params.push(format!("purpose={purpose}"));
}
Ok(if params.is_empty() { base.into() } else { format!("{base}?{}", params.join("&")) })
}
impl Client {
pub async fn upload_file(
&self,
filename: &str,
data: &[u8],
options: FileUploadOptions,
) -> Result<FileObject, Error> {
if filename.is_empty() || filename.len() > 255 || filename.chars().any(char::is_control) {
return Err(Error::InvalidRequest("filename must be 1-255 bytes without control characters"));
}
if data.len() > 200_000_000 {
return Err(Error::InvalidRequest("input file exceeds 200 MB"));
}
if options.expires_after_seconds.is_some_and(|s| !(3600..=2_592_000).contains(&s)) {
return Err(Error::InvalidRequest("file expiry must be 3600-2592000 seconds"));
}
let random = std::collections::hash_map::RandomState::new();
let mut counter = 0u64;
let boundary = loop {
let mut h = random.build_hasher();
h.write_u64(counter);
let candidate = format!("basal-sdk-{:016x}-{counter:016x}", h.finish());
if !data.windows(candidate.len()).any(|w| w == candidate.as_bytes()) {
break candidate;
}
counter = counter.checked_add(1).ok_or(Error::InvalidRequest("multipart boundary unavailable"))?;
};
let mut form = format!("--{boundary}\r\nContent-Disposition: form-data; name=\"purpose\"\r\n\r\nbatch\r\n");
if let Some(seconds) = options.expires_after_seconds {
for (name, value) in
[("expires_after[anchor]", "created_at".to_string()), ("expires_after[seconds]", seconds.to_string())]
{
form.push_str(&format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
));
}
}
let filename = filename.replace('\\', "\\\\").replace('"', "\\\"");
form.push_str(&format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/jsonl\r\n\r\n"));
let mut body = form.into_bytes();
body.extend_from_slice(data);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let content_type = format!("multipart/form-data; boundary={boundary}");
let bytes =
self.request_bytes(Method::POST, "v1/files", Some(body), Some(&content_type), HeaderMap::new()).await?;
serde_json::from_slice(&bytes).map_err(Error::Protocol)
}
pub async fn list_files(
&self,
options: BatchListOptions<'_>,
purpose: Option<&str>,
) -> Result<BatchPage<FileObject>, Error> {
self.send(Method::GET, &list_path("v1/files", &options, "file_", purpose)?, None).await
}
pub async fn get_file(&self, id: &str) -> Result<FileObject, Error> {
id_component(id, "file_")?;
self.send(Method::GET, &format!("v1/files/{id}"), None).await
}
pub async fn delete_file(&self, id: &str) -> Result<DeletedFile, Error> {
id_component(id, "file_")?;
self.send(Method::DELETE, &format!("v1/files/{id}"), None).await
}
pub async fn download_file(&self, id: &str) -> Result<Vec<u8>, Error> {
id_component(id, "file_")?;
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
self.request_bytes(Method::GET, &format!("v1/files/{id}/content"), None, None, headers).await
}
pub async fn create_batch(
&self,
request: &CreateBatchRequest,
idempotency_key: Option<&str>,
) -> Result<Batch, Error> {
let mut headers = HeaderMap::new();
if let Some(key) = idempotency_key {
if key.is_empty() || key.len() > 128 || !key.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
return Err(Error::InvalidRequest("invalid idempotency key"));
}
let value = HeaderValue::from_str(key).map_err(|_| Error::InvalidRequest("invalid idempotency key"))?;
headers.insert("idempotency-key", value);
}
let bytes = self
.request_bytes(
Method::POST,
"v1/batches",
Some(serde_json::to_vec(request).map_err(Error::Encode)?),
Some("application/json"),
headers,
)
.await?;
serde_json::from_slice(&bytes).map_err(Error::Protocol)
}
pub async fn list_batches(&self, options: BatchListOptions<'_>) -> Result<BatchPage<Batch>, Error> {
self.send(Method::GET, &list_path("v1/batches", &options, "batch_", None)?, None).await
}
pub async fn get_batch(&self, id: &str) -> Result<Batch, Error> {
id_component(id, "batch_")?;
self.send(Method::GET, &format!("v1/batches/{id}"), None).await
}
pub async fn cancel_batch(&self, id: &str) -> Result<Batch, Error> {
id_component(id, "batch_")?;
self.send(Method::POST, &format!("v1/batches/{id}/cancel"), None).await
}
pub async fn batch_health(&self) -> Result<Value, Error> {
self.send(Method::GET, "health/batches", None).await
}
}