openai-interface 0.10.0

A low-level Rust interface for the OpenAI API
Documentation
//! Create a `CreateFileRequest` object for uploading a file.

use serde::Serialize;
use std::path::PathBuf;
use url::Url;

use crate::errors::OapiError;
use crate::rest::post::{Post, PostNoStream};

/// Upload a file that can be used across various endpoints.
///
/// Individual files can be up to 512 MB, and the size of all files uploaded by one
/// organization can be up to 1 TB.
///
/// The Assistants API supports files up to 2 million tokens and of specific file
/// types. See the
/// [OpenAI Assistants Tools guide](https://platform.openai.com/docs/assistants/tools) for
/// details.
///
/// The Fine-tuning API only supports `.jsonl` files. The input also has certain
/// required formats for fine-tuning
/// [chat](https://platform.openai.com/docs/api-reference/fine-tuning/chat-input) or
/// [completions](https://platform.openai.com/docs/api-reference/fine-tuning/completions-input)
/// models.
///
/// The Batch API only supports `.jsonl` files up to 200 MB in size. The input also
/// has a specific required
/// [format](https://platform.openai.com/docs/api-reference/batch/request-input).
///
/// Please [contact OpenAI](https://help.openai.com/) if you need to increase these
/// storage limits.
#[derive(Debug, Serialize, Clone, Default)]
pub struct CreateFileRequest {
    /// The File object (not file name) to be uploaded.
    #[serde(skip_serializing)]
    pub file: PathBuf,
    /// The intended purpose of the uploaded file. One of: - `assistants`: Used in the
    /// Assistants API - `batch`: Used in the Batch API - `fine-tune`: Used for
    /// fine-tuning - `vision`: Images used for vision fine-tuning - `user_data`:
    /// Flexible file type for any purpose - `evals`: Used for eval data sets
    pub purpose: FilePurpose,
    /// The expiration policy for a file. By default, files with `purpose=batch` expire
    /// after 30 days and all other files are persisted until they are manually deleted.
    ///
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_after: Option<ExpiresAfter>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
}

#[derive(Debug, Serialize, Clone, Default)]
pub enum FilePurpose {
    #[serde(rename = "assistants")]
    Assistants,
    #[serde(rename = "batch")]
    #[default]
    Batch,
    #[serde(rename = "fine-tune")]
    FineTune,
    #[serde(rename = "vision")]
    Vision,
    #[serde(rename = "user_data")]
    UserData,
    #[serde(rename = "evals")]
    Evals,
    #[serde(untagged)]
    Other(String),
}

// #[derive(Debug, Serialize, Clone)]
// pub enum FileTypes {
//     /// file (or bytes)
//     FileContent(Vec<u8>),
//     /// (filename, file (or bytes))
//     FileNameAndContent(String, Vec<u8>),
//     /// (filename, file (or bytes), content_type)
//     FileNameAndContentAndType(String, Vec<u8>, String),
//     /// (filename, file (or bytes), content_type, headers)
//     FileNameAndContentAndTypeAndHeaders(String, Vec<u8>, String, HashMap<String, String>),
// }

/// The expiration policy for a file.
///
/// By default, files with `purpose=batch` expire after 30 days and all other files
/// are persisted until they are manually deleted.
///
/// Matches the official multipart form fields `expires_after[anchor]` and
/// `expires_after[seconds]`.
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "anchor", rename_all = "snake_case")]
pub enum ExpiresAfter {
    /// Anchor timestamp after which the expiration policy applies.
    /// Supported anchors: `created_at`.
    CreatedAt {
        /// The number of seconds after the anchor time that the file will expire.
        /// Must be between 3600 (1 hour) and 2592000 (30 days).
        seconds: usize,
    },
}

impl Post for CreateFileRequest {
    #[inline]
    fn is_streaming(&self) -> bool {
        false
    }

    /// Builds the URL for the request.
    ///
    /// `base_url` should be like <https://api.openai.com/v1>
    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
        url.path_segments_mut()
            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
            .push("files");

        Ok(url.to_string())
    }
}

impl PostNoStream for CreateFileRequest {
    type Response = crate::files::FileObject;

    /// Sends a file upload POST request using multipart/form-data format.
    /// This implementation handles the actual file upload with proper file handling.
    async fn get_response_string(
        &self,
        client: &reqwest::Client,
        url: &str,
        key: &str,
    ) -> Result<String, OapiError> {
        // Read file content
        let file_content = tokio::fs::read(&self.file).await?;

        // Get file name from path
        let file_name = self
            .file
            .file_name()
            .and_then(|name| name.to_str())
            .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
            .to_string();

        // Create multipart form with file and purpose
        let file_part = reqwest::multipart::Part::bytes(file_content).file_name(file_name);

        let mut form = reqwest::multipart::Form::new().part("file", file_part);

        // Add purpose field
        let purpose_str = serde_json::to_string(&self.purpose)
            .map_err(|e| OapiError::ResponseError(format!("Failed to serialize purpose: {}", e)))?;
        let trimmed_purpose = purpose_str.trim_matches('"').to_string();
        form = form.text("purpose", trimmed_purpose);

        // Add expires_after if present, using the official multipart form
        // fields `expires_after[anchor]` and `expires_after[seconds]`.
        if let Some(expires_after) = &self.expires_after {
            let (anchor, seconds) = match expires_after {
                ExpiresAfter::CreatedAt { seconds } => ("created_at", *seconds),
            };
            form = form
                .text("expires_after[anchor]", anchor)
                .text("expires_after[seconds]", seconds.to_string());
        }

        // Extra body properties are sent as additional multipart text
        // fields, matching the official SDK (strings verbatim, other JSON
        // values as their serialized form).
        if let Some(extra_body) = &self.extra_body {
            for (key, value) in extra_body {
                let text = match value {
                    serde_json::Value::String(s) => s.clone(),
                    other => other.to_string(),
                };
                form = form.text(key.clone(), text);
            }
        }

        let response = client
            .post(url)
            .header("Accept", "application/json")
            .bearer_auth(key)
            .multipart(form)
            .send()
            .await?;

        crate::rest::response_text_checked(response).await
    }
}