diff --git a/crates/api_clients/grok_api_client/Cargo.toml b/crates/api_clients/grok_api_client/Cargo.toml index 9950bb258b..6d2a972f8d 100644 --- a/crates/api_clients/grok_api_client/Cargo.toml +++ b/crates/api_clients/grok_api_client/Cargo.toml @@ -20,7 +20,9 @@ errors.workspace = true # External log.workspace = true -reqwest.workspace = true +# `multipart` is enabled on top of the workspace feature set so we can use +# `reqwest::multipart` for POST /v1/files uploads. +reqwest = { workspace = true, features = ["multipart"] } serde.workspace = true serde_derive.workspace = true serde_json.workspace = true diff --git a/crates/api_clients/grok_api_client/src/requests/delete_file/delete_file.rs b/crates/api_clients/grok_api_client/src/requests/delete_file/delete_file.rs new file mode 100644 index 0000000000..0fcb951062 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/delete_file/delete_file.rs @@ -0,0 +1,92 @@ +use log::info; + +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::delete_file::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct DeleteFileArgs { + pub api_key: GrokApiKey, + pub file_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct DeleteFileSuccess { + pub file_id: Option, + /// Should be `true` after a successful delete. + pub deleted: bool, +} + +// ── Implementation ── + +/// DELETE https://api.x.ai/v1/files/{file_id} — delete a previously-uploaded +/// file. After deletion the `file_id` becomes invalid and cannot be +/// referenced in further requests. +/// +/// Docs: +pub async fn delete_file(args: DeleteFileArgs) -> Result { + let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id); + + info!("Grok delete_file: file_id={}", args.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.delete(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok delete_file response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: DeleteFileResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(DeleteFileSuccess { + file_id: parsed.id, + deleted: parsed.deleted.unwrap_or(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_body_deserializes_success() { + // serde drops the `object` field on parse; only `id` and `deleted` are + // load-bearing for our DTO. + let json = r#"{ "id": "file_abc", "object": "file", "deleted": true }"#; + let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id.as_deref(), Some("file_abc")); + assert_eq!(parsed.deleted, Some(true)); + } + + #[test] + fn response_body_deserializes_minimal() { + // xAI may omit some fields; only `deleted` is load-bearing. + let json = r#"{ "deleted": true }"#; + let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.deleted, Some(true)); + assert!(parsed.id.is_none()); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/delete_file/mod.rs b/crates/api_clients/grok_api_client/src/requests/delete_file/mod.rs new file mode 100644 index 0000000000..d909d0ba4d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/delete_file/mod.rs @@ -0,0 +1,2 @@ +pub mod delete_file; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/delete_file/request_types.rs b/crates/api_clients/grok_api_client/src/requests/delete_file/request_types.rs new file mode 100644 index 0000000000..b88ed7eff9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/delete_file/request_types.rs @@ -0,0 +1,12 @@ +use serde_derive::Deserialize; + +/// Response shape for DELETE /v1/files/{file_id}. +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct DeleteFileResponseBody { + pub id: Option, + + /// xAI returns `true` on a successful delete. + pub deleted: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/download_file/download_file.rs b/crates/api_clients/grok_api_client/src/requests/download_file/download_file.rs new file mode 100644 index 0000000000..0754b87610 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/download_file/download_file.rs @@ -0,0 +1,103 @@ +use log::info; + +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::xai_host::XAI_API_BASE_URL; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct DownloadFileArgs { + pub api_key: GrokApiKey, + pub file_id: String, + + /// Optional `?format=` query — `"original"` returns the bytes as uploaded; + /// `"text"` returns a textual transcription where applicable. Omit for the + /// xAI default. + pub format: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DownloadFormat { + Original, + Text, +} + +impl DownloadFormat { + pub fn as_str(&self) -> &'static str { + match self { + Self::Original => "original", + Self::Text => "text", + } + } +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct DownloadFileSuccess { + /// Raw file content. The xAI response Content-Type is `application/octet-stream`. + pub bytes: Vec, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/files/{file_id}/content — download the raw bytes +/// of a previously-uploaded file. +/// +/// Docs: +pub async fn download_file(args: DownloadFileArgs) -> Result { + let mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, args.file_id); + if let Some(fmt) = args.format { + url.push_str("?format="); + url.push_str(fmt.as_str()); + } + + info!("Grok download_file: file_id={}", args.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.get(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + + // On error, read as text so the classifier can quote it back. + if !status.is_success() { + let body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + classify_grok_http_error(status, Some(&body))?; + // classify_grok_http_error always returns Err on non-success. + unreachable!(); + } + + let bytes = response.bytes() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok download_file response: status={}, bytes={}", status, bytes.len()); + + Ok(DownloadFileSuccess { bytes: bytes.to_vec() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn download_format_serializes() { + assert_eq!(DownloadFormat::Original.as_str(), "original"); + assert_eq!(DownloadFormat::Text.as_str(), "text"); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/download_file/mod.rs b/crates/api_clients/grok_api_client/src/requests/download_file/mod.rs new file mode 100644 index 0000000000..db2beffde9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/download_file/mod.rs @@ -0,0 +1 @@ +pub mod download_file; diff --git a/crates/api_clients/grok_api_client/src/requests/get_file_metadata/get_file_metadata.rs b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/get_file_metadata.rs new file mode 100644 index 0000000000..7d8a7b605a --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/get_file_metadata.rs @@ -0,0 +1,115 @@ +use log::info; + +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::get_file_metadata::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct GetFileMetadataArgs { + pub api_key: GrokApiKey, + pub file_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub file_id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/files/{file_id} — fetch metadata for an uploaded +/// file (no content; use `download_file` for that). +/// +/// Docs: +pub async fn get_file_metadata(args: GetFileMetadataArgs) -> Result { + let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id); + + info!("Grok get_file_metadata: file_id={}", args.file_id); + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.get(&url) + .header("Authorization", bearer) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok get_file_metadata response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: FileMetadataResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(FileMetadata { + file_id: parsed.id, + bytes: parsed.bytes, + created_at: parsed.created_at, + expires_at: parsed.expires_at, + filename: parsed.filename, + purpose: parsed.purpose, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + #[test] + fn response_body_deserializes() { + let json = r#"{ + "id": "file_abc123", + "object": "file", + "bytes": 4096, + "created_at": 1716489600, + "expires_at": 1719081600, + "filename": "photo.png", + "purpose": "vision" + }"#; + let parsed: FileMetadataResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "file_abc123"); + assert_eq!(parsed.expires_at, Some(1719081600)); + } + + #[tokio::test] + #[ignore] // manually test — requires real API key + valid file_id + async fn live_test_unknown_file_returns_not_found() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + let result = get_file_metadata(GetFileMetadataArgs { + api_key: get_test_api_key()?, + file_id: "file_doesnotexist000000".to_string(), + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)), + "expected NotFound, got: {:?}", err); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/get_file_metadata/mod.rs b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/mod.rs new file mode 100644 index 0000000000..28a25301bc --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/mod.rs @@ -0,0 +1,2 @@ +pub mod get_file_metadata; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/get_file_metadata/request_types.rs b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/request_types.rs new file mode 100644 index 0000000000..2a59ffd0f2 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/get_file_metadata/request_types.rs @@ -0,0 +1,15 @@ +use serde_derive::Deserialize; + +/// Response shape for GET /v1/files/{file_id}. Same envelope as the upload +/// response (xAI returns identical file objects from both endpoints). +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct FileMetadataResponseBody { + pub id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_edit/image_edit.rs b/crates/api_clients/grok_api_client/src/requests/image_edit/image_edit.rs index b7cf381cea..68e997268b 100644 --- a/crates/api_clients/grok_api_client/src/requests/image_edit/image_edit.rs +++ b/crates/api_clients/grok_api_client/src/requests/image_edit/image_edit.rs @@ -7,10 +7,12 @@ use crate::error::grok_error::GrokError; use crate::error::grok_generic_api_error::GrokGenericApiError; use crate::error::grok_specific_api_error::GrokSpecificApiError; use crate::requests::image_edit::request_types::*; +use crate::requests::image_types::image_aspect_ratio::ImageAspectRatio; +use crate::requests::image_types::image_model::ImageModel; +use crate::requests::image_types::image_resolution::ImageResolution; +use crate::requests::image_types::image_response_format::ImageResponseFormat; use crate::requests::xai_host::XAI_API_BASE_URL; -const DEFAULT_MODEL: &str = "grok-imagine-image-quality"; - // ── Public args ── #[derive(Clone, Debug)] @@ -24,38 +26,43 @@ pub struct ImageEditArgs { /// The output aspect ratio follows the first input image unless overridden. pub source_images: Vec, - /// Model identifier. Defaults to `grok-imagine-image-quality` if `None`. - pub model: Option, + /// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`. + pub model: Option, + + /// Number of edited images to render. Server default is 1 when `None`. + pub number_images: Option, + + /// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to + /// matching the first input image. + pub aspect_ratio: Option, + + /// Output resolution tier. See [`ImageResolution`]. + pub resolution: Option, + + /// `Url` (default) or `B64Json`. + pub response_format: Option, - pub n: Option, - pub aspect_ratio: Option, - pub resolution: Option, - pub response_format: Option, pub user: Option, } -/// Source image — pick by URL or by file_id (from a prior /v1/files upload). +/// Source image — pick by URL or by `file_id` (from a prior upload). #[derive(Clone, Debug)] pub enum ImageSource { + /// Either a public HTTPS URL or a `data:` URI containing base64-encoded + /// image bytes. Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::requests::upload_file::upload_file::upload_file`]. The file + /// must still exist at request time (it has not been deleted and any + /// `expires_after` has not lapsed). + /// + /// Docs: + /// - + /// - FileId(String), } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum ImageEditResponseFormat { - Url, - B64Json, -} - -impl ImageEditResponseFormat { - pub fn as_str(&self) -> &'static str { - match self { - Self::Url => "url", - Self::B64Json => "b64_json", - } - } -} - // ── Public response ── #[derive(Debug, Clone)] @@ -86,11 +93,13 @@ pub async fn image_edit(args: ImageEditArgs) -> Result Result, + /// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] + /// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed + /// in the enum. + pub model: Option, - /// Number of images to generate. Server default (typically 1) if `None`. - pub n: Option, + /// Number of images to render in this request. xAI's docs don't state a + /// hard maximum; server default is 1 when `None`. + pub number_images: Option, - /// Aspect ratio (e.g. "1:1", "16:9", "auto"). Server default if `None`. - pub aspect_ratio: Option, + /// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted + /// values. Server default when `None`. + pub aspect_ratio: Option, - /// Resolution (`"1k"` or `"2k"`). Server default if `None`. - pub resolution: Option, + /// Output resolution tier. See [`ImageResolution`]. Server default when `None`. + pub resolution: Option, - /// `"url"` (default) or `"b64_json"`. + /// Url (default) or b64 inline. See [`ImageResponseFormat`]. pub response_format: Option, /// Optional opaque user identifier for usage attribution. pub user: Option, } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum ImageResponseFormat { - Url, - B64Json, -} - -impl ImageResponseFormat { - pub fn as_str(&self) -> &'static str { - match self { - Self::Url => "url", - Self::B64Json => "b64_json", - } - } -} - // ── Public response ── #[derive(Debug, Clone)] @@ -83,19 +74,21 @@ pub struct GeneratedImage { pub async fn image_generation(args: ImageGenerationArgs) -> Result { let url = format!("{}/v1/images/generations", XAI_API_BASE_URL); - let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let model = args.model.unwrap_or(ImageModel::GrokImagineImageQuality); info!( - "Grok image_generation: model={}, n={:?}, aspect_ratio={:?}, resolution={:?}", - model, args.n, args.aspect_ratio, args.resolution + "Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}", + model.as_str(), args.number_images, + args.aspect_ratio.map(|a| a.as_str()), + args.resolution.map(|r| r.as_str()), ); let request_body = ImageGenerationRequestBody { prompt: args.prompt, - model: Some(model), - n: args.n, - aspect_ratio: args.aspect_ratio, - resolution: args.resolution, + model: Some(model.as_str().to_string()), + n: args.number_images, + aspect_ratio: args.aspect_ratio.map(|a| a.as_str().to_string()), + resolution: args.resolution.map(|r| r.as_str().to_string()), response_format: args.response_format.map(|f| f.as_str().to_string()), user: args.user, }; @@ -207,10 +200,21 @@ mod tests { assert!(parsed.data[0].url.is_none()); } - #[test] - fn response_format_enum_round_trips() { - assert_eq!(ImageResponseFormat::Url.as_str(), "url"); - assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json"); + // (Enum-specific round-trips live in their own modules under + // `requests::image_types::*`; here we just verify the public args wire up.) + + #[tokio::test] + async fn args_with_enums_serializes_correct_strings() { + // We can't reach the inner serde body directly without calling the API, + // but we can verify the conversion functions yield exactly the docs values. + let m = ImageModel::GrokImagineImageQuality; + let a = ImageAspectRatio::Landscape16x9; + let r = ImageResolution::OneK; + let f = ImageResponseFormat::B64Json; + assert_eq!(m.as_str(), "grok-imagine-image-quality"); + assert_eq!(a.as_str(), "16:9"); + assert_eq!(r.as_str(), "1k"); + assert_eq!(f.as_str(), "b64_json"); } // ── Live API tests (ignored — incur cost) ── @@ -227,9 +231,9 @@ mod tests { api_key, prompt: "A serene mountain lake at sunrise, photorealistic".to_string(), model: None, - n: None, - aspect_ratio: Some("16:9".to_string()), - resolution: Some("1k".to_string()), + number_images: None, + aspect_ratio: Some(ImageAspectRatio::Landscape16x9), + resolution: Some(ImageResolution::OneK), response_format: None, user: None, }).await.map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/api_clients/grok_api_client/src/requests/image_types/image_aspect_ratio.rs b/crates/api_clients/grok_api_client/src/requests/image_types/image_aspect_ratio.rs new file mode 100644 index 0000000000..c7c0604369 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_types/image_aspect_ratio.rs @@ -0,0 +1,112 @@ +/// Aspect ratios accepted by the xAI image endpoints. +/// +/// The wire format is `":"` — e.g. `Square` serialises as +/// `"1:1"`, `Cinematic20x9` as `"20:9"`. `Auto` defers to xAI to pick. +/// +/// xAI documents this closed set; see +/// . +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageAspectRatio { + /// 1:1 square. + Square, + + /// 3:4 portrait (slight tall). + Portrait3x4, + + /// 4:3 landscape (slight wide). + Landscape4x3, + + /// 9:16 portrait (phone/vertical-video). + Portrait9x16, + + /// 16:9 landscape (widescreen / YouTube). + Landscape16x9, + + /// 2:3 portrait (photo). + Portrait2x3, + + /// 3:2 landscape (photo). + Landscape3x2, + + /// 9:19.5 portrait (modern phone screens). + Portrait9x19_5, + + /// 19.5:9 landscape (modern phone screens, rotated). + Landscape19_5x9, + + /// 9:20 portrait (taller phone). + Portrait9x20, + + /// 20:9 landscape (taller phone, rotated). + Landscape20x9, + + /// 1:2 ultra-portrait. + Portrait1x2, + + /// 2:1 ultra-landscape. + Landscape2x1, + + /// Let xAI pick the aspect ratio (typically matches the source image for edits). + Auto, +} + +impl ImageAspectRatio { + /// Wire representation — the exact string xAI expects in the + /// `"aspect_ratio"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::Square => "1:1", + Self::Portrait3x4 => "3:4", + Self::Landscape4x3 => "4:3", + Self::Portrait9x16 => "9:16", + Self::Landscape16x9 => "16:9", + Self::Portrait2x3 => "2:3", + Self::Landscape3x2 => "3:2", + Self::Portrait9x19_5 => "9:19.5", + Self::Landscape19_5x9 => "19.5:9", + Self::Portrait9x20 => "9:20", + Self::Landscape20x9 => "20:9", + Self::Portrait1x2 => "1:2", + Self::Landscape2x1 => "2:1", + Self::Auto => "auto", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_maps_to_a_distinct_string() { + let all = [ + ImageAspectRatio::Square, + ImageAspectRatio::Portrait3x4, + ImageAspectRatio::Landscape4x3, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait2x3, + ImageAspectRatio::Landscape3x2, + ImageAspectRatio::Portrait9x19_5, + ImageAspectRatio::Landscape19_5x9, + ImageAspectRatio::Portrait9x20, + ImageAspectRatio::Landscape20x9, + ImageAspectRatio::Portrait1x2, + ImageAspectRatio::Landscape2x1, + ImageAspectRatio::Auto, + ]; + let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect(); + strs.sort(); + strs.dedup(); + assert_eq!(strs.len(), 14, "expected 14 distinct wire values, got {:?}", strs); + } + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageAspectRatio::Square.as_str(), "1:1"); + assert_eq!(ImageAspectRatio::Landscape16x9.as_str(), "16:9"); + assert_eq!(ImageAspectRatio::Portrait9x19_5.as_str(), "9:19.5"); + assert_eq!(ImageAspectRatio::Landscape19_5x9.as_str(), "19.5:9"); + assert_eq!(ImageAspectRatio::Auto.as_str(), "auto"); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_types/image_model.rs b/crates/api_clients/grok_api_client/src/requests/image_types/image_model.rs new file mode 100644 index 0000000000..67f67ecc16 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_types/image_model.rs @@ -0,0 +1,45 @@ +/// Models accepted by the xAI image endpoints (`/v1/images/generations` and +/// `/v1/images/edits`). +/// +/// xAI may add or deprecate models faster than this crate ships releases, so +/// the [`ImageModel::Custom`] escape hatch lets callers pass an arbitrary +/// identifier without waiting for a code change. +/// +/// Docs: +/// - +/// - +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ImageModel { + /// `grok-imagine-image-quality` — the current default high-quality model. + GrokImagineImageQuality, + + /// Escape hatch for model identifiers not yet enumerated here (e.g. a + /// newly-released model or a deprecated one xAI hasn't yet removed). + Custom(String), +} + +impl ImageModel { + /// Wire representation — the exact string xAI expects in the `"model"` field. + pub fn as_str(&self) -> &str { + match self { + Self::GrokImagineImageQuality => "grok-imagine-image-quality", + Self::Custom(s) => s.as_str(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_model_serializes() { + assert_eq!(ImageModel::GrokImagineImageQuality.as_str(), "grok-imagine-image-quality"); + } + + #[test] + fn custom_model_passes_through() { + let m = ImageModel::Custom("grok-imagine-image-future".to_string()); + assert_eq!(m.as_str(), "grok-imagine-image-future"); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_types/image_resolution.rs b/crates/api_clients/grok_api_client/src/requests/image_types/image_resolution.rs new file mode 100644 index 0000000000..5dccbe5932 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_types/image_resolution.rs @@ -0,0 +1,38 @@ +/// Output resolution for xAI image endpoints. +/// +/// xAI's actual pixel counts depend on the chosen `aspect_ratio` — the `1k` +/// and `2k` labels denote the *target* size class (roughly 1024-px and +/// 2048-px on the longest edge). When omitted, xAI falls back to its server +/// default (currently 1k as of these docs). +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageResolution { + /// `"1k"` — ~1024-px class (faster, cheaper). + OneK, + /// `"2k"` — ~2048-px class (higher fidelity, slower, more expensive). + TwoK, +} + +impl ImageResolution { + /// Wire representation — the exact string xAI expects in the `"resolution"` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::OneK => "1k", + Self::TwoK => "2k", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageResolution::OneK.as_str(), "1k"); + assert_eq!(ImageResolution::TwoK.as_str(), "2k"); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_types/image_response_format.rs b/crates/api_clients/grok_api_client/src/requests/image_types/image_response_format.rs new file mode 100644 index 0000000000..a2e7e78803 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_types/image_response_format.rs @@ -0,0 +1,35 @@ +/// Wire format for returned images. +/// +/// - `Url` (default) — xAI returns a temporary CDN URL in `data[].url`. +/// - `B64Json` — xAI returns the raw image bytes inlined as base64 in +/// `data[].b64_json`. Use this when you can't / don't want to make a +/// follow-up HTTP request to fetch the image. +/// +/// Docs: +/// - +/// - +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ImageResponseFormat { + Url, + B64Json, +} + +impl ImageResponseFormat { + pub fn as_str(&self) -> &'static str { + match self { + Self::Url => "url", + Self::B64Json => "b64_json", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_docs_strings() { + assert_eq!(ImageResponseFormat::Url.as_str(), "url"); + assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json"); + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_types/mod.rs b/crates/api_clients/grok_api_client/src/requests/image_types/mod.rs new file mode 100644 index 0000000000..47f24cbca6 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_types/mod.rs @@ -0,0 +1,10 @@ +//! Shared image-endpoint enums (model, aspect ratio, resolution, response format). +//! +//! These are reused by both [`crate::requests::image_generation`] and +//! [`crate::requests::image_edit`] so callers can pass type-safe values +//! instead of memorising the allowed strings. + +pub mod image_aspect_ratio; +pub mod image_model; +pub mod image_resolution; +pub mod image_response_format; diff --git a/crates/api_clients/grok_api_client/src/requests/mod.rs b/crates/api_clients/grok_api_client/src/requests/mod.rs index 8c21f83af9..7463f1d002 100644 --- a/crates/api_clients/grok_api_client/src/requests/mod.rs +++ b/crates/api_clients/grok_api_client/src/requests/mod.rs @@ -1,5 +1,10 @@ +pub mod delete_file; +pub mod download_file; +pub mod get_file_metadata; pub mod image_edit; pub mod image_generation; +pub mod image_types; +pub mod upload_file; pub mod video_edit; pub mod video_extension; pub mod video_generation; diff --git a/crates/api_clients/grok_api_client/src/requests/upload_file/mod.rs b/crates/api_clients/grok_api_client/src/requests/upload_file/mod.rs new file mode 100644 index 0000000000..ffc5d71d95 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/upload_file/mod.rs @@ -0,0 +1,2 @@ +pub mod upload_file; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/upload_file/request_types.rs b/crates/api_clients/grok_api_client/src/requests/upload_file/request_types.rs new file mode 100644 index 0000000000..4bbebb2862 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/upload_file/request_types.rs @@ -0,0 +1,14 @@ +use serde_derive::Deserialize; + +/// Response shape for POST /v1/files. Matches OpenAI-style file objects. +/// +/// Docs: +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct UploadFileResponseBody { + pub id: String, + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/upload_file/upload_file.rs b/crates/api_clients/grok_api_client/src/requests/upload_file/upload_file.rs new file mode 100644 index 0000000000..620d649459 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/upload_file/upload_file.rs @@ -0,0 +1,226 @@ +use log::info; +use reqwest::multipart::{Form, Part}; + +use crate::creds::grok_api_key::GrokApiKey; +use crate::error::classify_grok_http_error::classify_grok_http_error; +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::requests::upload_file::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct UploadFileArgs { + pub api_key: GrokApiKey, + + /// Raw bytes of the file. xAI caps uploads at 50 MB. + pub file_bytes: Vec, + + /// Original filename. xAI extracts this from the multipart + /// `Content-Disposition: filename=...` header and surfaces it back in the + /// response. + pub filename: String, + + /// Optional MIME type. If `None`, reqwest uses `application/octet-stream`. + pub maybe_content_type: Option, + + /// Time-to-live in seconds (3600 – 2_592_000, i.e. 1 hour to 30 days). If + /// `None`, the file persists indefinitely until explicitly deleted. + pub expires_after_seconds: Option, + + /// OpenAI-compatibility label. xAI accepts but doesn't enforce this — set + /// it if porting code that already uses OpenAI's Files API. + pub purpose: Option, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct UploadFileSuccess { + /// The xAI file identifier. Use this in the `FileId(_)` variant of + /// [`crate::requests::image_edit::image_edit::ImageSource`], + /// [`crate::requests::video_generation::video_generation::VideoImageSource`], + /// [`crate::requests::video_edit::video_edit::VideoSource`], or + /// [`crate::requests::video_extension::video_extension::VideoExtensionSource`]. + pub file_id: String, + + pub bytes: Option, + pub created_at: Option, + pub expires_at: Option, + pub filename: Option, + pub purpose: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/files — upload a file and receive a `file_id` +/// you can reference in subsequent image/video edit, generation, or +/// extension calls. +/// +/// xAI documentation: +/// - +pub async fn upload_file(args: UploadFileArgs) -> Result { + if args.file_bytes.is_empty() { + return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest( + "upload_file: file_bytes is empty".to_string(), + ).into()); + } + if args.file_bytes.len() as u64 > MAX_FILE_BYTES { + return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest( + format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", args.file_bytes.len()), + ).into()); + } + + let url = format!("{}/v1/files", XAI_API_BASE_URL); + + info!( + "Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}", + args.filename, args.file_bytes.len(), args.expires_after_seconds, args.purpose + ); + + let mut file_part = Part::bytes(args.file_bytes).file_name(args.filename.clone()); + if let Some(ct) = args.maybe_content_type.as_deref() { + file_part = file_part.mime_str(ct) + .map_err(GrokClientError::ReqwestClientError)?; + } + + let mut form = Form::new().part("file", file_part); + if let Some(secs) = args.expires_after_seconds { + form = form.text("expires_after", secs.to_string()); + } + if let Some(purpose) = args.purpose { + form = form.text("purpose", purpose); + } + + let client = reqwest::Client::builder() + .build() + .map_err(GrokClientError::ReqwestClientError)?; + + let bearer = format!("Bearer {}", args.api_key.api_key); + + let response = client.post(&url) + .header("Authorization", bearer) + .multipart(form) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok upload_file response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: UploadFileResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(UploadFileSuccess { + file_id: parsed.id, + bytes: parsed.bytes, + created_at: parsed.created_at, + expires_at: parsed.expires_at, + filename: parsed.filename, + purpose: parsed.purpose, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + // ── Client-side validation ── + + #[tokio::test] + async fn empty_bytes_returns_bad_request() { + let result = upload_file(UploadFileArgs { + api_key: GrokApiKey::new("dummy".to_string()), + file_bytes: vec![], + filename: "x.png".to_string(), + maybe_content_type: None, + expires_after_seconds: None, + purpose: None, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_)))); + } + + #[tokio::test] + async fn over_size_limit_returns_bad_request() { + let oversized: Vec = vec![0u8; (MAX_FILE_BYTES + 1) as usize]; + let result = upload_file(UploadFileArgs { + api_key: GrokApiKey::new("dummy".to_string()), + file_bytes: oversized, + filename: "x.png".to_string(), + maybe_content_type: None, + expires_after_seconds: None, + purpose: None, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_)))); + } + + // ── Response parsing ── + + #[test] + fn response_body_deserializes() { + let json = r#"{ + "id": "file_abc123", + "object": "file", + "bytes": 1024, + "created_at": 1716489600, + "expires_at": null, + "filename": "photo.png", + "purpose": "vision" + }"#; + let parsed: UploadFileResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "file_abc123"); + assert_eq!(parsed.bytes, Some(1024)); + assert_eq!(parsed.filename.as_deref(), Some("photo.png")); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn live_test_upload_small_png() -> AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + use crate::test_utils::setup_test_logging::setup_test_logging; + setup_test_logging(); + + // 1x1 transparent PNG + let png_bytes: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, + ]; + + let result = upload_file(UploadFileArgs { + api_key: get_test_api_key()?, + file_bytes: png_bytes, + filename: "test_1x1.png".to_string(), + maybe_content_type: Some("image/png".to_string()), + expires_after_seconds: Some(3600), + purpose: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Uploaded file_id={}, bytes={:?}", result.file_id, result.bytes); + assert!(!result.file_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/video_edit/video_edit.rs b/crates/api_clients/grok_api_client/src/requests/video_edit/video_edit.rs index 9833f961a3..d68b583b1c 100644 --- a/crates/api_clients/grok_api_client/src/requests/video_edit/video_edit.rs +++ b/crates/api_clients/grok_api_client/src/requests/video_edit/video_edit.rs @@ -32,9 +32,20 @@ pub struct VideoEditArgs { pub user: Option, } +/// Source video to edit. Pick by public URL or by a previously-uploaded +/// xAI file_id. #[derive(Clone, Debug)] pub enum VideoSource { + /// Public HTTPS URL pointing to the source video. xAI fetches the bytes + /// on its end. Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::requests::upload_file::upload_file::upload_file`]. + /// + /// Docs: + /// - + /// - FileId(String), } diff --git a/crates/api_clients/grok_api_client/src/requests/video_extension/video_extension.rs b/crates/api_clients/grok_api_client/src/requests/video_extension/video_extension.rs index 9489350c36..795e655286 100644 --- a/crates/api_clients/grok_api_client/src/requests/video_extension/video_extension.rs +++ b/crates/api_clients/grok_api_client/src/requests/video_extension/video_extension.rs @@ -33,9 +33,19 @@ pub struct VideoExtensionArgs { pub upload_url: Option, } +/// Source video to extend. Pick by public URL or by a previously-uploaded +/// xAI file_id. #[derive(Clone, Debug)] pub enum VideoExtensionSource { + /// Public HTTPS URL pointing to the source video. Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::requests::upload_file::upload_file::upload_file`]. + /// + /// Docs: + /// - + /// - FileId(String), } diff --git a/crates/api_clients/grok_api_client/src/requests/video_generation/video_generation.rs b/crates/api_clients/grok_api_client/src/requests/video_generation/video_generation.rs index a544cbde73..763f3d3429 100644 --- a/crates/api_clients/grok_api_client/src/requests/video_generation/video_generation.rs +++ b/crates/api_clients/grok_api_client/src/requests/video_generation/video_generation.rs @@ -48,9 +48,21 @@ pub struct VideoGenerationArgs { pub user: Option, } +/// Source image for `image` (image-to-video) or `reference_images` +/// (reference-to-video). #[derive(Clone, Debug)] pub enum VideoImageSource { + /// Either a public HTTPS URL or a `data:` URI containing base64-encoded + /// image bytes. Url(String), + + /// xAI file identifier (`file_...`) obtained from a successful upload via + /// [`crate::requests::upload_file::upload_file::upload_file`]. The file + /// must still exist at request time. + /// + /// Docs: + /// - + /// - FileId(String), }