1use crate::error::LingerError;
2use crate::transport::{BodyStream, HttpRequest};
3use crate::RequestId;
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct CreateFileRequest {
14 pub file: FileUpload,
17 pub purpose: String,
20 pub expires_after: Option<FileExpirationPolicy>,
23}
24
25impl CreateFileRequest {
26 pub fn builder() -> CreateFileRequestBuilder {
29 CreateFileRequestBuilder::default()
30 }
31
32 pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
33 let boundary = multipart_boundary(&self.file.content);
34 request.insert_header(
35 "content-type",
36 format!("multipart/form-data; boundary={boundary}"),
37 );
38 request.set_body_stream(self.multipart_stream(boundary));
39 }
40
41 fn multipart_stream(
42 &self,
43 boundary: String,
44 ) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
45 let mut chunks = Vec::new();
46 push_text_field(&mut chunks, &boundary, "purpose", &self.purpose);
47 if let Some(expires_after) = &self.expires_after {
48 push_text_field(
49 &mut chunks,
50 &boundary,
51 "expires_after[anchor]",
52 &expires_after.anchor,
53 );
54 push_text_field(
55 &mut chunks,
56 &boundary,
57 "expires_after[seconds]",
58 &expires_after.seconds.to_string(),
59 );
60 }
61 chunks.push(Ok(Bytes::from(format!(
62 "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
63 escape_multipart_param(&self.file.filename),
64 self.file.content_type
65 ))));
66 chunks.push(Ok(self.file.content.clone()));
67 chunks.push(Ok(Bytes::from(format!("\r\n--{boundary}--\r\n"))));
68 futures_util::stream::iter(chunks)
69 }
70}
71
72#[derive(Clone, Debug, Default)]
75#[non_exhaustive]
76pub struct CreateFileRequestBuilder {
77 file: Option<FileUpload>,
78 purpose: Option<String>,
79 expires_after: Option<FileExpirationPolicy>,
80}
81
82impl CreateFileRequestBuilder {
83 pub fn file(mut self, file: FileUpload) -> Self {
86 self.file = Some(file);
87 self
88 }
89
90 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
93 self.purpose = Some(purpose.into());
94 self
95 }
96
97 pub fn expires_after(mut self, expires_after: FileExpirationPolicy) -> Self {
100 self.expires_after = Some(expires_after);
101 self
102 }
103
104 pub fn build(self) -> Result<CreateFileRequest, LingerError> {
107 let file = self
108 .file
109 .ok_or_else(|| LingerError::invalid_config("file is required"))?;
110 let purpose = self
111 .purpose
112 .filter(|value| !value.trim().is_empty())
113 .ok_or_else(|| LingerError::invalid_config("purpose is required"))?;
114 if let Some(expires_after) = &self.expires_after {
115 expires_after.validate()?;
116 }
117 Ok(CreateFileRequest {
118 file,
119 purpose,
120 expires_after: self.expires_after,
121 })
122 }
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct FileUpload {
130 pub filename: String,
133 pub content_type: String,
136 content: Bytes,
137}
138
139impl FileUpload {
140 pub fn from_bytes(
143 filename: impl Into<String>,
144 content: impl Into<Bytes>,
145 ) -> Result<Self, LingerError> {
146 let filename = filename.into();
147 validate_header_param("filename", &filename)?;
148 Ok(Self {
149 filename,
150 content_type: "application/octet-stream".to_string(),
151 content: content.into(),
152 })
153 }
154
155 pub fn content_type(mut self, content_type: impl Into<String>) -> Result<Self, LingerError> {
158 let content_type = content_type.into();
159 validate_header_value("content_type", &content_type)?;
160 self.content_type = content_type;
161 Ok(self)
162 }
163
164 pub fn bytes(&self) -> Bytes {
167 self.content.clone()
168 }
169}
170
171#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
174#[non_exhaustive]
175pub struct FileExpirationPolicy {
176 pub anchor: String,
179 pub seconds: u64,
182}
183
184impl FileExpirationPolicy {
185 pub fn new(anchor: impl Into<String>, seconds: u64) -> Self {
188 Self {
189 anchor: anchor.into(),
190 seconds,
191 }
192 }
193
194 pub(crate) fn validate_for_uploads(&self) -> Result<(), LingerError> {
195 if self.anchor != "created_at" {
196 return Err(LingerError::invalid_config(
197 "expires_after.anchor must be created_at",
198 ));
199 }
200 if !(3_600..=2_592_000).contains(&self.seconds) {
201 return Err(LingerError::invalid_config(
202 "expires_after.seconds must be between 3600 and 2592000",
203 ));
204 }
205 Ok(())
206 }
207
208 fn validate(&self) -> Result<(), LingerError> {
209 self.validate_for_uploads()
210 }
211}
212
213#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
216#[non_exhaustive]
217pub struct FileObject {
218 pub id: String,
221 pub object: String,
224 pub bytes: u64,
227 pub created_at: u64,
230 #[serde(default)]
233 pub expires_at: Option<u64>,
234 pub filename: String,
237 pub purpose: String,
240 #[serde(flatten)]
243 pub extra: BTreeMap<String, Value>,
244 #[serde(skip)]
247 request_id: Option<RequestId>,
248}
249
250impl FileObject {
251 pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
252 self.request_id = request_id;
253 self
254 }
255
256 pub fn request_id(&self) -> Option<&RequestId> {
259 self.request_id.as_ref()
260 }
261}
262
263#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
266#[non_exhaustive]
267pub struct FilesPage {
268 pub object: String,
271 #[serde(default)]
274 pub data: Vec<FileObject>,
275 #[serde(default)]
278 pub first_id: Option<String>,
279 #[serde(default)]
282 pub last_id: Option<String>,
283 pub has_more: bool,
286 #[serde(skip)]
289 request_id: Option<RequestId>,
290}
291
292impl FilesPage {
293 pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
294 self.request_id = request_id;
295 self
296 }
297
298 pub fn request_id(&self) -> Option<&RequestId> {
301 self.request_id.as_ref()
302 }
303}
304
305#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
308#[non_exhaustive]
309pub struct FileDeletion {
310 pub id: String,
313 pub object: String,
316 pub deleted: bool,
319 #[serde(skip)]
322 request_id: Option<RequestId>,
323}
324
325impl FileDeletion {
326 pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
327 self.request_id = request_id;
328 self
329 }
330
331 pub fn request_id(&self) -> Option<&RequestId> {
334 self.request_id.as_ref()
335 }
336}
337
338pub struct FileContent {
341 request_id: Option<RequestId>,
342 body: BodyStream,
343}
344
345impl FileContent {
346 pub(crate) fn new(request_id: Option<RequestId>, body: BodyStream) -> Self {
347 Self { request_id, body }
348 }
349
350 pub fn request_id(&self) -> Option<&RequestId> {
353 self.request_id.as_ref()
354 }
355
356 pub fn into_stream(self) -> BodyStream {
359 self.body
360 }
361}
362
363fn push_text_field(
364 chunks: &mut Vec<Result<Bytes, LingerError>>,
365 boundary: &str,
366 name: &str,
367 value: &str,
368) {
369 chunks.push(Ok(Bytes::from(format!(
370 "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
371 ))));
372}
373
374fn multipart_boundary(content: &Bytes) -> String {
375 for counter in 0.. {
376 let boundary = format!("linger-openai-sdk-boundary-{counter}");
377 if !contains_bytes(content, boundary.as_bytes()) {
378 return boundary;
379 }
380 }
381 unreachable!("unbounded boundary counter")
382}
383
384fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
385 if needle.is_empty() {
386 return true;
387 }
388 haystack
389 .windows(needle.len())
390 .any(|window| window == needle)
391}
392
393fn validate_header_param(name: &str, value: &str) -> Result<(), LingerError> {
394 if value.trim().is_empty() {
395 return Err(LingerError::invalid_config(format!("{name} is required")));
396 }
397 validate_header_value(name, value)
398}
399
400fn validate_header_value(name: &str, value: &str) -> Result<(), LingerError> {
401 if value.contains('\r') || value.contains('\n') {
402 return Err(LingerError::invalid_config(format!(
403 "{name} must not contain CR or LF"
404 )));
405 }
406 Ok(())
407}
408
409fn escape_multipart_param(value: &str) -> String {
410 value.replace('\\', "\\\\").replace('"', "\\\"")
411}