Compare commits

...
Author SHA1 Message Date
Brandon Thomas de55c3b195 tests 2026-05-23 20:45:14 -04:00
Brandon Thomas 6dca95557d adjust tests 2026-05-23 20:31:46 -04:00
Brandon Thomas c0d6106c08 update tests 2026-05-23 20:17:01 -04:00
Brandon Thomas 3b167c9a1a fix video apis 2026-05-23 20:09:11 -04:00
Brandon Thomas 39930cbed2 no 1080p 2026-05-23 19:48:41 -04:00
Brandon Thomas f25303cba5 update types 2026-05-23 19:23:27 -04:00
Brandon Thomas 4c766965be refactor 2026-05-23 18:52:13 -04:00
Brandon Thomas fee649e453 reshape api 2026-05-23 18:46:08 -04:00
Brandon Thomas db4e5ada29 refactor 2026-05-23 18:23:49 -04:00
Brandon Thomas e1fc1e678c enums 2026-05-23 18:08:56 -04:00
Brandon Thomas affba42619 api client adjustments 2026-05-23 18:00:41 -04:00
Brandon Thomas eeddfaa92b grok api client 2026-05-23 17:40:10 -04:00
Brandon Thomas dd3f7638e1 rename crate 2026-05-23 16:50:01 -04:00
197 changed files with 4016 additions and 58 deletions
Generated
+19 -2
View File
@@ -711,7 +711,7 @@ dependencies = [
"fal_client",
"filesys",
"futures 0.3.32",
"grok_client",
"grok_consumer_client",
"image",
"images",
"indexmap 2.13.0",
@@ -4898,7 +4898,24 @@ dependencies = [
]
[[package]]
name = "grok_client"
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",
"test_data",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "grok_consumer_client"
version = "0.0.1"
dependencies = [
"anyhow",
+4 -2
View File
@@ -14,7 +14,8 @@ members = [
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_downloader",
"crates/api_clients/google_sign_in",
"crates/api_clients/grok_client",
"crates/api_clients/grok_api_client",
"crates/api_clients/grok_consumer_client",
"crates/api_clients/gmicloud_client",
"crates/api_clients/midjourney_client",
"crates/api_clients/muapi_client",
@@ -253,7 +254,8 @@ 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_client = { path = "crates/api_clients/grok_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" }
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
@@ -0,0 +1,34 @@
[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
# `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
[dev-dependencies]
anyhow.workspace = true
env_logger.workspace = true
test_data.workspace = true
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,2 @@
pub mod requests;
pub mod types;
@@ -0,0 +1,115 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::files::delete_file::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
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;
// ── Public args ──
/// Top-level argument to [`delete_file`]. Borrows the API key separately from
/// the request body so callers can log/save [`DeleteFileRequest`] without
/// leaking the credential.
#[derive(Clone, Debug)]
pub struct DeleteFileArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: DeleteFileRequest,
}
/// The material part of a delete-file request. Derives [`Serialize`].
#[derive(Clone, Debug, Serialize)]
pub struct DeleteFileRequest {
pub file_id: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct DeleteFileSuccess {
pub file_id: Option<String>,
/// 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: <https://docs.x.ai/developers/rest-api-reference/files/manage>
pub async fn delete_file(args: DeleteFileArgs<'_>) -> Result<DeleteFileSuccess, GrokError> {
let req = args.request;
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id);
info!("Grok delete_file: file_id={}", req.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());
}
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = DeleteFileArgs {
api_key: &key,
request: DeleteFileRequest { file_id: "file_abc".to_string() },
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"file_id\":\"file_abc\""));
}
}
@@ -0,0 +1,2 @@
pub mod delete_file;
pub(crate) mod request_types;
@@ -0,0 +1,12 @@
use serde_derive::Deserialize;
/// Response shape for DELETE /v1/files/{file_id}.
///
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct DeleteFileResponseBody {
pub id: Option<String>,
/// xAI returns `true` on a successful delete.
pub deleted: Option<bool>,
}
@@ -0,0 +1,144 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
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;
// ── Public args ──
/// Top-level argument to [`download_file`]. Borrows the API key separately
/// from the request body so callers can log/save [`DownloadFileRequest`]
/// without leaking the credential.
#[derive(Clone, Debug)]
pub struct DownloadFileArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: DownloadFileRequest,
}
/// The material part of a download-file request. Derives [`Serialize`].
#[derive(Clone, Debug, Serialize)]
pub struct DownloadFileRequest {
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.
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<DownloadFormat>,
}
#[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",
}
}
}
// Serialize as the wire string ("original" or "text").
impl serde::Serialize for DownloadFormat {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct DownloadFileSuccess {
/// Raw file content. The xAI response Content-Type is `application/octet-stream`.
pub bytes: Vec<u8>,
}
// ── Implementation ──
/// GET https://api.x.ai/v1/files/{file_id}/content — download the raw bytes
/// of a previously-uploaded file.
///
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/download>
pub async fn download_file(args: DownloadFileArgs<'_>) -> Result<DownloadFileSuccess, GrokError> {
let req = args.request;
let mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, req.file_id);
if let Some(fmt) = req.format {
url.push_str("?format=");
url.push_str(fmt.as_str());
}
info!("Grok download_file: file_id={}", req.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");
}
#[test]
fn download_format_serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&DownloadFormat::Original).unwrap(), "\"original\"");
assert_eq!(serde_json::to_string(&DownloadFormat::Text).unwrap(), "\"text\"");
}
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = DownloadFileArgs {
api_key: &key,
request: DownloadFileRequest {
file_id: "file_abc".to_string(),
format: Some(DownloadFormat::Original),
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"file_id\":\"file_abc\""));
assert!(json.contains("\"format\":\"original\""));
}
}
@@ -0,0 +1 @@
pub mod download_file;
@@ -0,0 +1,141 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::files::get_file_metadata::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
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;
// ── Public args ──
/// Top-level argument to [`get_file_metadata`]. Borrows the API key separately
/// from the request body so callers can log/save [`GetFileMetadataRequest`]
/// without leaking the credential.
#[derive(Clone, Debug)]
pub struct GetFileMetadataArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: GetFileMetadataRequest,
}
/// The material part of a get-file-metadata request. Derives [`Serialize`].
#[derive(Clone, Debug, Serialize)]
pub struct GetFileMetadataRequest {
pub file_id: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub file_id: String,
pub bytes: Option<u64>,
pub created_at: Option<i64>,
pub expires_at: Option<i64>,
pub filename: Option<String>,
pub purpose: Option<String>,
}
// ── Implementation ──
/// GET https://api.x.ai/v1/files/{file_id} — fetch metadata for an uploaded
/// file (no content; use `download_file` for that).
///
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
pub async fn get_file_metadata(args: GetFileMetadataArgs<'_>) -> Result<FileMetadata, GrokError> {
let req = args.request;
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id);
info!("Grok get_file_metadata: file_id={}", req.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));
}
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = GetFileMetadataArgs {
api_key: &key,
request: GetFileMetadataRequest { file_id: "file_abc".to_string() },
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"file_id\":\"file_abc\""));
}
#[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 api_key = get_test_api_key()?;
let result = get_file_metadata(GetFileMetadataArgs {
api_key: &api_key,
request: GetFileMetadataRequest {
file_id: "file_doesnotexist000000".to_string(),
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
"expected NotFound, got: {:?}", err);
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod get_file_metadata;
pub(crate) mod request_types;
@@ -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: <https://docs.x.ai/developers/rest-api-reference/files/manage>
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct FileMetadataResponseBody {
pub id: String,
pub bytes: Option<u64>,
pub created_at: Option<i64>,
pub expires_at: Option<i64>,
pub filename: Option<String>,
pub purpose: Option<String>,
}
@@ -0,0 +1,4 @@
pub mod delete_file;
pub mod download_file;
pub mod get_file_metadata;
pub mod upload_file;
@@ -0,0 +1,2 @@
pub mod upload_file;
pub(crate) mod request_types;
@@ -0,0 +1,14 @@
use serde_derive::Deserialize;
/// Response shape for POST /v1/files. Matches OpenAI-style file objects.
///
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/upload>
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct UploadFileResponseBody {
pub id: String,
pub bytes: Option<u64>,
pub created_at: Option<i64>,
pub expires_at: Option<i64>,
pub filename: Option<String>,
pub purpose: Option<String>,
}
@@ -0,0 +1,312 @@
use std::fmt;
use log::info;
use reqwest::multipart::{Form, Part};
use serde_derive::Serialize;
use crate::api::requests::files::upload_file::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
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;
const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
// ── Public args ──
/// Top-level argument to [`upload_file`]. Borrows the API key separately from
/// the request body so callers can log/save [`UploadFileRequest`] without
/// leaking the credential.
#[derive(Clone, Debug)]
pub struct UploadFileArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: UploadFileRequest,
}
/// The material part of a file-upload request.
///
/// Derives [`Serialize`] for log/audit, BUT `file_bytes` is skipped during
/// serialization (a megabyte-scale binary blob shouldn't end up in a JSON
/// log). [`Debug`] is implemented manually to print the byte length instead
/// of the raw contents, so accidental `{:?}` formatting can't dump the file.
#[derive(Clone, Serialize)]
pub struct UploadFileRequest {
/// Raw bytes of the file. xAI caps uploads at 50 MB. Skipped from both
/// `Serialize` and `Debug` output — log/save the rest of the request and
/// recover the bytes from disk or cache out-of-band.
#[serde(skip)]
pub file_bytes: Vec<u8>,
/// 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`.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_content_type: Option<String>,
/// 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.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_after_seconds: Option<u32>,
/// OpenAI-compatibility label. xAI accepts but doesn't enforce this — set
/// it if porting code that already uses OpenAI's Files API.
#[serde(skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
}
impl fmt::Debug for UploadFileRequest {
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
// the entire file into the log line.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UploadFileRequest")
.field("file_bytes_len", &self.file_bytes.len())
.field("filename", &self.filename)
.field("maybe_content_type", &self.maybe_content_type)
.field("expires_after_seconds", &self.expires_after_seconds)
.field("purpose", &self.purpose)
.finish()
}
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct UploadFileSuccess {
/// The xAI file identifier. Use this in the `FileId(_)` variant of
/// [`crate::api::requests::images::image_edit::image_edit::ImageSource`],
/// [`crate::api::requests::videos::video_generation::video_generation::VideoImageSource`],
/// [`crate::api::requests::videos::video_edit::video_edit::VideoSource`], or
/// [`crate::api::requests::videos::video_extension::video_extension::VideoExtensionSource`].
pub file_id: String,
pub bytes: Option<u64>,
pub created_at: Option<i64>,
pub expires_at: Option<i64>,
pub filename: Option<String>,
pub purpose: Option<String>,
}
// ── 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:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
pub async fn upload_file(args: UploadFileArgs<'_>) -> Result<UploadFileSuccess, GrokError> {
let req = args.request;
if req.file_bytes.is_empty() {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
"upload_file: file_bytes is empty".to_string(),
).into());
}
if req.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", req.file_bytes.len()),
).into());
}
let url = format!("{}/v1/files", XAI_API_BASE_URL);
info!(
"Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}",
req.filename, req.file_bytes.len(), req.expires_after_seconds, req.purpose
);
let mut file_part = Part::bytes(req.file_bytes).file_name(req.filename.clone());
if let Some(ct) = req.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) = req.expires_after_seconds {
form = form.text("expires_after", secs.to_string());
}
if let Some(purpose) = req.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 api_key = GrokApiKey::new("dummy".to_string());
let result = upload_file(UploadFileArgs {
api_key: &api_key,
request: UploadFileRequest {
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 api_key = GrokApiKey::new("dummy".to_string());
let oversized: Vec<u8> = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
let result = upload_file(UploadFileArgs {
api_key: &api_key,
request: UploadFileRequest {
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(_))));
}
// ── Public Request shape — must not leak api_key OR file_bytes ──
#[test]
fn request_serializes_without_api_key_and_without_file_bytes() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = UploadFileArgs {
api_key: &key,
request: UploadFileRequest {
file_bytes: vec![0x89, 0x50, 0x4E, 0x47], // "PNG" header — recognizable
filename: "photo.png".to_string(),
maybe_content_type: Some("image/png".to_string()),
expires_after_seconds: Some(3600),
purpose: None,
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"), "api_key leaked in: {}", json);
assert!(!json.contains("PNG"), "file_bytes leaked in: {}", json);
assert!(!json.contains("file_bytes"), "file_bytes field present in: {}", json);
assert!(json.contains("\"filename\":\"photo.png\""));
assert!(json.contains("\"maybe_content_type\":\"image/png\""));
assert!(json.contains("\"expires_after_seconds\":3600"));
assert!(!json.contains("\"purpose\""));
}
#[test]
fn debug_does_not_dump_file_bytes() {
let req = UploadFileRequest {
file_bytes: vec![0u8; 1_000_000],
filename: "huge.bin".to_string(),
maybe_content_type: None,
expires_after_seconds: None,
purpose: None,
};
let debug = format!("{:?}", req);
assert!(debug.contains("file_bytes_len: 1000000"), "expected length, got: {}", debug);
assert!(!debug.contains(", 0, 0, 0, 0"), "raw bytes leaked into Debug: {}", debug);
}
// ── 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<u8> = 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 api_key = get_test_api_key()?;
let result = upload_file(UploadFileArgs {
api_key: &api_key,
request: UploadFileRequest {
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(())
}
}
@@ -0,0 +1,372 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::images::image_edit::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
use crate::api::types::image_types::image_model::ImageModel;
use crate::api::types::image_types::image_resolution::ImageResolution;
use crate::api::types::image_types::image_response_format::ImageResponseFormat;
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;
// ── Public args ──
/// Top-level argument to [`image_edit`]. Borrows the API key separately from
/// the request body so callers can log/save [`ImageEditRequest`] without
/// leaking the credential.
#[derive(Clone, Debug)]
pub struct ImageEditArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: ImageEditRequest,
}
/// The material part of an image-edit request. Derives [`Serialize`] so it
/// can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct ImageEditRequest {
/// 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<ImageSource>,
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<ImageModel>,
/// Number of edited images to render. Server default is 1 when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub number_images: Option<u32>,
/// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to
/// matching the first input image.
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<ImageAspectRatio>,
/// Output resolution tier. See [`ImageResolution`].
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<ImageResolution>,
/// `Url` (default) or `B64Json`.
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ImageResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
/// Source image — pick by URL or by `file_id` (from a prior upload).
#[derive(Clone, Debug, Serialize)]
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::api::requests::files::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:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
FileId(String),
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct ImageEditSuccess {
pub images: Vec<EditedImage>,
}
#[derive(Debug, Clone)]
pub struct EditedImage {
pub url: Option<String>,
pub b64_json: Option<String>,
pub revised_prompt: Option<String>,
}
// ── Implementation ──
/// POST https://api.x.ai/v1/images/edits — edit one or more source images
/// according to a text prompt.
///
/// Docs:
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
/// - <https://docs.x.ai/developers/model-capabilities/images/multi-image-editing>
pub async fn image_edit(args: ImageEditArgs<'_>) -> Result<ImageEditSuccess, GrokError> {
let req = args.request;
if req.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 = req.model.unwrap_or(ImageModel::GrokImagineImageQuality);
info!(
"Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}",
model.as_str(), req.source_images.len(),
req.aspect_ratio.map(|a| a.as_str()),
req.resolution.map(|r| r.as_str()),
);
// Single source → `image`. Multiple sources → `images`.
let (image, images) = if req.source_images.len() == 1 {
(Some(to_image_ref(&req.source_images[0])), None)
} else {
(None, Some(req.source_images.iter().map(to_image_ref).collect()))
};
let request_body = ImageEditRequestBody {
prompt: req.prompt,
image,
images,
model: Some(model.as_str().to_string()),
n: req.number_images,
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
resolution: req.resolution.map(|r| r.as_str().to_string()),
response_format: req.response_format.map(|f| f.as_str().to_string()),
user: req.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;
// ── Wire-format shape tests (against the internal ImageEditRequestBody) ──
#[test]
fn wire_body_single_source_uses_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 wire_body_multi_source_uses_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\""));
}
// ── Public-API shape tests: ImageEditRequest must serialize cleanly ──
#[test]
fn request_serializes_with_typed_enums() {
let req = ImageEditRequest {
prompt: "edit me".to_string(),
source_images: vec![ImageSource::Url("https://example.com/a.png".to_string())],
model: Some(ImageModel::GrokImagineImageQuality),
number_images: Some(2),
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
resolution: Some(ImageResolution::TwoK),
response_format: Some(ImageResponseFormat::B64Json),
user: Some("user_xyz".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"prompt\":\"edit me\""));
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
assert!(json.contains("\"resolution\":\"2k\""));
assert!(json.contains("\"response_format\":\"b64_json\""));
assert!(json.contains("\"number_images\":2"));
}
#[test]
fn request_omits_none_fields() {
let req = ImageEditRequest {
prompt: "p".to_string(),
source_images: vec![ImageSource::FileId("file_abc".to_string())],
model: None,
number_images: None,
aspect_ratio: None,
resolution: None,
response_format: None,
user: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("\"model\""));
assert!(!json.contains("\"aspect_ratio\""));
assert!(!json.contains("\"resolution\""));
assert!(!json.contains("\"user\""));
assert!(!json.contains("\"number_images\""));
}
#[test]
fn request_does_not_serialize_api_key() {
let key = GrokApiKey::new("secret_value_must_not_leak".to_string());
let args = ImageEditArgs {
api_key: &key,
request: ImageEditRequest {
prompt: "p".to_string(),
source_images: vec![ImageSource::Url("u".to_string())],
model: None,
number_images: None,
aspect_ratio: None,
resolution: None,
response_format: None,
user: None,
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_value_must_not_leak"),
"serialized request must not contain the API key. got: {}", json);
}
#[tokio::test]
async fn empty_sources_returns_bad_request() {
let api_key = GrokApiKey::new("dummy".to_string());
let result = image_edit(ImageEditArgs {
api_key: &api_key,
request: ImageEditRequest {
prompt: "edit".to_string(),
source_images: vec![],
model: None,
number_images: 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;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
setup_test_logging();
let api_key = get_test_api_key()?;
let result = image_edit(ImageEditArgs {
api_key: &api_key,
request: ImageEditRequest {
prompt: "Render this as a pencil sketch with detailed shading".to_string(),
source_images: vec![ImageSource::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())],
model: None,
number_images: 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(())
}
}
@@ -0,0 +1,2 @@
pub mod image_edit;
pub(crate) mod request_types;
@@ -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<ImageRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<ImageRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
// ── Response ──
#[derive(Deserialize, Debug)]
pub(crate) struct ImageEditResponseBody {
pub data: Vec<ImageEditResponseDatum>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct ImageEditResponseDatum {
pub url: Option<String>,
pub b64_json: Option<String>,
pub revised_prompt: Option<String>,
}
@@ -0,0 +1,293 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::images::image_generation::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
use crate::api::types::image_types::image_model::ImageModel;
use crate::api::types::image_types::image_resolution::ImageResolution;
use crate::api::types::image_types::image_response_format::ImageResponseFormat;
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;
// ── Public args ──
/// Top-level argument to [`image_generation`]. Borrows the API key separately
/// from the request body so callers can log/save [`ImageGenerationRequest`]
/// without leaking the credential.
#[derive(Clone, Debug)]
pub struct ImageGenerationArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: ImageGenerationRequest,
}
/// The material part of an image-generation request. Derives [`Serialize`] so
/// it can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct ImageGenerationRequest {
/// Text prompt describing the image. Required.
pub prompt: String,
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`]
/// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed
/// in the enum.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<ImageModel>,
/// Number of images to render in this request. xAI's docs don't state a
/// hard maximum; server default is 1 when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub number_images: Option<u32>,
/// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted
/// values. Server default when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<ImageAspectRatio>,
/// Output resolution tier. See [`ImageResolution`]. Server default when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<ImageResolution>,
/// Url (default) or b64 inline. See [`ImageResponseFormat`].
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ImageResponseFormat>,
/// Optional opaque user identifier for usage attribution.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct ImageGenerationSuccess {
/// One entry per generated image. Order matches the order xAI returned.
pub images: Vec<GeneratedImage>,
}
#[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<String>,
/// Base64-encoded image bytes. Present when `response_format` was `"b64_json"`.
pub b64_json: Option<String>,
/// xAI's revised version of the input prompt, if it edited it.
pub revised_prompt: Option<String>,
}
// ── Implementation ──
/// POST https://api.x.ai/v1/images/generations — generate one or more images
/// from a text prompt using xAI's Imagine API.
///
/// Docs: <https://docs.x.ai/developers/model-capabilities/images/generation>
pub async fn image_generation(args: ImageGenerationArgs<'_>) -> Result<ImageGenerationSuccess, GrokError> {
let req = args.request;
let url = format!("{}/v1/images/generations", XAI_API_BASE_URL);
let model = req.model.unwrap_or(ImageModel::GrokImagineImageQuality);
info!(
"Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}",
model.as_str(), req.number_images,
req.aspect_ratio.map(|a| a.as_str()),
req.resolution.map(|r| r.as_str()),
);
let request_body = ImageGenerationRequestBody {
prompt: req.prompt,
model: Some(model.as_str().to_string()),
n: req.number_images,
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
resolution: req.resolution.map(|r| r.as_str().to_string()),
response_format: req.response_format.map(|f| f.as_str().to_string()),
user: req.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;
// ── Wire-format shape tests (internal ImageGenerationRequestBody) ──
#[test]
fn wire_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\""));
assert!(!json.contains("\"n\""));
assert!(!json.contains("\"aspect_ratio\""));
assert!(!json.contains("\"user\""));
}
#[test]
fn wire_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\""));
}
// ── Public-API shape tests: ImageGenerationRequest must serialize cleanly ──
#[test]
fn request_serializes_with_typed_enums() {
let req = ImageGenerationRequest {
prompt: "a serene lake".to_string(),
model: Some(ImageModel::GrokImagineImageQuality),
number_images: Some(3),
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
resolution: Some(ImageResolution::OneK),
response_format: Some(ImageResponseFormat::Url),
user: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"prompt\":\"a serene lake\""));
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
assert!(json.contains("\"number_images\":3"));
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
assert!(json.contains("\"resolution\":\"1k\""));
assert!(json.contains("\"response_format\":\"url\""));
assert!(!json.contains("\"user\""));
}
#[test]
fn request_does_not_serialize_api_key() {
let key = GrokApiKey::new("secret_value_must_not_leak".to_string());
let args = ImageGenerationArgs {
api_key: &key,
request: ImageGenerationRequest {
prompt: "p".to_string(),
model: None,
number_images: None,
aspect_ratio: None,
resolution: None,
response_format: None,
user: None,
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_value_must_not_leak"),
"serialized request must not contain the API key. got: {}", json);
}
#[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());
}
// ── 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: &api_key,
request: ImageGenerationRequest {
prompt: "A serene mountain lake at sunrise, photorealistic".to_string(),
model: None,
number_images: None,
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
resolution: Some(ImageResolution::OneK),
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(())
}
}
@@ -0,0 +1,2 @@
pub mod image_generation;
pub(crate) mod request_types;
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
// ── Response ──
#[derive(Deserialize, Debug)]
pub(crate) struct ImageGenerationResponseBody {
pub data: Vec<ImageGenerationResponseDatum>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct ImageGenerationResponseDatum {
pub url: Option<String>,
pub b64_json: Option<String>,
/// xAI may revise the prompt (their docs call this "revised_prompt" in
/// OpenAI-compat responses). Optional, included if present.
pub revised_prompt: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod image_edit;
pub mod image_generation;
@@ -0,0 +1,5 @@
pub mod files;
pub mod images;
pub mod videos;
pub(crate) mod xai_host;
@@ -0,0 +1,4 @@
pub mod video_edit;
pub mod video_extension;
pub mod video_generation;
pub mod video_status;
@@ -0,0 +1,2 @@
pub mod video_edit;
pub(crate) mod request_types;
@@ -0,0 +1,32 @@
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
#[derive(Serialize, Debug, Clone)]
pub(crate) struct VideoSourceRef {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
// ── Response ──
#[derive(Deserialize, Debug)]
pub(crate) struct VideoEditResponseBody {
pub request_id: String,
}
@@ -0,0 +1,214 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::videos::video_edit::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::api::types::video_types::video_model::VideoModel;
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;
// ── Public args ──
/// Top-level argument to [`video_edit`]. Borrows the API key separately from
/// the request body so callers can log/save [`VideoEditRequest`] without
/// leaking the credential.
#[derive(Clone, Debug)]
pub struct VideoEditArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: VideoEditRequest,
}
/// The material part of a video-edit request. Derives [`Serialize`] so it
/// can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct VideoEditRequest {
/// Edit instruction. Required.
pub prompt: String,
/// Source video to modify (xAI reads this as the input).
pub source_video: VideoSource,
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<VideoModel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
/// Source video to edit. Pick by public URL or by a previously-uploaded
/// xAI file_id.
#[derive(Clone, Debug, Serialize)]
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::api::requests::files::upload_file::upload_file::upload_file`].
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
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: <https://docs.x.ai/developers/model-capabilities/video/editing>
pub async fn video_edit(args: VideoEditArgs<'_>) -> Result<VideoEditSuccess, GrokError> {
let req = args.request;
let url = format!("{}/v1/videos/edits", XAI_API_BASE_URL);
let model = req.model.unwrap_or(VideoModel::GrokImagineVideo);
info!("Grok video_edit: model={}", model.as_str());
let request_body = VideoEditRequestBody {
prompt: req.prompt,
video: to_video_source_ref(&req.source_video),
model: Some(model.as_str().to_string()),
user: req.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;
// ── Wire-format shape tests ──
#[test]
fn wire_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()),
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("\"file_id\""));
}
#[test]
fn wire_body_serializes_file_id_source() {
let body = VideoEditRequestBody {
prompt: "p".to_string(),
video: VideoSourceRef { url: None, file_id: Some("file_v".to_string()) },
model: None,
user: Some("u".to_string()),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
assert!(json.contains("\"user\":\"u\""));
}
// ── Public Request shape ──
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = VideoEditArgs {
api_key: &key,
request: VideoEditRequest {
prompt: "p".to_string(),
source_video: VideoSource::FileId("file_abc".to_string()),
model: None,
user: None,
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"source_video\":{\"FileId\":\"file_abc\"}"));
}
#[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;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
setup_test_logging();
let api_key = get_test_api_key()?;
let result = video_edit(VideoEditArgs {
api_key: &api_key,
request: VideoEditRequest {
prompt: "Change the lighting to midnight. Starry sky with the mily way overhead. Add some shooting stars. Everything is glowing under the starlight.".to_string(),
source_video: VideoSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()),
model: None,
user: None,
},
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Edit request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod video_extension;
pub(crate) mod request_types;
@@ -0,0 +1,34 @@
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<String>,
/// 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<u32>,
}
#[derive(Serialize, Debug, Clone)]
pub(crate) struct VideoExtensionSourceRef {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
// ── Response ──
#[derive(Deserialize, Debug)]
pub(crate) struct VideoExtensionResponseBody {
pub request_id: String,
}
@@ -0,0 +1,215 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::videos::video_extension::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::api::types::video_types::video_model::VideoModel;
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;
// ── Public args ──
/// Top-level argument to [`video_extension`]. Borrows the API key separately
/// from the request body so callers can log/save [`VideoExtensionRequest`]
/// without leaking the credential.
#[derive(Clone, Debug)]
pub struct VideoExtensionArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: VideoExtensionRequest,
}
/// The material part of a video-extension request. Derives [`Serialize`] so
/// it can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct VideoExtensionRequest {
/// Prompt describing what should happen in the extension.
pub prompt: String,
/// Source video to extend.
pub source_video: VideoExtensionSource,
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<VideoModel>,
/// 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<u32>,
}
/// Source video to extend. Pick by public URL or by a previously-uploaded
/// xAI file_id.
#[derive(Clone, Debug, Serialize)]
pub enum VideoExtensionSource {
/// Public HTTPS URL pointing to the source video.
Url(String),
/// xAI file identifier (`file_...`) obtained from a successful upload via
/// [`crate::api::requests::files::upload_file::upload_file::upload_file`].
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
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: <https://docs.x.ai/developers/model-capabilities/video/extension>
pub async fn video_extension(args: VideoExtensionArgs<'_>) -> Result<VideoExtensionSuccess, GrokError> {
let req = args.request;
let url = format!("{}/v1/videos/extensions", XAI_API_BASE_URL);
let model = req.model.unwrap_or(VideoModel::GrokImagineVideo);
info!("Grok video_extension: model={}, duration={:?}", model.as_str(), req.duration);
let request_body = VideoExtensionRequestBody {
prompt: req.prompt,
video: to_extension_source_ref(&req.source_video),
model: Some(model.as_str().to_string()),
duration: req.duration,
};
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;
// ── Wire-format shape tests ──
#[test]
fn wire_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,
};
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\""));
}
#[test]
fn wire_body_serializes_with_duration() {
let body = VideoExtensionRequestBody {
prompt: "p".to_string(),
video: VideoExtensionSourceRef { url: None, file_id: Some("file_v".to_string()) },
model: None,
duration: Some(8),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"duration\":8"));
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
}
// ── Public Request shape ──
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = VideoExtensionArgs {
api_key: &key,
request: VideoExtensionRequest {
prompt: "p".to_string(),
source_video: VideoExtensionSource::Url("u".to_string()),
model: None,
duration: Some(5),
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"duration\":5"));
assert!(json.contains("\"source_video\":{\"Url\":\"u\"}"));
}
#[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;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
setup_test_logging();
let api_key = get_test_api_key()?;
let result = video_extension(VideoExtensionArgs {
api_key: &api_key,
request: VideoExtensionRequest {
prompt: "Continue the walk down the same street".to_string(),
source_video: VideoExtensionSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()),
model: None,
duration: Some(5),
},
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Extension request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod video_generation;
pub(crate) mod request_types;
@@ -0,0 +1,49 @@
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<String>,
/// Image-to-video: single source image. Mutually exclusive with
/// `reference_images`.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<VideoImageRef>,
/// Reference-to-video: array of reference images.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_images: Option<Vec<VideoImageRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
// ── Response ──
#[derive(Deserialize, Debug)]
pub(crate) struct VideoGenerationResponseBody {
pub request_id: String,
}
@@ -0,0 +1,388 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::videos::video_generation::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio;
use crate::api::types::video_types::video_model::VideoModel;
use crate::api::types::video_types::video_resolution::VideoResolution;
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;
// ── Public args ──
/// Top-level argument to [`video_generation`]. Borrows the API key separately
/// from the request body so callers can log/save [`VideoGenerationRequest`]
/// without leaking the credential.
#[derive(Clone, Debug)]
pub struct VideoGenerationArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: VideoGenerationRequest,
}
/// The material part of a video-generation request. Derives [`Serialize`] so
/// it can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct VideoGenerationRequest {
/// Text prompt. Required.
pub prompt: String,
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<VideoModel>,
/// Image-to-video: a single source image. Mutually exclusive with
/// `reference_images` — supplying both returns a `BadRequest` before the
/// HTTP call.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<VideoImageSource>,
/// Reference-to-video: zero or more reference images.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_images: Option<Vec<VideoImageSource>>,
/// Aspect ratio. See [`VideoAspectRatio`] for the closed set of accepted
/// values (7 ratios — note the video endpoint accepts fewer than image
/// endpoints). Server default is `16:9` when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<VideoAspectRatio>,
/// Duration in seconds (1–15). xAI default is 8.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u32>,
/// Output resolution tier. See [`VideoResolution`] (`480p` or `720p`).
/// Server default is `480p` when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<VideoResolution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
/// Source image for `image` (image-to-video) or `reference_images`
/// (reference-to-video).
#[derive(Clone, Debug, Serialize)]
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::api::requests::files::upload_file::upload_file::upload_file`]. The file
/// must still exist at request time.
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
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:
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
/// - <https://docs.x.ai/developers/model-capabilities/video/image-to-video>
/// - <https://docs.x.ai/developers/model-capabilities/video/reference-to-video>
pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGenerationSuccess, GrokError> {
let req = args.request;
if req.image.is_some() && req.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 = req.model.unwrap_or(VideoModel::GrokImagineVideo);
info!(
"Grok video_generation: model={}, has_image={}, ref_imgs={}, aspect_ratio={:?}, duration={:?}, resolution={:?}",
model.as_str(),
req.image.is_some(),
req.reference_images.as_ref().map(|v| v.len()).unwrap_or(0),
req.aspect_ratio.map(|a| a.as_str()),
req.duration,
req.resolution.map(|r| r.as_str()),
);
let request_body = VideoGenerationRequestBody {
prompt: req.prompt,
model: Some(model.as_str().to_string()),
image: req.image.as_ref().map(to_video_image_ref),
reference_images: req.reference_images.map(|v| v.iter().map(to_video_image_ref).collect()),
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
duration: req.duration,
resolution: req.resolution.map(|r| r.as_str().to_string()),
user: req.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 crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use test_data::web::image_urls::{SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
// ── Wire-format shape tests ──
#[test]
fn wire_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()),
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\""));
}
#[test]
fn wire_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,
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 wire_body_serializes_reference_to_video() {
let body = VideoGenerationRequestBody {
prompt: "<IMAGE_1> 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,
user: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"reference_images\":["));
assert!(json.contains("\"file_id\":\"file_xyz\""));
}
// ── Public Request shape ──
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = VideoGenerationArgs {
api_key: &key,
request: VideoGenerationRequest {
prompt: "p".to_string(),
model: Some(VideoModel::GrokImagineVideo),
image: Some(VideoImageSource::Url("u".to_string())),
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
duration: Some(8),
resolution: Some(VideoResolution::SevenTwentyP),
user: None,
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"),
"serialized request must not contain the API key. got: {}", json);
assert!(json.contains("\"prompt\":\"p\""));
assert!(json.contains("\"model\":\"grok-imagine-video\""));
assert!(json.contains("\"image\":{\"Url\":\"u\"}"));
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
assert!(json.contains("\"resolution\":\"720p\""));
}
#[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: &api_key,
request: VideoGenerationRequest {
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,
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<()> {
setup_test_logging();
let api_key = get_test_api_key()?;
let result = video_generation(VideoGenerationArgs {
api_key: &api_key,
request: VideoGenerationRequest {
prompt: "A glowing crystal rocket launching from Mars".to_string(),
model: None,
image: None,
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
duration: Some(5),
resolution: Some(VideoResolution::FourEightyP),
user: None,
},
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Video request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
/// First-frame-to-video: a single source image becomes the opening frame
/// and the prompt drives the animation. Uses `image` (NOT
/// `reference_images`) — that's the xAI distinction between
/// image-to-video and reference-to-video.
#[tokio::test]
#[ignore] // manually test — requires real API key and incurs costs
async fn live_test_video_generation_first_frame_to_video() -> AnyhowResult<()> {
setup_test_logging();
let api_key = get_test_api_key()?;
let result = video_generation(VideoGenerationArgs {
api_key: &api_key,
request: VideoGenerationRequest {
prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(),
model: None,
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
duration: Some(5),
resolution: Some(VideoResolution::SevenTwentyP),
user: None,
},
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("First-frame video request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
/// Reference-to-video: multiple source images influence the generated
/// video. xAI accepts up to 3. The prompt references them by index using
/// `<IMAGE_1>`, `<IMAGE_2>`, … placeholders per xAI's reference-to-video
/// docs.
///
/// Different aspect ratio than the first-frame test (portrait 9:16
/// instead of landscape 16:9) so the two live tests don't accidentally
/// share a code path.
#[tokio::test]
#[ignore] // manually test — requires real API key and incurs costs
async fn live_test_video_generation_reference_images() -> AnyhowResult<()> {
setup_test_logging();
let api_key = get_test_api_key()?;
let result = video_generation(VideoGenerationArgs {
api_key: &api_key,
request: VideoGenerationRequest {
prompt: "The dogs from <IMAGE_1> in the scene from <IMAGE_2>. Make them play together.".to_string(),
model: None,
image: None,
reference_images: Some(vec![
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
VideoImageSource::Url(SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL.to_string()),
]),
aspect_ratio: Some(VideoAspectRatio::Portrait9x16),
duration: Some(5),
resolution: Some(VideoResolution::SevenTwentyP),
user: None,
},
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Reference-to-video request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod video_status;
pub(crate) mod request_types;
@@ -0,0 +1,34 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct VideoStatusResponseBody {
pub status: String,
pub progress: Option<u8>,
pub model: Option<String>,
pub video: Option<VideoStatusVideo>,
pub error: Option<VideoStatusError>,
pub usage: Option<VideoStatusUsage>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct VideoStatusVideo {
pub url: Option<String>,
pub duration: Option<u32>,
pub respect_moderation: Option<bool>,
}
#[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<u64>,
}
@@ -0,0 +1,363 @@
use log::info;
use serde_derive::Serialize;
use crate::api::requests::videos::video_status::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
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;
// ── Public args ──
/// Top-level argument to [`video_status`]. Borrows the API key separately from
/// the request body so callers can log/save [`VideoStatusRequest`] without
/// leaking the credential.
#[derive(Clone, Debug)]
pub struct VideoStatusArgs<'a> {
pub api_key: &'a GrokApiKey,
pub request: VideoStatusRequest,
}
/// The material part of a video-status poll. Derives [`Serialize`] so it
/// can be persisted to a log or audit store independently of the API key.
#[derive(Clone, Debug, Serialize)]
pub struct VideoStatusRequest {
/// 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<u8>,
/// The model that fulfilled the request.
pub model: Option<String>,
/// Populated when `state == Done`.
pub video: Option<VideoOutputInfo>,
/// xAI cost reporting (in micro-dollars / "USD ticks").
pub cost_in_usd_ticks: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct VideoOutputInfo {
/// Temporary xAI-hosted URL, or the caller-supplied upload URL once xAI has
/// finished uploading.
pub url: Option<String>,
pub duration: Option<u32>,
pub respect_moderation: Option<bool>,
}
// ── 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: <https://docs.x.ai/developers/model-capabilities/video/generation>
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSuccess, GrokError> {
let req = args.request;
let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, req.request_id);
info!("Grok video_status: request_id={}", req.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<VideoStatusSuccess, GrokError> {
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(_)));
}
// ── Public Request shape ──
#[test]
fn request_serializes_without_api_key() {
let key = GrokApiKey::new("secret_must_not_leak".to_string());
let args = VideoStatusArgs {
api_key: &key,
request: VideoStatusRequest {
request_id: "req_abc".to_string(),
},
};
let json = serde_json::to_string(&args.request).unwrap();
assert!(!json.contains("secret_must_not_leak"));
assert!(json.contains("\"request_id\":\"req_abc\""));
}
// ── 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: &api_key,
request: VideoStatusRequest {
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(())
}
/// Polls a known request_id and prints the result. Doesn't assert a
/// specific state — depending on when the test runs, the job may be
/// pending, done, failed, or expired. The point is to exercise the
/// happy-path against the real xAI API.
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_video_status_known_id() -> 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_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Err(e) => println!("status error: {:?}", e),
}
// No assertion on the specific state — the job may have completed,
// failed, or expired by the time this runs. We just want to confirm
// the request_id was accepted and parsed.
Ok(())
}
/// Polls another known request_id. Same lax assertions as the test above
/// — state varies with time.
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_video_status_known_id_2() -> 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_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
request_id: "7b2fa3dc-8e63-9100-b117-faffb3178773".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
}
/// Polls yet another known request_id.
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_video_status_known_id_3() -> 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_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
}
}
@@ -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";
@@ -0,0 +1,128 @@
/// Aspect ratios accepted by the xAI image endpoints.
///
/// The wire format is `"<width>:<height>"` — e.g. `Square` serialises as
/// `"1:1"`, `Cinematic20x9` as `"20:9"`. `Auto` defers to xAI to pick.
///
/// xAI documents this closed set; see
/// <https://docs.x.ai/developers/rest-api-reference/inference/images>.
#[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",
}
}
}
// Serialize as the wire string ("1:1", "16:9", "auto", …) rather than the
// default external-tag enum form. Lets request structs round-trip cleanly
// through a log/audit pipeline.
impl serde::Serialize for ImageAspectRatio {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[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");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&ImageAspectRatio::Square).unwrap(), "\"1:1\"");
assert_eq!(serde_json::to_string(&ImageAspectRatio::Portrait9x19_5).unwrap(), "\"9:19.5\"");
assert_eq!(serde_json::to_string(&ImageAspectRatio::Auto).unwrap(), "\"auto\"");
}
}
@@ -0,0 +1,96 @@
/// Models accepted by the xAI image endpoints (`/v1/images/generations` and
/// `/v1/images/edits`). Both endpoints accept all variants here.
///
/// 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:
/// - Models list: <https://docs.x.ai/docs/models>
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageModel {
/// `grok-imagine-image` — the standard image model. $0.02 / image as of
/// the docs at time of writing; lower cost and faster than the `-quality`
/// variant, at the expense of fidelity.
GrokImagineImage,
/// `grok-imagine-image-quality` — the higher-quality image model. $0.05 /
/// image as of the docs at time of writing. xAI recommends this as the
/// default for new requests.
GrokImagineImageQuality,
/// Escape hatch for model identifiers not yet enumerated here.
///
/// Useful for forward compat and for legacy/deprecated identifiers (e.g.
/// `grok-imagine-image-pro`, deprecated as of 2026-05-15) that still
/// function until xAI removes them.
Custom(String),
}
impl ImageModel {
/// Wire representation — the exact string xAI expects in the `"model"` field.
pub fn as_str(&self) -> &str {
match self {
Self::GrokImagineImage => "grok-imagine-image",
Self::GrokImagineImageQuality => "grok-imagine-image-quality",
Self::Custom(s) => s.as_str(),
}
}
}
// Serialize as the wire string ("grok-imagine-image", "grok-imagine-image-quality",
// or the Custom inner string) rather than the default external-tag enum format.
// Lets `ImageGenerationRequest` / `ImageEditRequest` round-trip through any
// log/audit pipeline as readable JSON.
impl serde::Serialize for ImageModel {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_model_serializes() {
assert_eq!(ImageModel::GrokImagineImage.as_str(), "grok-imagine-image");
}
#[test]
fn quality_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");
}
#[test]
fn deprecated_pro_model_still_usable_via_custom() {
let m = ImageModel::Custom("grok-imagine-image-pro".to_string());
assert_eq!(m.as_str(), "grok-imagine-image-pro");
}
#[test]
fn standard_and_quality_are_distinct_strings() {
assert_ne!(
ImageModel::GrokImagineImage.as_str(),
ImageModel::GrokImagineImageQuality.as_str()
);
}
#[test]
fn serializes_as_plain_wire_string() {
let m = ImageModel::GrokImagineImageQuality;
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-quality\"");
let m = ImageModel::GrokImagineImage;
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image\"");
let m = ImageModel::Custom("grok-imagine-image-pro".to_string());
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-pro\"");
}
}
@@ -0,0 +1,51 @@
/// 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:
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
#[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",
}
}
}
// Serialize as the wire string ("1k", "2k").
impl serde::Serialize for ImageResolution {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[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");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&ImageResolution::OneK).unwrap(), "\"1k\"");
assert_eq!(serde_json::to_string(&ImageResolution::TwoK).unwrap(), "\"2k\"");
}
}
@@ -0,0 +1,48 @@
/// 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:
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
#[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",
}
}
}
// Serialize as the wire string ("url" or "b64_json").
impl serde::Serialize for ImageResponseFormat {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[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");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&ImageResponseFormat::Url).unwrap(), "\"url\"");
assert_eq!(serde_json::to_string(&ImageResponseFormat::B64Json).unwrap(), "\"b64_json\"");
}
}
@@ -0,0 +1,10 @@
//! Shared image-endpoint enums (model, aspect ratio, resolution, response format).
//!
//! These are reused by both [`crate::api::requests::image_generation`] and
//! [`crate::api::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;
@@ -0,0 +1,2 @@
pub mod image_types;
pub mod video_types;
@@ -0,0 +1,14 @@
//! Shared video-endpoint enums (model, aspect ratio, resolution).
//!
//! `VideoModel` is reused by every video endpoint
//! ([`crate::api::requests::videos::video_generation`],
//! [`crate::api::requests::videos::video_edit`],
//! [`crate::api::requests::videos::video_extension`]).
//!
//! `VideoAspectRatio` and `VideoResolution` are accepted only by
//! [`crate::api::requests::videos::video_generation`] — the edit and
//! extension endpoints inherit dimensions from the source video.
pub mod video_aspect_ratio;
pub mod video_model;
pub mod video_resolution;
@@ -0,0 +1,101 @@
/// Aspect ratios accepted by `/v1/videos/generations`.
///
/// The wire format is `"<width>:<height>"` — e.g. [`VideoAspectRatio::Square`]
/// serialises as `"1:1"`, [`VideoAspectRatio::Landscape16x9`] as `"16:9"`.
///
/// Note: the video endpoint accepts FEWER aspect ratios than image endpoints
/// — there's no `auto`, no ultra-portrait `9:19.5` or `9:20`, no `1:2`/`2:1`.
/// xAI documents this closed set of 7.
///
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
/// don't accept this field — they inherit the source video's dimensions.
///
/// Docs:
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
/// - <https://docs.x.ai/developers/rest-api-reference/inference/videos>
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum VideoAspectRatio {
/// 1:1 square.
Square,
/// 16:9 landscape (widescreen / YouTube). xAI default.
Landscape16x9,
/// 9:16 portrait (phone / vertical video).
Portrait9x16,
/// 4:3 landscape (classic TV).
Landscape4x3,
/// 3:4 portrait.
Portrait3x4,
/// 3:2 landscape (photo).
Landscape3x2,
/// 2:3 portrait (photo).
Portrait2x3,
}
impl VideoAspectRatio {
/// 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::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Landscape4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Landscape3x2 => "3:2",
Self::Portrait2x3 => "2:3",
}
}
}
// Serialize as the wire string ("1:1", "16:9", …) rather than the default
// external-tag enum form.
impl serde::Serialize for VideoAspectRatio {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_variant_maps_to_a_distinct_string() {
let all = [
VideoAspectRatio::Square,
VideoAspectRatio::Landscape16x9,
VideoAspectRatio::Portrait9x16,
VideoAspectRatio::Landscape4x3,
VideoAspectRatio::Portrait3x4,
VideoAspectRatio::Landscape3x2,
VideoAspectRatio::Portrait2x3,
];
let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
strs.sort();
strs.dedup();
assert_eq!(strs.len(), 7, "expected 7 distinct wire values, got {:?}", strs);
}
#[test]
fn matches_docs_strings() {
assert_eq!(VideoAspectRatio::Square.as_str(), "1:1");
assert_eq!(VideoAspectRatio::Landscape16x9.as_str(), "16:9");
assert_eq!(VideoAspectRatio::Portrait9x16.as_str(), "9:16");
assert_eq!(VideoAspectRatio::Landscape4x3.as_str(), "4:3");
assert_eq!(VideoAspectRatio::Portrait3x4.as_str(), "3:4");
assert_eq!(VideoAspectRatio::Landscape3x2.as_str(), "3:2");
assert_eq!(VideoAspectRatio::Portrait2x3.as_str(), "2:3");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&VideoAspectRatio::Landscape16x9).unwrap(), "\"16:9\"");
assert_eq!(serde_json::to_string(&VideoAspectRatio::Square).unwrap(), "\"1:1\"");
}
}
@@ -0,0 +1,65 @@
/// Models accepted by the xAI video endpoints (`/v1/videos/generations`,
/// `/v1/videos/edits`, `/v1/videos/extensions`). All three endpoints accept
/// the same model identifier.
///
/// xAI may add or deprecate models faster than this crate ships releases, so
/// the [`VideoModel::Custom`] escape hatch lets callers pass an arbitrary
/// identifier without waiting for a code change.
///
/// Docs:
/// - Models list: <https://docs.x.ai/docs/models>
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
/// - <https://docs.x.ai/developers/model-capabilities/video/editing>
/// - <https://docs.x.ai/developers/model-capabilities/video/extension>
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VideoModel {
/// `grok-imagine-video` — the (currently sole) video model. Used by
/// generation, editing, and extension endpoints.
GrokImagineVideo,
/// Escape hatch for model identifiers not yet enumerated here.
Custom(String),
}
impl VideoModel {
/// Wire representation — the exact string xAI expects in the `"model"` field.
pub fn as_str(&self) -> &str {
match self {
Self::GrokImagineVideo => "grok-imagine-video",
Self::Custom(s) => s.as_str(),
}
}
}
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
// string) rather than the default external-tag enum format. Lets the public
// VideoGeneration/Edit/ExtensionRequest types round-trip through any
// log/audit pipeline as readable JSON.
impl serde::Serialize for VideoModel {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_model_serializes() {
assert_eq!(VideoModel::GrokImagineVideo.as_str(), "grok-imagine-video");
}
#[test]
fn custom_model_passes_through() {
let m = VideoModel::Custom("grok-imagine-video-future".to_string());
assert_eq!(m.as_str(), "grok-imagine-video-future");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&VideoModel::GrokImagineVideo).unwrap(), "\"grok-imagine-video\"");
let m = VideoModel::Custom("grok-imagine-video-v2".to_string());
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-video-v2\"");
}
}
@@ -0,0 +1,62 @@
/// Output resolution for `/v1/videos/generations`.
///
/// Currently `480p` (server default) and `720p` are the only supported output
/// resolutions. The xAI REST schema also lists `"1080p"`, but the capability
/// page contradicts this — it documents that "a 1080p input will be downsized
/// to 720p", indicating 720p is the actual output cap. We follow the
/// capability page since it describes observed behavior rather than just the
/// wire schema.
///
/// If/when xAI removes the cap and 1080p is genuinely produced as output,
/// add a `TenEightyP` variant here.
///
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
/// don't accept this field — they inherit the source video's resolution
/// (capped at 720p).
///
/// Docs:
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
/// - <https://docs.x.ai/developers/rest-api-reference/inference/videos>
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum VideoResolution {
/// `"480p"` — standard definition, faster processing. Server default.
FourEightyP,
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
SevenTwentyP,
}
impl VideoResolution {
/// Wire representation — the exact string xAI expects in the
/// `"resolution"` field.
pub fn as_str(&self) -> &'static str {
match self {
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
}
}
}
// Serialize as the wire string ("480p", "720p", "1080p").
impl serde::Serialize for VideoResolution {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_docs_strings() {
assert_eq!(VideoResolution::FourEightyP.as_str(), "480p");
assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p");
}
#[test]
fn serializes_as_plain_wire_string() {
assert_eq!(serde_json::to_string(&VideoResolution::FourEightyP).unwrap(), "\"480p\"");
assert_eq!(serde_json::to_string(&VideoResolution::SevenTwentyP).unwrap(), "\"720p\"");
}
}
@@ -0,0 +1,21 @@
/// Holds the API key for authenticating with the Grok (xAI Imagine) API.
///
/// Sent on every request as `Authorization: Bearer <api_key>`.
#[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(<redacted>)")
}
}
@@ -0,0 +1 @@
pub mod grok_api_key;
@@ -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<String> {
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 { .. })));
}
}
@@ -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),
}
}
}
@@ -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<GrokClientError> for GrokError {
fn from(error: GrokClientError) -> Self {
Self::Client(error)
}
}
impl From<GrokSpecificApiError> for GrokError {
fn from(error: GrokSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<GrokGenericApiError> for GrokError {
fn from(error: GrokGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -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),
}
}
}
@@ -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"),
}
}
}
@@ -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;
@@ -0,0 +1,7 @@
#[cfg(test)]
pub(crate) mod test_utils;
// Public API
pub mod api;
pub mod creds;
pub mod error;
@@ -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<GrokApiKey> {
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))
}
@@ -0,0 +1,5 @@
#[cfg(test)]
pub(crate) mod get_test_api_key;
#[cfg(test)]
pub(crate) mod setup_test_logging;
@@ -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();
});
}
@@ -1,5 +1,5 @@
[package]
name = "grok_client"
name = "grok_consumer_client"
edition = "2024"
version = "0.0.1"
publish = false
@@ -8,7 +8,7 @@ publish = false
normal = ["workspace-build-acceleration"]
[lib]
name = "grok_client"
name = "grok_consumer_client"
path = "src/lib.rs"
[dependencies]

Some files were not shown because too many files have changed in this diff Show More