api client adjustments

This commit is contained in:
Brandon Thomas committed 2026-05-23 18:00:41 -04:00
1 parent eeddfaa92b
commit affba42619
23 files changed
+950 -73

No files matched your search

@@ -20,7 +20,9 @@ errors.workspace = true
# External
log.workspace = true
reqwest.workspace = true
# `multipart` is enabled on top of the workspace feature set so we can use
# `reqwest::multipart` for POST /v1/files uploads.
reqwest = { workspace = true, features = ["multipart"] }
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
@@ -0,0 +1,92 @@
use log::info;
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::delete_file::request_types::*;
use crate::requests::xai_host::XAI_API_BASE_URL;
// ── Public args ──
#[derive(Clone, Debug)]
pub struct DeleteFileArgs {
pub api_key: GrokApiKey,
pub file_id: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct DeleteFileSuccess {
pub file_id: Option<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 url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id);
info!("Grok delete_file: file_id={}", args.file_id);
let client = reqwest::Client::builder()
.build()
.map_err(GrokClientError::ReqwestClientError)?;
let bearer = format!("Bearer {}", args.api_key.api_key);
let response = client.delete(&url)
.header("Authorization", bearer)
.send()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok delete_file response: status={}", status);
classify_grok_http_error(status, Some(&response_body))?;
let parsed: DeleteFileResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
Ok(DeleteFileSuccess {
file_id: parsed.id,
deleted: parsed.deleted.unwrap_or(false),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn response_body_deserializes_success() {
// serde drops the `object` field on parse; only `id` and `deleted` are
// load-bearing for our DTO.
let json = r#"{ "id": "file_abc", "object": "file", "deleted": true }"#;
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id.as_deref(), Some("file_abc"));
assert_eq!(parsed.deleted, Some(true));
}
#[test]
fn response_body_deserializes_minimal() {
// xAI may omit some fields; only `deleted` is load-bearing.
let json = r#"{ "deleted": true }"#;
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.deleted, Some(true));
assert!(parsed.id.is_none());
}
}
@@ -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,103 @@
use log::info;
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::xai_host::XAI_API_BASE_URL;
// ── Public args ──
#[derive(Clone, Debug)]
pub struct DownloadFileArgs {
pub api_key: GrokApiKey,
pub file_id: String,
/// Optional `?format=` query — `"original"` returns the bytes as uploaded;
/// `"text"` returns a textual transcription where applicable. Omit for the
/// xAI default.
pub format: Option<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",
}
}
}
// ── 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 mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, args.file_id);
if let Some(fmt) = args.format {
url.push_str("?format=");
url.push_str(fmt.as_str());
}
info!("Grok download_file: file_id={}", args.file_id);
let client = reqwest::Client::builder()
.build()
.map_err(GrokClientError::ReqwestClientError)?;
let bearer = format!("Bearer {}", args.api_key.api_key);
let response = client.get(&url)
.header("Authorization", bearer)
.send()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
// On error, read as text so the classifier can quote it back.
if !status.is_success() {
let body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
classify_grok_http_error(status, Some(&body))?;
// classify_grok_http_error always returns Err on non-success.
unreachable!();
}
let bytes = response.bytes()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok download_file response: status={}, bytes={}", status, bytes.len());
Ok(DownloadFileSuccess { bytes: bytes.to_vec() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_format_serializes() {
assert_eq!(DownloadFormat::Original.as_str(), "original");
assert_eq!(DownloadFormat::Text.as_str(), "text");
}
}
@@ -0,0 +1 @@
pub mod download_file;
@@ -0,0 +1,115 @@
use log::info;
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::get_file_metadata::request_types::*;
use crate::requests::xai_host::XAI_API_BASE_URL;
// ── Public args ──
#[derive(Clone, Debug)]
pub struct GetFileMetadataArgs {
pub api_key: GrokApiKey,
pub file_id: String,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub file_id: String,
pub bytes: Option<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 url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id);
info!("Grok get_file_metadata: file_id={}", args.file_id);
let client = reqwest::Client::builder()
.build()
.map_err(GrokClientError::ReqwestClientError)?;
let bearer = format!("Bearer {}", args.api_key.api_key);
let response = client.get(&url)
.header("Authorization", bearer)
.send()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok get_file_metadata response: status={}", status);
classify_grok_http_error(status, Some(&response_body))?;
let parsed: FileMetadataResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
Ok(FileMetadata {
file_id: parsed.id,
bytes: parsed.bytes,
created_at: parsed.created_at,
expires_at: parsed.expires_at,
filename: parsed.filename,
purpose: parsed.purpose,
})
}
#[cfg(test)]
mod tests {
use super::*;
use errors::AnyhowResult;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
#[test]
fn response_body_deserializes() {
let json = r#"{
"id": "file_abc123",
"object": "file",
"bytes": 4096,
"created_at": 1716489600,
"expires_at": 1719081600,
"filename": "photo.png",
"purpose": "vision"
}"#;
let parsed: FileMetadataResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "file_abc123");
assert_eq!(parsed.expires_at, Some(1719081600));
}
#[tokio::test]
#[ignore] // manually test — requires real API key + valid file_id
async fn live_test_unknown_file_returns_not_found() -> AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
setup_test_logging();
let result = get_file_metadata(GetFileMetadataArgs {
api_key: get_test_api_key()?,
file_id: "file_doesnotexist000000".to_string(),
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
"expected NotFound, got: {:?}", err);
Ok(())
}
}
@@ -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>,
}
@@ -7,10 +7,12 @@ use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
use crate::requests::image_edit::request_types::*;
use crate::requests::image_types::image_aspect_ratio::ImageAspectRatio;
use crate::requests::image_types::image_model::ImageModel;
use crate::requests::image_types::image_resolution::ImageResolution;
use crate::requests::image_types::image_response_format::ImageResponseFormat;
use crate::requests::xai_host::XAI_API_BASE_URL;
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
// ── Public args ──
#[derive(Clone, Debug)]
@@ -24,38 +26,43 @@ pub struct ImageEditArgs {
/// The output aspect ratio follows the first input image unless overridden.
pub source_images: Vec<ImageSource>,
/// Model identifier. Defaults to `grok-imagine-image-quality` if `None`.
pub model: Option<String>,
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`.
pub model: Option<ImageModel>,
/// Number of edited images to render. Server default is 1 when `None`.
pub number_images: Option<u32>,
/// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to
/// matching the first input image.
pub aspect_ratio: Option<ImageAspectRatio>,
/// Output resolution tier. See [`ImageResolution`].
pub resolution: Option<ImageResolution>,
/// `Url` (default) or `B64Json`.
pub response_format: Option<ImageResponseFormat>,
pub n: Option<u32>,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub response_format: Option<ImageEditResponseFormat>,
pub user: Option<String>,
}
/// Source image — pick by URL or by file_id (from a prior /v1/files upload).
/// Source image — pick by URL or by `file_id` (from a prior upload).
#[derive(Clone, Debug)]
pub enum ImageSource {
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
/// image bytes.
Url(String),
/// xAI file identifier (`file_...`) obtained from a successful upload via
/// [`crate::requests::upload_file::upload_file::upload_file`]. The file
/// must still exist at request time (it has not been deleted and any
/// `expires_after` has not lapsed).
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
FileId(String),
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ImageEditResponseFormat {
Url,
B64Json,
}
impl ImageEditResponseFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Url => "url",
Self::B64Json => "b64_json",
}
}
}
// ── Public response ──
#[derive(Debug, Clone)]
@@ -86,11 +93,13 @@ pub async fn image_edit(args: ImageEditArgs) -> Result<ImageEditSuccess, GrokErr
}
let url = format!("{}/v1/images/edits", XAI_API_BASE_URL);
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let model = args.model.unwrap_or(ImageModel::GrokImagineImageQuality);
info!(
"Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}",
model, args.source_images.len(), args.aspect_ratio, args.resolution
model.as_str(), args.source_images.len(),
args.aspect_ratio.map(|a| a.as_str()),
args.resolution.map(|r| r.as_str()),
);
// Single source → `image`. Multiple sources → `images`.
@@ -104,10 +113,10 @@ pub async fn image_edit(args: ImageEditArgs) -> Result<ImageEditSuccess, GrokErr
prompt: args.prompt,
image,
images,
model: Some(model),
n: args.n,
aspect_ratio: args.aspect_ratio,
resolution: args.resolution,
model: Some(model.as_str().to_string()),
n: args.number_images,
aspect_ratio: args.aspect_ratio.map(|a| a.as_str().to_string()),
resolution: args.resolution.map(|r| r.as_str().to_string()),
response_format: args.response_format.map(|f| f.as_str().to_string()),
user: args.user,
};
@@ -211,7 +220,7 @@ mod tests {
prompt: "edit".to_string(),
source_images: vec![],
model: None,
n: None,
number_images: None,
aspect_ratio: None,
resolution: None,
response_format: None,
@@ -261,7 +270,7 @@ mod tests {
"https://docs.x.ai/assets/api-examples/images/style-realistic.png".to_string()
)],
model: None,
n: None,
number_images: None,
aspect_ratio: None,
resolution: None,
response_format: None,
@@ -6,10 +6,12 @@ use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::image_generation::request_types::*;
use crate::requests::image_types::image_aspect_ratio::ImageAspectRatio;
use crate::requests::image_types::image_model::ImageModel;
use crate::requests::image_types::image_resolution::ImageResolution;
use crate::requests::image_types::image_response_format::ImageResponseFormat;
use crate::requests::xai_host::XAI_API_BASE_URL;
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
// ── Public args ──
#[derive(Clone, Debug)]
@@ -19,40 +21,29 @@ pub struct ImageGenerationArgs {
/// Text prompt describing the image. Required.
pub prompt: String,
/// Model identifier. Defaults to `grok-imagine-image-quality` if `None`.
pub model: Option<String>,
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`]
/// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed
/// in the enum.
pub model: Option<ImageModel>,
/// Number of images to generate. Server default (typically 1) if `None`.
pub n: Option<u32>,
/// Number of images to render in this request. xAI's docs don't state a
/// hard maximum; server default is 1 when `None`.
pub number_images: Option<u32>,
/// Aspect ratio (e.g. "1:1", "16:9", "auto"). Server default if `None`.
pub aspect_ratio: Option<String>,
/// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted
/// values. Server default when `None`.
pub aspect_ratio: Option<ImageAspectRatio>,
/// Resolution (`"1k"` or `"2k"`). Server default if `None`.
pub resolution: Option<String>,
/// Output resolution tier. See [`ImageResolution`]. Server default when `None`.
pub resolution: Option<ImageResolution>,
/// `"url"` (default) or `"b64_json"`.
/// Url (default) or b64 inline. See [`ImageResponseFormat`].
pub response_format: Option<ImageResponseFormat>,
/// Optional opaque user identifier for usage attribution.
pub user: Option<String>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ImageResponseFormat {
Url,
B64Json,
}
impl ImageResponseFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Url => "url",
Self::B64Json => "b64_json",
}
}
}
// ── Public response ──
#[derive(Debug, Clone)]
@@ -83,19 +74,21 @@ pub struct GeneratedImage {
pub async fn image_generation(args: ImageGenerationArgs) -> Result<ImageGenerationSuccess, GrokError> {
let url = format!("{}/v1/images/generations", XAI_API_BASE_URL);
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let model = args.model.unwrap_or(ImageModel::GrokImagineImageQuality);
info!(
"Grok image_generation: model={}, n={:?}, aspect_ratio={:?}, resolution={:?}",
model, args.n, args.aspect_ratio, args.resolution
"Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}",
model.as_str(), args.number_images,
args.aspect_ratio.map(|a| a.as_str()),
args.resolution.map(|r| r.as_str()),
);
let request_body = ImageGenerationRequestBody {
prompt: args.prompt,
model: Some(model),
n: args.n,
aspect_ratio: args.aspect_ratio,
resolution: args.resolution,
model: Some(model.as_str().to_string()),
n: args.number_images,
aspect_ratio: args.aspect_ratio.map(|a| a.as_str().to_string()),
resolution: args.resolution.map(|r| r.as_str().to_string()),
response_format: args.response_format.map(|f| f.as_str().to_string()),
user: args.user,
};
@@ -207,10 +200,21 @@ mod tests {
assert!(parsed.data[0].url.is_none());
}
#[test]
fn response_format_enum_round_trips() {
assert_eq!(ImageResponseFormat::Url.as_str(), "url");
assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json");
// (Enum-specific round-trips live in their own modules under
// `requests::image_types::*`; here we just verify the public args wire up.)
#[tokio::test]
async fn args_with_enums_serializes_correct_strings() {
// We can't reach the inner serde body directly without calling the API,
// but we can verify the conversion functions yield exactly the docs values.
let m = ImageModel::GrokImagineImageQuality;
let a = ImageAspectRatio::Landscape16x9;
let r = ImageResolution::OneK;
let f = ImageResponseFormat::B64Json;
assert_eq!(m.as_str(), "grok-imagine-image-quality");
assert_eq!(a.as_str(), "16:9");
assert_eq!(r.as_str(), "1k");
assert_eq!(f.as_str(), "b64_json");
}
// ── Live API tests (ignored — incur cost) ──
@@ -227,9 +231,9 @@ mod tests {
api_key,
prompt: "A serene mountain lake at sunrise, photorealistic".to_string(),
model: None,
n: None,
aspect_ratio: Some("16:9".to_string()),
resolution: Some("1k".to_string()),
number_images: None,
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
resolution: Some(ImageResolution::OneK),
response_format: None,
user: None,
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
@@ -0,0 +1,112 @@
/// 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",
}
}
}
#[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");
}
}
@@ -0,0 +1,45 @@
/// Models accepted by the xAI image endpoints (`/v1/images/generations` and
/// `/v1/images/edits`).
///
/// xAI may add or deprecate models faster than this crate ships releases, so
/// the [`ImageModel::Custom`] escape hatch lets callers pass an arbitrary
/// identifier without waiting for a code change.
///
/// Docs:
/// - <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-quality` — the current default high-quality model.
GrokImagineImageQuality,
/// Escape hatch for model identifiers not yet enumerated here (e.g. a
/// newly-released model or a deprecated one xAI hasn't yet removed).
Custom(String),
}
impl ImageModel {
/// Wire representation — the exact string xAI expects in the `"model"` field.
pub fn as_str(&self) -> &str {
match self {
Self::GrokImagineImageQuality => "grok-imagine-image-quality",
Self::Custom(s) => s.as_str(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_model_serializes() {
assert_eq!(ImageModel::GrokImagineImageQuality.as_str(), "grok-imagine-image-quality");
}
#[test]
fn custom_model_passes_through() {
let m = ImageModel::Custom("grok-imagine-image-future".to_string());
assert_eq!(m.as_str(), "grok-imagine-image-future");
}
}
@@ -0,0 +1,38 @@
/// Output resolution for xAI image endpoints.
///
/// xAI's actual pixel counts depend on the chosen `aspect_ratio` — the `1k`
/// and `2k` labels denote the *target* size class (roughly 1024-px and
/// 2048-px on the longest edge). When omitted, xAI falls back to its server
/// default (currently 1k as of these docs).
///
/// Docs:
/// - <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",
}
}
}
#[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");
}
}
@@ -0,0 +1,35 @@
/// Wire format for returned images.
///
/// - `Url` (default) — xAI returns a temporary CDN URL in `data[].url`.
/// - `B64Json` — xAI returns the raw image bytes inlined as base64 in
/// `data[].b64_json`. Use this when you can't / don't want to make a
/// follow-up HTTP request to fetch the image.
///
/// Docs:
/// - <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",
}
}
}
#[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");
}
}
@@ -0,0 +1,10 @@
//! Shared image-endpoint enums (model, aspect ratio, resolution, response format).
//!
//! These are reused by both [`crate::requests::image_generation`] and
//! [`crate::requests::image_edit`] so callers can pass type-safe values
//! instead of memorising the allowed strings.
pub mod image_aspect_ratio;
pub mod image_model;
pub mod image_resolution;
pub mod image_response_format;
@@ -1,5 +1,10 @@
pub mod delete_file;
pub mod download_file;
pub mod get_file_metadata;
pub mod image_edit;
pub mod image_generation;
pub mod image_types;
pub mod upload_file;
pub mod video_edit;
pub mod video_extension;
pub mod video_generation;
@@ -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,226 @@
use log::info;
use reqwest::multipart::{Form, Part};
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::upload_file::request_types::*;
use crate::requests::xai_host::XAI_API_BASE_URL;
const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
// ── Public args ──
#[derive(Clone, Debug)]
pub struct UploadFileArgs {
pub api_key: GrokApiKey,
/// Raw bytes of the file. xAI caps uploads at 50 MB.
pub file_bytes: Vec<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`.
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.
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.
pub purpose: Option<String>,
}
// ── Public response ──
#[derive(Debug, Clone)]
pub struct UploadFileSuccess {
/// The xAI file identifier. Use this in the `FileId(_)` variant of
/// [`crate::requests::image_edit::image_edit::ImageSource`],
/// [`crate::requests::video_generation::video_generation::VideoImageSource`],
/// [`crate::requests::video_edit::video_edit::VideoSource`], or
/// [`crate::requests::video_extension::video_extension::VideoExtensionSource`].
pub file_id: String,
pub bytes: Option<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> {
if args.file_bytes.is_empty() {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
"upload_file: file_bytes is empty".to_string(),
).into());
}
if args.file_bytes.len() as u64 > MAX_FILE_BYTES {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", args.file_bytes.len()),
).into());
}
let url = format!("{}/v1/files", XAI_API_BASE_URL);
info!(
"Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}",
args.filename, args.file_bytes.len(), args.expires_after_seconds, args.purpose
);
let mut file_part = Part::bytes(args.file_bytes).file_name(args.filename.clone());
if let Some(ct) = args.maybe_content_type.as_deref() {
file_part = file_part.mime_str(ct)
.map_err(GrokClientError::ReqwestClientError)?;
}
let mut form = Form::new().part("file", file_part);
if let Some(secs) = args.expires_after_seconds {
form = form.text("expires_after", secs.to_string());
}
if let Some(purpose) = args.purpose {
form = form.text("purpose", purpose);
}
let client = reqwest::Client::builder()
.build()
.map_err(GrokClientError::ReqwestClientError)?;
let bearer = format!("Bearer {}", args.api_key.api_key);
let response = client.post(&url)
.header("Authorization", bearer)
.multipart(form)
.send()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok upload_file response: status={}", status);
classify_grok_http_error(status, Some(&response_body))?;
let parsed: UploadFileResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
Ok(UploadFileSuccess {
file_id: parsed.id,
bytes: parsed.bytes,
created_at: parsed.created_at,
expires_at: parsed.expires_at,
filename: parsed.filename,
purpose: parsed.purpose,
})
}
#[cfg(test)]
mod tests {
use super::*;
use errors::AnyhowResult;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
// ── Client-side validation ──
#[tokio::test]
async fn empty_bytes_returns_bad_request() {
let result = upload_file(UploadFileArgs {
api_key: GrokApiKey::new("dummy".to_string()),
file_bytes: vec![],
filename: "x.png".to_string(),
maybe_content_type: None,
expires_after_seconds: None,
purpose: None,
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
}
#[tokio::test]
async fn over_size_limit_returns_bad_request() {
let oversized: Vec<u8> = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
let result = upload_file(UploadFileArgs {
api_key: GrokApiKey::new("dummy".to_string()),
file_bytes: oversized,
filename: "x.png".to_string(),
maybe_content_type: None,
expires_after_seconds: None,
purpose: None,
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
}
// ── Response parsing ──
#[test]
fn response_body_deserializes() {
let json = r#"{
"id": "file_abc123",
"object": "file",
"bytes": 1024,
"created_at": 1716489600,
"expires_at": null,
"filename": "photo.png",
"purpose": "vision"
}"#;
let parsed: UploadFileResponseBody = serde_json::from_str(json).unwrap();
assert_eq!(parsed.id, "file_abc123");
assert_eq!(parsed.bytes, Some(1024));
assert_eq!(parsed.filename.as_deref(), Some("photo.png"));
}
// ── Live API tests ──
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_upload_small_png() -> AnyhowResult<()> {
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
setup_test_logging();
// 1x1 transparent PNG
let png_bytes: Vec<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 result = upload_file(UploadFileArgs {
api_key: get_test_api_key()?,
file_bytes: png_bytes,
filename: "test_1x1.png".to_string(),
maybe_content_type: Some("image/png".to_string()),
expires_after_seconds: Some(3600),
purpose: None,
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("Uploaded file_id={}, bytes={:?}", result.file_id, result.bytes);
assert!(!result.file_id.is_empty());
Ok(())
}
}
@@ -32,9 +32,20 @@ pub struct VideoEditArgs {
pub user: Option<String>,
}
/// Source video to edit. Pick by public URL or by a previously-uploaded
/// xAI file_id.
#[derive(Clone, Debug)]
pub enum VideoSource {
/// Public HTTPS URL pointing to the source video. xAI fetches the bytes
/// on its end.
Url(String),
/// xAI file identifier (`file_...`) obtained from a successful upload via
/// [`crate::requests::upload_file::upload_file::upload_file`].
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
FileId(String),
}
@@ -33,9 +33,19 @@ pub struct VideoExtensionArgs {
pub upload_url: Option<String>,
}
/// Source video to extend. Pick by public URL or by a previously-uploaded
/// xAI file_id.
#[derive(Clone, Debug)]
pub enum VideoExtensionSource {
/// Public HTTPS URL pointing to the source video.
Url(String),
/// xAI file identifier (`file_...`) obtained from a successful upload via
/// [`crate::requests::upload_file::upload_file::upload_file`].
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
FileId(String),
}
@@ -48,9 +48,21 @@ pub struct VideoGenerationArgs {
pub user: Option<String>,
}
/// Source image for `image` (image-to-video) or `reference_images`
/// (reference-to-video).
#[derive(Clone, Debug)]
pub enum VideoImageSource {
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
/// image bytes.
Url(String),
/// xAI file identifier (`file_...`) obtained from a successful upload via
/// [`crate::requests::upload_file::upload_file::upload_file`]. The file
/// must still exist at request time.
///
/// Docs:
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
FileId(String),
}