Compare commits

...
Author SHA1 Message Date
Brandon Thomas 700e9b8815 better error messages 2026-06-27 09:15:02 -04:00
Brandon Thomas 6e59f240db update model name, add 1080p 2026-06-27 07:37:33 -04:00
19 changed files with 250 additions and 128 deletions
@@ -14,7 +14,7 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
///
/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p
/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is
/// `grok-imagine-video-1.5-preview`; the cost calculator in
/// `grok-imagine-video-1.5`; the cost calculator in
/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5%
/// ArtCraft markup on top of the upstream rates).
pub fn build_artcraft_grok_imagine_video_1p5(
@@ -23,7 +23,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::GrokImagineVideo1p5,
SupportedResolutions::Fast,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
@@ -34,7 +34,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
{
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -113,10 +113,10 @@ mod tests {
}
#[test]
fn res_1080p_downgrades_to_720p() {
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
fn res_1080p_is_supported() {
// Grok Imagine 1.5 produces genuine 1080p output.
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
@@ -71,7 +71,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
if self.input_image_count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -99,8 +99,9 @@ impl ArtcraftGrokImagineVideo1p5CostState {
fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest {
let resolution = Some(match self.resolution {
CommonResolution::FourEightyP => GrokResolution::FourEightyP,
// Grok Imagine caps at 720p (and our build step downgrades higher
// tiers via SupportedResolutions::Fast); price anything else as 720p.
CommonResolution::TenEightyP => GrokResolution::TenEightyP,
// Grok Imagine 1.5 tops out at 1080p (the build step downgrades higher
// tiers via SupportedResolutions::Full); price anything else as 720p.
_ => GrokResolution::SevenTwentyP,
});
@@ -119,7 +120,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
GrokVideoGenerationRequest {
prompt: String::new(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image,
reference_images,
aspect_ratio: None,
@@ -317,12 +318,13 @@ mod tests {
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
fn ten_eighty_p_prices_higher_than_720p() {
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1)
.cost_in_usd_cents.unwrap();
let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1)
.cost_in_usd_cents.unwrap();
assert_eq!(p1080, p720);
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
}
}
@@ -20,7 +20,7 @@ impl ArtcraftGrokImagineVideo1p5RequestState {
{
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -28,11 +28,11 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
/// the only material differences are the `model` identifier on the wire and
/// the steeper pricing in the cost calculator (see `cost.rs`).
///
/// xAI's grok-imagine-video-1.5-preview accepts:
/// xAI's grok-imagine-video-1.5 accepts:
/// - `image` (single source image, image-to-video mode) OR `reference_images`
/// (multi-image reference-to-video). These two are mutually exclusive per
/// xAI's API.
/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt.
/// - Aspect ratio, resolution (480p/720p/1080p), duration (1–15s), prompt.
///
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`,
/// `reference_audio`, and `MediaFileToken`-style image refs) are silently
@@ -78,7 +78,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
if image.is_none() && reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -86,7 +86,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
prompt,
// Pin to the 1.5 preview identifier so the grok_api_client cost
// calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier).
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image,
reference_images,
aspect_ratio,
@@ -146,10 +146,11 @@ fn plan_resolution(
None => None,
Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP)
| Some(RouterResolution::TwoK)
Some(RouterResolution::TenEightyP) => Some(GrokResolution::TenEightyP),
// 2K/3K/4K aren't produced — cap at the model's max of 1080p.
Some(RouterResolution::TwoK)
| Some(RouterResolution::ThreeK)
| Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP),
| Some(RouterResolution::FourK) => Some(GrokResolution::TenEightyP),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => {
Some(GrokResolution::FourEightyP)
}
@@ -279,7 +280,7 @@ mod tests {
let req = unwrap_request(make_builder(|_| {}));
assert_eq!(
req.request.model,
Some(GrokVideoModel::GrokImagineVideo1p5Preview),
Some(GrokVideoModel::GrokImagineVideo1p5),
);
}
@@ -289,7 +290,7 @@ mod tests {
// calculator in grok_api_client keys off this string.
let req = unwrap_request(make_builder(|_| {}));
let model = req.request.model.as_ref().expect("model should be set");
assert_eq!(model.as_str(), "grok-imagine-video-1.5-preview");
assert_eq!(model.as_str(), "grok-imagine-video-1.5");
}
}
@@ -417,15 +418,15 @@ mod tests {
}
#[test]
fn res_1080p_clamps_to_720p() {
fn res_1080p_maps_to_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
}
#[test]
fn res_4k_clamps_to_720p() {
fn res_4k_caps_to_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
}
#[test]
@@ -542,7 +543,7 @@ mod tests {
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
assert_eq!(req.request.duration, Some(5));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
}
#[test]
@@ -660,7 +661,7 @@ mod tests {
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
assert_eq!(req.request.duration, Some(8));
// Still pinned to the 1.5 model regardless of kitchen-sink inputs.
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
}
}
@@ -21,13 +21,13 @@ impl GrokApiGrokImagineVideo1p5CostState {
if self.request.request.image.is_none() && self.request.request.reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
// grok_api_client's calculator picks the v1.5 pricing tier from the
// request's `model` field (see VideoModel::pricing_tier). The build step
// sets `model = Some(GrokImagineVideo1p5Preview)`, so this returns the
// sets `model = Some(GrokImagineVideo1p5)`, so this returns the
// v1.5 rates:
// Output: 80 mills/s @ 480p, 140 mills/s @ 720p
// Input: 10 mills per source image
@@ -205,10 +205,11 @@ mod tests {
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
fn ten_eighty_p_prices_higher_than_720p() {
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None);
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
assert_eq!(p1080, p720);
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
}
}
@@ -305,7 +306,7 @@ mod tests {
fn estimate_cost_returns_err_when_no_image_sources() {
let request = GrokVideoGenerationRequest {
prompt: "text only".to_string(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image: None,
reference_images: None,
aspect_ratio: None,
@@ -15,7 +15,7 @@ use crate::generate::generate_video::generate_video_response::{
#[derive(Clone, Debug)]
pub struct GrokApiGrokImagineVideo1p5RequestState {
/// The fully-resolved Grok request body, with `model` set to
/// `VideoModel::GrokImagineVideo1p5Preview`. Doesn't carry the API key —
/// `VideoModel::GrokImagineVideo1p5`. Doesn't carry the API key —
/// that gets borrowed at send time from the router client.
pub request: GrokVideoGenerationRequest,
}
@@ -27,7 +27,7 @@ impl GrokApiGrokImagineVideo1p5RequestState {
if self.request.image.is_none() && self.request.reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -77,7 +77,7 @@ mod tests {
..grok_builder()
};
let request = unwrap_request(builder);
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5Preview));
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5));
}
// ── Live API tests (manual) ──
@@ -134,7 +134,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
"expected NotFound, got: {:?}", err);
Ok(())
}
@@ -103,12 +103,12 @@ pub async fn upload_file(args: UploadFileArgs<'_>) -> Result<UploadFileSuccess,
let req = args.request;
if req.file_bytes.is_empty() {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
"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(
return Err(GrokClientError::InvalidRequest(
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", req.file_bytes.len()),
).into());
}
@@ -192,7 +192,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[tokio::test]
@@ -210,7 +210,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
// ── Public Request shape — must not leak api_key OR file_bytes ──
@@ -105,7 +105,7 @@ pub async fn image_edit(args: ImageEditArgs<'_>) -> Result<ImageEditSuccess, Gro
let req = args.request;
if req.source_images.is_empty() {
return Err(GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
"image_edit requires at least one source image".to_string(),
).into());
}
@@ -311,7 +311,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[test]
@@ -10,15 +10,15 @@ use crate::api::types::video_types::video_resolution::VideoResolution;
// Output: 480p $0.05/sec, 720p $0.07/sec
// Input: $0.01/sec (source video) + $0.002/img (source images)
//
// grok-imagine-video-1.5-preview (v1.5)
// Output: 480p $0.08/sec, 720p $0.14/sec
// grok-imagine-video-1.5 (v1.5)
// Output: 480p $0.08/sec, 720p $0.14/sec, 1080p $0.25/sec
// Input: $0.01/img (source images)
//
// In mills (1¢ = 10 mills):
//
// v1 output: 480p 50 mills/sec, 720p 70 mills/sec
// v1 input: 10 mills/sec (video), 2 mills/img
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec, 1080p 250 mills/sec
// v1.5 input: 10 mills/img
//
// video_generation has NO input video — only optional input images. xAI
@@ -78,17 +78,20 @@ fn output_mills_per_second_for_tier(
tier: VideoModelPricingTier,
) -> UsdMills {
match (tier, resolution) {
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
(VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80,
(VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140,
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
// v1 doesn't produce 1080p output (it downsizes to 720p); price at 720p.
(VideoModelPricingTier::V1, VideoResolution::TenEightyP) => 70,
(VideoModelPricingTier::V1p5, VideoResolution::FourEightyP) => 80,
(VideoModelPricingTier::V1p5, VideoResolution::SevenTwentyP) => 140,
(VideoModelPricingTier::V1p5, VideoResolution::TenEightyP) => 250,
}
}
fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills {
match tier {
VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE,
VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5,
VideoModelPricingTier::V1p5 => INPUT_MILLS_PER_IMAGE_V1P5,
}
}
@@ -303,7 +306,7 @@ mod tests {
reference_images: Option<Vec<VideoImageSource>>,
) -> VideoGenerationRequest {
let mut req = make_request(duration, resolution, image, reference_images);
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
req.model = Some(VideoModel::GrokImagineVideo1p5);
req
}
@@ -323,6 +326,22 @@ mod tests {
assert_eq!(req.calculate_cost_in_cents(), 70);
}
#[test]
fn five_seconds_1080p_text_only() {
// 250 × 5 = 1250 mills = $1.25
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), None, None);
assert_eq!(req.calculate_cost_in_mills(), 1250);
assert_eq!(req.calculate_cost_in_cents(), 125);
}
#[test]
fn five_seconds_1080p_one_source_image() {
// 250 × 5 + 10 = 1260 mills = $1.26
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), Some(url_source()), None);
assert_eq!(req.calculate_cost_in_mills(), 1260);
assert_eq!(req.calculate_cost_in_cents(), 126);
}
#[test]
fn five_seconds_720p_one_source_image() {
// 140 × 5 + 10 × 1 = 710 mills = 71¢
@@ -348,7 +367,7 @@ mod tests {
// canonical 1.5 variant exactly.
let canonical = {
let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
req.model = Some(VideoModel::GrokImagineVideo1p5);
req.calculate_cost_in_mills()
};
let via_alias = {
@@ -396,6 +415,12 @@ mod tests {
// 720p with reference images
( 5, VideoResolution::SevenTwentyP, false, 2, 720),
( 5, VideoResolution::SevenTwentyP, false, 3, 730),
// 1080p (250 mills/sec)
( 1, VideoResolution::TenEightyP, false, 0, 250),
( 5, VideoResolution::TenEightyP, false, 0, 1250),
(15, VideoResolution::TenEightyP, false, 0, 3750),
( 5, VideoResolution::TenEightyP, true, 0, 1260),
( 5, VideoResolution::TenEightyP, false, 2, 1270),
];
#[test]
@@ -1,4 +1,4 @@
use log::info;
use log::{info, warn};
use serde_derive::Serialize;
use crate::api::requests::videos::video_generation::request_types::*;
@@ -106,7 +106,7 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
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(
return Err(GrokClientError::InvalidRequest(
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
).into());
}
@@ -141,6 +141,14 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
let bearer = format!("Bearer {}", args.api_key.api_key);
// DEBUG: log exactly what we send (request body has no secrets — the API key
// travels in the Authorization header, not the body). Helps confirm the wire
// shape when diagnosing 4xx responses.
match serde_json::to_string(&request_body) {
Ok(body_json) => info!("Grok video_generation request: POST {url} body={body_json}"),
Err(err) => warn!("Grok video_generation: failed to serialize request body for logging: {err}"),
}
let response = client.post(&url)
.header("Authorization", bearer)
.header("Content-Type", "application/json")
@@ -150,12 +158,27 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
// Capture headers BEFORE `.text()` consumes the response. Response headers
// reveal WAF/CDN blocks (e.g. `server: cloudflare`, `cf-ray`, `cf-mitigated`)
// vs. genuine API errors, and any rate-limit / retry hints.
let response_headers = format!("{:#?}", response.headers());
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok video_generation response: status={}", status);
if !status.is_success() {
// The response body now travels in the returned error (`raw_http_body`),
// so log the HEADERS here — those aren't captured in the error type and
// reveal CDN/WAF context (e.g. `server: cloudflare`, `cf-ray`) vs. a
// genuine API rejection.
warn!(
"Grok video_generation FAILED: status={status} ({} body bytes)\n--- response headers ---\n{response_headers}",
response_body.len(),
);
}
classify_grok_http_error(status, Some(&response_body))?;
let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body)
@@ -283,7 +306,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[test]
@@ -414,7 +437,7 @@ mod tests {
api_key: &api_key,
request: VideoGenerationRequest {
prompt: "Timelapse of a flower blooming in a sunlit garden.".to_string(),
model: Some(VideoModel::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: None,
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
@@ -441,7 +464,7 @@ mod tests {
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: Some(VideoModel::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
@@ -472,7 +495,7 @@ mod tests {
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: Some(VideoModel::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: None,
reference_images: Some(vec![
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
@@ -286,7 +286,7 @@ mod tests {
#[test]
fn pending_response_with_v1p5_model_does_not_break_dispatch() {
// `model` doesn't surface on Pending; it flows through on Complete only.
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed, json).unwrap();
assert!(matches!(result.status, VideoStatus::Pending { .. }));
@@ -400,7 +400,7 @@ mod tests {
#[test]
fn moderation_4xx_body_becomes_failed_content_moderated() {
// The real-world body that previously surfaced as
// `Err(GrokSpecificApiError::PromptModerated(..))`. It should now be
// `Err(GrokSpecificApiError::PromptModerated { .. })`. It should now be
// an Ok(Failed { ContentModerated }) with all fields populated from
// the JSON.
let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#;
@@ -482,7 +482,7 @@ mod tests {
println!("Result: {:?}", result.as_ref().map(|r| &r.status));
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
"expected NotFound, got: {:?}", err);
Ok(())
}
@@ -17,11 +17,11 @@ pub enum VideoModel {
/// generation, editing, and extension endpoints.
GrokImagineVideo,
/// `grok-imagine-video-1.5-preview` — the 1.5 preview model. xAI also
/// accepts the dated alias `grok-imagine-video-1.5-2026-05-30`, which
/// resolves to the same model server-side; serialize via this variant for
/// the canonical name.
GrokImagineVideo1p5Preview,
/// `grok-imagine-video-1.5` — the GA 1.5 model (formerly
/// `grok-imagine-video-1.5-preview`). xAI still accepts the old `-preview`
/// name and the dated alias `grok-imagine-video-1.5-2026-05-30` as synonyms
/// server-side; this variant serializes to the new canonical name.
GrokImagineVideo1p5,
/// Escape hatch for model identifiers not yet enumerated here.
Custom(String),
@@ -32,7 +32,7 @@ impl VideoModel {
pub fn as_str(&self) -> &str {
match self {
Self::GrokImagineVideo => "grok-imagine-video",
Self::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview",
Self::GrokImagineVideo1p5 => "grok-imagine-video-1.5",
Self::Custom(s) => s.as_str(),
}
}
@@ -44,10 +44,12 @@ impl VideoModel {
pub fn pricing_tier(&self) -> VideoModelPricingTier {
match self {
Self::GrokImagineVideo => VideoModelPricingTier::V1,
Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview,
Self::GrokImagineVideo1p5 => VideoModelPricingTier::V1p5,
Self::Custom(s) => match s.as_str() {
"grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => {
VideoModelPricingTier::V1p5Preview
"grok-imagine-video-1.5"
| "grok-imagine-video-1.5-preview"
| "grok-imagine-video-1.5-2026-05-30" => {
VideoModelPricingTier::V1p5
}
_ => VideoModelPricingTier::V1,
},
@@ -63,8 +65,8 @@ pub enum VideoModelPricingTier {
/// `grok-imagine-video` and any unrecognized identifier that defaults
/// to v1 rates.
V1,
/// `grok-imagine-video-1.5-preview` (and its dated alias).
V1p5Preview,
/// `grok-imagine-video-1.5` (and its old `-preview` / dated aliases).
V1p5,
}
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
@@ -89,12 +91,12 @@ mod tests {
#[test]
fn one_point_five_preview_serializes_canonical_name() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.as_str(),
"grok-imagine-video-1.5-preview",
VideoModel::GrokImagineVideo1p5.as_str(),
"grok-imagine-video-1.5",
);
assert_eq!(
serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(),
"\"grok-imagine-video-1.5-preview\"",
serde_json::to_string(&VideoModel::GrokImagineVideo1p5).unwrap(),
"\"grok-imagine-video-1.5\"",
);
}
@@ -124,22 +126,28 @@ mod tests {
#[test]
fn one_point_five_preview_enum_is_v1p5() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.pricing_tier(),
VideoModelPricingTier::V1p5Preview,
VideoModel::GrokImagineVideo1p5.pricing_tier(),
VideoModelPricingTier::V1p5,
);
}
#[test]
fn custom_with_canonical_1p5_name_is_v1p5() {
let m = VideoModel::Custom("grok-imagine-video-1.5".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
}
#[test]
fn custom_with_old_preview_name_is_still_v1p5() {
let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
}
#[test]
fn custom_with_dated_1p5_alias_is_v1p5() {
// xAI treats this alias as a synonym for the 1.5 preview model.
let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
}
#[test]
@@ -1,14 +1,7 @@
/// 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.
/// `480p` (server default), `720p`, and `1080p`. The 1.5 model produces
/// genuine 1080p output (the original `grok-imagine-video` capped at 720p).
///
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
/// don't accept this field — they inherit the source video's resolution
@@ -22,8 +15,11 @@ pub enum VideoResolution {
/// `"480p"` — standard definition, faster processing. Server default.
FourEightyP,
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
/// `"720p"` — HD quality.
SevenTwentyP,
/// `"1080p"` — full HD. Supported by `grok-imagine-video-1.5`.
TenEightyP,
}
impl VideoResolution {
@@ -33,6 +29,7 @@ impl VideoResolution {
match self {
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
@@ -52,11 +49,13 @@ mod tests {
fn matches_docs_strings() {
assert_eq!(VideoResolution::FourEightyP.as_str(), "480p");
assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p");
assert_eq!(VideoResolution::TenEightyP.as_str(), "1080p");
}
#[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\"");
assert_eq!(serde_json::to_string(&VideoResolution::TenEightyP).unwrap(), "\"1080p\"");
}
}
@@ -14,11 +14,15 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
return Ok(());
}
// Owned copy of the raw response body, attached to every error variant so
// downstream logging/alerting/pages have the full upstream context.
let raw_body = || maybe_body.map(|b| b.to_string());
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()),
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized { raw_http_body: raw_body() }.into()),
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits { raw_http_body: raw_body() }.into()),
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound { raw_http_body: raw_body() }.into()),
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited { raw_http_body: raw_body() }.into()),
_ => {}
}
@@ -27,10 +31,10 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
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::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
}
}
return Err(GrokSpecificApiError::Forbidden.into());
return Err(GrokSpecificApiError::Forbidden { raw_http_body: raw_body() }.into());
}
// 400 Bad Request is usually a malformed body or moderation rejection.
@@ -38,12 +42,12 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
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::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
}
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
return Err(GrokSpecificApiError::BadRequest(message).into());
return Err(GrokSpecificApiError::BadRequest { reason: message, raw_http_body: raw_body() }.into());
}
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
return Err(GrokSpecificApiError::BadRequest { reason: String::new(), raw_http_body: None }.into());
}
// Everything else falls through to the generic catch-all.
@@ -99,25 +103,25 @@ mod tests {
#[test]
fn unauthorized_401() {
let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized)));
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)));
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)));
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)));
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited { .. })));
}
// ── 403 Forbidden ──
@@ -125,14 +129,14 @@ mod tests {
#[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)));
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(_))));
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
}
// ── 400 Bad Request ──
@@ -142,7 +146,7 @@ mod tests {
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)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert_eq!(msg, "unknown field 'foo'");
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -154,7 +158,7 @@ mod tests {
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)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert_eq!(msg, "definitely not json");
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -165,7 +169,7 @@ mod tests {
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)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert!(msg.is_empty());
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -177,7 +181,7 @@ mod tests {
// 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(_))));
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
}
// ── Generic fallback ──
@@ -13,6 +13,11 @@ pub enum GrokClientError {
/// The request body could not be serialized to JSON.
RequestSerializationError(serde_json::Error),
/// The request failed our own validation before being sent to xAI — e.g.
/// mutually-exclusive fields supplied together, a required input missing, or
/// an oversize payload. Detected client-side; no HTTP call was made.
InvalidRequest(String),
}
impl Error for GrokClientError {}
@@ -23,6 +28,7 @@ impl Display for GrokClientError {
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),
Self::InvalidRequest(msg) => write!(f, "Invalid request (rejected client-side before sending): {}", msg),
}
}
}
@@ -5,30 +5,59 @@ use std::fmt::{Display, Formatter};
///
/// Distinguishes things like "your key is bad" from generic 5xx so callers can
/// react appropriately (retry vs. surface to user vs. alert).
///
/// Every variant carries the raw HTTP response body (`raw_http_body`) when one
/// was available, so downstream logging, alerting, and pages have the full
/// upstream context — not just our classification. It's `None` for errors we
/// raise client-side before making the HTTP call (e.g. pre-flight validation).
#[derive(Debug)]
pub enum GrokSpecificApiError {
/// 401 Unauthorized — API key is missing, invalid, or revoked.
Unauthorized,
Unauthorized {
raw_http_body: Option<String>,
},
/// 402 Payment Required / insufficient credits to fulfill the request.
InsufficientCredits,
InsufficientCredits {
raw_http_body: Option<String>,
},
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
Forbidden,
/// 403 Forbidden — the API key is valid but lacks permission for this
/// resource/model (also covers team-level "out of credits / over spending
/// limit" responses, which xAI returns as 403 `permission-denied`).
Forbidden {
raw_http_body: Option<String>,
},
/// 404 Not Found — typically returned for an unknown `request_id` on the
/// video status endpoint.
NotFound,
NotFound {
raw_http_body: Option<String>,
},
/// 429 Too Many Requests — rate limit exceeded.
RateLimited,
RateLimited {
raw_http_body: Option<String>,
},
/// The prompt was rejected by xAI's content moderation.
PromptModerated(String),
PromptModerated {
/// The reason we parsed out of the response (e.g. the `error.message`).
reason: String,
/// The raw HTTP response body.
raw_http_body: Option<String>,
},
/// 400 Bad Request — the request body shape was invalid (e.g. missing
/// required field, unsupported model, invalid aspect ratio).
BadRequest(String),
BadRequest {
/// The reason we parsed out of the response (or the raw body when it
/// wasn't parseable). For client-side pre-flight failures, the message
/// we generated.
reason: String,
/// The raw HTTP response body. `None` for client-side pre-flight failures.
raw_http_body: Option<String>,
},
}
impl Error for GrokSpecificApiError {}
@@ -36,13 +65,36 @@ 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::Unauthorized { raw_http_body } => {
write!(f, "Grok API: Unauthorized (invalid or missing API key){}", fmt_raw_body(raw_http_body))
}
Self::InsufficientCredits { raw_http_body } => {
write!(f, "Grok API: Insufficient credits{}", fmt_raw_body(raw_http_body))
}
Self::Forbidden { raw_http_body } => {
write!(f, "Grok API: Forbidden{}", fmt_raw_body(raw_http_body))
}
Self::NotFound { raw_http_body } => {
write!(f, "Grok API: Not found{}", fmt_raw_body(raw_http_body))
}
Self::RateLimited { raw_http_body } => {
write!(f, "Grok API: Rate limited{}", fmt_raw_body(raw_http_body))
}
Self::PromptModerated { reason, raw_http_body } => {
write!(f, "Grok API: Prompt moderated: {}{}", reason, fmt_raw_body(raw_http_body))
}
Self::BadRequest { reason, raw_http_body } => {
write!(f, "Grok API: Bad request: {}{}", reason, fmt_raw_body(raw_http_body))
}
}
}
}
/// Append the raw HTTP body to a `Display` message when one is present, so
/// `{}`-formatted logs/pages carry the upstream context. Empty when absent.
fn fmt_raw_body(raw_http_body: &Option<String>) -> String {
match raw_http_body {
Some(body) if !body.is_empty() => format!(" [raw http body: {}]", body),
_ => String::new(),
}
}
@@ -45,7 +45,7 @@ pub async fn process_video_status_error(
err: GrokError,
) {
match &err {
GrokError::ApiSpecific(GrokSpecificApiError::NotFound) => {
GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. }) => {
let reason_text = "Grok video job not found (likely expired)";
info!(
"Grok request {} for job {} not found. Marking job failed.",
@@ -48,8 +48,8 @@ pub fn grok_video_models() -> Vec<OmniGenVideoModelDetails> {
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::GrokImagineVideo1p5,
model_creator: Some(ModelCreator::Grok),
full_name: Some("Grok Imagine 1.5 Preview".to_string()),
extra_info: Some("Fast and high quality".to_string()),
full_name: Some("Grok Imagine 1.5".to_string()),
extra_info: Some("Fast and high quality. Requires a starting frame.".to_string()),
extra_info_short: Some("Fast and high quality".to_string()),
text_to_video_supported: Some(false), // NB: This might be temporary
text_prompt_supported: Some(true),
@@ -75,6 +75,7 @@ pub fn grok_video_models() -> Vec<OmniGenVideoModelDetails> {
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
//batch_size_options: Some(vec![1, 2, 4]),