linger-openai-sdk 0.1.1

Rust-native async SDK for OpenAI APIs with typed requests, streaming, uploads, retries, and pluggable transports.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
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;

/// EN: Request body descriptor for `POST /v1/files`.
/// 中文:`POST /v1/files` 的请求体描述。
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateFileRequest {
    /// EN: File content and filename metadata.
    /// 中文:文件内容和文件名元数据。
    pub file: FileUpload,
    /// EN: OpenAI file purpose, such as `fine-tune`, `assistants`, or `batch`.
    /// 中文:OpenAI 文件用途,例如 `fine-tune`、`assistants` 或 `batch`。
    pub purpose: String,
    /// EN: Optional expiration policy for supported purposes.
    /// 中文:受支持用途的可选过期策略。
    pub expires_after: Option<FileExpirationPolicy>,
}

impl CreateFileRequest {
    /// EN: Starts building a file upload request.
    /// 中文:开始构建文件上传请求。
    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)
    }
}

/// EN: Builder for file upload requests.
/// 中文:文件上传请求的构建器。
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateFileRequestBuilder {
    file: Option<FileUpload>,
    purpose: Option<String>,
    expires_after: Option<FileExpirationPolicy>,
}

impl CreateFileRequestBuilder {
    /// EN: Sets the file to upload.
    /// 中文:设置要上传的文件。
    pub fn file(mut self, file: FileUpload) -> Self {
        self.file = Some(file);
        self
    }

    /// EN: Sets the file purpose.
    /// 中文:设置文件用途。
    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
        self.purpose = Some(purpose.into());
        self
    }

    /// EN: Sets the optional expiration policy.
    /// 中文:设置可选的过期策略。
    pub fn expires_after(mut self, expires_after: FileExpirationPolicy) -> Self {
        self.expires_after = Some(expires_after);
        self
    }

    /// EN: Builds and validates the request.
    /// 中文:构建并校验请求。
    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,
        })
    }
}

/// EN: Uploadable file bytes and multipart metadata.
/// 中文:可上传文件字节及 multipart 元数据。
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileUpload {
    /// EN: Filename sent in the multipart part.
    /// 中文:multipart 分段中发送的文件名。
    pub filename: String,
    /// EN: Content type sent for the file part.
    /// 中文:文件分段发送的内容类型。
    pub content_type: String,
    content: Bytes,
}

impl FileUpload {
    /// EN: Creates an upload from already available bytes without copying them.
    /// 中文:通过已可用字节创建上传对象,不复制这些字节。
    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(),
        })
    }

    /// EN: Sets the file part content type.
    /// 中文:设置文件分段的内容类型。
    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)
    }

    /// EN: Returns the file bytes as a cheap `Bytes` clone.
    /// 中文:以廉价 `Bytes` 克隆返回文件字节。
    pub fn bytes(&self) -> Bytes {
        self.content.clone()
    }
}

/// EN: File expiration policy sent as multipart fields.
/// 中文:作为 multipart 字段发送的文件过期策略。
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileExpirationPolicy {
    /// EN: Expiration anchor, for example `created_at`.
    /// 中文:过期锚点,例如 `created_at`。
    pub anchor: String,
    /// EN: Expiration offset in seconds.
    /// 中文:过期偏移秒数。
    pub seconds: u64,
}

impl FileExpirationPolicy {
    /// EN: Creates an expiration policy.
    /// 中文:创建过期策略。
    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()
    }
}

/// EN: File object returned by the Files API.
/// 中文:Files API 返回的文件对象。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct FileObject {
    /// EN: File id.
    /// 中文:文件 ID。
    pub id: String,
    /// EN: API object type.
    /// 中文:API 对象类型。
    pub object: String,
    /// EN: File size in bytes.
    /// 中文:文件大小,单位为字节。
    pub bytes: u64,
    /// EN: Unix timestamp for creation.
    /// 中文:创建时间的 Unix 时间戳。
    pub created_at: u64,
    /// EN: Unix timestamp for expiration, when returned.
    /// 中文:过期时间的 Unix 时间戳,如响应中存在。
    #[serde(default)]
    pub expires_at: Option<u64>,
    /// EN: Original filename.
    /// 中文:原始文件名。
    pub filename: String,
    /// EN: File purpose.
    /// 中文:文件用途。
    pub purpose: String,
    /// EN: Additional fields preserved for forward compatibility.
    /// 中文:为前向兼容保留的额外字段。
    #[serde(flatten)]
    pub extra: BTreeMap<String, Value>,
    /// EN: OpenAI request id from response headers.
    /// 中文:响应头中的 OpenAI 请求 ID。
    #[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
    }

    /// EN: Returns the OpenAI request id, when present.
    /// 中文:返回 OpenAI 请求 ID,如存在。
    pub fn request_id(&self) -> Option<&RequestId> {
        self.request_id.as_ref()
    }
}

/// EN: Paginated file list returned by the Files API.
/// 中文:Files API 返回的分页文件列表。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct FilesPage {
    /// EN: API list object type.
    /// 中文:API 列表对象类型。
    pub object: String,
    /// EN: Files on this page.
    /// 中文:本页文件。
    #[serde(default)]
    pub data: Vec<FileObject>,
    /// EN: First file id on this page.
    /// 中文:本页第一个文件 ID。
    #[serde(default)]
    pub first_id: Option<String>,
    /// EN: Last file id on this page.
    /// 中文:本页最后一个文件 ID。
    #[serde(default)]
    pub last_id: Option<String>,
    /// EN: Whether more files are available.
    /// 中文:是否还有更多文件。
    pub has_more: bool,
    /// EN: OpenAI request id from response headers.
    /// 中文:响应头中的 OpenAI 请求 ID。
    #[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
    }

    /// EN: Returns the OpenAI request id, when present.
    /// 中文:返回 OpenAI 请求 ID,如存在。
    pub fn request_id(&self) -> Option<&RequestId> {
        self.request_id.as_ref()
    }
}

/// EN: Deletion result returned by the Files API.
/// 中文:Files API 返回的删除结果。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileDeletion {
    /// EN: Deleted file id.
    /// 中文:已删除的文件 ID。
    pub id: String,
    /// EN: API object type.
    /// 中文:API 对象类型。
    pub object: String,
    /// EN: Whether the file was deleted.
    /// 中文:文件是否已删除。
    pub deleted: bool,
    /// EN: OpenAI request id from response headers.
    /// 中文:响应头中的 OpenAI 请求 ID。
    #[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
    }

    /// EN: Returns the OpenAI request id, when present.
    /// 中文:返回 OpenAI 请求 ID,如存在。
    pub fn request_id(&self) -> Option<&RequestId> {
        self.request_id.as_ref()
    }
}

/// EN: Incremental file content response.
/// 中文:增量文件内容响应。
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 }
    }

    /// EN: Returns the OpenAI request id, when present.
    /// 中文:返回 OpenAI 请求 ID,如存在。
    pub fn request_id(&self) -> Option<&RequestId> {
        self.request_id.as_ref()
    }

    /// EN: Consumes this response and returns the incremental content stream.
    /// 中文:消耗此响应并返回增量内容流。
    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('"', "\\\"")
}