mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 04:00:10 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700e9b8815 | ||
|
|
6e59f240db |
+6
-6
@@ -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]
|
||||
|
||||
+8
-6
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+16
-15
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+3
-3
@@ -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) ──
|
||||
|
||||
+1
-1
@@ -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(())
|
||||
}
|
||||
|
||||
+4
-4
@@ -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]
|
||||
|
||||
+35
-10
@@ -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]
|
||||
|
||||
+29
-6
@@ -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()),
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+3
-2
@@ -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]),
|
||||
|
||||
Reference in New Issue
Block a user