Skip to main content

linger_openai_sdk/
files.rs

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/// EN: Request body descriptor for `POST /v1/files`.
10/// 中文:`POST /v1/files` 的请求体描述。
11#[derive(Clone, Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct CreateFileRequest {
14    /// EN: File content and filename metadata.
15    /// 中文:文件内容和文件名元数据。
16    pub file: FileUpload,
17    /// EN: OpenAI file purpose, such as `fine-tune`, `assistants`, or `batch`.
18    /// 中文:OpenAI 文件用途,例如 `fine-tune`、`assistants` 或 `batch`。
19    pub purpose: String,
20    /// EN: Optional expiration policy for supported purposes.
21    /// 中文:受支持用途的可选过期策略。
22    pub expires_after: Option<FileExpirationPolicy>,
23}
24
25impl CreateFileRequest {
26    /// EN: Starts building a file upload request.
27    /// 中文:开始构建文件上传请求。
28    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/// EN: Builder for file upload requests.
73/// 中文:文件上传请求的构建器。
74#[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    /// EN: Sets the file to upload.
84    /// 中文:设置要上传的文件。
85    pub fn file(mut self, file: FileUpload) -> Self {
86        self.file = Some(file);
87        self
88    }
89
90    /// EN: Sets the file purpose.
91    /// 中文:设置文件用途。
92    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
93        self.purpose = Some(purpose.into());
94        self
95    }
96
97    /// EN: Sets the optional expiration policy.
98    /// 中文:设置可选的过期策略。
99    pub fn expires_after(mut self, expires_after: FileExpirationPolicy) -> Self {
100        self.expires_after = Some(expires_after);
101        self
102    }
103
104    /// EN: Builds and validates the request.
105    /// 中文:构建并校验请求。
106    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/// EN: Uploadable file bytes and multipart metadata.
126/// 中文:可上传文件字节及 multipart 元数据。
127#[derive(Clone, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct FileUpload {
130    /// EN: Filename sent in the multipart part.
131    /// 中文:multipart 分段中发送的文件名。
132    pub filename: String,
133    /// EN: Content type sent for the file part.
134    /// 中文:文件分段发送的内容类型。
135    pub content_type: String,
136    content: Bytes,
137}
138
139impl FileUpload {
140    /// EN: Creates an upload from already available bytes without copying them.
141    /// 中文:通过已可用字节创建上传对象,不复制这些字节。
142    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    /// EN: Sets the file part content type.
156    /// 中文:设置文件分段的内容类型。
157    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    /// EN: Returns the file bytes as a cheap `Bytes` clone.
165    /// 中文:以廉价 `Bytes` 克隆返回文件字节。
166    pub fn bytes(&self) -> Bytes {
167        self.content.clone()
168    }
169}
170
171/// EN: File expiration policy sent as multipart fields.
172/// 中文:作为 multipart 字段发送的文件过期策略。
173#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
174#[non_exhaustive]
175pub struct FileExpirationPolicy {
176    /// EN: Expiration anchor, for example `created_at`.
177    /// 中文:过期锚点,例如 `created_at`。
178    pub anchor: String,
179    /// EN: Expiration offset in seconds.
180    /// 中文:过期偏移秒数。
181    pub seconds: u64,
182}
183
184impl FileExpirationPolicy {
185    /// EN: Creates an expiration policy.
186    /// 中文:创建过期策略。
187    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/// EN: File object returned by the Files API.
214/// 中文:Files API 返回的文件对象。
215#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
216#[non_exhaustive]
217pub struct FileObject {
218    /// EN: File id.
219    /// 中文:文件 ID。
220    pub id: String,
221    /// EN: API object type.
222    /// 中文:API 对象类型。
223    pub object: String,
224    /// EN: File size in bytes.
225    /// 中文:文件大小,单位为字节。
226    pub bytes: u64,
227    /// EN: Unix timestamp for creation.
228    /// 中文:创建时间的 Unix 时间戳。
229    pub created_at: u64,
230    /// EN: Unix timestamp for expiration, when returned.
231    /// 中文:过期时间的 Unix 时间戳,如响应中存在。
232    #[serde(default)]
233    pub expires_at: Option<u64>,
234    /// EN: Original filename.
235    /// 中文:原始文件名。
236    pub filename: String,
237    /// EN: File purpose.
238    /// 中文:文件用途。
239    pub purpose: String,
240    /// EN: Additional fields preserved for forward compatibility.
241    /// 中文:为前向兼容保留的额外字段。
242    #[serde(flatten)]
243    pub extra: BTreeMap<String, Value>,
244    /// EN: OpenAI request id from response headers.
245    /// 中文:响应头中的 OpenAI 请求 ID。
246    #[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    /// EN: Returns the OpenAI request id, when present.
257    /// 中文:返回 OpenAI 请求 ID,如存在。
258    pub fn request_id(&self) -> Option<&RequestId> {
259        self.request_id.as_ref()
260    }
261}
262
263/// EN: Paginated file list returned by the Files API.
264/// 中文:Files API 返回的分页文件列表。
265#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
266#[non_exhaustive]
267pub struct FilesPage {
268    /// EN: API list object type.
269    /// 中文:API 列表对象类型。
270    pub object: String,
271    /// EN: Files on this page.
272    /// 中文:本页文件。
273    #[serde(default)]
274    pub data: Vec<FileObject>,
275    /// EN: First file id on this page.
276    /// 中文:本页第一个文件 ID。
277    #[serde(default)]
278    pub first_id: Option<String>,
279    /// EN: Last file id on this page.
280    /// 中文:本页最后一个文件 ID。
281    #[serde(default)]
282    pub last_id: Option<String>,
283    /// EN: Whether more files are available.
284    /// 中文:是否还有更多文件。
285    pub has_more: bool,
286    /// EN: OpenAI request id from response headers.
287    /// 中文:响应头中的 OpenAI 请求 ID。
288    #[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    /// EN: Returns the OpenAI request id, when present.
299    /// 中文:返回 OpenAI 请求 ID,如存在。
300    pub fn request_id(&self) -> Option<&RequestId> {
301        self.request_id.as_ref()
302    }
303}
304
305/// EN: Deletion result returned by the Files API.
306/// 中文:Files API 返回的删除结果。
307#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
308#[non_exhaustive]
309pub struct FileDeletion {
310    /// EN: Deleted file id.
311    /// 中文:已删除的文件 ID。
312    pub id: String,
313    /// EN: API object type.
314    /// 中文:API 对象类型。
315    pub object: String,
316    /// EN: Whether the file was deleted.
317    /// 中文:文件是否已删除。
318    pub deleted: bool,
319    /// EN: OpenAI request id from response headers.
320    /// 中文:响应头中的 OpenAI 请求 ID。
321    #[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    /// EN: Returns the OpenAI request id, when present.
332    /// 中文:返回 OpenAI 请求 ID,如存在。
333    pub fn request_id(&self) -> Option<&RequestId> {
334        self.request_id.as_ref()
335    }
336}
337
338/// EN: Incremental file content response.
339/// 中文:增量文件内容响应。
340pub 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    /// EN: Returns the OpenAI request id, when present.
351    /// 中文:返回 OpenAI 请求 ID,如存在。
352    pub fn request_id(&self) -> Option<&RequestId> {
353        self.request_id.as_ref()
354    }
355
356    /// EN: Consumes this response and returns the incremental content stream.
357    /// 中文:消耗此响应并返回增量内容流。
358    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}