use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchEndpoint {
ChatCompletions,
}
impl BatchEndpoint {
pub fn as_str(self) -> &'static str {
match self {
Self::ChatCompletions => "/v1/chat/completions",
}
}
}
impl fmt::Display for BatchEndpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchItem {
pub custom_id: String,
pub body: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct SubmitBatch {
endpoint: &'static str,
model: String,
requests: Vec<BatchItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EmbeddingRequest {
model: String,
input: Vec<String>,
encoding_format: &'static str,
}
impl EmbeddingRequest {
pub fn new(model: impl Into<String>, input: Vec<String>) -> Self {
Self {
model: model.into(),
input,
encoding_format: "float",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmbeddingData {
pub index: i64,
pub embedding: Vec<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmbeddingResponseBody {
pub data: Vec<EmbeddingData>,
}
#[derive(Debug, Clone)]
pub struct RawEmbeddingResponse {
pub raw: serde_json::Value,
pub provider_request_id: Option<String>,
}
impl SubmitBatch {
pub fn new(
endpoint: BatchEndpoint,
model: impl Into<String>,
requests: Vec<BatchItem>,
) -> Self {
Self {
endpoint: endpoint.as_str(),
model: model.into(),
requests,
}
}
pub fn requests(&self) -> &[BatchItem] {
&self.requests
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BatchStatus {
Validating,
InProgress,
Finalizing,
Completed,
Failed,
Expired,
Cancelling,
Cancelled,
#[serde(other)]
Unknown,
}
impl BatchStatus {
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Expired | Self::Cancelled
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub struct RequestCounts {
#[serde(default)]
pub total: i64,
#[serde(default)]
pub completed: i64,
#[serde(default)]
pub failed: i64,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct BatchUsage {
#[serde(default)]
pub prompt_tokens: Option<i64>,
#[serde(default)]
pub completion_tokens: Option<i64>,
#[serde(default)]
pub total_tokens: Option<i64>,
#[serde(default)]
pub cost: Option<f64>,
#[serde(default)]
pub is_byok: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BatchResultLine {
Parsed(ResultLine),
Undecodable(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultLine {
#[serde(default)]
pub id: Option<String>,
pub custom_id: String,
#[serde(default)]
pub response: Option<ResultResponse>,
#[serde(default)]
pub error: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, thiserror::Error)]
pub enum BatchLineError {
#[error("result envelope decode: line does not match the documented result envelope")]
Undecodable,
#[error("line carries an error instead of a response")]
NoResponse,
#[error("provider returned status {0}")]
ProviderStatus(u16),
#[error("response body has no assistant message content")]
NoContent,
}
impl BatchResultLine {
pub fn custom_id(&self) -> Option<&str> {
match self {
Self::Parsed(line) => Some(&line.custom_id),
Self::Undecodable(raw) => raw.get("custom_id").and_then(serde_json::Value::as_str),
}
}
pub fn raw(&self) -> serde_json::Value {
match self {
Self::Parsed(line) => {
serde_json::to_value(line).expect("result line re-serialization cannot fail")
}
Self::Undecodable(raw) => raw.clone(),
}
}
pub fn chat_content(&self) -> Result<&str, BatchLineError> {
let line = match self {
Self::Parsed(line) => line,
Self::Undecodable(_) => return Err(BatchLineError::Undecodable),
};
let response = line.response.as_ref().ok_or(BatchLineError::NoResponse)?;
if response.status_code != 200 {
return Err(BatchLineError::ProviderStatus(response.status_code));
}
chat_completion_content(&response.body).ok_or(BatchLineError::NoContent)
}
}
pub fn chat_completion_content(body: &serde_json::Value) -> Option<&str> {
body.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(serde_json::Value::as_str)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultResponse {
pub status_code: u16,
#[serde(default)]
pub request_id: Option<String>,
pub body: serde_json::Value,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct RawChatResponse {
pub raw: serde_json::Value,
pub provider_request_id: Option<String>,
}
impl RawChatResponse {
pub fn content(&self) -> Result<&str, BatchLineError> {
chat_completion_content(&self.raw).ok_or(BatchLineError::NoContent)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BatchObject {
pub id: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub model: Option<String>,
pub status: BatchStatus,
#[serde(default)]
pub request_counts: Option<RequestCounts>,
#[serde(default)]
pub usage: Option<BatchUsage>,
#[serde(default)]
pub results: Option<Vec<BatchResultLine>>,
#[serde(default)]
pub error: Option<serde_json::Value>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub finalized_at: Option<i64>,
#[serde(skip)]
pub provider_request_id: Option<String>,
}
impl BatchObject {
pub fn batch_age_seconds(&self) -> Option<i64> {
let created = self.created_at?;
let end = self
.finalized_at
.unwrap_or_else(|| finalized_or_now(None).unwrap_or(0));
Some(end - created)
}
}
fn finalized_or_now(finalized_at: Option<i64>) -> Option<i64> {
finalized_at.or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
})
}
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
#[error("provider rejected the request ({status}): {message}")]
Api {
status: u16,
message: String,
provider_request_id: Option<String>,
},
#[error("custom_id exceeds the provider's 64-character cap: {custom_id}")]
CustomIdTooLong { custom_id: String },
#[error("http transport failure: {source}")]
Http {
#[source]
source: reqwest::Error,
provider_request_id: Option<String>,
},
#[error("response decode failure: {source}")]
Decode {
#[source]
source: serde_json::Error,
provider_request_id: Option<String>,
},
}
impl From<reqwest::Error> for TransportError {
fn from(source: reqwest::Error) -> Self {
Self::Http {
source,
provider_request_id: None,
}
}
}
impl From<serde_json::Error> for TransportError {
fn from(source: serde_json::Error) -> Self {
Self::Decode {
source,
provider_request_id: None,
}
}
}
impl TransportError {
pub fn retryable(&self) -> bool {
fn status_retryable(status: u16) -> bool {
status == 408 || status == 429 || status >= 500
}
match self {
Self::Api { status, .. } => status_retryable(*status),
Self::CustomIdTooLong { .. } => false,
Self::Http { source, .. } => {
source.is_timeout()
|| source.is_connect()
|| source.is_body()
|| source.is_decode()
|| source
.status()
.is_some_and(|status| status_retryable(status.as_u16()))
}
Self::Decode { .. } => false,
}
}
pub fn provider_request_id(&self) -> Option<&str> {
match self {
Self::CustomIdTooLong { .. } => None,
Self::Api {
provider_request_id,
..
} => provider_request_id.as_deref(),
Self::Http {
provider_request_id,
..
}
| Self::Decode {
provider_request_id,
..
} => provider_request_id.as_deref(),
}
}
pub fn not_found(&self) -> bool {
matches!(self, Self::Api { status: 404, .. })
}
}
#[async_trait]
pub trait BatchTransport: Send + Sync {
async fn submit(
&self,
batch: &SubmitBatch,
idempotency_key: &str,
) -> Result<BatchObject, TransportError>;
async fn get(&self, batch_id: &str) -> Result<BatchObject, TransportError>;
}
#[async_trait]
pub trait EmbeddingTransport: Send + Sync {
async fn embed(
&self,
model: &str,
inputs: &[String],
idempotency_key: &str,
) -> Result<RawEmbeddingResponse, TransportError>;
}