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;
use std::fmt;
#[derive(Clone, Debug, Serialize, PartialEq)]
#[non_exhaustive]
pub struct CreateContainerRequest {
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub file_ids: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl CreateContainerRequest {
pub fn builder() -> CreateContainerRequestBuilder {
CreateContainerRequestBuilder::default()
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateContainerRequestBuilder {
name: Option<String>,
file_ids: Vec<String>,
extra: BTreeMap<String, Value>,
}
impl CreateContainerRequestBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn file_id(mut self, file_id: impl Into<String>) -> Self {
self.file_ids.push(file_id.into());
self
}
pub fn file_ids(mut self, file_ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.file_ids = file_ids.into_iter().map(Into::into).collect();
self
}
pub fn extra(mut self, name: impl Into<String>, value: Value) -> Self {
self.extra.insert(name.into(), value);
self
}
pub fn build(self) -> Result<CreateContainerRequest, LingerError> {
validate_non_empty_values("file_ids", &self.file_ids, false)?;
validate_extra_fields(&self.extra)?;
Ok(CreateContainerRequest {
name: required_string("name", self.name)?,
file_ids: self.file_ids,
extra: self.extra,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateContainerFileRequest {
pub file_id: Option<String>,
pub file: Option<ContainerFileUpload>,
}
impl CreateContainerFileRequest {
pub fn builder() -> CreateContainerFileRequestBuilder {
CreateContainerFileRequestBuilder::default()
}
pub(crate) fn apply_body(&self, request: &mut HttpRequest) -> Result<(), LingerError> {
match (&self.file_id, &self.file) {
(Some(file_id), None) => {
request.insert_header("content-type", "application/json");
request.set_body(serde_json::to_vec(&ContainerFileIdBody { file_id })?);
}
(None, Some(file)) => {
let boundary = multipart_boundary(&file.content);
request.insert_header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
);
request.set_body_stream(file.multipart_stream(boundary));
}
_ => {
return Err(LingerError::invalid_config(
"exactly one of file_id or file is required",
));
}
}
Ok(())
}
}
#[derive(Serialize)]
struct ContainerFileIdBody<'a> {
file_id: &'a str,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateContainerFileRequestBuilder {
file_id: Option<String>,
file: Option<ContainerFileUpload>,
}
impl CreateContainerFileRequestBuilder {
pub fn file_id(mut self, file_id: impl Into<String>) -> Self {
self.file_id = Some(file_id.into());
self
}
pub fn file(mut self, file: ContainerFileUpload) -> Self {
self.file = Some(file);
self
}
pub fn build(self) -> Result<CreateContainerFileRequest, LingerError> {
match (&self.file_id, &self.file) {
(Some(file_id), None) if !file_id.trim().is_empty() => {}
(Some(_), None) => return Err(LingerError::invalid_config("file_id is required")),
(None, Some(_)) => {}
(None, None) => {
return Err(LingerError::invalid_config(
"exactly one of file_id or file is required",
));
}
(Some(_), Some(_)) => {
return Err(LingerError::invalid_config(
"file_id and file are mutually exclusive",
));
}
}
Ok(CreateContainerFileRequest {
file_id: self.file_id,
file: self.file,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ContainerFileUpload {
pub filename: String,
pub content_type: String,
content: Bytes,
}
impl ContainerFileUpload {
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()
}
fn multipart_stream(
&self,
boundary: String,
) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
let chunks = vec![
Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
escape_multipart_param(&self.filename),
self.content_type
))),
Ok(self.content.clone()),
Ok(Bytes::from(format!("\r\n--{boundary}--\r\n"))),
];
futures_util::stream::iter(chunks)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct Container {
pub id: String,
pub object: String,
pub created_at: u64,
pub name: String,
pub status: String,
#[serde(default)]
pub expires_after: Option<Value>,
#[serde(default)]
pub last_active_at: Option<u64>,
#[serde(default)]
pub memory_limit: Option<String>,
#[serde(default)]
pub network_policy: Option<Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl Container {
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 ContainerPage {
pub object: String,
#[serde(default)]
pub data: Vec<Container>,
#[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 ContainerPage {
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 ContainerDeletion {
pub id: String,
pub object: String,
pub deleted: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl ContainerDeletion {
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 ContainerFile {
pub id: String,
pub object: String,
pub created_at: u64,
pub bytes: u64,
pub container_id: String,
pub path: String,
pub source: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl ContainerFile {
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 ContainerFilePage {
pub object: String,
#[serde(default)]
pub data: Vec<ContainerFile>,
#[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 ContainerFilePage {
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 ContainerFileDeletion {
pub id: String,
pub object: String,
pub deleted: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl ContainerFileDeletion {
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 ContainerFileContent {
request_id: Option<RequestId>,
body: BodyStream,
}
impl fmt::Debug for ContainerFileContent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContainerFileContent")
.field("request_id", &self.request_id)
.field("body", &"<stream>")
.finish()
}
}
impl ContainerFileContent {
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 required_string(name: &str, value: Option<String>) -> Result<String, LingerError> {
value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| LingerError::invalid_config(format!("{name} is required")))
}
fn validate_non_empty_values(
name: &str,
values: &[String],
require_non_empty: bool,
) -> Result<(), LingerError> {
if require_non_empty && values.is_empty() {
return Err(LingerError::invalid_config(format!("{name} is required")));
}
if values.iter().any(|value| value.trim().is_empty()) {
return Err(LingerError::invalid_config(format!(
"{name} must not contain empty values"
)));
}
Ok(())
}
fn validate_extra_fields(extra: &BTreeMap<String, Value>) -> Result<(), LingerError> {
for (key, value) in extra {
if key.trim().is_empty() {
return Err(LingerError::invalid_config(
"extra field names must not be empty",
));
}
if value.is_null() {
return Err(LingerError::invalid_config(format!(
"extra field {key} must not be null"
)));
}
}
Ok(())
}
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('"', "\\\"")
}