diff --git a/Cargo.lock b/Cargo.lock index adf170d43d..5e651af94d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4897,6 +4897,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "grok_api_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "env_logger 0.11.9", + "errors", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "grok_consumer_client" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index adf7767a31..89f753a9ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/api_clients/fal_client", "crates/api_clients/google_drive_downloader", "crates/api_clients/google_sign_in", + "crates/api_clients/grok_api_client", "crates/api_clients/grok_consumer_client", "crates/api_clients/gmicloud_client", "crates/api_clients/midjourney_client", @@ -253,6 +254,7 @@ ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" } file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" } filesys = { path = "crates/lib/files/filesys" } beeble_client = { path = "crates/api_clients/beeble_client" } +grok_api_client = { path = "crates/api_clients/grok_api_client" } grok_consumer_client = { path = "crates/api_clients/grok_consumer_client" } rootly_client = { path = "crates/api_clients/rootly_client" } rootly_config = { path = "crates/schema/config/rootly_config" } diff --git a/crates/api_clients/grok_api_client/Cargo.toml b/crates/api_clients/grok_api_client/Cargo.toml new file mode 100644 index 0000000000..9950bb258b --- /dev/null +++ b/crates/api_clients/grok_api_client/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "grok_api_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "grok_api_client" +path = "src/lib.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# Internal +errors.workspace = true + +# External +log.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +anyhow.workspace = true +env_logger.workspace = true +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs b/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs new file mode 100644 index 0000000000..0f3399aee3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/creds/grok_api_key.rs @@ -0,0 +1,21 @@ +/// Holds the API key for authenticating with the Grok (xAI Imagine) API. +/// +/// Sent on every request as `Authorization: Bearer `. +#[derive(Clone)] +pub struct GrokApiKey { + pub(crate) api_key: String, +} + +impl GrokApiKey { + pub fn new(api_key: String) -> Self { + Self { api_key } + } +} + +// Debug is redacted so the key can't end up in a log line via accidental +// `{:?}` on an enclosing args struct. +impl std::fmt::Debug for GrokApiKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GrokApiKey()") + } +} diff --git a/crates/api_clients/grok_api_client/src/creds/mod.rs b/crates/api_clients/grok_api_client/src/creds/mod.rs new file mode 100644 index 0000000000..a5900cdaad --- /dev/null +++ b/crates/api_clients/grok_api_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod grok_api_key; diff --git a/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs b/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs new file mode 100644 index 0000000000..6d81ccc58f --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/classify_grok_http_error.rs @@ -0,0 +1,196 @@ +use reqwest::StatusCode; + +use crate::error::grok_error::GrokError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +/// Convert a non-2xx HTTP response into a `GrokError`. +/// +/// On 2xx returns `Ok(())`; on any error status returns the most-specific +/// `GrokError` variant we can classify from the status code and (optional) +/// response body. +pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), GrokError> { + if status_code.is_success() { + return Ok(()); + } + + match status_code { + StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized.into()), + StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits.into()), + StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound.into()), + StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited.into()), + _ => {} + } + + // 403 is Forbidden unless the body indicates content moderation. + if status_code == StatusCode::FORBIDDEN { + if let Some(body) = maybe_body { + if body_indicates_moderation(body) { + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::PromptModerated(message).into()); + } + } + return Err(GrokSpecificApiError::Forbidden.into()); + } + + // 400 Bad Request is usually a malformed body or moderation rejection. + if status_code == StatusCode::BAD_REQUEST { + if let Some(body) = maybe_body { + if body_indicates_moderation(body) { + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::PromptModerated(message).into()); + } + let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string()); + return Err(GrokSpecificApiError::BadRequest(message).into()); + } + return Err(GrokSpecificApiError::BadRequest(String::new()).into()); + } + + // Everything else falls through to the generic catch-all. + Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body: maybe_body.unwrap_or("").to_string(), + }.into()) +} + +/// xAI returns OpenAI-compatible error envelopes: +/// +/// ```json +/// { "error": { "code": "...", "message": "..." } } +/// ``` +/// +/// Extract `error.message` if present. +fn extract_xai_error_message(body: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(body).ok()?; + parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string()) +} + +/// Heuristic: does this response body mention content moderation? xAI's +/// safety filter rejections include words like "moderation", "safety", +/// "content_policy", or "blocked". +fn body_indicates_moderation(body: &str) -> bool { + let lower = body.to_lowercase(); + lower.contains("moderat") // moderation / moderated + || lower.contains("safety") + || lower.contains("content_policy") + || lower.contains("content policy") + || lower.contains("blocked by") + || lower.contains("disallowed") +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Success ── + + #[test] + fn ok_200_returns_ok() { + assert!(classify_grok_http_error(StatusCode::OK, None).is_ok()); + } + + #[test] + fn created_201_returns_ok() { + assert!(classify_grok_http_error(StatusCode::CREATED, Some("{}")).is_ok()); + } + + // ── Direct status mappings ── + + #[test] + fn unauthorized_401() { + let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized))); + } + + #[test] + fn payment_required_402() { + let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits))); + } + + #[test] + fn not_found_404() { + let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound))); + } + + #[test] + fn rate_limited_429() { + let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited))); + } + + // ── 403 Forbidden ── + + #[test] + fn plain_403_is_forbidden() { + let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden))); + } + + #[test] + fn moderation_403_is_prompt_moderated() { + let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#; + let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_)))); + } + + // ── 400 Bad Request ── + + #[test] + fn bad_request_400_extracts_message() { + let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => { + assert_eq!(msg, "unknown field 'foo'"); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn bad_request_400_unparseable_body_falls_back_to_raw() { + let body = "definitely not json"; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => { + assert_eq!(msg, "definitely not json"); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn bad_request_400_no_body_is_empty_string() { + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => { + assert!(msg.is_empty()); + } + other => panic!("expected BadRequest, got: {:?}", other), + } + } + + #[test] + fn moderation_400_is_prompt_moderated() { + // Some APIs return moderation as 400 rather than 403. + let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#; + let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_)))); + } + + // ── Generic fallback ── + + #[test] + fn server_error_500_is_generic() { + let err = classify_grok_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some("oops")).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } + + #[test] + fn bad_gateway_502_is_generic() { + let err = classify_grok_http_error(StatusCode::BAD_GATEWAY, None).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }))); + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_client_error.rs b/crates/api_clients/grok_api_client/src/error/grok_client_error.rs new file mode 100644 index 0000000000..b13a4599f7 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_client_error.rs @@ -0,0 +1,28 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Errors that happen entirely on the client side — before or independent of +/// the HTTP request reaching xAI's servers. +#[derive(Debug)] +pub enum GrokClientError { + /// No API key is present. + NoApiKeyPresent, + + /// An error was encountered building the reqwest client. + ReqwestClientError(reqwest::Error), + + /// The request body could not be serialized to JSON. + RequestSerializationError(serde_json::Error), +} + +impl Error for GrokClientError {} + +impl Display for GrokClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoApiKeyPresent => write!(f, "No API key present."), + Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err), + Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err), + } + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_error.rs b/crates/api_clients/grok_api_client/src/error/grok_error.rs new file mode 100644 index 0000000000..d33e238fe0 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_error.rs @@ -0,0 +1,44 @@ +use std::error::Error; + +use crate::error::grok_client_error::GrokClientError; +use crate::error::grok_generic_api_error::GrokGenericApiError; +use crate::error::grok_specific_api_error::GrokSpecificApiError; + +/// Top-level error for the Grok API client. Sum of every failure mode any +/// endpoint can produce. +#[derive(Debug)] +pub enum GrokError { + Client(GrokClientError), + ApiSpecific(GrokSpecificApiError), + ApiGeneric(GrokGenericApiError), +} + +impl Error for GrokError {} + +impl std::fmt::Display for GrokError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "GrokClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e), + } + } +} + +impl From for GrokError { + fn from(error: GrokClientError) -> Self { + Self::Client(error) + } +} + +impl From for GrokError { + fn from(error: GrokSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for GrokError { + fn from(error: GrokGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs b/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs new file mode 100644 index 0000000000..4bc1689295 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_generic_api_error.rs @@ -0,0 +1,37 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use reqwest::StatusCode; + +/// Grab-bag for server-side responses that don't map to a `GrokSpecificApiError`. +#[derive(Debug)] +pub enum GrokGenericApiError { + /// Response body didn't deserialize against the expected schema. Includes + /// the original body so we can diagnose drift. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the HTTP client (e.g. network/IO). + ReqwestError(reqwest::Error), +} + +impl Error for GrokGenericApiError {} + +impl Display for GrokGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::SerdeResponseParseErrorWithBody(err, body) => { + write!(f, "Failed to parse Grok API response body: {:?}. Body: {}", err, body) + } + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => { + write!(f, "Uncategorized Grok API response: status code {}, body: {}", status_code, body) + } + Self::ReqwestError(err) => write!(f, "Reqwest error during Grok API call: {}", err), + } + } +} diff --git a/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs b/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs new file mode 100644 index 0000000000..be85aed5e1 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/grok_specific_api_error.rs @@ -0,0 +1,65 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Well-known, classifiable error responses from the xAI Imagine API. +/// +/// Distinguishes things like "your key is bad" from generic 5xx so callers can +/// react appropriately (retry vs. surface to user vs. alert). +#[derive(Debug)] +pub enum GrokSpecificApiError { + /// 401 Unauthorized — API key is missing, invalid, or revoked. + Unauthorized, + + /// 402 Payment Required / insufficient credits to fulfill the request. + InsufficientCredits, + + /// 403 Forbidden — the API key is valid but lacks permission for this resource/model. + Forbidden, + + /// 404 Not Found — typically returned for an unknown `request_id` on the + /// video status endpoint. + NotFound, + + /// 429 Too Many Requests — rate limit exceeded. + RateLimited, + + /// The prompt was rejected by xAI's content moderation. + PromptModerated(String), + + /// 400 Bad Request — the request body shape was invalid (e.g. missing + /// required field, unsupported model, invalid aspect ratio). + BadRequest(String), + + /// The video generation/edit/extension job finished with `status:"failed"`. + /// + /// The `code` mirrors xAI's machine-readable error code + /// (`invalid_argument`, `permission_denied`, `failed_precondition`, + /// `internal_error`); `message` is the human-readable description. + VideoJobFailed { + code: String, + message: String, + }, + + /// The video job expired (poll status was `"expired"`). + VideoJobExpired, +} + +impl Error for GrokSpecificApiError {} + +impl Display for GrokSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unauthorized => write!(f, "Grok API: Unauthorized (invalid or missing API key)"), + Self::InsufficientCredits => write!(f, "Grok API: Insufficient credits"), + Self::Forbidden => write!(f, "Grok API: Forbidden"), + Self::NotFound => write!(f, "Grok API: Not found"), + Self::RateLimited => write!(f, "Grok API: Rate limited"), + Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg), + Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg), + Self::VideoJobFailed { code, message } => { + write!(f, "Grok API: Video job failed ({}): {}", code, message) + } + Self::VideoJobExpired => write!(f, "Grok API: Video job expired"), + } + } +} diff --git a/crates/api_clients/grok_api_client/src/error/mod.rs b/crates/api_clients/grok_api_client/src/error/mod.rs new file mode 100644 index 0000000000..e4da34d14d --- /dev/null +++ b/crates/api_clients/grok_api_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod classify_grok_http_error; +pub mod grok_client_error; +pub mod grok_error; +pub mod grok_generic_api_error; +pub mod grok_specific_api_error; diff --git a/crates/api_clients/grok_api_client/src/lib.rs b/crates/api_clients/grok_api_client/src/lib.rs new file mode 100644 index 0000000000..e9c19b8190 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +pub(crate) mod test_utils; + +// Public API +pub mod creds; +pub mod error; +pub mod requests; 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 new file mode 100644 index 0000000000..b7cf381cea --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_edit/image_edit.rs @@ -0,0 +1,278 @@ +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::error::grok_specific_api_error::GrokSpecificApiError; +use crate::requests::image_edit::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const DEFAULT_MODEL: &str = "grok-imagine-image-quality"; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct ImageEditArgs { + pub api_key: GrokApiKey, + + /// Edit instruction. Required. + pub prompt: String, + + /// One or more source images. xAI supports up to 3. + /// 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, + + 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). +#[derive(Clone, Debug)] +pub enum ImageSource { + Url(String), + 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)] +pub struct ImageEditSuccess { + pub images: Vec, +} + +#[derive(Debug, Clone)] +pub struct EditedImage { + pub url: Option, + pub b64_json: Option, + pub revised_prompt: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/images/edits — edit one or more source images +/// according to a text prompt. +/// +/// Docs: +/// - +/// - +pub async fn image_edit(args: ImageEditArgs) -> Result { + if args.source_images.is_empty() { + return Err(GrokSpecificApiError::BadRequest( + "image_edit requires at least one source image".to_string(), + ).into()); + } + + let url = format!("{}/v1/images/edits", XAI_API_BASE_URL); + let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + info!( + "Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}", + model, args.source_images.len(), args.aspect_ratio, args.resolution + ); + + // Single source → `image`. Multiple sources → `images`. + let (image, images) = if args.source_images.len() == 1 { + (Some(to_image_ref(&args.source_images[0])), None) + } else { + (None, Some(args.source_images.iter().map(to_image_ref).collect())) + }; + + let request_body = ImageEditRequestBody { + prompt: args.prompt, + image, + images, + model: Some(model), + n: args.n, + aspect_ratio: args.aspect_ratio, + resolution: args.resolution, + response_format: args.response_format.map(|f| f.as_str().to_string()), + user: args.user, + }; + + 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) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok image_edit response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: ImageEditResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(ImageEditSuccess { + images: parsed.data.into_iter().map(|d| EditedImage { + url: d.url, + b64_json: d.b64_json, + revised_prompt: d.revised_prompt, + }).collect(), + }) +} + +fn to_image_ref(source: &ImageSource) -> ImageRef { + match source { + ImageSource::Url(u) => ImageRef { url: Some(u.clone()), file_id: None }, + ImageSource::FileId(id) => ImageRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Shape tests ── + + #[test] + fn single_source_serializes_as_image_field() { + let body = ImageEditRequestBody { + prompt: "make it sepia".to_string(), + image: Some(ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }), + images: None, + model: Some("grok-imagine-image-quality".to_string()), + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"image\":{")); + assert!(!json.contains("\"images\"")); + assert!(json.contains("\"url\":\"https://example.com/a.png\"")); + } + + #[test] + fn multi_source_serializes_as_images_array() { + let body = ImageEditRequestBody { + prompt: "combine these".to_string(), + image: None, + images: Some(vec![ + ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }, + ImageRef { url: None, file_id: Some("file_xyz".to_string()) }, + ]), + model: None, + n: None, + aspect_ratio: Some("1:1".to_string()), + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"images\":[")); + assert!(!json.contains("\"image\":{")); + assert!(json.contains("\"file_id\":\"file_xyz\"")); + assert!(json.contains("\"aspect_ratio\":\"1:1\"")); + } + + #[tokio::test] + async fn empty_sources_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let result = image_edit(ImageEditArgs { + api_key, + prompt: "edit".to_string(), + source_images: vec![], + model: None, + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_)))); + } + + #[test] + fn response_body_deserializes_url() { + let json = r#"{ "data": [ { "url": "https://imagine.x.ai/edit.png" } ] }"#; + let parsed: ImageEditResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/edit.png")); + } + + #[test] + fn image_ref_url_only() { + let r = ImageRef { url: Some("u".to_string()), file_id: None }; + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"url\":\"u\"")); + assert!(!json.contains("\"file_id\"")); + } + + #[test] + fn image_ref_file_id_only() { + let r = ImageRef { url: None, file_id: Some("f".to_string()) }; + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"file_id\":\"f\"")); + assert!(!json.contains("\"url\"")); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_image_edit_single() -> 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 api_key = get_test_api_key()?; + let result = image_edit(ImageEditArgs { + api_key, + prompt: "Render this as a pencil sketch with detailed shading".to_string(), + source_images: vec![ImageSource::Url( + "https://docs.x.ai/assets/api-examples/images/style-realistic.png".to_string() + )], + model: None, + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Edited {} image(s)", result.images.len()); + for (i, img) in result.images.iter().enumerate() { + println!(" [{}] url={:?}", i, img.url); + } + assert!(!result.images.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_edit/mod.rs b/crates/api_clients/grok_api_client/src/requests/image_edit/mod.rs new file mode 100644 index 0000000000..f8db9d920f --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_edit/mod.rs @@ -0,0 +1,2 @@ +pub mod image_edit; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/image_edit/request_types.rs b/crates/api_clients/grok_api_client/src/requests/image_edit/request_types.rs new file mode 100644 index 0000000000..d7fa4c26ae --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_edit/request_types.rs @@ -0,0 +1,57 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct ImageEditRequestBody { + pub prompt: String, + + /// Exactly one of `image` or `images` is sent (single vs. multi-image edit). + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// xAI image reference — exactly one of `url` or `file_id` is set. +#[derive(Serialize, Debug, Clone)] +pub(crate) struct ImageRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageEditResponseBody { + pub data: Vec, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageEditResponseDatum { + pub url: Option, + pub b64_json: Option, + pub revised_prompt: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_generation/image_generation.rs b/crates/api_clients/grok_api_client/src/requests/image_generation/image_generation.rs new file mode 100644 index 0000000000..afa4c29fdd --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_generation/image_generation.rs @@ -0,0 +1,245 @@ +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::image_generation::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const DEFAULT_MODEL: &str = "grok-imagine-image-quality"; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct ImageGenerationArgs { + pub api_key: GrokApiKey, + + /// Text prompt describing the image. Required. + pub prompt: String, + + /// Model identifier. Defaults to `grok-imagine-image-quality` if `None`. + pub model: Option, + + /// Number of images to generate. Server default (typically 1) if `None`. + pub n: Option, + + /// Aspect ratio (e.g. "1:1", "16:9", "auto"). Server default if `None`. + pub aspect_ratio: Option, + + /// Resolution (`"1k"` or `"2k"`). Server default if `None`. + pub resolution: Option, + + /// `"url"` (default) or `"b64_json"`. + 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)] +pub struct ImageGenerationSuccess { + /// One entry per generated image. Order matches the order xAI returned. + pub images: Vec, +} + +#[derive(Debug, Clone)] +pub struct GeneratedImage { + /// URL to the rendered image. Present when `response_format` was `"url"` + /// (the default). xAI URLs are time-limited. + pub url: Option, + + /// Base64-encoded image bytes. Present when `response_format` was `"b64_json"`. + pub b64_json: Option, + + /// xAI's revised version of the input prompt, if it edited it. + pub revised_prompt: Option, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/images/generations — generate one or more images +/// from a text prompt using xAI's Imagine API. +/// +/// Docs: +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()); + + info!( + "Grok image_generation: model={}, n={:?}, aspect_ratio={:?}, resolution={:?}", + model, args.n, args.aspect_ratio, args.resolution + ); + + let request_body = ImageGenerationRequestBody { + prompt: args.prompt, + model: Some(model), + n: args.n, + aspect_ratio: args.aspect_ratio, + resolution: args.resolution, + response_format: args.response_format.map(|f| f.as_str().to_string()), + user: args.user, + }; + + 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) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok image_generation response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: ImageGenerationResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(ImageGenerationSuccess { + images: parsed.data.into_iter().map(|d| GeneratedImage { + url: d.url, + b64_json: d.b64_json, + revised_prompt: d.revised_prompt, + }).collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Shape tests (no API calls) ── + + #[test] + fn request_body_serializes_minimal() { + let body = ImageGenerationRequestBody { + prompt: "a cat".to_string(), + model: Some("grok-imagine-image-quality".to_string()), + n: None, + aspect_ratio: None, + resolution: None, + response_format: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"a cat\"")); + assert!(json.contains("\"model\":\"grok-imagine-image-quality\"")); + // Optional fields should be omitted + assert!(!json.contains("\"n\"")); + assert!(!json.contains("\"aspect_ratio\"")); + assert!(!json.contains("\"user\"")); + } + + #[test] + fn request_body_serializes_full() { + let body = ImageGenerationRequestBody { + prompt: "a cat".to_string(), + model: Some("grok-imagine-image-quality".to_string()), + n: Some(2), + aspect_ratio: Some("16:9".to_string()), + resolution: Some("2k".to_string()), + response_format: Some("b64_json".to_string()), + user: Some("user_abc".to_string()), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"n\":2")); + assert!(json.contains("\"aspect_ratio\":\"16:9\"")); + assert!(json.contains("\"resolution\":\"2k\"")); + assert!(json.contains("\"response_format\":\"b64_json\"")); + assert!(json.contains("\"user\":\"user_abc\"")); + } + + #[test] + fn response_body_deserializes_url() { + let json = r#"{ + "data": [ + { "url": "https://imagine.x.ai/abc.png", "revised_prompt": "a cat" } + ] + }"#; + let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data.len(), 1); + assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/abc.png")); + assert!(parsed.data[0].b64_json.is_none()); + assert_eq!(parsed.data[0].revised_prompt.as_deref(), Some("a cat")); + } + + #[test] + fn response_body_deserializes_b64_json() { + let json = r#"{ + "data": [ + { "b64_json": "iVBORw0KGgo=" } + ] + }"#; + let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.data[0].b64_json.as_deref(), Some("iVBORw0KGgo=")); + 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"); + } + + // ── Live API tests (ignored — incur cost) ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_image_generation_simple() -> 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 api_key = get_test_api_key()?; + let result = image_generation(ImageGenerationArgs { + 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()), + response_format: None, + user: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Generated {} image(s)", result.images.len()); + for (i, img) in result.images.iter().enumerate() { + println!(" [{}] url={:?} revised_prompt={:?}", i, img.url, img.revised_prompt); + } + assert!(!result.images.is_empty()); + assert!(result.images[0].url.is_some(), "expected url-format response by default"); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/image_generation/mod.rs b/crates/api_clients/grok_api_client/src/requests/image_generation/mod.rs new file mode 100644 index 0000000000..7b81356be5 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_generation/mod.rs @@ -0,0 +1,2 @@ +pub mod image_generation; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/image_generation/request_types.rs b/crates/api_clients/grok_api_client/src/requests/image_generation/request_types.rs new file mode 100644 index 0000000000..7d966720bf --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/image_generation/request_types.rs @@ -0,0 +1,43 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct ImageGenerationRequestBody { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageGenerationResponseBody { + pub data: Vec, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct ImageGenerationResponseDatum { + pub url: Option, + pub b64_json: Option, + + /// xAI may revise the prompt (their docs call this "revised_prompt" in + /// OpenAI-compat responses). Optional, included if present. + pub revised_prompt: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/mod.rs b/crates/api_clients/grok_api_client/src/requests/mod.rs new file mode 100644 index 0000000000..8c21f83af9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/mod.rs @@ -0,0 +1,8 @@ +pub mod image_edit; +pub mod image_generation; +pub mod video_edit; +pub mod video_extension; +pub mod video_generation; +pub mod video_status; + +pub(crate) mod xai_host; diff --git a/crates/api_clients/grok_api_client/src/requests/video_edit/mod.rs b/crates/api_clients/grok_api_client/src/requests/video_edit/mod.rs new file mode 100644 index 0000000000..c7b840296b --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_edit/mod.rs @@ -0,0 +1,2 @@ +pub mod video_edit; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/video_edit/request_types.rs b/crates/api_clients/grok_api_client/src/requests/video_edit/request_types.rs new file mode 100644 index 0000000000..761b970704 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_edit/request_types.rs @@ -0,0 +1,42 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoEditRequestBody { + pub prompt: String, + + pub video: VideoSourceRef, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Optional `output.upload_url`. See `video_generation` for the + /// docs-vs-REST-spec discrepancy about whether this is required. + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoSourceRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoEditOutput { + pub upload_url: String, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoEditResponseBody { + pub request_id: String, +} 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 new file mode 100644 index 0000000000..9833f961a3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_edit/video_edit.rs @@ -0,0 +1,180 @@ +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::video_edit::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const DEFAULT_MODEL: &str = "grok-imagine-video"; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct VideoEditArgs { + pub api_key: GrokApiKey, + + /// Edit instruction. Required. + pub prompt: String, + + /// Source video to modify. + pub source_video: VideoSource, + + /// Model identifier. Defaults to `grok-imagine-video`. + pub model: Option, + + /// Optional presigned PUT URL. See `video_generation` for the + /// docs-vs-REST-spec discrepancy. + pub upload_url: Option, + + pub user: Option, +} + +#[derive(Clone, Debug)] +pub enum VideoSource { + Url(String), + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoEditSuccess { + /// Use this with `video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/edits — modify an existing video based on +/// a text prompt. Asynchronous; poll `video_status` for completion. +/// +/// xAI states the `duration`, `aspectRatio`, and `resolution` parameters are +/// ignored for video edits — the output mirrors the source video. +/// +/// Docs: +pub async fn video_edit(args: VideoEditArgs) -> Result { + let url = format!("{}/v1/videos/edits", XAI_API_BASE_URL); + let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + info!("Grok video_edit: model={}", model); + + let request_body = VideoEditRequestBody { + prompt: args.prompt, + video: to_video_source_ref(&args.source_video), + model: Some(model), + output: args.upload_url.map(|upload_url| VideoEditOutput { upload_url }), + user: args.user, + }; + + 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) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_edit response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoEditResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoEditSuccess { request_id: parsed.request_id }) +} + +fn to_video_source_ref(source: &VideoSource) -> VideoSourceRef { + match source { + VideoSource::Url(u) => VideoSourceRef { url: Some(u.clone()), file_id: None }, + VideoSource::FileId(id) => VideoSourceRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Shape tests ── + + #[test] + fn body_serializes_url_source() { + let body = VideoEditRequestBody { + prompt: "make it stormy".to_string(), + video: VideoSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None }, + model: Some("grok-imagine-video".to_string()), + output: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"make it stormy\"")); + assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}")); + assert!(json.contains("\"model\":\"grok-imagine-video\"")); + assert!(!json.contains("\"output\"")); + assert!(!json.contains("\"file_id\"")); + } + + #[test] + fn body_serializes_file_id_source_with_upload_url() { + let body = VideoEditRequestBody { + prompt: "p".to_string(), + video: VideoSourceRef { url: None, file_id: Some("file_v".to_string()) }, + model: None, + output: Some(VideoEditOutput { upload_url: "https://r2.example.com/put".to_string() }), + user: Some("u".to_string()), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}")); + assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}")); + assert!(json.contains("\"user\":\"u\"")); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "0199c33d-3afa-7000-b400-deadbeefcafe" }"#; + let parsed: VideoEditResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "0199c33d-3afa-7000-b400-deadbeefcafe"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_edit() -> 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 api_key = get_test_api_key()?; + // Replace with a real publicly-reachable mp4 URL when running. + let result = video_edit(VideoEditArgs { + api_key, + prompt: "Change the lighting to golden hour".to_string(), + source_video: VideoSource::Url( + "https://docs.x.ai/assets/api-examples/videos/edit-source.mp4".to_string() + ), + model: None, + upload_url: None, + user: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Edit request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/video_extension/mod.rs b/crates/api_clients/grok_api_client/src/requests/video_extension/mod.rs new file mode 100644 index 0000000000..1953d77b65 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_extension/mod.rs @@ -0,0 +1,2 @@ +pub mod video_extension; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/video_extension/request_types.rs b/crates/api_clients/grok_api_client/src/requests/video_extension/request_types.rs new file mode 100644 index 0000000000..7757363ccd --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_extension/request_types.rs @@ -0,0 +1,44 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoExtensionRequestBody { + pub prompt: String, + + pub video: VideoExtensionSourceRef, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Length of the *extension only*, not the total output. xAI default is 6 + /// seconds; range 1–10. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Optional `output.upload_url`. See `video_generation` for the + /// docs-vs-REST-spec discrepancy. + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoExtensionSourceRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoExtensionOutput { + pub upload_url: String, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoExtensionResponseBody { + pub request_id: 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 new file mode 100644 index 0000000000..9489350c36 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_extension/video_extension.rs @@ -0,0 +1,180 @@ +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::video_extension::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const DEFAULT_MODEL: &str = "grok-imagine-video"; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct VideoExtensionArgs { + pub api_key: GrokApiKey, + + /// Prompt describing what should happen in the extension. + pub prompt: String, + + /// Source video to extend. + pub source_video: VideoExtensionSource, + + /// Model identifier. Defaults to `grok-imagine-video`. + pub model: Option, + + /// Length of the *extension only*, not the total output. xAI default is 6 + /// seconds; range 1–10. + pub duration: Option, + + /// Optional presigned PUT URL. + pub upload_url: Option, +} + +#[derive(Clone, Debug)] +pub enum VideoExtensionSource { + Url(String), + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoExtensionSuccess { + /// Use this with `video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/extensions — extend an existing video with +/// additional generated content. +/// +/// Asynchronous; poll `video_status` for completion. +/// +/// Docs: +pub async fn video_extension(args: VideoExtensionArgs) -> Result { + let url = format!("{}/v1/videos/extensions", XAI_API_BASE_URL); + let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + info!("Grok video_extension: model={}, duration={:?}", model, args.duration); + + let request_body = VideoExtensionRequestBody { + prompt: args.prompt, + video: to_extension_source_ref(&args.source_video), + model: Some(model), + duration: args.duration, + output: args.upload_url.map(|upload_url| VideoExtensionOutput { upload_url }), + }; + + 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) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_extension response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoExtensionResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoExtensionSuccess { request_id: parsed.request_id }) +} + +fn to_extension_source_ref(source: &VideoExtensionSource) -> VideoExtensionSourceRef { + match source { + VideoExtensionSource::Url(u) => VideoExtensionSourceRef { url: Some(u.clone()), file_id: None }, + VideoExtensionSource::FileId(id) => VideoExtensionSourceRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Shape tests ── + + #[test] + fn body_serializes_minimal() { + let body = VideoExtensionRequestBody { + prompt: "keep walking".to_string(), + video: VideoExtensionSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None }, + model: Some("grok-imagine-video".to_string()), + duration: None, + output: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"keep walking\"")); + assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}")); + assert!(json.contains("\"model\":\"grok-imagine-video\"")); + assert!(!json.contains("\"duration\"")); + assert!(!json.contains("\"output\"")); + } + + #[test] + fn body_serializes_with_duration_and_upload_url() { + let body = VideoExtensionRequestBody { + prompt: "p".to_string(), + video: VideoExtensionSourceRef { url: None, file_id: Some("file_v".to_string()) }, + model: None, + duration: Some(8), + output: Some(VideoExtensionOutput { upload_url: "https://r2.example.com/put".to_string() }), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"duration\":8")); + assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}")); + assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}")); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "ext-abc-123" }"#; + let parsed: VideoExtensionResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "ext-abc-123"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_extension() -> 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 api_key = get_test_api_key()?; + // Replace with a real publicly-reachable mp4 URL when running. + let result = video_extension(VideoExtensionArgs { + api_key, + prompt: "Continue the walk down the same street".to_string(), + source_video: VideoExtensionSource::Url( + "https://docs.x.ai/assets/api-examples/videos/extension-source.mp4".to_string() + ), + model: None, + duration: Some(5), + upload_url: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Extension request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/video_generation/mod.rs b/crates/api_clients/grok_api_client/src/requests/video_generation/mod.rs new file mode 100644 index 0000000000..f58fdea9f1 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_generation/mod.rs @@ -0,0 +1,2 @@ +pub mod video_generation; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/video_generation/request_types.rs b/crates/api_clients/grok_api_client/src/requests/video_generation/request_types.rs new file mode 100644 index 0000000000..a4009a3753 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_generation/request_types.rs @@ -0,0 +1,60 @@ +use serde_derive::{Deserialize, Serialize}; + +// ── Request ── + +#[derive(Serialize, Debug)] +pub(crate) struct VideoGenerationRequestBody { + pub prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Image-to-video: single source image. Mutually exclusive with + /// `reference_images`. + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + + /// Reference-to-video: array of reference images. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_images: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + + /// Output upload target. Per xAI REST spec this is required for production + /// usage; per the published examples it's optional and xAI hosts the result + /// temporarily if omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +/// xAI image/video reference — exactly one of `url` or `file_id` is set. +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoImageRef { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub file_id: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub(crate) struct VideoOutput { + pub upload_url: String, +} + +// ── Response ── + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoGenerationResponseBody { + pub request_id: 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 new file mode 100644 index 0000000000..a544cbde73 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_generation/video_generation.rs @@ -0,0 +1,282 @@ +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::error::grok_specific_api_error::GrokSpecificApiError; +use crate::requests::video_generation::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +const DEFAULT_MODEL: &str = "grok-imagine-video"; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct VideoGenerationArgs { + pub api_key: GrokApiKey, + + /// Text prompt. Required. + pub prompt: String, + + /// Model identifier. Defaults to `grok-imagine-video`. + pub model: Option, + + /// Image-to-video: a single source image. Mutually exclusive with + /// `reference_images` — supplying both returns a `BadRequest` before the + /// HTTP call. + pub image: Option, + + /// Reference-to-video: zero or more reference images. + pub reference_images: Option>, + + /// e.g. "16:9", "1:1", "9:16". + pub aspect_ratio: Option, + + /// Duration in seconds (1–15). xAI default is 8. + pub duration: Option, + + /// `"480p"`, `"720p"`, `"1080p"`. + pub resolution: Option, + + /// Optional presigned PUT URL where xAI should upload the rendered video. + /// Per the REST API reference this is required; per the published curl + /// examples it's optional. Omit unless your account requires it. + pub upload_url: Option, + + pub user: Option, +} + +#[derive(Clone, Debug)] +pub enum VideoImageSource { + Url(String), + FileId(String), +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct VideoGenerationSuccess { + /// Use this with `video_status::video_status` to poll for completion. + pub request_id: String, +} + +// ── Implementation ── + +/// POST https://api.x.ai/v1/videos/generations — start a video generation +/// job. The video is rendered asynchronously; poll +/// `video_status::video_status(request_id)` until it returns `done` or +/// `failed`. +/// +/// Docs: +/// - +/// - +/// - +pub async fn video_generation(args: VideoGenerationArgs) -> Result { + if args.image.is_some() && args.reference_images.as_ref().is_some_and(|v| !v.is_empty()) { + return Err(GrokSpecificApiError::BadRequest( + "video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(), + ).into()); + } + + let url = format!("{}/v1/videos/generations", XAI_API_BASE_URL); + let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + info!( + "Grok video_generation: model={}, has_image={}, ref_imgs={}, aspect_ratio={:?}, duration={:?}, resolution={:?}", + model, + args.image.is_some(), + args.reference_images.as_ref().map(|v| v.len()).unwrap_or(0), + args.aspect_ratio, + args.duration, + args.resolution, + ); + + let request_body = VideoGenerationRequestBody { + prompt: args.prompt, + model: Some(model), + image: args.image.as_ref().map(to_video_image_ref), + reference_images: args.reference_images.map(|v| v.iter().map(to_video_image_ref).collect()), + aspect_ratio: args.aspect_ratio, + duration: args.duration, + resolution: args.resolution, + output: args.upload_url.map(|upload_url| VideoOutput { upload_url }), + user: args.user, + }; + + 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) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(GrokGenericApiError::ReqwestError)?; + + info!("Grok video_generation response: status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + Ok(VideoGenerationSuccess { request_id: parsed.request_id }) +} + +fn to_video_image_ref(source: &VideoImageSource) -> VideoImageRef { + match source { + VideoImageSource::Url(u) => VideoImageRef { url: Some(u.clone()), file_id: None }, + VideoImageSource::FileId(id) => VideoImageRef { url: None, file_id: Some(id.clone()) }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + // ── Shape tests ── + + #[test] + fn body_serializes_text_only() { + let body = VideoGenerationRequestBody { + prompt: "a cat dancing".to_string(), + model: Some("grok-imagine-video".to_string()), + image: None, + reference_images: None, + aspect_ratio: Some("16:9".to_string()), + duration: Some(5), + resolution: Some("720p".to_string()), + output: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"a cat dancing\"")); + assert!(json.contains("\"duration\":5")); + assert!(json.contains("\"resolution\":\"720p\"")); + assert!(!json.contains("\"image\"")); + assert!(!json.contains("\"reference_images\"")); + assert!(!json.contains("\"output\"")); + } + + #[test] + fn body_serializes_image_to_video() { + let body = VideoGenerationRequestBody { + prompt: "animate this".to_string(), + model: None, + image: Some(VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }), + reference_images: None, + aspect_ratio: None, + duration: None, + resolution: None, + output: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"image\":{")); + assert!(json.contains("\"url\":\"https://example.com/a.png\"")); + } + + #[test] + fn body_serializes_reference_to_video() { + let body = VideoGenerationRequestBody { + prompt: " walking".to_string(), + model: None, + image: None, + reference_images: Some(vec![ + VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }, + VideoImageRef { url: None, file_id: Some("file_xyz".to_string()) }, + ]), + aspect_ratio: None, + duration: None, + resolution: None, + output: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"reference_images\":[")); + assert!(json.contains("\"file_id\":\"file_xyz\"")); + } + + #[test] + fn body_serializes_upload_url() { + let body = VideoGenerationRequestBody { + prompt: "p".to_string(), + model: None, + image: None, + reference_images: None, + aspect_ratio: None, + duration: None, + resolution: None, + output: Some(VideoOutput { upload_url: "https://r2.example.com/put".to_string() }), + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}")); + } + + #[tokio::test] + async fn image_plus_reference_images_returns_bad_request() { + let api_key = GrokApiKey::new("dummy".to_string()); + let result = video_generation(VideoGenerationArgs { + api_key, + prompt: "x".to_string(), + model: None, + image: Some(VideoImageSource::Url("u".to_string())), + reference_images: Some(vec![VideoImageSource::Url("v".to_string())]), + aspect_ratio: None, + duration: None, + resolution: None, + upload_url: None, + user: None, + }).await; + let err = result.unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_)))); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{ "request_id": "d97415a1-5796-b7ec-379f-4e6819e08fdf" }"#; + let parsed: VideoGenerationResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.request_id, "d97415a1-5796-b7ec-379f-4e6819e08fdf"); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn live_test_video_generation_text_only() -> 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 api_key = get_test_api_key()?; + let result = video_generation(VideoGenerationArgs { + api_key, + prompt: "A glowing crystal rocket launching from Mars".to_string(), + model: None, + image: None, + reference_images: None, + aspect_ratio: Some("16:9".to_string()), + duration: Some(5), + resolution: Some("480p".to_string()), + upload_url: None, + user: None, + }).await.map_err(|e| anyhow::anyhow!("{}", e))?; + + println!("Video request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } +} diff --git a/crates/api_clients/grok_api_client/src/requests/video_status/mod.rs b/crates/api_clients/grok_api_client/src/requests/video_status/mod.rs new file mode 100644 index 0000000000..3ce4f7995e --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_status/mod.rs @@ -0,0 +1,2 @@ +pub mod video_status; +pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/requests/video_status/request_types.rs b/crates/api_clients/grok_api_client/src/requests/video_status/request_types.rs new file mode 100644 index 0000000000..6c59eeefc9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_status/request_types.rs @@ -0,0 +1,34 @@ +use serde_derive::Deserialize; + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusResponseBody { + pub status: String, + + pub progress: Option, + + pub model: Option, + + pub video: Option, + + pub error: Option, + + pub usage: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusVideo { + pub url: Option, + pub duration: Option, + pub respect_moderation: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusError { + pub code: String, + pub message: String, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct VideoStatusUsage { + pub cost_in_usd_ticks: Option, +} diff --git a/crates/api_clients/grok_api_client/src/requests/video_status/video_status.rs b/crates/api_clients/grok_api_client/src/requests/video_status/video_status.rs new file mode 100644 index 0000000000..52335661f7 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/video_status/video_status.rs @@ -0,0 +1,249 @@ +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::video_status::request_types::*; +use crate::requests::xai_host::XAI_API_BASE_URL; + +// ── Public args ── + +#[derive(Clone, Debug)] +pub struct VideoStatusArgs { + pub api_key: GrokApiKey, + + /// The `request_id` returned by a prior `video_generation`, + /// `video_edit`, or `video_extension` call. + pub request_id: String, +} + +// ── Public response ── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VideoStatusState { + /// Still working. + Pending, + /// Finished successfully — `video.url` is populated. + Done, + /// xAI couldn't render this — see `VideoJobFailed` in the error path. + Failed, +} + +#[derive(Debug, Clone)] +pub struct VideoStatusSuccess { + pub state: VideoStatusState, + + /// 0–100, may be `None` while still queued. + pub progress: Option, + + /// The model that fulfilled the request. + pub model: Option, + + /// Populated when `state == Done`. + pub video: Option, + + /// xAI cost reporting (in micro-dollars / "USD ticks"). + pub cost_in_usd_ticks: Option, +} + +#[derive(Debug, Clone)] +pub struct VideoOutputInfo { + /// Temporary xAI-hosted URL, or the caller-supplied upload URL once xAI has + /// finished uploading. + pub url: Option, + pub duration: Option, + pub respect_moderation: Option, +} + +// ── Implementation ── + +/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation / +/// edit / extension job for its current status. +/// +/// Returns: +/// - `Ok(VideoStatusSuccess { state: Pending, .. })` while the job is in flight +/// - `Ok(VideoStatusSuccess { state: Done, video: Some(..), .. })` when finished +/// - `Err(GrokSpecificApiError::VideoJobFailed { code, message })` when xAI +/// reports the job as failed +/// - `Err(GrokSpecificApiError::VideoJobExpired)` when the job expired +/// +/// Docs: +pub async fn video_status(args: VideoStatusArgs) -> Result { + let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, args.request_id); + + info!("Grok video_status: request_id={}", args.request_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 video_status response: http_status={}", status); + + classify_grok_http_error(status, Some(&response_body))?; + + let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body) + .map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + classify_status_field(&parsed) +} + +fn classify_status_field(parsed: &VideoStatusResponseBody) -> Result { + let state = match parsed.status.as_str() { + "pending" | "queued" | "in_progress" | "processing" => VideoStatusState::Pending, + "done" | "completed" | "succeeded" => VideoStatusState::Done, + "failed" => { + let (code, message) = parsed.error.as_ref() + .map(|e| (e.code.clone(), e.message.clone())) + .unwrap_or_else(|| ("unknown".to_string(), "video job failed without details".to_string())); + return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobFailed { code, message }.into()); + } + "expired" => { + return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobExpired.into()); + } + other => { + return Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: reqwest::StatusCode::OK, + body: format!("unknown video status: {:?}", other), + }.into()); + } + }; + + let video = parsed.video.as_ref().map(|v| VideoOutputInfo { + url: v.url.clone(), + duration: v.duration, + respect_moderation: v.respect_moderation, + }); + + Ok(VideoStatusSuccess { + state, + progress: parsed.progress, + model: parsed.model.clone(), + video, + cost_in_usd_ticks: parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use errors::AnyhowResult; + + use crate::error::grok_specific_api_error::GrokSpecificApiError; + + // ── Shape tests ── + + #[test] + fn pending_response_classifies_as_pending() { + let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed).unwrap(); + assert_eq!(result.state, VideoStatusState::Pending); + assert_eq!(result.progress, Some(12)); + assert_eq!(result.model.as_deref(), Some("grok-imagine-video")); + assert!(result.video.is_none()); + } + + #[test] + fn done_response_classifies_as_done_with_video() { + let json = r#"{ + "status": "done", + "progress": 100, + "model": "grok-imagine-video", + "video": { "url": "https://imagine.x.ai/v.mp4", "duration": 5, "respect_moderation": true }, + "usage": { "cost_in_usd_ticks": 42 } + }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed).unwrap(); + assert_eq!(result.state, VideoStatusState::Done); + let video = result.video.unwrap(); + assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4")); + assert_eq!(video.duration, Some(5)); + assert_eq!(video.respect_moderation, Some(true)); + assert_eq!(result.cost_in_usd_ticks, Some(42)); + } + + #[test] + fn failed_response_returns_video_job_failed_error() { + let json = r#"{ + "status": "failed", + "progress": 50, + "error": { "code": "invalid_argument", "message": "duration too long" } + }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let err = classify_status_field(&parsed).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => { + assert_eq!(code, "invalid_argument"); + assert_eq!(message, "duration too long"); + } + other => panic!("expected VideoJobFailed, got: {:?}", other), + } + } + + #[test] + fn failed_response_without_error_object_uses_unknown() { + let json = r#"{ "status": "failed", "progress": 0 }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let err = classify_status_field(&parsed).unwrap_err(); + match err { + GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, .. }) => { + assert_eq!(code, "unknown"); + } + other => panic!("expected VideoJobFailed(unknown), got: {:?}", other), + } + } + + #[test] + fn expired_response_returns_expired_error() { + let json = r#"{ "status": "expired" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let err = classify_status_field(&parsed).unwrap_err(); + assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired))); + } + + #[test] + fn unknown_status_field_returns_generic_error() { + let json = r#"{ "status": "magnificent" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let err = classify_status_field(&parsed).unwrap_err(); + assert!(matches!(err, GrokError::ApiGeneric(_))); + } + + // ── Live API tests ── + + #[tokio::test] + #[ignore] // manually test — requires real API key + an existing request_id + async fn live_test_video_status_unknown_id_is_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 api_key = get_test_api_key()?; + // Random UUID — should yield 404. + let result = video_status(VideoStatusArgs { + api_key, + request_id: "00000000-0000-0000-0000-000000000000".to_string(), + }).await; + + println!("Result: {:?}", result.as_ref().map(|s| (&s.state, s.progress))); + 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/xai_host.rs b/crates/api_clients/grok_api_client/src/requests/xai_host.rs new file mode 100644 index 0000000000..3aeefe4244 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/requests/xai_host.rs @@ -0,0 +1,2 @@ +/// Base URL for the xAI Imagine API. Every request is `{XAI_API_BASE_URL}/v1/...`. +pub(crate) const XAI_API_BASE_URL: &str = "https://api.x.ai"; diff --git a/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs b/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs new file mode 100644 index 0000000000..ca9efea7e9 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/get_test_api_key.rs @@ -0,0 +1,19 @@ +use std::fs::read_to_string; + +use errors::AnyhowResult; + +use crate::creds::grok_api_key::GrokApiKey; + +const GROK_API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/grok_api_key.txt"; + +/// Load a Grok (xAI) API key from the local credentials file. Whitespace is +/// trimmed because base64-style key files often pick up a trailing newline. +#[cfg(test)] +pub fn get_test_api_key() -> AnyhowResult { + let raw = read_to_string(GROK_API_KEY_PATH)?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + anyhow::bail!("Grok API key file is empty: {}", GROK_API_KEY_PATH); + } + Ok(GrokApiKey::new(trimmed)) +} diff --git a/crates/api_clients/grok_api_client/src/test_utils/mod.rs b/crates/api_clients/grok_api_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..25f5e48d47 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +pub(crate) mod get_test_api_key; + +#[cfg(test)] +pub(crate) mod setup_test_logging; diff --git a/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..53194d939b --- /dev/null +++ b/crates/api_clients/grok_api_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,15 @@ +use std::sync::Once; + +static INIT: Once = Once::new(); + +/// Initialize env_logger so live-API tests print request/response details. +/// Idempotent — safe to call from every test. +#[cfg(test)] +pub fn setup_test_logging() { + INIT.call_once(|| { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + }); +}