1use std::path::Path;
40
41use reqwest::multipart::{Form, Part};
42use serde::{Deserialize, Serialize};
43
44use crate::config::OpenAIClient;
45use crate::error::OpenAIError;
46
47#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum UploadFilePurpose {
54 FineTune,
56 Other(String),
58}
59
60impl std::fmt::Display for UploadFilePurpose {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 UploadFilePurpose::FineTune => write!(f, "fine-tune"),
64 UploadFilePurpose::Other(val) => write!(f, "{val}"),
65 }
66 }
67}
68
69#[derive(Debug, Deserialize)]
74pub struct FileObject {
75 pub id: String,
77 pub object: String,
79 pub bytes: u64,
81 pub created_at: u64,
83 pub filename: String,
85 pub purpose: String,
87 pub status: Option<String>,
89 pub status_details: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
95pub struct FileListResponse {
96 pub object: String,
98 pub data: Vec<FileObject>,
100}
101
102#[derive(Debug, Deserialize)]
104pub struct DeleteFileResponse {
105 pub id: String,
107 pub object: String,
109 pub deleted: bool,
111}
112
113pub async fn upload_file(
133 client: &OpenAIClient,
134 file_path: &Path,
135 purpose: UploadFilePurpose,
136) -> Result<FileObject, OpenAIError> {
137 let endpoint = "files";
138 let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
139
140 let file_bytes = tokio::fs::read(file_path)
142 .await
143 .map_err(|e| OpenAIError::ConfigError(format!("Failed to read file: {}", e)))?;
144 let filename = file_path
145 .file_name()
146 .map(|os| os.to_string_lossy().into_owned())
147 .unwrap_or_else(|| "upload.bin".to_string());
148
149 let file_part = Part::bytes(file_bytes)
150 .file_name(filename)
151 .mime_str("application/octet-stream")
152 .unwrap_or_else(|_| {
153 Part::bytes(Vec::new()).file_name("default.bin")
155 });
156
157 let form = Form::new()
159 .part("file", file_part)
160 .text("purpose", purpose.to_string());
161
162 let response = client
164 .http_client
165 .post(&url)
166 .bearer_auth(client.api_key())
167 .multipart(form)
168 .send()
169 .await?;
170
171 handle_file_response(response).await
172}
173
174pub async fn list_files(client: &OpenAIClient) -> Result<FileListResponse, OpenAIError> {
182 let endpoint = "files";
183 let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
184
185 let response = client
186 .http_client
187 .get(&url)
188 .bearer_auth(client.api_key())
189 .send()
190 .await?;
191
192 let status = response.status();
193 if status.is_success() {
194 let files = response.json::<FileListResponse>().await?;
195 Ok(files)
196 } else {
197 crate::api::parse_error_response(response).await
198 }
199}
200
201pub async fn retrieve_file_metadata(
209 client: &OpenAIClient,
210 file_id: &str,
211) -> Result<FileObject, OpenAIError> {
212 let endpoint = format!("files/{}", file_id);
213 let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
214
215 let response = client
216 .http_client
217 .get(&url)
218 .bearer_auth(client.api_key())
219 .send()
220 .await?;
221
222 handle_file_response(response).await
223}
224
225pub async fn retrieve_file_content(
236 client: &OpenAIClient,
237 file_id: &str,
238) -> Result<Vec<u8>, OpenAIError> {
239 let endpoint = format!("files/{}/content", file_id);
242 let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
243
244 let response = client
245 .http_client
246 .get(&url)
247 .bearer_auth(client.api_key())
248 .send()
249 .await?;
250
251 if response.status().is_success() {
252 let bytes = response.bytes().await?;
253 Ok(bytes.to_vec())
254 } else {
255 crate::api::parse_error_response(response).await
256 }
257}
258
259pub async fn delete_file(
268 client: &OpenAIClient,
269 file_id: &str,
270) -> Result<DeleteFileResponse, OpenAIError> {
271 let endpoint = format!("files/{}", file_id);
272 let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
273
274 let response = client
275 .http_client
276 .delete(&url)
277 .bearer_auth(client.api_key())
278 .send()
279 .await?;
280
281 let status = response.status();
282 if status.is_success() {
283 let res_body = response.json::<DeleteFileResponse>().await?;
284 Ok(res_body)
285 } else {
286 crate::api::parse_error_response(response).await
287 }
288}
289
290async fn handle_file_response(response: reqwest::Response) -> Result<FileObject, OpenAIError> {
292 let status = response.status();
293 if status.is_success() {
294 let file_obj = response.json::<FileObject>().await?;
295 Ok(file_obj)
296 } else {
297 crate::api::parse_error_response(response).await
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
316 use crate::config::OpenAIClient;
317 use crate::error::OpenAIError;
318 use serde_json::json;
319 use std::io::Write as _;
320 use tempfile::NamedTempFile;
321 use wiremock::matchers::{method, path, path_regex};
322 use wiremock::{Mock, MockServer, ResponseTemplate};
323
324 fn create_temp_file(contents: &str) -> NamedTempFile {
327 let mut file = NamedTempFile::new().expect("Failed to create temp file");
328 write!(file, "{}", contents).expect("Unable to write to temp file");
329 file
330 }
331
332 #[tokio::test]
333 async fn test_upload_file_success() {
334 let mock_server = MockServer::start().await;
336
337 let success_body = json!({
339 "id": "file-abc123",
340 "object": "file",
341 "bytes": 1024,
342 "created_at": 1673643147,
343 "filename": "mydata.jsonl",
344 "purpose": "fine-tune",
345 "status": "uploaded",
346 "status_details": null
347 });
348
349 Mock::given(method("POST"))
350 .and(path("/files"))
351 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
352 .mount(&mock_server)
353 .await;
354
355 let client = OpenAIClient::builder()
357 .with_api_key("test-key")
358 .with_base_url(&mock_server.uri())
359 .build()
360 .unwrap();
361
362 let temp_file = create_temp_file("some jsonl contents");
364 let result = upload_file(&client, temp_file.path(), UploadFilePurpose::FineTune).await;
365 assert!(result.is_ok(), "Expected success, got: {:?}", result);
366
367 let file_obj = result.unwrap();
368 assert_eq!(file_obj.id, "file-abc123");
369 assert_eq!(file_obj.object, "file");
370 assert_eq!(file_obj.bytes, 1024);
371 assert_eq!(file_obj.filename, "mydata.jsonl");
372 assert_eq!(file_obj.purpose, "fine-tune");
373 assert_eq!(file_obj.status.as_deref(), Some("uploaded"));
374 }
375
376 #[tokio::test]
377 async fn test_upload_file_api_error() {
378 let mock_server = MockServer::start().await;
379
380 let error_body = json!({
381 "error": {
382 "message": "File size too large",
383 "type": "invalid_request_error",
384 "code": "file_size_exceeded"
385 }
386 });
387
388 Mock::given(method("POST"))
390 .and(path("/files"))
391 .respond_with(ResponseTemplate::new(400).set_body_json(error_body))
392 .mount(&mock_server)
393 .await;
394
395 let client = OpenAIClient::builder()
396 .with_api_key("test-key")
397 .with_base_url(&mock_server.uri())
398 .build()
399 .unwrap();
400
401 let temp_file = create_temp_file("some jsonl contents");
402 let result = upload_file(&client, temp_file.path(), UploadFilePurpose::FineTune).await;
403
404 match result {
405 Err(OpenAIError::APIError { message, .. }) => {
406 assert!(message.contains("File size too large"));
407 }
408 other => panic!("Expected APIError, got {:?}", other),
409 }
410 }
411
412 #[tokio::test]
413 async fn test_upload_file_config_error_when_file_missing() {
414 let mock_server = MockServer::start().await;
416 let client = OpenAIClient::builder()
417 .with_api_key("test-key")
418 .with_base_url(&mock_server.uri())
419 .build()
420 .unwrap();
421
422 let non_existent_path = std::path::Path::new("/some/path/that/does/not/exist.jsonl");
423 let result = upload_file(&client, non_existent_path, UploadFilePurpose::FineTune).await;
424 match result {
425 Err(OpenAIError::ConfigError(msg)) => {
426 assert!(
427 msg.contains("Failed to read file:"),
428 "Expected a file read error, got: {}",
429 msg
430 );
431 }
432 other => panic!("Expected ConfigError, got {:?}", other),
433 }
434 }
435
436 #[tokio::test]
437 async fn test_list_files_success() {
438 let mock_server = MockServer::start().await;
439
440 let success_body = json!({
441 "object": "list",
442 "data": [
443 {
444 "id": "file-xyz789",
445 "object": "file",
446 "bytes": 999,
447 "created_at": 1673644000,
448 "filename": "data.jsonl",
449 "purpose": "fine-tune",
450 "status": "uploaded",
451 "status_details": null
452 }
453 ]
454 });
455
456 Mock::given(method("GET"))
457 .and(path("/files"))
458 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
459 .mount(&mock_server)
460 .await;
461
462 let client = OpenAIClient::builder()
463 .with_api_key("test-key")
464 .with_base_url(&mock_server.uri())
465 .build()
466 .unwrap();
467
468 let result = list_files(&client).await;
469 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
470
471 let files = result.unwrap();
472 assert_eq!(files.object, "list");
473 assert_eq!(files.data.len(), 1);
474 let file_obj = &files.data[0];
475 assert_eq!(file_obj.id, "file-xyz789");
476 assert_eq!(file_obj.bytes, 999);
477 }
478
479 #[tokio::test]
480 async fn test_list_files_api_error() {
481 let mock_server = MockServer::start().await;
482 let error_body = json!({
483 "error": {
484 "message": "Could not list files",
485 "type": "internal_server_error",
486 "code": null
487 }
488 });
489
490 Mock::given(method("GET"))
491 .and(path("/files"))
492 .respond_with(ResponseTemplate::new(500).set_body_json(error_body))
493 .mount(&mock_server)
494 .await;
495
496 let client = OpenAIClient::builder()
497 .with_api_key("test-key")
498 .with_base_url(&mock_server.uri())
499 .build()
500 .unwrap();
501
502 let result = list_files(&client).await;
503 match result {
504 Err(OpenAIError::APIError { message, .. }) => {
505 assert!(message.contains("Could not list files"));
506 }
507 other => panic!("Expected APIError, got {:?}", other),
508 }
509 }
510
511 #[tokio::test]
512 async fn test_retrieve_file_metadata_success() {
513 let mock_server = MockServer::start().await;
514 let success_body = json!({
515 "id": "file-abc123",
516 "object": "file",
517 "bytes": 2048,
518 "created_at": 1673645000,
519 "filename": "info.jsonl",
520 "purpose": "fine-tune",
521 "status": "uploaded",
522 "status_details": null
523 });
524
525 Mock::given(method("GET"))
526 .and(path_regex(r"^/files/file-abc123$"))
527 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
528 .mount(&mock_server)
529 .await;
530
531 let client = OpenAIClient::builder()
532 .with_api_key("test-key")
533 .with_base_url(&mock_server.uri())
534 .build()
535 .unwrap();
536
537 let result = retrieve_file_metadata(&client, "file-abc123").await;
538 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
539
540 let file_obj = result.unwrap();
541 assert_eq!(file_obj.id, "file-abc123");
542 assert_eq!(file_obj.bytes, 2048);
543 assert_eq!(file_obj.filename, "info.jsonl");
544 }
545
546 #[tokio::test]
547 async fn test_retrieve_file_metadata_api_error() {
548 let mock_server = MockServer::start().await;
549 let error_body = json!({
550 "error": {
551 "message": "File not found",
552 "type": "invalid_request_error",
553 "code": null
554 }
555 });
556
557 Mock::given(method("GET"))
558 .and(path_regex(r"^/files/file-999$"))
559 .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
560 .mount(&mock_server)
561 .await;
562
563 let client = OpenAIClient::builder()
564 .with_api_key("test-key")
565 .with_base_url(&mock_server.uri())
566 .build()
567 .unwrap();
568
569 let result = retrieve_file_metadata(&client, "file-999").await;
570 match result {
571 Err(OpenAIError::APIError { message, .. }) => {
572 assert!(message.contains("File not found"));
573 }
574 other => panic!("Expected APIError, got {:?}", other),
575 }
576 }
577
578 #[tokio::test]
579 async fn test_retrieve_file_content_success() {
580 let mock_server = MockServer::start().await;
581 let file_data = b"this is the file content";
582
583 Mock::given(method("GET"))
584 .and(path_regex(r"^/files/file-abc123/content$"))
585 .respond_with(
586 ResponseTemplate::new(200).set_body_raw(file_data, "application/octet-stream"),
587 )
588 .mount(&mock_server)
589 .await;
590
591 let client = OpenAIClient::builder()
592 .with_api_key("test-key")
593 .with_base_url(&mock_server.uri())
594 .build()
595 .unwrap();
596
597 let result = retrieve_file_content(&client, "file-abc123").await;
598 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
599
600 let content = result.unwrap();
601 assert_eq!(content, file_data);
602 }
603
604 #[tokio::test]
605 async fn test_retrieve_file_content_api_error() {
606 let mock_server = MockServer::start().await;
607 let error_body = json!({
608 "error": {
609 "message": "Content not found",
610 "type": "invalid_request_error",
611 "code": null
612 }
613 });
614
615 Mock::given(method("GET"))
616 .and(path_regex(r"^/files/file-000/content$"))
617 .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
618 .mount(&mock_server)
619 .await;
620
621 let client = OpenAIClient::builder()
622 .with_api_key("test-key")
623 .with_base_url(&mock_server.uri())
624 .build()
625 .unwrap();
626
627 let result = retrieve_file_content(&client, "file-000").await;
628 match result {
629 Err(OpenAIError::APIError { message, .. }) => {
630 assert!(message.contains("Content not found"));
631 }
632 other => panic!("Expected APIError, got {:?}", other),
633 }
634 }
635
636 #[tokio::test]
637 async fn test_delete_file_success() {
638 let mock_server = MockServer::start().await;
639 let success_body = json!({
640 "id": "file-abc123",
641 "object": "file",
642 "deleted": true
643 });
644
645 Mock::given(method("DELETE"))
646 .and(path_regex(r"^/files/file-abc123$"))
647 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
648 .mount(&mock_server)
649 .await;
650
651 let client = OpenAIClient::builder()
652 .with_api_key("test-key")
653 .with_base_url(&mock_server.uri())
654 .build()
655 .unwrap();
656
657 let result = delete_file(&client, "file-abc123").await;
658 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
659
660 let del_resp = result.unwrap();
661 assert_eq!(del_resp.id, "file-abc123");
662 assert_eq!(del_resp.object, "file");
663 assert!(del_resp.deleted);
664 }
665
666 #[tokio::test]
667 async fn test_delete_file_api_error() {
668 let mock_server = MockServer::start().await;
669 let error_body = json!({
670 "error": {
671 "message": "No file with ID file-xyz found",
672 "type": "invalid_request_error",
673 "code": null
674 }
675 });
676
677 Mock::given(method("DELETE"))
678 .and(path_regex(r"^/files/file-xyz$"))
679 .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
680 .mount(&mock_server)
681 .await;
682
683 let client = OpenAIClient::builder()
684 .with_api_key("test-key")
685 .with_base_url(&mock_server.uri())
686 .build()
687 .unwrap();
688
689 let result = delete_file(&client, "file-xyz").await;
690 match result {
691 Err(OpenAIError::APIError { message, .. }) => {
692 assert!(message.contains("No file with ID file-xyz found"));
693 }
694 other => panic!("Expected APIError, got {:?}", other),
695 }
696 }
697}