use crate::error::LingerError;
use crate::transport::{BodyStream, HttpRequest};
use crate::RequestId;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateFileRequest {
pub file: FileUpload,
pub purpose: String,
pub expires_after: Option<FileExpirationPolicy>,
}
impl CreateFileRequest {
pub fn builder() -> CreateFileRequestBuilder {
CreateFileRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let boundary = multipart_boundary(&self.file.content);
request.insert_header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
);
request.set_body_stream(self.multipart_stream(boundary));
}
fn multipart_stream(
&self,
boundary: String,
) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
let mut chunks = Vec::new();
push_text_field(&mut chunks, &boundary, "purpose", &self.purpose);
if let Some(expires_after) = &self.expires_after {
push_text_field(
&mut chunks,
&boundary,
"expires_after[anchor]",
&expires_after.anchor,
);
push_text_field(
&mut chunks,
&boundary,
"expires_after[seconds]",
&expires_after.seconds.to_string(),
);
}
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
escape_multipart_param(&self.file.filename),
self.file.content_type
))));
chunks.push(Ok(self.file.content.clone()));
chunks.push(Ok(Bytes::from(format!("\r\n--{boundary}--\r\n"))));
futures_util::stream::iter(chunks)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateFileRequestBuilder {
file: Option<FileUpload>,
purpose: Option<String>,
expires_after: Option<FileExpirationPolicy>,
}
impl CreateFileRequestBuilder {
pub fn file(mut self, file: FileUpload) -> Self {
self.file = Some(file);
self
}
pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
self.purpose = Some(purpose.into());
self
}
pub fn expires_after(mut self, expires_after: FileExpirationPolicy) -> Self {
self.expires_after = Some(expires_after);
self
}
pub fn build(self) -> Result<CreateFileRequest, LingerError> {
let file = self
.file
.ok_or_else(|| LingerError::invalid_config("file is required"))?;
let purpose = self
.purpose
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| LingerError::invalid_config("purpose is required"))?;
if let Some(expires_after) = &self.expires_after {
expires_after.validate()?;
}
Ok(CreateFileRequest {
file,
purpose,
expires_after: self.expires_after,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileUpload {
pub filename: String,
pub content_type: String,
content: Bytes,
}
impl FileUpload {
pub fn from_bytes(
filename: impl Into<String>,
content: impl Into<Bytes>,
) -> Result<Self, LingerError> {
let filename = filename.into();
validate_header_param("filename", &filename)?;
Ok(Self {
filename,
content_type: "application/octet-stream".to_string(),
content: content.into(),
})
}
pub fn content_type(mut self, content_type: impl Into<String>) -> Result<Self, LingerError> {
let content_type = content_type.into();
validate_header_value("content_type", &content_type)?;
self.content_type = content_type;
Ok(self)
}
pub fn bytes(&self) -> Bytes {
self.content.clone()
}
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileExpirationPolicy {
pub anchor: String,
pub seconds: u64,
}
impl FileExpirationPolicy {
pub fn new(anchor: impl Into<String>, seconds: u64) -> Self {
Self {
anchor: anchor.into(),
seconds,
}
}
pub(crate) fn validate_for_uploads(&self) -> Result<(), LingerError> {
if self.anchor != "created_at" {
return Err(LingerError::invalid_config(
"expires_after.anchor must be created_at",
));
}
if !(3_600..=2_592_000).contains(&self.seconds) {
return Err(LingerError::invalid_config(
"expires_after.seconds must be between 3600 and 2592000",
));
}
Ok(())
}
fn validate(&self) -> Result<(), LingerError> {
self.validate_for_uploads()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct FileObject {
pub id: String,
pub object: String,
pub bytes: u64,
pub created_at: u64,
#[serde(default)]
pub expires_at: Option<u64>,
pub filename: String,
pub purpose: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl FileObject {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct FilesPage {
pub object: String,
#[serde(default)]
pub data: Vec<FileObject>,
#[serde(default)]
pub first_id: Option<String>,
#[serde(default)]
pub last_id: Option<String>,
pub has_more: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl FilesPage {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileDeletion {
pub id: String,
pub object: String,
pub deleted: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl FileDeletion {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
pub struct FileContent {
request_id: Option<RequestId>,
body: BodyStream,
}
impl FileContent {
pub(crate) fn new(request_id: Option<RequestId>, body: BodyStream) -> Self {
Self { request_id, body }
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
pub fn into_stream(self) -> BodyStream {
self.body
}
}
fn push_text_field(
chunks: &mut Vec<Result<Bytes, LingerError>>,
boundary: &str,
name: &str,
value: &str,
) {
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
))));
}
fn multipart_boundary(content: &Bytes) -> String {
for counter in 0.. {
let boundary = format!("linger-openai-sdk-boundary-{counter}");
if !contains_bytes(content, boundary.as_bytes()) {
return boundary;
}
}
unreachable!("unbounded boundary counter")
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn validate_header_param(name: &str, value: &str) -> Result<(), LingerError> {
if value.trim().is_empty() {
return Err(LingerError::invalid_config(format!("{name} is required")));
}
validate_header_value(name, value)
}
fn validate_header_value(name: &str, value: &str) -> Result<(), LingerError> {
if value.contains('\r') || value.contains('\n') {
return Err(LingerError::invalid_config(format!(
"{name} must not contain CR or LF"
)));
}
Ok(())
}
fn escape_multipart_param(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}