Skip to main content

sie_sdk/client/
files.rs

1//! `/v1/files`: the OpenAI-compatible file store that feeds offline batches.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use reqwest::Method;
7
8use crate::client::{Client, meta::parse_json};
9use crate::error::{Error, Result};
10use crate::http::{PreparedRequest, headers};
11use crate::retry::RetryPolicy;
12use crate::types::{File, FileDeleted, FileList};
13
14/// Upload and batch operations get a longer floor than the client timeout: they move
15/// whole files, not single requests.
16pub(crate) const TRANSFER_TIMEOUT_FLOOR: Duration = Duration::from_mins(2);
17
18/// Sort order for a listing.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SortOrder {
21    /// Oldest first.
22    Ascending,
23    /// Newest first.
24    Descending,
25}
26
27impl SortOrder {
28    fn as_str(self) -> &'static str {
29        match self {
30            Self::Ascending => "asc",
31            Self::Descending => "desc",
32        }
33    }
34}
35
36/// The files namespace. Obtain one with [`Client::files`].
37#[derive(Debug, Clone)]
38pub struct Files {
39    client: Client,
40}
41
42impl Client {
43    /// Operations on uploaded files.
44    pub fn files(&self) -> Files {
45        Files {
46            client: self.clone(),
47        }
48    }
49}
50
51impl Files {
52    /// Upload bytes already in memory.
53    pub fn upload(&self, data: impl Into<Vec<u8>>) -> FileUpload {
54        FileUpload {
55            client: self.client.clone(),
56            source: UploadSource::Bytes(data.into()),
57            filename: None,
58            purpose: "batch".to_string(),
59        }
60    }
61
62    /// Upload a file from disk. The filename defaults to the path's basename.
63    pub fn upload_path(&self, path: impl Into<PathBuf>) -> FileUpload {
64        FileUpload {
65            client: self.client.clone(),
66            source: UploadSource::Path(path.into()),
67            filename: None,
68            purpose: "batch".to_string(),
69        }
70    }
71
72    /// Fetch one file's metadata.
73    pub async fn retrieve(&self, file_id: &str) -> Result<File> {
74        let request = self
75            .client
76            .request(Method::GET, &format!("/v1/files/{file_id}"))?
77            .header("accept", headers::JSON_CONTENT_TYPE);
78        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
79        parse_json(&response, "file")
80    }
81
82    /// List files.
83    pub fn list(&self) -> FileListRequest {
84        FileListRequest {
85            client: self.client.clone(),
86            after: None,
87            limit: None,
88            order: None,
89            purpose: None,
90        }
91    }
92
93    /// Download a file's contents.
94    pub async fn content(&self, file_id: &str) -> Result<bytes::Bytes> {
95        let request = self
96            .client
97            .request(Method::GET, &format!("/v1/files/{file_id}/content"))?
98            .header("accept", headers::JSONL_CONTENT_TYPE);
99        Ok(self
100            .client
101            .send_once(request, RetryPolicy::NONE)
102            .await?
103            .body)
104    }
105
106    /// Delete a file.
107    pub async fn delete(&self, file_id: &str) -> Result<FileDeleted> {
108        let request = self
109            .client
110            .request(Method::DELETE, &format!("/v1/files/{file_id}"))?
111            .header("accept", headers::JSON_CONTENT_TYPE);
112        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
113        parse_json(&response, "file deletion")
114    }
115}
116
117#[derive(Debug, Clone)]
118enum UploadSource {
119    Bytes(Vec<u8>),
120    Path(PathBuf),
121}
122
123/// Uploads a file. Build with [`Files::upload`] or [`Files::upload_path`].
124#[derive(Debug, Clone)]
125pub struct FileUpload {
126    client: Client,
127    source: UploadSource,
128    filename: Option<String>,
129    purpose: String,
130}
131
132impl FileUpload {
133    /// Name recorded for the file. Defaults to the path's basename, or `upload.jsonl`.
134    pub fn filename(mut self, filename: impl Into<String>) -> Self {
135        self.filename = Some(filename.into());
136        self
137    }
138
139    /// What the file is for. Defaults to `batch`.
140    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
141        self.purpose = purpose.into();
142        self
143    }
144
145    /// Send the upload.
146    ///
147    /// The API takes the raw bytes as the request body, with the metadata in the query
148    /// string; there is no multipart form anywhere in it.
149    pub async fn send(self) -> Result<File> {
150        let (data, default_name) = match &self.source {
151            UploadSource::Bytes(data) => (data.clone(), "upload.jsonl".to_string()),
152            UploadSource::Path(path) => {
153                let data = std::fs::read(path).map_err(|err| {
154                    Error::Io(std::io::Error::new(
155                        err.kind(),
156                        format!("could not read {}: {err}", path.display()),
157                    ))
158                })?;
159                (data, basename(path))
160            }
161        };
162        let filename = self.filename.unwrap_or(default_name);
163
164        let mut url = self.client.url("/v1/files")?;
165        url.query_pairs_mut()
166            .append_pair("purpose", &self.purpose)
167            .append_pair("filename", &filename);
168
169        let request = PreparedRequest::new(Method::POST, url)
170            .header("content-type", headers::JSONL_CONTENT_TYPE)
171            .header("accept", headers::JSON_CONTENT_TYPE)
172            .body(data);
173
174        let response = self
175            .client
176            .send_with_timeout(request, RetryPolicy::NONE, TRANSFER_TIMEOUT_FLOOR)
177            .await?;
178        parse_json(&response, "file")
179    }
180}
181
182/// A filename never carries a directory: a caller-supplied path must not become one
183/// server-side.
184fn basename(path: &Path) -> String {
185    path.file_name().map_or_else(
186        || "upload.jsonl".to_string(),
187        |name| name.to_string_lossy().into_owned(),
188    )
189}
190
191/// Lists files. Build with [`Files::list`].
192#[derive(Debug, Clone)]
193pub struct FileListRequest {
194    client: Client,
195    after: Option<String>,
196    limit: Option<u32>,
197    order: Option<SortOrder>,
198    purpose: Option<String>,
199}
200
201impl FileListRequest {
202    /// Start after this file id.
203    pub fn after(mut self, after: impl Into<String>) -> Self {
204        self.after = Some(after.into());
205        self
206    }
207
208    /// Cap on the page size.
209    pub fn limit(mut self, limit: u32) -> Self {
210        self.limit = Some(limit);
211        self
212    }
213
214    /// Sort order.
215    pub fn order(mut self, order: SortOrder) -> Self {
216        self.order = Some(order);
217        self
218    }
219
220    /// Only files uploaded for this purpose.
221    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
222        self.purpose = Some(purpose.into());
223        self
224    }
225
226    /// Fetch one page, with its pagination cursors.
227    pub async fn page(self) -> Result<FileList> {
228        let mut url = self.client.url("/v1/files")?;
229        {
230            let mut query = url.query_pairs_mut();
231            if let Some(after) = &self.after {
232                query.append_pair("after", after);
233            }
234            if let Some(limit) = self.limit {
235                query.append_pair("limit", &limit.to_string());
236            }
237            if let Some(order) = self.order {
238                query.append_pair("order", order.as_str());
239            }
240            if let Some(purpose) = &self.purpose {
241                query.append_pair("purpose", purpose);
242            }
243        }
244
245        let request =
246            PreparedRequest::new(Method::GET, url).header("accept", headers::JSON_CONTENT_TYPE);
247        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
248
249        // Older gateways answer with a bare array; synthesize the envelope around it.
250        if let Ok(files) = serde_json::from_slice::<Vec<File>>(&response.body) {
251            return Ok(FileList {
252                object_kind: "list".to_string(),
253                first_id: files.first().map(|file| file.id.clone()),
254                last_id: files.last().map(|file| file.id.clone()),
255                has_more: false,
256                data: files,
257            });
258        }
259        parse_json(&response, "file list")
260    }
261
262    /// Fetch one page and return only its files.
263    pub async fn send(self) -> Result<Vec<File>> {
264        Ok(self.page().await?.data)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn a_path_contributes_only_its_basename() {
274        assert_eq!(basename(Path::new("/var/data/in.jsonl")), "in.jsonl");
275        assert_eq!(basename(Path::new("in.jsonl")), "in.jsonl");
276        assert_eq!(basename(Path::new("/")), "upload.jsonl");
277    }
278
279    #[tokio::test]
280    async fn a_missing_upload_path_fails_before_any_request() {
281        let client = Client::new("https://sie.invalid").unwrap();
282        let err = client
283            .files()
284            .upload_path("/nonexistent/sie-sdk/in.jsonl")
285            .send()
286            .await
287            .unwrap_err();
288        assert!(
289            err.to_string().contains("/nonexistent/sie-sdk/in.jsonl"),
290            "{err}"
291        );
292    }
293
294    #[test]
295    fn sort_order_renders_the_wire_tokens() {
296        assert_eq!(SortOrder::Ascending.as_str(), "asc");
297        assert_eq!(SortOrder::Descending.as_str(), "desc");
298    }
299}