Skip to main content

linger_openai_sdk/
containers.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;
8use std::fmt;
9
10/// EN: Request body for `POST /v1/containers`.
11/// 中文:`POST /v1/containers` 的请求体。
12#[derive(Clone, Debug, Serialize, PartialEq)]
13#[non_exhaustive]
14pub struct CreateContainerRequest {
15    /// EN: Container name.
16    /// 中文:容器名称。
17    pub name: String,
18    /// EN: Files to copy into the container at creation time.
19    /// 中文:创建容器时复制到容器内的文件 ID。
20    #[serde(skip_serializing_if = "Vec::is_empty")]
21    pub file_ids: Vec<String>,
22    /// EN: Forward-compatible optional fields not yet covered by handwritten types.
23    /// 中文:手写类型尚未覆盖的前向兼容可选字段。
24    #[serde(flatten)]
25    pub extra: BTreeMap<String, Value>,
26}
27
28impl CreateContainerRequest {
29    /// EN: Starts building a container creation request.
30    /// 中文:开始构建容器创建请求。
31    pub fn builder() -> CreateContainerRequestBuilder {
32        CreateContainerRequestBuilder::default()
33    }
34}
35
36/// EN: Builder for container creation requests.
37/// 中文:容器创建请求的构建器。
38#[derive(Clone, Debug, Default)]
39#[non_exhaustive]
40pub struct CreateContainerRequestBuilder {
41    name: Option<String>,
42    file_ids: Vec<String>,
43    extra: BTreeMap<String, Value>,
44}
45
46impl CreateContainerRequestBuilder {
47    /// EN: Sets the container name.
48    /// 中文:设置容器名称。
49    pub fn name(mut self, name: impl Into<String>) -> Self {
50        self.name = Some(name.into());
51        self
52    }
53
54    /// EN: Adds a file id to copy into the container.
55    /// 中文:添加一个要复制到容器中的文件 ID。
56    pub fn file_id(mut self, file_id: impl Into<String>) -> Self {
57        self.file_ids.push(file_id.into());
58        self
59    }
60
61    /// EN: Replaces the file id list.
62    /// 中文:替换文件 ID 列表。
63    pub fn file_ids(mut self, file_ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
64        self.file_ids = file_ids.into_iter().map(Into::into).collect();
65        self
66    }
67
68    /// EN: Adds a forward-compatible JSON field.
69    /// 中文:添加一个前向兼容 JSON 字段。
70    pub fn extra(mut self, name: impl Into<String>, value: Value) -> Self {
71        self.extra.insert(name.into(), value);
72        self
73    }
74
75    /// EN: Builds and validates the request.
76    /// 中文:构建并校验请求。
77    pub fn build(self) -> Result<CreateContainerRequest, LingerError> {
78        validate_non_empty_values("file_ids", &self.file_ids, false)?;
79        validate_extra_fields(&self.extra)?;
80        Ok(CreateContainerRequest {
81            name: required_string("name", self.name)?,
82            file_ids: self.file_ids,
83            extra: self.extra,
84        })
85    }
86}
87
88/// EN: Request body descriptor for `POST /v1/containers/{container_id}/files`.
89/// 中文:`POST /v1/containers/{container_id}/files` 的请求体描述。
90#[derive(Clone, Debug, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct CreateContainerFileRequest {
93    /// EN: Existing OpenAI file id to copy into the container.
94    /// 中文:要复制到容器中的已有 OpenAI 文件 ID。
95    pub file_id: Option<String>,
96    /// EN: Raw file content to upload directly into the container.
97    /// 中文:要直接上传到容器中的原始文件内容。
98    pub file: Option<ContainerFileUpload>,
99}
100
101impl CreateContainerFileRequest {
102    /// EN: Starts building a container file creation request.
103    /// 中文:开始构建容器文件创建请求。
104    pub fn builder() -> CreateContainerFileRequestBuilder {
105        CreateContainerFileRequestBuilder::default()
106    }
107
108    pub(crate) fn apply_body(&self, request: &mut HttpRequest) -> Result<(), LingerError> {
109        match (&self.file_id, &self.file) {
110            (Some(file_id), None) => {
111                request.insert_header("content-type", "application/json");
112                request.set_body(serde_json::to_vec(&ContainerFileIdBody { file_id })?);
113            }
114            (None, Some(file)) => {
115                let boundary = multipart_boundary(&file.content);
116                request.insert_header(
117                    "content-type",
118                    format!("multipart/form-data; boundary={boundary}"),
119                );
120                request.set_body_stream(file.multipart_stream(boundary));
121            }
122            _ => {
123                return Err(LingerError::invalid_config(
124                    "exactly one of file_id or file is required",
125                ));
126            }
127        }
128        Ok(())
129    }
130}
131
132#[derive(Serialize)]
133struct ContainerFileIdBody<'a> {
134    file_id: &'a str,
135}
136
137/// EN: Builder for container file creation requests.
138/// 中文:容器文件创建请求的构建器。
139#[derive(Clone, Debug, Default)]
140#[non_exhaustive]
141pub struct CreateContainerFileRequestBuilder {
142    file_id: Option<String>,
143    file: Option<ContainerFileUpload>,
144}
145
146impl CreateContainerFileRequestBuilder {
147    /// EN: Sets an existing OpenAI file id to copy into the container.
148    /// 中文:设置要复制到容器中的已有 OpenAI 文件 ID。
149    pub fn file_id(mut self, file_id: impl Into<String>) -> Self {
150        self.file_id = Some(file_id.into());
151        self
152    }
153
154    /// EN: Sets raw file content to upload into the container.
155    /// 中文:设置要上传到容器中的原始文件内容。
156    pub fn file(mut self, file: ContainerFileUpload) -> Self {
157        self.file = Some(file);
158        self
159    }
160
161    /// EN: Builds and validates the request.
162    /// 中文:构建并校验请求。
163    pub fn build(self) -> Result<CreateContainerFileRequest, LingerError> {
164        match (&self.file_id, &self.file) {
165            (Some(file_id), None) if !file_id.trim().is_empty() => {}
166            (Some(_), None) => return Err(LingerError::invalid_config("file_id is required")),
167            (None, Some(_)) => {}
168            (None, None) => {
169                return Err(LingerError::invalid_config(
170                    "exactly one of file_id or file is required",
171                ));
172            }
173            (Some(_), Some(_)) => {
174                return Err(LingerError::invalid_config(
175                    "file_id and file are mutually exclusive",
176                ));
177            }
178        }
179        Ok(CreateContainerFileRequest {
180            file_id: self.file_id,
181            file: self.file,
182        })
183    }
184}
185
186/// EN: Uploadable container file bytes and multipart metadata.
187/// 中文:可上传容器文件字节和 multipart 元数据。
188#[derive(Clone, Debug, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct ContainerFileUpload {
191    /// EN: Filename sent in the multipart part.
192    /// 中文:multipart 分段中发送的文件名。
193    pub filename: String,
194    /// EN: Content type sent for the file part.
195    /// 中文:文件分段发送的内容类型。
196    pub content_type: String,
197    content: Bytes,
198}
199
200impl ContainerFileUpload {
201    /// EN: Creates an upload from already available bytes without copying them.
202    /// 中文:通过已有字节创建上传对象,不复制这些字节。
203    pub fn from_bytes(
204        filename: impl Into<String>,
205        content: impl Into<Bytes>,
206    ) -> Result<Self, LingerError> {
207        let filename = filename.into();
208        validate_header_param("filename", &filename)?;
209        Ok(Self {
210            filename,
211            content_type: "application/octet-stream".to_string(),
212            content: content.into(),
213        })
214    }
215
216    /// EN: Sets the file part content type.
217    /// 中文:设置文件分段的内容类型。
218    pub fn content_type(mut self, content_type: impl Into<String>) -> Result<Self, LingerError> {
219        let content_type = content_type.into();
220        validate_header_value("content_type", &content_type)?;
221        self.content_type = content_type;
222        Ok(self)
223    }
224
225    /// EN: Returns the file bytes as a cheap `Bytes` clone.
226    /// 中文:以低成本 `Bytes` 克隆返回文件字节。
227    pub fn bytes(&self) -> Bytes {
228        self.content.clone()
229    }
230
231    fn multipart_stream(
232        &self,
233        boundary: String,
234    ) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
235        let chunks = vec![
236            Ok(Bytes::from(format!(
237                "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
238                escape_multipart_param(&self.filename),
239                self.content_type
240            ))),
241            Ok(self.content.clone()),
242            Ok(Bytes::from(format!("\r\n--{boundary}--\r\n"))),
243        ];
244        futures_util::stream::iter(chunks)
245    }
246}
247
248/// EN: Container object returned by the Containers API.
249/// 中文:Containers API 返回的容器对象。
250#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
251#[non_exhaustive]
252pub struct Container {
253    /// EN: Container id.
254    /// 中文:容器 ID。
255    pub id: String,
256    /// EN: API object type.
257    /// 中文:API 对象类型。
258    pub object: String,
259    /// EN: Unix timestamp for creation.
260    /// 中文:创建时间的 Unix 时间戳。
261    pub created_at: u64,
262    /// EN: Container name.
263    /// 中文:容器名称。
264    pub name: String,
265    /// EN: Container status.
266    /// 中文:容器状态。
267    pub status: String,
268    /// EN: Expiration policy, when returned.
269    /// 中文:响应中存在时的过期策略。
270    #[serde(default)]
271    pub expires_after: Option<Value>,
272    /// EN: Last active timestamp, when returned.
273    /// 中文:响应中存在时的最后活跃时间戳。
274    #[serde(default)]
275    pub last_active_at: Option<u64>,
276    /// EN: Configured memory limit, when returned.
277    /// 中文:响应中存在时配置的内存限制。
278    #[serde(default)]
279    pub memory_limit: Option<String>,
280    /// EN: Configured network policy, when returned.
281    /// 中文:响应中存在时配置的网络策略。
282    #[serde(default)]
283    pub network_policy: Option<Value>,
284    /// EN: Additional fields preserved for forward compatibility.
285    /// 中文:为前向兼容保留的额外字段。
286    #[serde(flatten)]
287    pub extra: BTreeMap<String, Value>,
288    /// EN: OpenAI request id from response headers.
289    /// 中文:响应头中的 OpenAI 请求 ID。
290    #[serde(skip)]
291    request_id: Option<RequestId>,
292}
293
294impl Container {
295    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
296        self.request_id = request_id;
297        self
298    }
299
300    /// EN: Returns the OpenAI request id, when present.
301    /// 中文:返回 OpenAI 请求 ID,如存在。
302    pub fn request_id(&self) -> Option<&RequestId> {
303        self.request_id.as_ref()
304    }
305}
306
307/// EN: Paginated container list returned by the Containers API.
308/// 中文:Containers API 返回的分页容器列表。
309#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
310#[non_exhaustive]
311pub struct ContainerPage {
312    /// EN: API list object type.
313    /// 中文:API 列表对象类型。
314    pub object: String,
315    /// EN: Containers on this page.
316    /// 中文:本页容器。
317    #[serde(default)]
318    pub data: Vec<Container>,
319    /// EN: First container id on this page.
320    /// 中文:本页第一个容器 ID。
321    #[serde(default)]
322    pub first_id: Option<String>,
323    /// EN: Last container id on this page.
324    /// 中文:本页最后一个容器 ID。
325    #[serde(default)]
326    pub last_id: Option<String>,
327    /// EN: Whether more containers are available.
328    /// 中文:是否还有更多容器。
329    pub has_more: bool,
330    /// EN: OpenAI request id from response headers.
331    /// 中文:响应头中的 OpenAI 请求 ID。
332    #[serde(skip)]
333    request_id: Option<RequestId>,
334}
335
336impl ContainerPage {
337    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
338        self.request_id = request_id;
339        self
340    }
341
342    /// EN: Returns the OpenAI request id, when present.
343    /// 中文:返回 OpenAI 请求 ID,如存在。
344    pub fn request_id(&self) -> Option<&RequestId> {
345        self.request_id.as_ref()
346    }
347}
348
349/// EN: Deletion result returned by the Containers API.
350/// 中文:Containers API 返回的删除结果。
351#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
352#[non_exhaustive]
353pub struct ContainerDeletion {
354    /// EN: Deleted container id.
355    /// 中文:已删除的容器 ID。
356    pub id: String,
357    /// EN: API object type.
358    /// 中文:API 对象类型。
359    pub object: String,
360    /// EN: Whether the container was deleted.
361    /// 中文:容器是否已删除。
362    pub deleted: bool,
363    /// EN: OpenAI request id from response headers.
364    /// 中文:响应头中的 OpenAI 请求 ID。
365    #[serde(skip)]
366    request_id: Option<RequestId>,
367}
368
369impl ContainerDeletion {
370    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
371        self.request_id = request_id;
372        self
373    }
374
375    /// EN: Returns the OpenAI request id, when present.
376    /// 中文:返回 OpenAI 请求 ID,如存在。
377    pub fn request_id(&self) -> Option<&RequestId> {
378        self.request_id.as_ref()
379    }
380}
381
382/// EN: Container file object returned by the Container Files API.
383/// 中文:Container Files API 返回的容器文件对象。
384#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
385#[non_exhaustive]
386pub struct ContainerFile {
387    /// EN: Container file id.
388    /// 中文:容器文件 ID。
389    pub id: String,
390    /// EN: API object type.
391    /// 中文:API 对象类型。
392    pub object: String,
393    /// EN: Unix timestamp for creation.
394    /// 中文:创建时间的 Unix 时间戳。
395    pub created_at: u64,
396    /// EN: File size in bytes.
397    /// 中文:文件大小,单位为字节。
398    pub bytes: u64,
399    /// EN: Parent container id.
400    /// 中文:父容器 ID。
401    pub container_id: String,
402    /// EN: File path inside the container.
403    /// 中文:文件在容器内的路径。
404    pub path: String,
405    /// EN: Source of the file.
406    /// 中文:文件来源。
407    pub source: String,
408    /// EN: Additional fields preserved for forward compatibility.
409    /// 中文:为前向兼容保留的额外字段。
410    #[serde(flatten)]
411    pub extra: BTreeMap<String, Value>,
412    /// EN: OpenAI request id from response headers.
413    /// 中文:响应头中的 OpenAI 请求 ID。
414    #[serde(skip)]
415    request_id: Option<RequestId>,
416}
417
418impl ContainerFile {
419    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
420        self.request_id = request_id;
421        self
422    }
423
424    /// EN: Returns the OpenAI request id, when present.
425    /// 中文:返回 OpenAI 请求 ID,如存在。
426    pub fn request_id(&self) -> Option<&RequestId> {
427        self.request_id.as_ref()
428    }
429}
430
431/// EN: Paginated container file list.
432/// 中文:分页容器文件列表。
433#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
434#[non_exhaustive]
435pub struct ContainerFilePage {
436    /// EN: API list object type.
437    /// 中文:API 列表对象类型。
438    pub object: String,
439    /// EN: Files on this page.
440    /// 中文:本页文件。
441    #[serde(default)]
442    pub data: Vec<ContainerFile>,
443    /// EN: First file id on this page.
444    /// 中文:本页第一个文件 ID。
445    #[serde(default)]
446    pub first_id: Option<String>,
447    /// EN: Last file id on this page.
448    /// 中文:本页最后一个文件 ID。
449    #[serde(default)]
450    pub last_id: Option<String>,
451    /// EN: Whether more files are available.
452    /// 中文:是否还有更多文件。
453    pub has_more: bool,
454    /// EN: OpenAI request id from response headers.
455    /// 中文:响应头中的 OpenAI 请求 ID。
456    #[serde(skip)]
457    request_id: Option<RequestId>,
458}
459
460impl ContainerFilePage {
461    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
462        self.request_id = request_id;
463        self
464    }
465
466    /// EN: Returns the OpenAI request id, when present.
467    /// 中文:返回 OpenAI 请求 ID,如存在。
468    pub fn request_id(&self) -> Option<&RequestId> {
469        self.request_id.as_ref()
470    }
471}
472
473/// EN: Deletion result returned by the Container Files API.
474/// 中文:Container Files API 返回的删除结果。
475#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
476#[non_exhaustive]
477pub struct ContainerFileDeletion {
478    /// EN: Deleted container file id.
479    /// 中文:已删除的容器文件 ID。
480    pub id: String,
481    /// EN: API object type.
482    /// 中文:API 对象类型。
483    pub object: String,
484    /// EN: Whether the container file was deleted.
485    /// 中文:容器文件是否已删除。
486    pub deleted: bool,
487    /// EN: OpenAI request id from response headers.
488    /// 中文:响应头中的 OpenAI 请求 ID。
489    #[serde(skip)]
490    request_id: Option<RequestId>,
491}
492
493impl ContainerFileDeletion {
494    pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
495        self.request_id = request_id;
496        self
497    }
498
499    /// EN: Returns the OpenAI request id, when present.
500    /// 中文:返回 OpenAI 请求 ID,如存在。
501    pub fn request_id(&self) -> Option<&RequestId> {
502        self.request_id.as_ref()
503    }
504}
505
506/// EN: Incremental container file content response.
507/// 中文:增量容器文件内容响应。
508pub struct ContainerFileContent {
509    request_id: Option<RequestId>,
510    body: BodyStream,
511}
512
513impl fmt::Debug for ContainerFileContent {
514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515        f.debug_struct("ContainerFileContent")
516            .field("request_id", &self.request_id)
517            .field("body", &"<stream>")
518            .finish()
519    }
520}
521
522impl ContainerFileContent {
523    pub(crate) fn new(request_id: Option<RequestId>, body: BodyStream) -> Self {
524        Self { request_id, body }
525    }
526
527    /// EN: Returns the OpenAI request id, when present.
528    /// 中文:返回 OpenAI 请求 ID,如存在。
529    pub fn request_id(&self) -> Option<&RequestId> {
530        self.request_id.as_ref()
531    }
532
533    /// EN: Consumes this response and returns the incremental content stream.
534    /// 中文:消耗此响应并返回增量内容流。
535    pub fn into_stream(self) -> BodyStream {
536        self.body
537    }
538}
539
540fn required_string(name: &str, value: Option<String>) -> Result<String, LingerError> {
541    value
542        .filter(|value| !value.trim().is_empty())
543        .ok_or_else(|| LingerError::invalid_config(format!("{name} is required")))
544}
545
546fn validate_non_empty_values(
547    name: &str,
548    values: &[String],
549    require_non_empty: bool,
550) -> Result<(), LingerError> {
551    if require_non_empty && values.is_empty() {
552        return Err(LingerError::invalid_config(format!("{name} is required")));
553    }
554    if values.iter().any(|value| value.trim().is_empty()) {
555        return Err(LingerError::invalid_config(format!(
556            "{name} must not contain empty values"
557        )));
558    }
559    Ok(())
560}
561
562fn validate_extra_fields(extra: &BTreeMap<String, Value>) -> Result<(), LingerError> {
563    for (key, value) in extra {
564        if key.trim().is_empty() {
565            return Err(LingerError::invalid_config(
566                "extra field names must not be empty",
567            ));
568        }
569        if value.is_null() {
570            return Err(LingerError::invalid_config(format!(
571                "extra field {key} must not be null"
572            )));
573        }
574    }
575    Ok(())
576}
577
578fn multipart_boundary(content: &Bytes) -> String {
579    for counter in 0.. {
580        let boundary = format!("linger-openai-sdk-boundary-{counter}");
581        if !contains_bytes(content, boundary.as_bytes()) {
582            return boundary;
583        }
584    }
585    unreachable!("unbounded boundary counter")
586}
587
588fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
589    if needle.is_empty() {
590        return true;
591    }
592    haystack
593        .windows(needle.len())
594        .any(|window| window == needle)
595}
596
597fn validate_header_param(name: &str, value: &str) -> Result<(), LingerError> {
598    if value.trim().is_empty() {
599        return Err(LingerError::invalid_config(format!("{name} is required")));
600    }
601    validate_header_value(name, value)
602}
603
604fn validate_header_value(name: &str, value: &str) -> Result<(), LingerError> {
605    if value.contains('\r') || value.contains('\n') {
606        return Err(LingerError::invalid_config(format!(
607            "{name} must not contain CR or LF"
608        )));
609    }
610    Ok(())
611}
612
613fn escape_multipart_param(value: &str) -> String {
614    value.replace('\\', "\\\\").replace('"', "\\\"")
615}