use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{ZaiResult, client::ZaiClient};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BatchEndpoint {
#[serde(rename = "/v4/chat/completions")]
ChatCompletions,
}
#[derive(Clone, Serialize, Deserialize, Validate)]
pub struct BatchCreateBody {
#[validate(length(min = 1))]
pub input_file_id: String,
pub endpoint: BatchEndpoint,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_delete_input_file: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<BTreeMap<String, String>>,
}
impl std::fmt::Debug for BatchCreateBody {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BatchCreateBody")
.field("input_file_id", &"[REDACTED]")
.field("endpoint", &self.endpoint)
.field("auto_delete_input_file", &self.auto_delete_input_file)
.field(
"metadata_entries",
&self.metadata.as_ref().map(BTreeMap::len),
)
.finish()
}
}
impl BatchCreateBody {
pub fn new(input_file_id: impl Into<String>, endpoint: BatchEndpoint) -> Self {
Self {
input_file_id: input_file_id.into(),
endpoint,
auto_delete_input_file: Some(true),
metadata: None,
}
}
pub fn with_auto_delete_input_file(mut self, v: bool) -> Self {
self.auto_delete_input_file = Some(v);
self
}
pub fn with_metadata(mut self, v: BTreeMap<String, String>) -> Self {
self.metadata = Some(v);
self
}
}
pub struct BatchCreateRequest {
body: BatchCreateBody,
}
impl BatchCreateRequest {
pub fn new(input_file_id: impl Into<String>, endpoint: BatchEndpoint) -> Self {
let body = BatchCreateBody::new(input_file_id, endpoint);
Self { body }
}
pub fn with_auto_delete_input_file(mut self, v: bool) -> Self {
self.body = self.body.with_auto_delete_input_file(v);
self
}
pub fn with_metadata(mut self, v: BTreeMap<String, String>) -> Self {
self.body = self.body.with_metadata(v);
self
}
pub fn validate(&self) -> ZaiResult<()> {
self.body.validate()?;
if self.body.input_file_id.trim().is_empty() {
return Err(crate::client::validation::invalid(
"input_file_id cannot be blank",
));
}
if let Some(metadata) = self.body.metadata.as_ref() {
if metadata.len() > 16 {
return Err(crate::client::validation::invalid(
"metadata cannot contain more than 16 entries",
));
}
for (key, value) in metadata {
if key.trim().is_empty() || key.chars().count() > 64 {
return Err(crate::client::validation::invalid(
"metadata keys must contain between 1 and 64 non-blank characters",
));
}
if value.chars().count() > 512 {
return Err(crate::client::validation::invalid(
"metadata values cannot exceed 512 characters",
));
}
}
}
Ok(())
}
pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<BatchCreateResponse> {
self.validate()?;
let route = crate::client::routes::BATCHES_CREATE;
let url = client.endpoints().resolve_route(route, &[])?;
client
.send_json::<_, BatchCreateResponse>(route.method(), url, &self.body)
.await
}
}
pub type BatchCreateResponse = super::types::BatchItem;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_is_a_bounded_string_map() {
let metadata = BTreeMap::from([("customer".to_owned(), "acme".to_owned())]);
let request = BatchCreateRequest::new("file-1", BatchEndpoint::ChatCompletions)
.with_metadata(metadata);
assert!(request.validate().is_ok());
assert_eq!(
serde_json::to_value(&request.body).unwrap()["metadata"]["customer"],
"acme"
);
let too_many = (0..17)
.map(|index| (format!("key-{index}"), "value".to_owned()))
.collect();
assert!(
BatchCreateRequest::new("file-1", BatchEndpoint::ChatCompletions)
.with_metadata(too_many)
.validate()
.is_err()
);
assert!(
BatchCreateRequest::new("file-1", BatchEndpoint::ChatCompletions)
.with_metadata(BTreeMap::from([(" ".to_owned(), "value".to_owned())]))
.validate()
.is_err()
);
}
#[test]
fn debug_redacts_file_id_and_metadata() {
let body =
BatchCreateBody::new("private-file-id", BatchEndpoint::ChatCompletions).with_metadata(
BTreeMap::from([("private-key".to_owned(), "private-value".to_owned())]),
);
let debug = format!("{body:?}");
for secret in ["private-file-id", "private-key", "private-value"] {
assert!(!debug.contains(secret));
}
assert!(debug.contains("metadata_entries: Some(1)"));
}
}