use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use zenwave::{Client, client, header};
use crate::error::OpenAIError;
#[cfg(not(target_arch = "wasm32"))]
use crate::mime::mime_from_path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FilePurpose {
Vision,
Assistants,
FineTune,
Batch,
}
impl FilePurpose {
const fn as_str(self) -> &'static str {
match self {
Self::Vision => "vision",
Self::Assistants => "assistants",
Self::FineTune => "fine-tune",
Self::Batch => "batch",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Uploaded,
Processed,
Error,
Deleting,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFile {
pub id: String,
pub object: String,
pub bytes: u64,
pub created_at: u64,
pub filename: String,
pub purpose: String,
#[serde(default)]
pub status: Option<FileStatus>,
#[serde(default)]
pub status_details: Option<String>,
}
impl OpenAIFile {
#[must_use]
pub fn created(&self) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(self.created_at)
}
#[must_use]
pub const fn is_ready(&self) -> bool {
matches!(self.status, Some(FileStatus::Processed) | None)
}
}
#[derive(Debug, Deserialize)]
pub struct DeleteFileResponse {
pub id: String,
pub object: String,
pub deleted: bool,
}
#[derive(Debug, Deserialize)]
pub struct ListFilesResponse {
pub object: String,
pub data: Vec<OpenAIFile>,
}
#[derive(Debug, Clone)]
pub struct FilesConfig {
pub api_key: String,
pub base_url: String,
pub organization: Option<String>,
}
impl FilesConfig {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: "https://api.openai.com/v1".to_string(),
organization: None,
}
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub fn with_organization(mut self, org: impl Into<String>) -> Self {
self.organization = Some(org.into());
self
}
fn files_endpoint(&self) -> String {
format!("{}/files", self.base_url.trim_end_matches('/'))
}
}
pub async fn upload_bytes(
cfg: &FilesConfig,
file_name: &str,
mime_type: &str,
data: Vec<u8>,
purpose: FilePurpose,
) -> Result<OpenAIFile, OpenAIError> {
let endpoint = cfg.files_endpoint();
let boundary = format!(
"----aither{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"purpose\"\r\n\r\n");
body.extend_from_slice(purpose.as_str().as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n")
.as_bytes(),
);
body.extend_from_slice(format!("Content-Type: {mime_type}\r\n\r\n").as_bytes());
body.extend_from_slice(&data);
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
let content_type = format!("multipart/form-data; boundary={boundary}");
let mut backend = client();
let mut builder = backend
.post(&endpoint)
.map_err(OpenAIError::from_http)?
.header(header::CONTENT_TYPE.as_str(), &content_type)
.map_err(OpenAIError::from_http)?
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", cfg.api_key),
)
.map_err(OpenAIError::from_http)?;
if let Some(org) = &cfg.organization {
builder = builder
.header("OpenAI-Organization", org)
.map_err(OpenAIError::from_http)?;
}
let builder = builder.bytes_body(body);
builder.json().await.map_err(OpenAIError::from_http)
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_file(
cfg: &FilesConfig,
path: &Path,
purpose: FilePurpose,
) -> Result<OpenAIFile, OpenAIError> {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let mime_type = mime_from_path(path).unwrap_or("application/octet-stream");
let data = async_fs::read(path)
.await
.map_err(|e| OpenAIError::Api(format!("Failed to read file: {e}")))?;
upload_bytes(cfg, file_name, mime_type, data, purpose).await
}
pub async fn delete_file(
cfg: &FilesConfig,
file_id: &str,
) -> Result<DeleteFileResponse, OpenAIError> {
let endpoint = format!("{}/{}", cfg.files_endpoint(), file_id);
let mut backend = client();
let mut builder = backend
.delete(&endpoint)
.map_err(OpenAIError::from_http)?
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", cfg.api_key),
)
.map_err(OpenAIError::from_http)?;
if let Some(org) = &cfg.organization {
builder = builder
.header("OpenAI-Organization", org)
.map_err(OpenAIError::from_http)?;
}
builder.json().await.map_err(OpenAIError::from_http)
}
pub async fn get_file(cfg: &FilesConfig, file_id: &str) -> Result<OpenAIFile, OpenAIError> {
let endpoint = format!("{}/{}", cfg.files_endpoint(), file_id);
let mut backend = client();
let mut builder = backend
.get(&endpoint)
.map_err(OpenAIError::from_http)?
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", cfg.api_key),
)
.map_err(OpenAIError::from_http)?;
if let Some(org) = &cfg.organization {
builder = builder
.header("OpenAI-Organization", org)
.map_err(OpenAIError::from_http)?;
}
builder.json().await.map_err(OpenAIError::from_http)
}
pub async fn list_files(
cfg: &FilesConfig,
purpose: Option<FilePurpose>,
) -> Result<ListFilesResponse, OpenAIError> {
let mut endpoint = cfg.files_endpoint();
if let Some(p) = purpose {
endpoint.push_str("?purpose=");
endpoint.push_str(p.as_str());
}
let mut backend = client();
let mut builder = backend
.get(&endpoint)
.map_err(OpenAIError::from_http)?
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", cfg.api_key),
)
.map_err(OpenAIError::from_http)?;
if let Some(org) = &cfg.organization {
builder = builder
.header("OpenAI-Organization", org)
.map_err(OpenAIError::from_http)?;
}
builder.json().await.map_err(OpenAIError::from_http)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_from_path() {
assert_eq!(
mime_from_path(Path::new("/path/to/image.png")),
Some("image/png")
);
assert_eq!(
mime_from_path(Path::new("/path/to/video.mp4")),
Some("video/mp4")
);
assert_eq!(
mime_from_path(Path::new("/path/to/doc.pdf")),
Some("application/pdf")
);
}
#[test]
fn test_file_purpose_as_str() {
assert_eq!(FilePurpose::Vision.as_str(), "vision");
assert_eq!(FilePurpose::Assistants.as_str(), "assistants");
assert_eq!(FilePurpose::FineTune.as_str(), "fine-tune");
assert_eq!(FilePurpose::Batch.as_str(), "batch");
}
}