Compare commits

...
Author SHA1 Message Date
Brandon Thomas 45d08d6f79 new limits on video model 2026-05-24 11:26:24 -04:00
Brandon Thomas a10f36b652 debug log grok 2026-05-24 11:11:58 -04:00
Brandon Thomas e64dc5245e minor query tweaks 2026-05-24 10:58:37 -04:00
Brandon Thomas 6e3a848da7 fix error type 2026-05-24 10:33:49 -04:00
Brandon Thomas 03e816edc2 grok job 2026-05-24 10:25:39 -04:00
Brandon Thomas 0147ac6fa3 add to model selector 2026-05-24 08:38:48 -04:00
Brandon Thomas 59cc03db09 storyteller-web with grok video 2026-05-24 08:31:44 -04:00
Brandon Thomas 718d4ec4ee margin test 2026-05-24 01:14:42 -04:00
Brandon Thomas 1f943bff01 cleanup 2026-05-24 01:06:27 -04:00
Brandon Thomas 0379bb3187 artcraft grok video 2026-05-24 01:03:12 -04:00
Brandon Thomas 8672b5a05b make behavior more graceful 2026-05-24 00:44:26 -04:00
Brandon Thomas 0701d718ee first iteration of pipeline 2026-05-24 00:36:07 -04:00
Brandon Thomas 086c43b059 add grok client to router 2026-05-24 00:11:04 -04:00
Brandon Thomas 0640493867 image enum 2026-05-24 00:00:26 -04:00
Brandon Thomas 1a10c21f34 new video enums 2026-05-23 23:19:02 -04:00
73 changed files with 3142 additions and 14 deletions
Generated
+36
View File
@@ -803,6 +803,7 @@ dependencies = [
"enums",
"fal_client",
"gmicloud_client",
"grok_api_client",
"log",
"muapi_client",
"reqwest 0.12.23",
@@ -4897,6 +4898,40 @@ dependencies = [
"web-time",
]
[[package]]
name = "grok-api-job"
version = "0.0.1"
dependencies = [
"actix-web",
"actix_helpers",
"anyhow",
"bootstrap",
"bucket_paths",
"cloud_storage",
"concurrency",
"easyenv",
"enums",
"errors",
"grok_api_client",
"hashing",
"jobs_common",
"log",
"mysql_queries",
"pager",
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "grok_api_client"
version = "0.0.1"
@@ -11518,6 +11553,7 @@ dependencies = [
"futures-util",
"gmicloud_client",
"google_sign_in",
"grok_api_client",
"hashing",
"hostname 0.4.0",
"http_server_common",
+1
View File
@@ -128,6 +128,7 @@ members = [
# Services: API servers, jobs, workers, etc.
"crates/service/job/gmicloud_job",
"crates/service/job/grok_api_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/video_thumbnail_job",
"crates/service/job/worldlabs_job",
@@ -149,7 +149,12 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_seconds_max: Option<u16>,
/// Specific maximum when image references are used,
/// eg. Grok limits to 10 seconds in this mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_seconds_max_with_image_references: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_seconds_options: Option<Vec<u16>>,
@@ -204,6 +209,7 @@ impl Default for OmniGenVideoModelDetails {
default_quality: None,
duration_seconds_min: None,
duration_seconds_max: None,
duration_seconds_max_with_image_references: None,
duration_seconds_options: None,
duration_seconds_default: None,
batch_size_min: None,
@@ -21,6 +21,7 @@ enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
grok_api_client.workspace = true
muapi_client.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
@@ -19,6 +19,10 @@ pub enum CommonImageModel {
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "grok_imagine_image")]
GrokImagineImage,
#[serde(rename = "grok_imagine_image_q")]
GrokImagineImageQuality,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -8,6 +8,9 @@ pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -7,6 +7,7 @@ pub enum Provider {
Artcraft,
Fal,
GmiCloud,
GrokApi,
Muapi,
Seedance2Pro,
}
@@ -7,5 +7,6 @@ pub mod router_client;
pub mod router_fal_client;
pub mod router_fal_webhook_optional_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_muapi_client;
pub mod router_seedance2pro_client;
@@ -2,6 +2,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,6 +12,7 @@ pub struct MultiRouterClient {
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) muapi_client: Option<RouterMuapiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -36,6 +38,11 @@ impl MultiRouterClient {
.ok_or(ClientError::ClientNotConfigured(ClientType::GmiCloud))
}
pub fn get_grok_api_client_ref(&self) -> Result<&RouterGrokApiClient, ClientError> {
self.grok_api_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::GrokApi))
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
self.muapi_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Muapi))
@@ -3,6 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
@@ -11,6 +12,7 @@ pub struct MultiRouterClientBuilder {
fal_client: Option<RouterFalClient>,
fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
muapi_client: Option<RouterMuapiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -22,6 +24,7 @@ impl MultiRouterClientBuilder {
fal_client: None,
fal_webhook_optional_client: None,
gmicloud_client: None,
grok_api_client: None,
muapi_client: None,
seedance2pro_client: None,
}
@@ -47,6 +50,11 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_grok_api_client(mut self, client: RouterGrokApiClient) -> Self {
self.grok_api_client = Some(client);
self
}
pub fn set_muapi_client(mut self, client: RouterMuapiClient) -> Self {
self.muapi_client = Some(client);
self
@@ -63,6 +71,7 @@ impl MultiRouterClientBuilder {
fal_client: self.fal_client,
fal_webhook_optional_client: self.fal_webhook_optional_client,
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
muapi_client: self.muapi_client,
seedance2pro_client: self.seedance2pro_client,
}
@@ -3,6 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -13,6 +14,7 @@ pub enum RouterClient {
Fal(RouterFalClient),
FalWebhookOptional(RouterFalWebhookOptionalClient),
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Muapi(RouterMuapiClient),
Seedance2Pro(RouterSeedance2ProClient),
}
@@ -50,6 +52,14 @@ impl RouterClient {
}
}
pub fn get_grok_api_client_ref(&self) -> Result<&RouterGrokApiClient, ClientError> {
match self {
RouterClient::GrokApi(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_grok_api_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::GrokApi)),
}
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
match self {
RouterClient::Muapi(client) => Ok(client),
@@ -0,0 +1,11 @@
use grok_api_client::creds::grok_api_key::GrokApiKey;
pub struct RouterGrokApiClient {
pub(crate) api_key: GrokApiKey,
}
impl RouterGrokApiClient {
pub fn new(api_key: GrokApiKey) -> Self {
RouterGrokApiClient { api_key }
}
}
@@ -9,6 +9,7 @@ pub enum ClientType {
Fal,
FalWebhookOptional,
GmiCloud,
GrokApi,
Muapi,
Seedance2Pro,
}
@@ -20,6 +21,7 @@ impl Display for ClientType {
Self::Fal => write!(f, "Fal"),
Self::FalWebhookOptional => write!(f, "FalWebhookOptional"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::Muapi => write!(f, "Muapi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
}
@@ -3,6 +3,7 @@ use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use muapi_client::error::muapi_error::MuapiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
@@ -11,6 +12,7 @@ pub enum ProviderError {
Storyteller(StorytellerError),
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Grok(GrokError),
Muapi(MuapiError),
Seedance2Pro(Seedance2ProError),
}
@@ -23,6 +25,7 @@ impl Display for ProviderError {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::Muapi(e) => write!(f, "Muapi provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
}
@@ -47,6 +50,12 @@ impl From<GmiCloudError> for ProviderError {
}
}
impl From<GrokError> for ProviderError {
fn from(error: GrokError) -> Self {
Self::Grok(error)
}
}
impl From<MuapiError> for ProviderError {
fn from(error: MuapiError) -> Self {
Self::Muapi(error)
@@ -47,6 +47,7 @@ use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seed
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::build::build_artcraft_grok_imagine_video;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::build::build_artcraft_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::artcraft::preview_model::build::build_artcraft_preview_model;
use crate::generate::generate_video_v2::providers::artcraft::preview_model_fast::build::build_artcraft_preview_model_fast;
@@ -60,6 +61,7 @@ use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::buil
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -153,6 +155,7 @@ impl GenerateVideoRequestBuilder {
pub fn use_new_builder(&self) -> bool {
match (self.provider, self.model) {
// Artcraft
(Provider::Artcraft, CommonVideoModel::GrokImagineVideo) => true,
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
@@ -165,6 +168,8 @@ impl GenerateVideoRequestBuilder {
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => true,
// Grok
(Provider::GrokApi, CommonVideoModel::GrokImagineVideo) => true,
// Kinovi
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
@@ -177,6 +182,7 @@ impl GenerateVideoRequestBuilder {
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(Provider::Artcraft, CommonVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
@@ -189,6 +195,8 @@ impl GenerateVideoRequestBuilder {
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Grok
(Provider::GrokApi, CommonVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
// Kinovi
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
@@ -203,6 +211,7 @@ impl GenerateVideoRequestBuilder {
Provider::Artcraft => self.build_artcraft(),
Provider::Fal => self.build_fal(),
Provider::GmiCloud => self.unsupported_provider(), // GmiCloud uses build2() only
Provider::GrokApi => self.unsupported_provider(), // Grok uses build2() only
Provider::Muapi => self.build_muapi(),
Provider::Seedance2Pro => self.build_seedance2pro(),
}
@@ -39,10 +39,23 @@ pub struct GmiCloudVideoResponsePayload {
pub request_id: String,
}
#[derive(Clone, Debug)]
pub struct GrokVideoResponsePayload {
/// xAI request_id; poll with `grok_api_client::api::requests::videos::video_status`
/// to fetch the rendered video URL once it's ready.
pub request_id: String,
/// The outbound request that was sent to Grok (xAI).
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub enum GenerateVideoResponse {
Artcraft(ArtcraftVideoResponsePayload),
GmiCloud(GmiCloudVideoResponsePayload),
Grok(GrokVideoResponsePayload),
Muapi(MuapiVideoResponsePayload),
Seedance2Pro(Seedance2proVideoResponsePayload),
Fal(FalVideoResponsePayload),
@@ -77,6 +90,13 @@ impl GenerateVideoResponse {
}
}
pub fn get_grok_payload(&self) -> Option<GrokVideoResponsePayload> {
match self {
Self::Grok(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalVideoResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
@@ -0,0 +1,180 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
/// Build an ArtCraft-routed Grok Imagine Video request.
///
/// xAI's grok-imagine-video supports 480p and 720p (no 1080p — see
/// VideoResolution docs in grok_api_client), and does NOT have a 21:9
/// ultra-wide aspect ratio. Hence `Fast` resolution support and
/// `Unsupported` ultra-wide.
pub fn build_artcraft_grok_imagine_video(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::GrokImagineVideo,
SupportedResolutions::Fast,
UltraWideSupport::Unsupported,
)?;
let state = ArtcraftGrokImagineVideoRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftGrokImagineVideo(state),
))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_grok_imagine_video() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::GrokImagineVideo)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
// ArtCraft's omni endpoint requires media tokens — URLs are rejected
// (unlike the direct Grok provider, which is URL-only).
let result = build_artcraft_grok_imagine_video(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftGrokImagineVideoRequestState {
let result = build_artcraft_grok_imagine_video(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftGrokImagineVideo(state)
) => state,
_ => panic!("expected ArtcraftGrokImagineVideo request"),
}
}
}
@@ -0,0 +1,470 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState;
// -- Pricing constants --
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// We keep these as f64 because per-second rates are fractional; rounding
// happens once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 6.5;
const CENTS_PER_SECOND_720P: f64 = 9.1;
// Per-source-image surcharge. Kept as a separate term so it doesn't get
// lost in rounding when the per-second video cost is small.
const CENTS_PER_INPUT_IMAGE: f64 = 0.26;
pub struct ArtcraftGrokImagineVideoCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub input_image_count: u64,
}
impl ArtcraftGrokImagineVideoCostState {
pub fn from_request(request: &ArtcraftGrokImagineVideoRequestState) -> Self {
// Default duration is 8s when omitted, matching the upstream model's
// default — keeps cost estimates from reading 0 for under-specified
// requests.
let resolution = request.request.resolution.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(8);
let batch_count = request.request.video_batch_count.unwrap_or(1);
// Mirror grok_api_client's input-image counting: start_frame + reference_images.
let input_image_count = (request.request.start_frame_image_media_token.is_some() as u64)
+ (request.request.reference_image_media_tokens
.as_ref()
.map(|v| v.len() as u64)
.unwrap_or(0));
Self { resolution, duration_seconds, batch_count, input_image_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Grok Imagine Video caps output at 720p; price anything else as 720p.
_ => CENTS_PER_SECOND_720P,
};
let video_cents = self.duration_seconds as f64 * cents_per_second * self.batch_count as f64;
// Input images are billed once, not per output in the batch.
let input_cents = self.input_image_count as f64 * CENTS_PER_INPUT_IMAGE;
let usd_cents = (video_cents + input_cents).ceil() as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── 720p pricing (9.1 ¢/s base × batch, ceil) ──
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 9.1 × 5 = 45.5 → 46
// 9.1 × 10 = 91.0 → 91
// 9.1 × 15 = 136.5 → 137
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 46);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 91);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 137);
}
#[test]
fn batch_2() {
// 9.1 × 5 × 2 = 91 → 91
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 91);
// 9.1 × 15 × 2 = 273 → 273
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 273);
}
#[test]
fn batch_4() {
// 9.1 × 5 × 4 = 182 → 182
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 182);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(
cost_cents(None, 5, 1),
cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1),
);
}
}
// ── 480p pricing (6.5 ¢/s base × batch, ceil) ──
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
// 6.5 × 5 = 32.5 → 33
// 6.5 × 10 = 65.0 → 65
// 6.5 × 15 = 97.5 → 98
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 33);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 65);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 98);
}
#[test]
fn batch_2() {
// 6.5 × 5 × 2 = 65 → 65
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 65);
}
#[test]
fn batch_4() {
// 6.5 × 5 × 4 = 130 → 130
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 130);
}
}
// ── Input-image surcharge ──
mod input_image_surcharge {
use super::*;
#[test]
fn single_start_frame_adds_input_charge() {
// 720p 5s batch 1 = 45.5 + 0.26 (one start_frame) = 45.76 → ceil 46
// Identical to the no-image case (45.5 → 46) because of ceil rounding.
let with_img = cost_cents_with_images(Some(CommonResolution::SevenTwentyP), 5, 1, true, 0);
let no_img = cost_cents_with_images(Some(CommonResolution::SevenTwentyP), 5, 1, false, 0);
assert_eq!(with_img, 46);
assert_eq!(no_img, 46);
}
#[test]
fn many_reference_images_eventually_bump_a_cent() {
// 480p 4s batch 1 = 6.5 × 4 = 26 + 0.26 × N
// N=0 → 26
// N=1 → 26.26 → 27
// N=4 → 27.04 → 28
assert_eq!(cost_cents_with_images(Some(CommonResolution::FourEightyP), 4, 1, false, 0), 26);
assert_eq!(cost_cents_with_images(Some(CommonResolution::FourEightyP), 4, 1, false, 1), 27);
assert_eq!(cost_cents_with_images(Some(CommonResolution::FourEightyP), 4, 1, false, 4), 28);
}
}
// ── Relative pricing ──
mod relative_pricing {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 10, 1);
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
assert!(c480 < c720, "480p ({c480}) should be cheaper than 720p ({c720})");
}
#[test]
fn cost_scales_with_duration() {
let c5 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
let b2 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2);
let b4 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// ── Credits equal cents ──
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
];
for res in resolutions {
for dur in [4, 5, 10, 15] {
for batch in [1, 2, 4] {
let cost = build_cost(res, dur, batch);
assert_eq!(
cost.cost_in_credits, cost.cost_in_usd_cents,
"credits should equal cents for res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
mod small_margin {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
#[test]
fn small_margin() {
let resolutions = [
None,
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP), // clamps to 720p on both sides
];
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
let aspect_ratios = [
None,
Some(CommonAspectRatio::Auto),
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::WideFourByThree),
Some(CommonAspectRatio::TallThreeByFour),
Some(CommonAspectRatio::WideThreeByTwo),
Some(CommonAspectRatio::TallTwoByThree),
];
// (has_start_frame, num_reference_images). At most one of the two is
// non-default — start_frame and reference_images are mutually exclusive
// on the GrokApi wire (image-to-video vs reference-to-video), so we
// don't mix them here to keep the comparison apples-to-apples.
let image_combos: &[(bool, usize)] = &[
(false, 0), // text-to-video
(true, 0), // image-to-video, 1 source image
(false, 1), // reference-to-video, 1 ref image
(false, 2),
(false, 3),
];
let mut total_cases = 0;
let mut failures = Vec::new();
for &res in &resolutions {
for &dur in durations {
for &ar in &aspect_ratios {
for &(has_start, num_refs) in image_combos {
total_cases += 1;
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
// The (-1, 1) floor accounts for one cent of GrokApi-side
// ceiling-rounding noise (see test doc comment above).
let grokapi_compare = grokapi.saturating_sub(1).max(1);
let lhs = artcraft.saturating_mul(100);
let rhs = grokapi_compare.saturating_mul(129);
if lhs < rhs {
let pct = if grokapi == 0 { 0.0 }
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
failures.push(format!(
"res={:?} dur={}s ar={:?} start={} refs={} → \
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
));
}
// Defense in depth: ArtCraft must always cost at least as
// much as GrokApi, regardless of rounding noise.
assert!(
artcraft >= grokapi,
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
);
}
}
}
}
if !failures.is_empty() {
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
panic!(
"{} / {} (res × duration × aspect × image_combo) tuples failed \
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
failures.len(), total_cases, summary,
);
}
}
// ── Cross-provider helpers ──
fn artcraft_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
aspect_ratio: Option<CommonAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::Artcraft,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
// Batch is fixed at 1 because GrokApi's per-request cost calculator
// doesn't multiply by batch (Grok returns one video per call); the
// markup test would compare apples to oranges otherwise.
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (artcraft) should succeed")
.estimate_cost()
.expect("estimate_cost (artcraft) should succeed")
.cost_in_usd_cents
.unwrap()
}
fn grokapi_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
aspect_ratio: Option<CommonAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// GrokApi (direct xAI) takes URLs for image refs.
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::Urls(
(0..num_reference_images)
.map(|i| format!("https://example.com/ref_{i}.png"))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::GrokApi,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
// ── Helpers ──
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
fn cost_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count)
.cost_in_usd_cents
.unwrap()
}
fn cost_cents_with_images(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
has_start_frame: bool,
extra_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if extra_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..extra_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftGrokImagineVideoRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftGrokImagineVideoRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -1,4 +1,5 @@
pub(crate) mod build_common;
pub mod grok_imagine_video;
pub mod happy_horse_1p0;
pub mod preview_model;
pub mod preview_model_fast;
@@ -0,0 +1,553 @@
use log::info;
use grok_api_client::api::requests::videos::video_generation::video_generation::{
VideoGenerationRequest as GrokVideoGenerationRequest,
VideoImageSource as GrokVideoImageSource,
};
use grok_api_client::api::types::video_types::video_aspect_ratio::VideoAspectRatio as GrokAspectRatio;
use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
/// Builds a Grok Imagine Video request from the generic GenerateVideoRequestBuilder.
///
/// xAI's grok-imagine-video 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.
///
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`, `reference_audio`,
/// and `MediaFileToken`-style image refs) are silently dropped with an info-level log.
/// Same goes for the `start_frame` + `reference_images` conflict — we prefer
/// `start_frame` (image-to-video) and drop `reference_images`. This keeps the
/// pipeline tolerant when callers fan a single builder out to multiple providers
/// with different capabilities.
pub fn build_grok_api_grok_imagine_video(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Scalar fields.
let prompt = builder.prompt.take().unwrap_or_default();
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy);
let resolution = plan_resolution(builder.resolution.take(), strategy);
let duration = builder.duration_seconds.take().map(|d| (d as u32).clamp(1, 15));
// Wholly-unsupported fields → drop with a log so it shows up in operator traces.
log_and_drop_end_frame(builder.end_frame.take());
log_and_drop_reference_videos(builder.reference_videos.take());
log_and_drop_reference_audio(builder.reference_audio.take());
// Image sources: prefer start_frame (image-to-video). If both start_frame and
// reference_images are supplied, drop reference_images (xAI rejects the
// combination on the wire).
let start_frame = resolve_url_to_image_source(builder.start_frame.take());
let reference_images_supplied = builder.reference_images.take();
let (image, reference_images) = match (start_frame, reference_images_supplied) {
(Some(img), Some(refs)) => {
info!(
"grok_imagine_video: both start_frame and reference_images supplied — \
preferring start_frame (image-to-video). Dropping {} reference images.",
count_image_list_ref(&refs),
);
(Some(img), None)
}
(Some(img), None) => (Some(img), None),
(None, Some(refs)) => (None, resolve_url_list_to_image_sources(refs)),
(None, None) => (None, None),
};
let request = GrokVideoGenerationRequest {
prompt,
model: None, // defaults to grok-imagine-video in the client
image,
reference_images,
aspect_ratio,
duration,
resolution,
user: None,
};
let state = GrokApiGrokImagineVideoRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::GrokApiGrokImagineVideo(state),
))
}
// ── Field planners ──
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Option<GrokAspectRatio> {
// xAI supports exactly: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3.
// Auto / unsupported ratios fall back to the closest match (or None → xAI default 16:9).
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Some(GrokAspectRatio::Square)
}
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Some(GrokAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Some(GrokAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::WideFourByThree) => Some(GrokAspectRatio::Landscape4x3),
Some(CommonAspectRatio::TallThreeByFour) => Some(GrokAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideThreeByTwo) => Some(GrokAspectRatio::Landscape3x2),
Some(CommonAspectRatio::TallTwoByThree) => Some(GrokAspectRatio::Portrait2x3),
// No exact xAI match — pick the closest cardinal direction.
Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideTwentyOneByNine) => Some(GrokAspectRatio::Landscape16x9),
Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineByTwentyOne) => Some(GrokAspectRatio::Portrait9x16),
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Option<GrokResolution> {
// Grok supports 480p and 720p only (1080p is downsized to 720p per xAI docs).
match resolution {
None => None,
Some(CommonResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
// Higher-than-720p requests get clamped to 720p (Grok's cap).
Some(CommonResolution::TenEightyP)
| Some(CommonResolution::TwoK)
| Some(CommonResolution::ThreeK)
| Some(CommonResolution::FourK) => Some(GrokResolution::SevenTwentyP),
// Lower-than-480p requests get bumped to 480p (Grok's floor).
Some(CommonResolution::HalfK) | Some(CommonResolution::OneK) => {
Some(GrokResolution::FourEightyP)
}
}
}
// ── Image source resolvers (silent drops for unsupported variants) ──
fn resolve_url_to_image_source(image_ref: Option<ImageRef>) -> Option<GrokVideoImageSource> {
match image_ref {
None => None,
Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)),
Some(ImageRef::MediaFileToken(token)) => {
info!(
"grok_imagine_video: dropping MediaFileToken {:?} for start_frame — \
Grok only accepts public URLs. Resolve the token to a URL upstream if needed.",
token,
);
None
}
}
}
fn resolve_url_list_to_image_sources(list_ref: ImageListRef) -> Option<Vec<GrokVideoImageSource>> {
match list_ref {
ImageListRef::Urls(urls) if urls.is_empty() => None,
ImageListRef::Urls(urls) => Some(urls.into_iter().map(GrokVideoImageSource::Url).collect()),
ImageListRef::MediaFileTokens(tokens) => {
info!(
"grok_imagine_video: dropping {} MediaFileToken reference image(s) — \
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
tokens.len(),
);
None
}
}
}
fn count_image_list_ref(refs: &ImageListRef) -> usize {
match refs {
ImageListRef::Urls(v) => v.len(),
ImageListRef::MediaFileTokens(v) => v.len(),
}
}
// ── Drop-with-log helpers for fields Grok doesn't accept at all ──
fn log_and_drop_end_frame(end_frame: Option<ImageRef>) {
if end_frame.is_some() {
info!("grok_imagine_video: dropping end_frame — Grok doesn't support end-frame keyframes.");
}
}
fn log_and_drop_reference_videos(refs: Option<VideoListRef>) {
if let Some(refs) = refs {
let count = match refs {
VideoListRef::Urls(v) => v.len(),
VideoListRef::MediaFileTokens(v) => v.len(),
};
info!(
"grok_imagine_video: dropping {} reference video(s) — Grok doesn't accept reference videos.",
count,
);
}
}
fn log_and_drop_reference_audio(refs: Option<AudioListRef>) {
if let Some(refs) = refs {
let count = match refs {
AudioListRef::Urls(v) => v.len(),
AudioListRef::MediaFileTokens(v) => v.len(),
};
info!(
"grok_imagine_video: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.",
count,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
// ── Field passthrough ──
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
assert_eq!(req.request.prompt, "test prompt");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
assert_eq!(req.request.duration, Some(8));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(0); }));
assert_eq!(req.request.duration, Some(1));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration, Some(15));
}
#[test]
fn duration_none_stays_none() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
assert_eq!(req.request.duration, None);
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Square); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Square));
}
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
}
#[test]
fn portrait_9x16() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16));
}
#[test]
fn landscape_4x3() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideFourByThree); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape4x3));
}
#[test]
fn portrait_3x4() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallThreeByFour); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait3x4));
}
#[test]
fn landscape_3x2() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideThreeByTwo); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape3x2));
}
#[test]
fn portrait_2x3() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallTwoByThree); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait2x3));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Auto); }));
assert_eq!(req.request.aspect_ratio, None);
}
#[test]
fn unsupported_wide_falls_back_to_16x9() {
let req = unwrap_request(make_builder(|b| {
b.aspect_ratio = Some(CommonAspectRatio::WideTwentyOneByNine);
}));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
}
#[test]
fn unsupported_tall_falls_back_to_9x16() {
let req = unwrap_request(make_builder(|b| {
b.aspect_ratio = Some(CommonAspectRatio::TallNineByTwentyOne);
}));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_1080p_clamps_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_4k_clamps_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_1k_bumps_to_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::OneK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Image source plumbing (graceful — never errors) ──
mod image_tests {
use super::*;
#[test]
fn start_frame_url_becomes_image() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
}));
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"),
other => panic!("expected Url variant, got {:?}", other),
}
assert!(req.request.reference_images.is_none());
}
#[test]
fn start_frame_media_file_token_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string())));
}));
// Dropped, request still builds successfully with no image.
assert!(req.request.image.is_none());
assert!(req.request.reference_images.is_none());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
let refs = req.request.reference_images.expect("reference_images should be set");
assert_eq!(refs.len(), 2);
assert!(req.request.image.is_none());
}
#[test]
fn reference_image_tokens_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
]));
}));
// All tokens dropped; reference_images becomes None.
assert!(req.request.reference_images.is_none());
assert!(req.request.image.is_none());
}
#[test]
fn start_frame_and_reference_images_together_prefers_start_frame() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref1.png".to_string(),
"https://example.com/ref2.png".to_string(),
]));
}));
// start_frame wins; reference_images dropped (mutually exclusive on the wire).
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"),
other => panic!("expected start_frame URL, got {:?}", other),
}
assert!(req.request.reference_images.is_none());
}
#[test]
fn empty_reference_image_url_list_becomes_none() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(vec![]));
}));
assert!(req.request.reference_images.is_none());
}
}
// ── Unsupported fields are silently dropped, not rejected ──
mod unsupported_fields_graceful {
use super::*;
#[test]
fn end_frame_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
}));
// Request still builds; Grok's request body has no end_frame field.
assert!(req.request.image.is_none());
assert!(req.request.reference_images.is_none());
}
#[test]
fn reference_videos_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
}));
assert!(req.request.reference_images.is_none());
}
#[test]
fn reference_audio_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()]));
}));
// No audio field on Grok; just gone.
assert!(req.request.reference_images.is_none());
}
#[test]
fn kitchen_sink_with_every_unsupported_field_still_succeeds() {
// Worst-case builder fanned out from a multi-provider context. Grok
// should keep what it can and drop the rest without erroring.
let req = unwrap_request(make_builder(|b| {
b.prompt = Some("kitchen sink".to_string());
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref.png".to_string(),
]));
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()]));
b.resolution = Some(CommonResolution::SevenTwentyP);
b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine);
b.duration_seconds = Some(8);
}));
// Kept: prompt + start_frame + resolution + aspect_ratio + duration.
assert_eq!(req.request.prompt, "kitchen sink");
assert!(matches!(
req.request.image,
Some(GrokVideoImageSource::Url(_))
));
// Dropped because start_frame won the image-source contest.
assert!(req.request.reference_images.is_none());
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
assert_eq!(req.request.duration, Some(8));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::GrokApi,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut b = base_builder();
f(&mut b);
b
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideoRequestState {
let result = build_grok_api_grok_imagine_video(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo(s)) => s,
_ => panic!("expected GrokApiGrokImagineVideo request"),
}
}
}
@@ -0,0 +1,188 @@
use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
pub struct GrokApiGrokImagineVideoCostState {
request: GrokApiGrokImagineVideoRequestState,
}
impl GrokApiGrokImagineVideoCostState {
pub fn from_request(request: &GrokApiGrokImagineVideoRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// grok_api_client's GrokRequestCostCalculator does the math:
// - Output: resolution × duration (50 mills/s @ 480p, 70 mills/s @ 720p)
// - Input: 2 mills × source image count
// No source-video duration to factor in for `video_generation` — it's
// text/image → video, not edit/extension.
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// Pricing reference (from grok_api_client/cost.rs, mirrors xAI's table):
// Output: 480p 50 mills/s ($0.05/s)
// 720p 70 mills/s ($0.07/s)
// Input: 2 mills per source image ($0.002/img)
//
// Cost in cents = ceil(mills / 10).
mod pricing_720p {
use super::*;
#[test]
fn five_s_no_image_ref() {
// 70 × 5 = 350 mills → 35¢
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, None, None), 35);
}
#[test]
fn ten_s_no_image_ref() {
// 70 × 10 = 700 mills → 70¢
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None), 70);
}
#[test]
fn fifteen_s_no_image_ref() {
// 70 × 15 = 1050 mills → 105¢
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, None, None), 105);
}
}
mod pricing_480p {
use super::*;
#[test]
fn five_s_no_image_ref() {
// 50 × 5 = 250 mills → 25¢
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, None, None), 25);
}
#[test]
fn ten_s_no_image_ref() {
// 50 × 10 = 500 mills → 50¢
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, None, None), 50);
}
#[test]
fn fifteen_s_no_image_ref() {
// 50 × 15 = 750 mills → 75¢
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, None, None), 75);
}
}
mod image_inputs_add_to_cost {
use super::*;
#[test]
fn single_start_frame_image_adds_2_mills() {
// 5s @ 720p output 350 mills + 1 input image 2 mills = 352 mills → 36¢ (ceil)
let cents = cost_cents(
Some(CommonResolution::SevenTwentyP),
5,
Some(ImageRef::Url("https://example.com/a.png".to_string())),
None,
);
assert_eq!(cents, 36);
}
#[test]
fn three_reference_images_adds_6_mills() {
// 5s @ 720p output 350 mills + 3 input images 6 mills = 356 mills → 36¢ (ceil)
let cents = cost_cents(
Some(CommonResolution::SevenTwentyP),
5,
None,
Some(ImageListRef::Urls(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
])),
);
assert_eq!(cents, 36);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let p480 = cost_cents(Some(CommonResolution::FourEightyP), 10, None, None);
let p720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None);
assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})");
}
#[test]
fn longer_duration_costs_more() {
let c5 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, None, None);
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None);
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, None, None);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn one_k_request_clamps_to_480p_pricing() {
// OneK gets bumped to 480p in plan_resolution, so cost equals 480p cost.
let one_k = cost_cents(Some(CommonResolution::OneK), 10, None, None);
let p480 = cost_cents(Some(CommonResolution::FourEightyP), 10, None, None);
assert_eq!(one_k, p480);
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
// TenEightyP gets clamped to 720p in plan_resolution.
let p1080 = cost_cents(Some(CommonResolution::TenEightyP), 10, None, None);
let p720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None);
assert_eq!(p1080, p720);
}
}
fn cost_cents(
resolution: Option<CommonResolution>,
duration_seconds: u16,
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::GrokApi,
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,111 @@
use std::sync::Arc;
use grok_api_client::api::requests::videos::video_generation::video_generation::{
video_generation, VideoGenerationArgs, VideoGenerationRequest as GrokVideoGenerationRequest,
};
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GrokVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GrokApiGrokImagineVideoRequestState {
/// The fully-resolved Grok request body. Doesn't carry the API key — that
/// gets borrowed at send time from the router client.
pub request: GrokVideoGenerationRequest,
}
impl GrokApiGrokImagineVideoRequestState {
pub async fn send(&self, client: &RouterGrokApiClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let outbound_request = Arc::new(self.request.clone());
let response = video_generation(VideoGenerationArgs {
api_key: &client.api_key,
request: self.request.clone(),
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Grok(err)))?;
Ok(GenerateVideoResponse::Grok(GrokVideoResponsePayload {
request_id: response.request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
}
#[cfg(test)]
mod tests {
use grok_api_client::creds::grok_api_key::GrokApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_client::RouterClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A glowing crystal rocket launching from Mars.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(5),
..grok_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Grok(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog leaps into the lake and splashes around.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(CommonResolution::FourEightyP),
duration_seconds: Some(5),
..grok_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Grok(_)));
}
fn grok_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::GrokImagineVideo,
provider: Provider::GrokApi,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_grok_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/grok_api_key.txt")
.expect("Failed to read Grok API key");
let api_key = GrokApiKey::new(secret.trim().to_string());
RouterClient::GrokApi(RouterGrokApiClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_grok_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Grok skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Grok(p) => println!("grok request_id={}", p.request_id),
other => println!("unexpected response: {:?}", other),
}
response
}
}
@@ -0,0 +1 @@
pub mod grok_imagine_video;
@@ -1,3 +1,4 @@
pub mod artcraft;
pub mod gmicloud;
pub mod grok_api;
pub mod kinovi;
@@ -3,6 +3,8 @@ use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::cost::ArtcraftGrokImagineVideoCostState;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::preview_model::cost::ArtcraftPreviewModelCostState;
@@ -25,6 +27,8 @@ use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cos
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
@@ -34,6 +38,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
#[derive(Clone, Debug)]
pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftPreviewModel(ArtcraftPreviewModelRequestState),
ArtcraftPreviewModelFast(ArtcraftPreviewModelFastRequestState),
@@ -45,6 +50,7 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -54,6 +60,7 @@ impl VideoGenerationRequest {
pub fn get_provider(&self) -> Provider {
match self {
Self::ArtcraftGrokImagineVideo(_) => Provider::Artcraft,
Self::ArtcraftHappyHorse1p0(_) => Provider::Artcraft,
Self::ArtcraftPreviewModel(_) => Provider::Artcraft,
Self::ArtcraftPreviewModelFast(_) => Provider::Artcraft,
@@ -65,6 +72,7 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance2p0BytePlusFast(_) => Provider::Artcraft,
Self::GmiCloudSeedance2p0Ultra(_) => Provider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => Provider::GmiCloud,
Self::GrokApiGrokImagineVideo(_) => Provider::GrokApi,
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
@@ -74,6 +82,7 @@ impl VideoGenerationRequest {
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModelFast(request) => Ok(ArtcraftPreviewModelFastCostState::from_request(request).estimate_cost()),
@@ -85,6 +94,7 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
@@ -95,6 +105,10 @@ impl VideoGenerationRequest {
/// If successful, returns the job IDs.
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -139,6 +153,10 @@ impl VideoGenerationRequest {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
@@ -347,7 +347,8 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
//request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
request_id: "4eea215a-9d21-9261-be1a-324409ec22c5".to_string(),
},
}).await;
@@ -0,0 +1,77 @@
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::queries::generic_inference::api_providers::common::insert_generic_inference_job_for_provider::{
insert_generic_inference_job_for_provider,
InsertGenericInferenceJobForProviderArgs,
};
pub struct InsertGrokApiInferenceJobArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub apriori_job_token: &'e InferenceJobToken,
pub uuid_idempotency_token: &'e str,
pub external_request_id: &'e str,
pub maybe_model_type: Option<CommonModelType>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_grok_api<'e, 'c: 'e, E>(
args: InsertGrokApiInferenceJobArgs<'e, 'c, E>,
) -> Result<InferenceJobToken, DatabaseQueryError>
where
E: 'e + Executor<'c, Database = MySql>,
{
let record_id = insert_generic_inference_job_for_provider(InsertGenericInferenceJobForProviderArgs {
apriori_job_token: args.apriori_job_token,
uuid_idempotency_token: args.uuid_idempotency_token,
job_type: InferenceJobType::GrokApiQueue,
external_third_party: InferenceJobExternalThirdParty::GrokApi,
external_third_party_id: args.external_request_id,
product_category: InferenceJobProductCategory::GrokApiVideo,
inference_category: InferenceCategory::VideoGeneration,
maybe_model_type: args.maybe_model_type,
maybe_prompt_token: args.maybe_prompt_token,
maybe_wallet_ledger_entry_token: None,
maybe_inference_args: None,
maybe_creator_user_token: args.maybe_creator_user_token,
maybe_avt_token: args.maybe_avt_token,
creator_ip_address: args.creator_ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
status: JobStatusPlus::Pending,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
}).await?;
info!(
"Insert generic inference job for Grok (xAI) API: {} with record ID {}",
args.apriori_job_token, record_id
);
Ok(args.apriori_job_token.clone())
}
@@ -0,0 +1,95 @@
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
/// A Grok (xAI) API job that is waiting for a result from the external API.
#[derive(Debug)]
pub struct PendingGrokApiJob {
pub job_token: InferenceJobToken,
/// The external `request_id` from xAI's video generation endpoint.
pub request_id: String,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub creator_set_visibility: Visibility,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
request_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
creator_set_visibility: Visibility,
maybe_prompt_token: Option<PromptToken>,
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Returns all non-terminal Grok (xAI) API jobs that have an associated request_id.
pub async fn list_pending_grok_api_jobs(pool: &MySqlPool) -> Result<Vec<PendingGrokApiJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.maybe_external_third_party_id as `request_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::GrokApi.to_str(),
)
.fetch_all(pool)
.await?;
let jobs = records
.into_iter()
.filter_map(|record| {
let request_id = match record.request_id {
Some(id) => id,
None => {
warn!("PendingGrokApiJob has no request_id, skipping");
return None;
}
};
Some(PendingGrokApiJob {
job_token: record.job_token,
request_id,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
creator_set_visibility: record.creator_set_visibility,
maybe_prompt_token: record.maybe_prompt_token,
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
})
})
.collect();
Ok(jobs)
}
@@ -0,0 +1,2 @@
pub mod insert_generic_inference_job_for_grok_api;
pub mod list_pending_grok_api_jobs;
@@ -2,5 +2,6 @@ pub mod beeble;
pub(crate) mod common;
pub mod fal;
pub mod gmicloud;
pub mod grok_api;
pub mod seedance2pro;
pub mod worldlabs;
@@ -21,6 +21,7 @@ pub enum DebugLogType {
HttpRequest,
FalRequest,
KinoviRequest,
GrokApiRequest,
FalWebhook,
BeebleWebhook,
}
@@ -35,6 +36,7 @@ impl DebugLogType {
Self::HttpRequest => "http_request",
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::GrokApiRequest => "grok_api_request",
Self::FalWebhook => "fal_webhook",
Self::BeebleWebhook => "beeble_webhook",
}
@@ -45,6 +47,7 @@ impl DebugLogType {
"http_request" => Ok(Self::HttpRequest),
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"grok_api_request" => Ok(Self::GrokApiRequest),
"fal_webhook" => Ok(Self::FalWebhook),
"beeble_webhook" => Ok(Self::BeebleWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
@@ -56,6 +59,7 @@ impl DebugLogType {
Self::HttpRequest,
Self::FalRequest,
Self::KinoviRequest,
Self::GrokApiRequest,
Self::FalWebhook,
Self::BeebleWebhook,
])
@@ -77,6 +81,7 @@ mod tests {
assert_serialization(DebugLogType::HttpRequest, "http_request");
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::GrokApiRequest, "grok_api_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook");
}
@@ -86,6 +91,7 @@ mod tests {
assert_eq!(DebugLogType::HttpRequest.to_str(), "http_request");
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::GrokApiRequest.to_str(), "grok_api_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook");
}
@@ -95,6 +101,7 @@ mod tests {
assert_eq!(DebugLogType::from_str("http_request").unwrap(), DebugLogType::HttpRequest);
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("grok_api_request").unwrap(), DebugLogType::GrokApiRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook);
}
@@ -113,10 +120,11 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 6);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::GrokApiRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook));
assert_eq!(variants.pop_first(), None);
@@ -25,6 +25,10 @@ pub enum InferenceJobExternalThirdParty {
#[serde(rename = "gmicloud")]
GmiCloud,
/// Grok (xAI) API jobs
#[serde(rename = "grok_api")]
GrokApi,
/// Seedance 2 Pro jobs
#[serde(rename = "seedance2pro")]
Seedance2Pro,
@@ -49,6 +53,7 @@ impl InferenceJobExternalThirdParty {
Self::Beeble => "beeble",
Self::Fal => "fal",
Self::GmiCloud => "gmicloud",
Self::GrokApi => "grok_api",
Self::Seedance2Pro => "seedance2pro",
Self::Seedance2ProAlt => "seedance2pro_alt",
Self::Worldlabs => "worldlabs",
@@ -60,6 +65,7 @@ impl InferenceJobExternalThirdParty {
"beeble" => Ok(Self::Beeble),
"fal" => Ok(Self::Fal),
"gmicloud" => Ok(Self::GmiCloud),
"grok_api" => Ok(Self::GrokApi),
"seedance2pro" => Ok(Self::Seedance2Pro),
"seedance2pro_alt" => Ok(Self::Seedance2ProAlt),
"worldlabs" => Ok(Self::Worldlabs),
@@ -74,6 +80,7 @@ impl InferenceJobExternalThirdParty {
Self::Beeble,
Self::Fal,
Self::GmiCloud,
Self::GrokApi,
Self::Seedance2Pro,
Self::Seedance2ProAlt,
Self::Worldlabs,
@@ -94,6 +101,7 @@ mod tests {
assert_serialization(InferenceJobExternalThirdParty::Beeble, "beeble");
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::GmiCloud, "gmicloud");
assert_serialization(InferenceJobExternalThirdParty::GrokApi, "grok_api");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
assert_serialization(InferenceJobExternalThirdParty::Seedance2ProAlt, "seedance2pro_alt");
assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs");
@@ -104,6 +112,7 @@ mod tests {
assert_eq!(InferenceJobExternalThirdParty::Beeble.to_str(), "beeble");
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::GmiCloud.to_str(), "gmicloud");
assert_eq!(InferenceJobExternalThirdParty::GrokApi.to_str(), "grok_api");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
assert_eq!(InferenceJobExternalThirdParty::Seedance2ProAlt.to_str(), "seedance2pro_alt");
assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs");
@@ -114,6 +123,7 @@ mod tests {
assert_eq!(InferenceJobExternalThirdParty::from_str("beeble").unwrap(), InferenceJobExternalThirdParty::Beeble);
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("gmicloud").unwrap(), InferenceJobExternalThirdParty::GmiCloud);
assert_eq!(InferenceJobExternalThirdParty::from_str("grok_api").unwrap(), InferenceJobExternalThirdParty::GrokApi);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro_alt").unwrap(), InferenceJobExternalThirdParty::Seedance2ProAlt);
assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs);
@@ -122,7 +132,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 6;
const EXPECTED_COUNT : usize = 7;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -35,6 +35,11 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "gmicloud_video")]
GmiCloudVideo,
// =============== GROK (xAI) ===============
#[serde(rename = "grok_api_video")]
GrokApiVideo,
// =============== BEEBLE ===============
#[serde(rename = "beeble_video")]
@@ -155,6 +160,7 @@ impl InferenceJobProductCategory {
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::GmiCloudVideo => "gmicloud_video",
Self::GrokApiVideo => "grok_api_video",
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
@@ -193,6 +199,7 @@ impl InferenceJobProductCategory {
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"gmicloud_video" => Ok(Self::GmiCloudVideo),
"grok_api_video" => Ok(Self::GrokApiVideo),
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
@@ -234,6 +241,7 @@ impl InferenceJobProductCategory {
Self::FalObject,
Self::FalBgRemoval,
Self::GmiCloudVideo,
Self::GrokApiVideo,
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
@@ -281,6 +289,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::GmiCloudVideo, "gmicloud_video");
assert_serialization(InferenceJobProductCategory::GrokApiVideo, "grok_api_video");
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
@@ -318,6 +327,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::GmiCloudVideo.to_str(), "gmicloud_video");
assert_eq!(InferenceJobProductCategory::GrokApiVideo.to_str(), "grok_api_video");
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
@@ -354,6 +364,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("gmicloud_video").unwrap(), InferenceJobProductCategory::GmiCloudVideo);
assert_eq!(InferenceJobProductCategory::from_str("grok_api_video").unwrap(), InferenceJobProductCategory::GrokApiVideo);
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
@@ -385,7 +396,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 33;
const EXPECTED_COUNT : usize = 34;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -23,6 +23,10 @@ pub enum InferenceJobType {
#[serde(rename = "gmicloud_queue")]
GmiCloudQueue,
/// Grok (xAI) API jobs. We poll for results.
#[serde(rename = "grok_api_queue")]
GrokApiQueue,
/// Seedance 2 Pro jobs. We poll for results.
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
@@ -135,6 +139,7 @@ impl InferenceJobType {
match self {
Self::FalQueue => "fal_queue",
Self::GmiCloudQueue => "gmicloud_queue",
Self::GrokApiQueue => "grok_api_queue",
Self::BeebleQueue => "beeble_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProAltQueue => "seedance2pro_alt_queue",
@@ -167,6 +172,7 @@ impl InferenceJobType {
match value {
"fal_queue" => Ok(Self::FalQueue),
"gmicloud_queue" => Ok(Self::GmiCloudQueue),
"grok_api_queue" => Ok(Self::GrokApiQueue),
"beeble_queue" => Ok(Self::BeebleQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
@@ -202,6 +208,7 @@ impl InferenceJobType {
BTreeSet::from([
Self::FalQueue,
Self::GmiCloudQueue,
Self::GrokApiQueue,
Self::BeebleQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
@@ -248,6 +255,7 @@ mod tests {
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::GmiCloudQueue, "gmicloud_queue");
assert_serialization(InferenceJobType::GrokApiQueue, "grok_api_queue");
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
@@ -279,6 +287,7 @@ mod tests {
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::GmiCloudQueue.to_str(), "gmicloud_queue");
assert_eq!(InferenceJobType::GrokApiQueue.to_str(), "grok_api_queue");
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
@@ -310,6 +319,7 @@ mod tests {
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("gmicloud_queue").unwrap(), InferenceJobType::GmiCloudQueue);
assert_eq!(InferenceJobType::from_str("grok_api_queue").unwrap(), InferenceJobType::GrokApiQueue);
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
@@ -340,7 +350,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 27;
const EXPECTED_COUNT : usize = 28;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -101,6 +101,10 @@ pub enum InferenceModelType {
GptImage2,
#[serde(rename = "grok_image")]
GrokImage,
#[serde(rename = "grok_imagine_image")]
GrokImagineImage,
#[serde(rename = "grok_imagine_image_q")]
GrokImagineImageQuality,
#[serde(rename = "recraft_3")]
Recraft3,
#[serde(rename = "seededit_3")]
@@ -143,6 +147,8 @@ pub enum InferenceModelType {
// Video models
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
@@ -251,6 +257,8 @@ impl InferenceModelType {
Self::GptImage1p5 => "gpt_image_1p5",
Self::GptImage2 => "gpt_image_2",
Self::GrokImage => "grok_image",
Self::GrokImagineImage => "grok_imagine_image",
Self::GrokImagineImageQuality => "grok_imagine_image_q",
Self::Recraft3 => "recraft_3",
Self::SeedEdit3 => "seededit_3",
Self::Qwen => "qwen",
@@ -273,6 +281,7 @@ impl InferenceModelType {
// Video models
Self::GrokVideo => "grok_video",
Self::GrokImagineVideo => "grok_imagine_video",
Self::Kling16Pro => "kling_1p6_pro",
Self::Kling21Pro => "kling_2p1_pro",
Self::Kling21Master => "kling_2p1_master",
@@ -344,6 +353,8 @@ impl InferenceModelType {
"gpt_image_1p5" => Ok(Self::GptImage1p5),
"gpt_image_2" => Ok(Self::GptImage2),
"grok_image" => Ok(Self::GrokImage),
"grok_imagine_image" => Ok(Self::GrokImagineImage),
"grok_imagine_image_q" => Ok(Self::GrokImagineImageQuality),
"recraft_3" => Ok(Self::Recraft3),
"seededit_3" => Ok(Self::SeedEdit3),
"qwen" => Ok(Self::Qwen),
@@ -366,6 +377,7 @@ impl InferenceModelType {
// Video models
"grok_video" => Ok(Self::GrokVideo),
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
"kling_1p6_pro" => Ok(Self::Kling16Pro),
"kling_2p1_pro" => Ok(Self::Kling21Pro),
"kling_2p1_master" => Ok(Self::Kling21Master),
@@ -441,6 +453,8 @@ impl InferenceModelType {
Self::GptImage1p5,
Self::GptImage2,
Self::GrokImage,
Self::GrokImagineImage,
Self::GrokImagineImageQuality,
Self::Recraft3,
Self::SeedEdit3,
Self::Qwen,
@@ -463,6 +477,7 @@ impl InferenceModelType {
// Video models
Self::GrokVideo,
Self::GrokImagineVideo,
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
@@ -521,6 +536,8 @@ impl InferenceModelType {
CommonModelType::GptImage1p5 => Self::GptImage1p5,
CommonModelType::GptImage2 => Self::GptImage2,
CommonModelType::GrokImage => Self::GrokImage,
CommonModelType::GrokImagineImage => Self::GrokImagineImage,
CommonModelType::GrokImagineImageQuality => Self::GrokImagineImageQuality,
CommonModelType::Recraft3 => Self::Recraft3,
CommonModelType::SeedEdit3 => Self::SeedEdit3,
CommonModelType::Qwen => Self::Qwen,
@@ -543,6 +560,7 @@ impl InferenceModelType {
// Video models
CommonModelType::GrokVideo => Self::GrokVideo,
CommonModelType::GrokImagineVideo => Self::GrokImagineVideo,
CommonModelType::Kling16Pro => Self::Kling16Pro,
CommonModelType::Kling21Pro => Self::Kling21Pro,
CommonModelType::Kling21Master => Self::Kling21Master,
@@ -624,6 +642,8 @@ mod tests {
assert_serialization(InferenceModelType::GptImage1p5, "gpt_image_1p5");
assert_serialization(InferenceModelType::GptImage2, "gpt_image_2");
assert_serialization(InferenceModelType::GrokImage, "grok_image");
assert_serialization(InferenceModelType::GrokImagineImage, "grok_imagine_image");
assert_serialization(InferenceModelType::GrokImagineImageQuality, "grok_imagine_image_q");
assert_serialization(InferenceModelType::Recraft3, "recraft_3");
assert_serialization(InferenceModelType::SeedEdit3, "seededit_3");
assert_serialization(InferenceModelType::Qwen, "qwen");
@@ -646,6 +666,7 @@ mod tests {
// Video models
assert_serialization(InferenceModelType::GrokVideo, "grok_video");
assert_serialization(InferenceModelType::GrokImagineVideo, "grok_imagine_video");
assert_serialization(InferenceModelType::Kling16Pro, "kling_1p6_pro");
assert_serialization(InferenceModelType::Kling21Pro, "kling_2p1_pro");
assert_serialization(InferenceModelType::Kling21Master, "kling_2p1_master");
@@ -716,6 +737,8 @@ mod tests {
assert_eq!(InferenceModelType::GptImage1p5.to_str(), "gpt_image_1p5");
assert_eq!(InferenceModelType::GptImage2.to_str(), "gpt_image_2");
assert_eq!(InferenceModelType::GrokImage.to_str(), "grok_image");
assert_eq!(InferenceModelType::GrokImagineImage.to_str(), "grok_imagine_image");
assert_eq!(InferenceModelType::GrokImagineImageQuality.to_str(), "grok_imagine_image_q");
assert_eq!(InferenceModelType::Recraft3.to_str(), "recraft_3");
assert_eq!(InferenceModelType::SeedEdit3.to_str(), "seededit_3");
assert_eq!(InferenceModelType::Qwen.to_str(), "qwen");
@@ -738,6 +761,7 @@ mod tests {
// Video models
assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video");
assert_eq!(InferenceModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
assert_eq!(InferenceModelType::Kling16Pro.to_str(), "kling_1p6_pro");
assert_eq!(InferenceModelType::Kling21Pro.to_str(), "kling_2p1_pro");
assert_eq!(InferenceModelType::Kling21Master.to_str(), "kling_2p1_master");
@@ -808,6 +832,8 @@ mod tests {
assert_eq!(InferenceModelType::from_str("gpt_image_1p5").unwrap(), InferenceModelType::GptImage1p5);
assert_eq!(InferenceModelType::from_str("gpt_image_2").unwrap(), InferenceModelType::GptImage2);
assert_eq!(InferenceModelType::from_str("grok_image").unwrap(), InferenceModelType::GrokImage);
assert_eq!(InferenceModelType::from_str("grok_imagine_image").unwrap(), InferenceModelType::GrokImagineImage);
assert_eq!(InferenceModelType::from_str("grok_imagine_image_q").unwrap(), InferenceModelType::GrokImagineImageQuality);
assert_eq!(InferenceModelType::from_str("recraft_3").unwrap(), InferenceModelType::Recraft3);
assert_eq!(InferenceModelType::from_str("seededit_3").unwrap(), InferenceModelType::SeedEdit3);
assert_eq!(InferenceModelType::from_str("qwen").unwrap(), InferenceModelType::Qwen);
@@ -830,6 +856,7 @@ mod tests {
// Video models
assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo);
assert_eq!(InferenceModelType::from_str("grok_imagine_video").unwrap(), InferenceModelType::GrokImagineVideo);
assert_eq!(InferenceModelType::from_str("kling_1p6_pro").unwrap(), InferenceModelType::Kling16Pro);
assert_eq!(InferenceModelType::from_str("kling_2p1_pro").unwrap(), InferenceModelType::Kling21Pro);
assert_eq!(InferenceModelType::from_str("kling_2p1_master").unwrap(), InferenceModelType::Kling21Master);
@@ -25,6 +25,10 @@ pub enum CommonImageModel {
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "grok_imagine_image")]
GrokImagineImage,
#[serde(rename = "grok_imagine_image_q")]
GrokImagineImageQuality,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -50,6 +54,8 @@ impl CommonImageModel {
Self::GptImage1 => CommonModelType::GptImage1,
Self::GptImage1p5 => CommonModelType::GptImage1p5,
Self::GptImage2 => CommonModelType::GptImage2,
Self::GrokImagineImage => CommonModelType::GrokImagineImage,
Self::GrokImagineImageQuality => CommonModelType::GrokImagineImageQuality,
Self::NanoBanana => CommonModelType::NanoBanana,
Self::NanoBanana2 => CommonModelType::NanoBanana2,
Self::NanoBananaPro => CommonModelType::NanoBananaPro,
@@ -79,6 +85,8 @@ mod tests {
assert_serialization(CommonImageModel::GptImage1, "gpt_image_1");
assert_serialization(CommonImageModel::GptImage1p5, "gpt_image_1p5");
assert_serialization(CommonImageModel::GptImage2, "gpt_image_2");
assert_serialization(CommonImageModel::GrokImagineImage, "grok_imagine_image");
assert_serialization(CommonImageModel::GrokImagineImageQuality, "grok_imagine_image_q");
assert_serialization(CommonImageModel::NanoBanana, "nano_banana");
assert_serialization(CommonImageModel::NanoBanana2, "nano_banana_2");
assert_serialization(CommonImageModel::NanoBananaPro, "nano_banana_pro");
@@ -97,6 +105,8 @@ mod tests {
(CommonImageModel::GptImage1, CommonModelType::GptImage1),
(CommonImageModel::GptImage1p5, CommonModelType::GptImage1p5),
(CommonImageModel::GptImage2, CommonModelType::GptImage2),
(CommonImageModel::GrokImagineImage, CommonModelType::GrokImagineImage),
(CommonImageModel::GrokImagineImageQuality, CommonModelType::GrokImagineImageQuality),
(CommonImageModel::NanoBanana, CommonModelType::NanoBanana),
(CommonImageModel::NanoBanana2, CommonModelType::NanoBanana2),
(CommonImageModel::NanoBananaPro, CommonModelType::NanoBananaPro),
@@ -129,7 +139,7 @@ mod tests {
#[test]
fn variant_count() {
use strum::IntoEnumIterator;
assert_eq!(CommonImageModel::iter().len(), 13);
assert_eq!(CommonImageModel::iter().len(), 15);
}
#[test]
@@ -53,6 +53,12 @@ pub enum CommonModelType {
// Generic grok image model without a version
#[serde(rename = "grok_image")]
GrokImage,
// xAI's Grok Imagine image model (grok-imagine-image), standard tier.
#[serde(rename = "grok_imagine_image")]
GrokImagineImage,
// xAI's Grok Imagine image model (grok-imagine-image-quality), high-quality tier.
#[serde(rename = "grok_imagine_image_q")]
GrokImagineImageQuality,
#[serde(rename = "recraft_3")]
Recraft3,
#[serde(rename = "seededit_3")]
@@ -101,9 +107,16 @@ pub enum CommonModelType {
// Video models
// Generic grok video model without a version
/// Generic grok video model without a version
/// NB: This was for the consumer web login API
#[deprecated(note = "use GrokImagineVideo instead")]
#[serde(rename = "grok_video")]
GrokVideo,
GrokVideo,
/// xAI's Grok Imagine video model (grok-imagine-video).
/// This is for the API-based version.
/// No version is specified.
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
@@ -196,6 +209,8 @@ impl CommonModelType {
Self::GptImage1p5 => "gpt_image_1p5",
Self::GptImage2 => "gpt_image_2",
Self::GrokImage => "grok_image",
Self::GrokImagineImage => "grok_imagine_image",
Self::GrokImagineImageQuality => "grok_imagine_image_q",
Self::Recraft3 => "recraft_3",
Self::SeedEdit3 => "seededit_3",
Self::Qwen => "qwen",
@@ -218,6 +233,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo => "grok_video",
Self::GrokImagineVideo => "grok_imagine_video",
Self::Kling16Pro => "kling_1p6_pro",
Self::Kling21Pro => "kling_2p1_pro",
Self::Kling21Master => "kling_2p1_master",
@@ -272,6 +288,8 @@ impl CommonModelType {
"gpt_image_1p5" => Ok(Self::GptImage1p5),
"gpt_image_2" => Ok(Self::GptImage2),
"grok_image" => Ok(Self::GrokImage),
"grok_imagine_image" => Ok(Self::GrokImagineImage),
"grok_imagine_image_q" => Ok(Self::GrokImagineImageQuality),
"recraft_3" => Ok(Self::Recraft3),
"seededit_3" => Ok(Self::SeedEdit3),
"qwen" => Ok(Self::Qwen),
@@ -294,6 +312,7 @@ impl CommonModelType {
// Video models
"grok_video" => Ok(Self::GrokVideo),
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
"kling_1p6_pro" => Ok(Self::Kling16Pro),
"kling_2p1_pro" => Ok(Self::Kling21Pro),
"kling_2p1_master" => Ok(Self::Kling21Master),
@@ -352,6 +371,8 @@ impl CommonModelType {
Self::GptImage1p5,
Self::GptImage2,
Self::GrokImage,
Self::GrokImagineImage,
Self::GrokImagineImageQuality,
Self::Recraft3,
Self::SeedEdit3,
Self::Qwen,
@@ -374,6 +395,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo,
Self::GrokImagineVideo,
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
@@ -429,6 +451,8 @@ impl CommonModelType {
Self::GptImage1p5 => CommonModelClass::Image,
Self::GptImage2 => CommonModelClass::Image,
Self::GrokImage => CommonModelClass::Image,
Self::GrokImagineImage => CommonModelClass::Image,
Self::GrokImagineImageQuality => CommonModelClass::Image,
Self::Recraft3 => CommonModelClass::Image,
Self::SeedEdit3 => CommonModelClass::Image,
Self::Qwen => CommonModelClass::Image,
@@ -451,6 +475,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo => CommonModelClass::Video,
Self::GrokImagineVideo => CommonModelClass::Video,
Self::Kling16Pro => CommonModelClass::Video,
Self::Kling21Pro => CommonModelClass::Video,
Self::Kling21Master => CommonModelClass::Video,
@@ -516,6 +541,8 @@ mod tests {
assert_serialization(CommonModelType::GptImage1p5, "gpt_image_1p5");
assert_serialization(CommonModelType::GptImage2, "gpt_image_2");
assert_serialization(CommonModelType::GrokImage, "grok_image");
assert_serialization(CommonModelType::GrokImagineImage, "grok_imagine_image");
assert_serialization(CommonModelType::GrokImagineImageQuality, "grok_imagine_image_q");
assert_serialization(CommonModelType::Recraft3, "recraft_3");
assert_serialization(CommonModelType::SeedEdit3, "seededit_3");
assert_serialization(CommonModelType::Qwen, "qwen");
@@ -537,6 +564,7 @@ mod tests {
assert_serialization(CommonModelType::MidjourneyV7Raw, "midjourney_v7_raw");
// Video models
assert_serialization(CommonModelType::GrokVideo, "grok_video");
assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video");
assert_serialization(CommonModelType::Kling16Pro, "kling_1p6_pro");
assert_serialization(CommonModelType::Kling21Pro, "kling_2p1_pro");
assert_serialization(CommonModelType::Kling21Master, "kling_2p1_master");
@@ -588,6 +616,8 @@ mod tests {
assert_eq!(CommonModelType::GptImage1p5.to_str(), "gpt_image_1p5");
assert_eq!(CommonModelType::GptImage2.to_str(), "gpt_image_2");
assert_eq!(CommonModelType::GrokImage.to_str(), "grok_image");
assert_eq!(CommonModelType::GrokImagineImage.to_str(), "grok_imagine_image");
assert_eq!(CommonModelType::GrokImagineImageQuality.to_str(), "grok_imagine_image_q");
assert_eq!(CommonModelType::Recraft3.to_str(), "recraft_3");
assert_eq!(CommonModelType::SeedEdit3.to_str(), "seededit_3");
assert_eq!(CommonModelType::Qwen.to_str(), "qwen");
@@ -610,6 +640,7 @@ mod tests {
// Video models
assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video");
assert_eq!(CommonModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
assert_eq!(CommonModelType::Kling16Pro.to_str(), "kling_1p6_pro");
assert_eq!(CommonModelType::Kling21Pro.to_str(), "kling_2p1_pro");
assert_eq!(CommonModelType::Kling21Master.to_str(), "kling_2p1_master");
@@ -662,6 +693,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("gpt_image_1p5").unwrap(), CommonModelType::GptImage1p5);
assert_eq!(CommonModelType::from_str("gpt_image_2").unwrap(), CommonModelType::GptImage2);
assert_eq!(CommonModelType::from_str("grok_image").unwrap(), CommonModelType::GrokImage);
assert_eq!(CommonModelType::from_str("grok_imagine_image").unwrap(), CommonModelType::GrokImagineImage);
assert_eq!(CommonModelType::from_str("grok_imagine_image_q").unwrap(), CommonModelType::GrokImagineImageQuality);
assert_eq!(CommonModelType::from_str("recraft_3").unwrap(), CommonModelType::Recraft3);
assert_eq!(CommonModelType::from_str("seededit_3").unwrap(), CommonModelType::SeedEdit3);
assert_eq!(CommonModelType::from_str("qwen").unwrap(), CommonModelType::Qwen);
@@ -683,6 +716,7 @@ mod tests {
assert_eq!(CommonModelType::from_str("midjourney_v7_raw").unwrap(), CommonModelType::MidjourneyV7Raw);
// Video models
assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo);
assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo);
assert_eq!(CommonModelType::from_str("kling_1p6_pro").unwrap(), CommonModelType::Kling16Pro);
assert_eq!(CommonModelType::from_str("kling_2p1_pro").unwrap(), CommonModelType::Kling21Pro);
assert_eq!(CommonModelType::from_str("kling_2p1_master").unwrap(), CommonModelType::Kling21Master);
@@ -723,7 +757,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 64);
assert_eq!(variants.len(), 67);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -737,6 +771,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage1p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::GptImage2));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImage));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineImage));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineImageQuality));
assert_eq!(variants.pop_first(), Some(CommonModelType::Recraft3));
assert_eq!(variants.pop_first(), Some(CommonModelType::SeedEdit3));
assert_eq!(variants.pop_first(), Some(CommonModelType::Qwen));
@@ -758,6 +794,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7Raw));
// Video models
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling16Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Master));
@@ -5,9 +5,15 @@ use utoipa::ToSchema;
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
/// NB: This was for the web login version
#[deprecated = "Use `GrokImagineVideo` instead."]
#[serde(rename = "grok_video")]
GrokVideo,
/// NB: This is for the API version
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -93,6 +99,7 @@ impl CommonVideoModel {
use crate::common::generation::common_model_type::CommonModelType;
match self {
Self::GrokVideo => CommonModelType::GrokVideo,
Self::GrokImagineVideo => CommonModelType::GrokImagineVideo,
Self::Kling16Pro => CommonModelType::Kling16Pro,
Self::Kling21Pro => CommonModelType::Kling21Pro,
Self::Kling21Master => CommonModelType::Kling21Master,
@@ -131,6 +138,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(CommonVideoModel::GrokVideo, "grok_video");
assert_serialization(CommonVideoModel::GrokImagineVideo, "grok_imagine_video");
assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro");
assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro");
assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master");
@@ -162,6 +170,7 @@ mod tests {
fn test_deserialization() {
let cases = [
("grok_video", CommonVideoModel::GrokVideo),
("grok_imagine_video", CommonVideoModel::GrokImagineVideo),
("kling_1p6_pro", CommonVideoModel::Kling16Pro),
("kling_2p1_pro", CommonVideoModel::Kling21Pro),
("kling_2p1_master", CommonVideoModel::Kling21Master),
@@ -200,6 +209,7 @@ mod tests {
fn test_round_trip() {
let all = [
CommonVideoModel::GrokVideo,
CommonVideoModel::GrokImagineVideo,
CommonVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master,
@@ -237,6 +247,7 @@ mod tests {
fn all_video_models_convert_to_common_model_type() {
let models = [
(CommonVideoModel::GrokVideo, CommonModelType::GrokVideo),
(CommonVideoModel::GrokImagineVideo, CommonModelType::GrokImagineVideo),
(CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro),
(CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro),
(CommonVideoModel::Kling21Master, CommonModelType::Kling21Master),
@@ -0,0 +1,56 @@
[package]
name = "grok-api-job"
edition = "2021"
version = "0.0.1"
publish = false
[[bin]]
name = "grok-api-job"
path = "src/main.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
actix_helpers.workspace = true
bootstrap.workspace = true
bucket_paths.workspace = true
cloud_storage.workspace = true
concurrency.workspace = true
easyenv.workspace = true
enums.workspace = true
errors.workspace = true
grok_api_client.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common.workspace = true
mysql_queries.workspace = true
pager = { path = "../../../lib/pager" }
rootly_client.workspace = true
rootly_config.workspace = true
server_environment = { path = "../../../lib/server_environment" }
shared_env_var_config.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
log.workspace = true
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
# Runtime
tokio = { version = "1.40.0", features = ["macros", "sync"] }
# HTTP Client
reqwest.workspace = true
# HTTP Server
actix-web.workspace = true
# Serialization
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
@@ -0,0 +1 @@
SERVER_ENVIRONMENT='development'
@@ -0,0 +1,101 @@
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use log::error;
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
#[derive(Serialize)]
pub struct HealthCheckResponse {
pub success: bool,
pub is_healthy: bool,
pub consecutive_failure_count: u64,
pub consecutive_success_count: u64,
pub total_failure_count: u64,
pub total_success_count: u64,
pub total_failure_ratio: f32,
pub total_success_ratio: f32,
}
#[derive(Debug, Serialize)]
pub enum HealthCheckError {
ServerError,
}
impl ResponseError for HealthCheckError {
fn status_code(&self) -> StatusCode {
match *self {
HealthCheckError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
error_to_json_http_response(self)
}
}
impl std::fmt::Display for HealthCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub async fn get_health_check_handler(
_http_request: HttpRequest,
server_state: web::Data<Arc<HttpServerSharedState>>,
) -> Result<HttpResponse, HealthCheckError> {
let job_stats = server_state
.job_stats
.get_status()
.map_err(|e| {
error!("Error serving health check status: {:?}", e);
HealthCheckError::ServerError
})?;
let total_tries = job_stats
.total_failure_count
.saturating_add(job_stats.total_success_count);
let total_success_ratio = if total_tries > 0 {
(job_stats.total_success_count as f32) / (total_tries as f32)
} else {
0.0
};
let total_failure_ratio = if total_tries > 0 {
1.0 - total_success_ratio
} else {
0.0
};
let is_healthy =
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
let response = HealthCheckResponse {
success: true,
is_healthy,
consecutive_failure_count: job_stats.consecutive_failure_count,
consecutive_success_count: job_stats.consecutive_success_count,
total_failure_count: job_stats.total_failure_count,
total_success_count: job_stats.total_success_count,
total_failure_ratio,
total_success_ratio,
};
let body = serde_json::to_string(&response).map_err(|_e| HealthCheckError::ServerError)?;
if is_healthy {
Ok(HttpResponse::Ok()
.content_type(ContentType::json())
.body(body))
} else {
Ok(HttpResponse::InternalServerError()
.content_type(ContentType::json())
.body(body))
}
}
@@ -0,0 +1 @@
pub mod health_check_handler;
@@ -0,0 +1,7 @@
use jobs_common::job_stats::JobStats;
#[derive(Clone)]
pub struct HttpServerSharedState {
pub job_stats: JobStats,
pub consecutive_failure_unhealthy_threshold: u64,
}
@@ -0,0 +1,3 @@
pub mod endpoints;
pub mod http_server_shared_state;
pub mod run_http_server;
@@ -0,0 +1,68 @@
use std::sync::Arc;
use actix_web::dev::Server;
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpResponse, HttpServer};
use log::info;
use bootstrap::bootstrap::ContainerEnvironment;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:12345";
const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
pub container_environment: ContainerEnvironment,
pub job_stats: JobStats,
}
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
let server_state = HttpServerSharedState {
job_stats: args.job_stats.clone(),
consecutive_failure_unhealthy_threshold: easyenv::get_env_num(
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
3,
)?,
};
let _ = args.container_environment;
let server_state_arc = web::Data::new(Arc::new(server_state));
info!("Starting HTTP service (for k8s health checking).");
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
let handle = HttpServer::new(move || {
App::new()
.app_data(server_state_arc.clone())
.wrap(Logger::new(&log_format).exclude("/_status"))
.service(
web::resource("/")
.route(web::get().to(|| HttpResponse::Ok()))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/_status")
.route(web::get().to(get_health_check_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
})
.bind(bind_address)?
.workers(num_workers)
.run();
Ok(handle)
}
pub async fn launch_http_server(args: CreateServerArgs) -> AnyhowResult<()> {
run_http_server(args)?.await?;
Ok(())
}
@@ -0,0 +1,44 @@
use std::sync::Arc;
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use grok_api_client::creds::grok_api_key::GrokApiKey;
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
use tokio::sync::Notify;
#[derive(Clone)]
pub struct JobDependencies {
pub mysql_pool: MySqlPool,
/// Public GCS/S3 bucket for storing generated videos.
pub public_bucket_client: BucketClient,
/// API key for xAI's Imagine API.
pub grok_api_key: GrokApiKey,
pub server_environment: ServerEnvironment,
pub pager: Pager,
pub job_stats: JobStats,
/// How long to sleep after a successful poll iteration (milliseconds).
pub poll_interval_success_millis: u64,
/// How long to sleep after a failed poll iteration (milliseconds).
pub poll_interval_failure_millis: u64,
/// How long to sleep between image_generation_job iterations (milliseconds).
/// Stub-job for now — will be exercised once image generation is wired up.
pub image_generation_poll_interval_millis: u64,
/// Set to `true` from another thread to trigger graceful shutdown.
pub application_shutdown: RelaxedAtomicBool,
/// Notified when `application_shutdown` is set. Allows sleeping tasks
/// to wake up immediately instead of waiting for the full sleep duration.
pub shutdown_notify: Arc<Notify>,
}
@@ -0,0 +1,27 @@
use std::time::Duration;
use log::{info, warn};
use crate::job_dependencies::JobDependencies;
/// Stub loop for synchronous Grok image generation.
///
/// xAI's image endpoints (`/v1/images/generations`, `/v1/images/edits`) are
/// synchronous, so this loop will eventually pull queued requests off our side
/// instead of polling xAI for status. For now it ticks at the configured
/// interval and does nothing — the next iteration of this work will fill it in.
pub async fn image_generation_main_loop(job_dependencies: JobDependencies) {
info!(
"Grok image_generation_main_loop started (tick interval: {}ms). Stub — no work yet.",
job_dependencies.image_generation_poll_interval_millis,
);
while !job_dependencies.application_shutdown.get() {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(job_dependencies.image_generation_poll_interval_millis)) => {}
_ = job_dependencies.shutdown_notify.notified() => {}
}
}
warn!("Grok image generation main loop is shut down.");
}
@@ -0,0 +1 @@
pub mod image_generation_main_loop;
@@ -0,0 +1,2 @@
pub mod image_generation_job;
pub mod video_polling_job;
@@ -0,0 +1,34 @@
use log::error;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::PendingGrokApiJob;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
/// Enqueue a pager alert for the error, then return it as `Err`.
pub fn alert_pager_and_return_err<T>(
pager: &Pager,
title: &str,
error: Box<dyn std::error::Error + Send + Sync + 'static>,
job: Option<&PendingGrokApiJob>,
) -> anyhow::Result<T> {
let err_message = format!("{:#}", error);
let mut builder = NotificationDetailsBuilder::from_boxed_error(error)
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium));
if let Some(job) = job {
builder = builder
.set_inference_job_token(Some(job.job_token.to_string()))
.set_third_party_id(Some(job.request_id.clone()))
.set_user_token(job.maybe_creator_user_token.as_ref().map(|t| t.to_string()));
}
let notification = builder.build();
if let Err(pager_err) = pager.enqueue_page(notification) {
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(anyhow::anyhow!(err_message))
}
@@ -0,0 +1,3 @@
pub mod alert_on_error;
pub mod process_job;
pub mod video_polling_main_loop;
@@ -0,0 +1,2 @@
pub mod process_failed_job;
pub mod process_successful_job;
@@ -0,0 +1,52 @@
use log::{error, warn};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::PendingGrokApiJob;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use crate::job_dependencies::JobDependencies;
/// Mark the job failed in the database.
///
/// Unlike Seedance2Pro/Kinovi, Grok API video jobs are **non-refundable** —
/// xAI's video generation is billed regardless of outcome, so we never issue
/// a wallet refund here.
pub async fn process_failed_job(
deps: &JobDependencies,
job: &PendingGrokApiJob,
reason: &str,
) {
let reason_lower = reason.to_lowercase();
let platform_rules_violation = reason_lower.contains("moderated")
|| reason_lower.contains("moderation")
|| reason_lower.contains("violates")
|| reason_lower.contains("platform rules")
|| reason_lower.contains("content policy");
let frontend_failure_category = if platform_rules_violation {
Some(FrontendFailureCategory::ModelRulesViolation)
} else {
Some(FrontendFailureCategory::GenerationFailed)
};
warn!(
"Request for job {} failed: {}. Marking job failed.",
job.job_token.as_str(), reason
);
let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &deps.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(reason),
internal_debugging_failure_reason: reason,
maybe_frontend_failure_category: frontend_failure_category,
}).await;
if let Err(err) = mark_failed_result {
error!(
"Error marking job {} as failed: {:?}",
job.job_token.as_str(), err
);
}
}
@@ -0,0 +1,148 @@
use anyhow::anyhow;
use log::{error, info};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::PendingGrokApiJob;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use crate::job_dependencies::JobDependencies;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
const VIDEO_PREFIX: &str = "artcraft_";
const VIDEO_SUFFIX: &str = ".mp4";
/// Download the completed video from xAI, upload to bucket, create media file
/// record, and mark the job done.
pub async fn process_successful_job(
deps: &JobDependencies,
job: &PendingGrokApiJob,
video_url: &str,
) -> AnyhowResult<()> {
// --- Step 1: Download the video. ---
info!("Downloading video for job {} from: {}", job.job_token.as_str(), video_url);
let video_bytes: Vec<u8> = match reqwest::get(video_url).await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(err) => {
error!("Error reading video bytes for job {}: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Grok API video download failed",
err.into(),
Some(job),
);
}
},
Err(err) => {
error!("Error downloading video for job {}: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Grok API video download failed",
err.into(),
Some(job),
);
}
};
info!("Downloaded {} bytes for job {}", video_bytes.len(), job.job_token.as_str());
// --- Step 2: Hash and upload. ---
let checksum = sha256_hash_bytes(&video_bytes)
.map_err(|err| anyhow!("error hashing video: {:?}", err))?;
let bucket_path = MediaFileBucketPath::generate_new(Some(VIDEO_PREFIX), Some(VIDEO_SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
info!("Uploading video to public bucket at path: {}", object_path);
let upload_result = deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &video_bytes, "video/mp4")
.await;
if let Err(err) = upload_result {
error!("Error uploading video for job {}: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Grok API bucket upload failed",
err.into(),
Some(job),
);
}
info!("Uploaded video for job {}. Creating media file record.", job.job_token.as_str());
// --- Step 3: Create media file record. ---
let media_file_result = MediaFileInsertBuilder::new()
.checksum_sha2(&checksum)
.creator_ip_address(&job.creator_ip_address)
.creator_set_visibility(job.creator_set_visibility)
.file_size_bytes(video_bytes.len() as u64)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.media_file_class(MediaFileClass::Video)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.media_file_type(MediaFileType::Mp4)
.mime_type("video/mp4")
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await;
let media_file_token = match media_file_result {
Ok(token) => token,
Err(err) => {
error!("Error inserting media file record for job {}: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Grok API media file insert failed",
err.into(),
Some(job),
);
}
};
info!(
"Created media file {} for job {}. Marking job complete.",
media_file_token.as_str(), job.job_token.as_str()
);
// --- Step 4: Mark job as done. ---
if let Err(err) = mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::MediaFile),
Some(media_file_token.as_str()),
None,
None,
).await {
error!("Error marking job {} done: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Grok API job completion update failed",
err.into(),
Some(job),
);
}
info!("Job {} completed successfully.", job.job_token.as_str());
Ok(())
}
@@ -0,0 +1,180 @@
use std::time::Duration;
use log::{error, info, warn};
use grok_api_client::api::requests::videos::video_status::video_status::{
video_status, VideoStatusArgs, VideoStatusRequest, VideoStatusState, VideoStatusSuccess,
};
use grok_api_client::error::grok_error::GrokError;
use grok_api_client::error::grok_specific_api_error::GrokSpecificApiError;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::{
list_pending_grok_api_jobs, PendingGrokApiJob,
};
use crate::job_dependencies::JobDependencies;
use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job;
use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job;
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let result = run_poll_iteration(&job_dependencies).await;
let sleep_millis = match result {
Ok(_) => job_dependencies.poll_interval_success_millis,
Err(err) => {
error!("Error in Grok API poll iteration: {:?}", err);
let _ = job_dependencies.job_stats.increment_failure_count();
job_dependencies.poll_interval_failure_millis
}
};
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(sleep_millis)) => {}
_ = job_dependencies.shutdown_notify.notified() => {}
}
}
warn!("Grok API video polling main loop is shut down.");
}
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
let pending_jobs = list_pending_grok_api_jobs(&deps.mysql_pool).await?;
if pending_jobs.is_empty() {
return Ok(());
}
info!("Found {} pending Grok API job(s).", pending_jobs.len());
for job in &pending_jobs {
if deps.application_shutdown.get() {
info!("Shutdown requested during Grok poll iteration. Stopping early.");
break;
}
poll_one_job(deps, job).await;
}
Ok(())
}
async fn poll_one_job(deps: &JobDependencies, job: &PendingGrokApiJob) {
let poll_result = video_status(VideoStatusArgs {
api_key: &deps.grok_api_key,
request: VideoStatusRequest {
request_id: job.request_id.clone(),
},
}).await;
let success = match poll_result {
Ok(success) => success,
Err(err) => {
handle_video_status_error(deps, job, err).await;
return;
}
};
match success.state {
VideoStatusState::Pending => {
let progress = success.progress.map(|p| format!("{}%", p)).unwrap_or_else(|| "?".to_string());
info!(
"Grok request {} for job {} still pending ({} progress).",
job.request_id, job.job_token.as_str(), progress,
);
}
VideoStatusState::Done => {
finalize_done_response(deps, job, success).await;
}
VideoStatusState::Failed => {
// The Failed variant is mapped into `GrokError::ApiSpecific(VideoJobFailed)`
// by the client. We shouldn't hit this branch, but treat defensively.
warn!(
"Unexpected VideoStatusState::Failed surfaced as Ok for request {} (job {}).",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, "Grok video status reported failed").await;
let _ = deps.job_stats.increment_failure_count();
}
}
}
async fn finalize_done_response(
deps: &JobDependencies,
job: &PendingGrokApiJob,
success: VideoStatusSuccess,
) {
let video_url = match success.video.as_ref().and_then(|v| v.url.clone()) {
Some(url) => url,
None => {
warn!(
"Grok request {} reported Done with no video.url for job {}. Skipping.",
job.request_id, job.job_token.as_str(),
);
return;
}
};
info!(
"Grok request {} completed, processing job {}.",
job.request_id, job.job_token.as_str(),
);
match process_successful_job(deps, job, &video_url).await {
Ok(()) => {
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
warn!(
"Error processing completed Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
let _ = deps.job_stats.increment_failure_count();
}
}
}
async fn handle_video_status_error(
deps: &JobDependencies,
job: &PendingGrokApiJob,
err: GrokError,
) {
match &err {
// Terminal: mark the job failed.
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
let reason = format!("Grok video failed ({}): {}", code, message);
info!(
"Grok request {} for job {} reported failed. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, &reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired) => {
let reason = "Grok video job expired";
info!(
"Grok request {} for job {} expired. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::NotFound) => {
// Treat NotFound as terminal — xAI doesn't know about this request_id
// anymore (expired retention or never existed).
let reason = "Grok video job not found (likely expired)";
info!(
"Grok request {} for job {} not found. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, reason).await;
let _ = deps.job_stats.increment_failure_count();
}
// Transient: just log and let the next iteration retry.
_ => {
warn!(
"Transient error polling Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
}
}
}
+210
View File
@@ -0,0 +1,210 @@
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
#[macro_use] extern crate serde_derive;
use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
use log::{info, warn};
use sqlx::mysql::MySqlPoolOptions;
use tokio::sync::Notify;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use errors::AnyhowResult;
use grok_api_client::creds::grok_api_key::GrokApiKey;
use jobs_common::job_stats::JobStats;
use server_environment::ServerEnvironment;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::job_dependencies::JobDependencies;
use crate::jobs::image_generation_job::image_generation_main_loop::image_generation_main_loop;
use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop;
use crate::startup::build_pager::build_pager;
pub mod http_server;
pub mod job_dependencies;
pub mod jobs;
pub mod startup;
// Bucket config
const ENV_ACCESS_KEY: &str = "ACCESS_KEY";
const ENV_SECRET_KEY: &str = "SECRET_KEY";
const ENV_REGION_NAME: &str = "REGION_NAME";
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
const ENV_GROK_API_KEY: &str = "GROK_API_KEY";
#[tokio::main]
async fn main() -> AnyhowResult<()> {
let container_environment = bootstrap(BootstrapArgs {
app_name: "grok-api-job",
default_logging_override: Some(DEFAULT_RUST_LOG),
config_search_directories: &[".", "./config", "crates/service/job/grok_api_job/config"],
ignore_legacy_dot_env_file: true,
})?;
info!("Hostname: {}", &container_environment.hostname);
let _k8s_node_name = easyenv::get_env_string_optional("K8S_NODE_NAME");
let _k8s_pod_name = easyenv::get_env_string_optional("K8S_POD_NAME");
let db_connection_string = env_get_mysql_connection_string_or_default();
info!("Connecting to database...");
let mysql_pool = MySqlPoolOptions::new()
.max_connections(2)
.connect(&db_connection_string)
.await?;
info!("Connected to MySQL.");
let server_environment = ServerEnvironment::from_str(
&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?,
)
.ok_or(anyhow!("invalid server environment"))?;
// Bucket setup
let access_key = easyenv::get_env_string_required(ENV_ACCESS_KEY)?;
let secret_key = easyenv::get_env_string_required(ENV_SECRET_KEY)?;
let region_name = easyenv::get_env_string_required(ENV_REGION_NAME)?;
let public_bucket_name = easyenv::get_env_string_required(ENV_PUBLIC_BUCKET_NAME)?;
let s3_compatible_endpoint_url = easyenv::get_env_string_required(ENV_S3_ENDPOINT)?;
let bucket_timeout = easyenv::get_env_duration_seconds_or_default(
"BUCKET_TIMEOUT_SECONDS",
Duration::from_secs(60 * 5),
);
let public_bucket_client = BucketClient::create(
&access_key,
&secret_key,
&region_name,
&public_bucket_name,
&s3_compatible_endpoint_url,
None,
Some(bucket_timeout),
)?;
// Grok API key
let grok_api_key_str = easyenv::get_env_string_required(ENV_GROK_API_KEY)?;
let grok_api_key = GrokApiKey::new(grok_api_key_str);
// How often to poll after a successful iteration (default: 5 seconds)
let poll_interval_success_millis: u64 = easyenv::get_env_num(
"GROK_POLL_INTERVAL_SUCCESS_MILLIS",
5_000,
)?;
// How often to poll after a failed iteration (default: 15 seconds)
let poll_interval_failure_millis: u64 = easyenv::get_env_num(
"GROK_POLL_INTERVAL_FAILURE_MILLIS",
15_000,
)?;
// Stub image_generation_job tick interval (default: 60 seconds).
let image_generation_poll_interval_millis: u64 = easyenv::get_env_num(
"GROK_IMAGE_POLL_INTERVAL_MILLIS",
60_000,
)?;
let application_shutdown = RelaxedAtomicBool::new(false);
let shutdown_notify = Arc::new(Notify::new());
let job_stats = JobStats::new();
// Pager setup
let (pager, pager_worker) = build_pager(server_environment, &container_environment.hostname);
info!("Spawning pager worker.");
// NB: The pager worker uses Condvar::wait() which is a blocking syscall.
// It must run on a dedicated OS thread, not a tokio task, to avoid blocking
// the tokio runtime.
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("pager worker tokio runtime");
rt.block_on(pager_worker.run());
});
let pager_for_shutdown = pager.clone();
let create_server_args = CreateServerArgs {
container_environment: container_environment.clone(),
job_stats: job_stats.clone(),
};
let job_dependencies = JobDependencies {
mysql_pool,
public_bucket_client,
grok_api_key,
server_environment,
pager,
job_stats,
poll_interval_success_millis,
poll_interval_failure_millis,
image_generation_poll_interval_millis,
application_shutdown: application_shutdown.clone(),
shutdown_notify: shutdown_notify.clone(),
};
// HTTP server runs on a separate OS thread with its own actix System.
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
actix_runtime.block_on(http_server_handle)
.expect("HTTP server should not exit.");
warn!("HTTP server thread is shut down.");
});
// Listen for SIGTERM / Ctrl-C to trigger graceful shutdown.
let application_shutdown_for_signal = application_shutdown.clone();
let shutdown_notify_for_signal = shutdown_notify.clone();
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
info!("Received shutdown signal. Shutting down...");
application_shutdown_for_signal.set(true);
shutdown_notify_for_signal.notify_waiters();
}
Err(err) => {
warn!("Error listening for shutdown signal: {:?}", err);
}
}
});
// Spawn all polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let image_deps = job_dependencies;
let video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
});
let image_handle = tokio::spawn(async move {
image_generation_main_loop(image_deps).await;
});
let _ = tokio::join!(video_handle, image_handle);
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
info!("Grok API job exiting.");
Ok(())
}
@@ -0,0 +1,64 @@
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_config::services::ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB;
use rootly_config::urgencies::{ROOTLY_URGENCY_ID_HIGH, ROOTLY_URGENCY_ID_LOW, ROOTLY_URGENCY_ID_MEDIUM};
use shared_env_var_config::paging::{env_enable_paging_default_false, env_optional_rootly_api_key, env_optional_rootly_notification_target_id, env_optional_rootly_notification_target_type};
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker) {
let is_paging_enabled = env_enable_paging_default_false();
info!("Paging enabled: {}", is_paging_enabled);
let environment = if server_environment.is_deployed_in_production() {
"production"
} else {
"development"
};
let builder = PagerBuilder::new()
.application_name("grok-api-job".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string())
.service_id(ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB.to_string());
if !is_paging_enabled {
warn!("ENABLE_PAGING is false. Pager will be NoOp.");
return builder.build_with_worker();
}
let maybe_api_key = env_optional_rootly_api_key();
match maybe_api_key {
Some(api_key) => {
info!("Rootly API key found. Configuring pager with Rootly backend.");
build_rootly_pager(builder, api_key)
}
None => {
warn!("ROOTLY_API_KEY not set. Pager will not send real pages.");
builder.build_with_worker()
}
}
}
fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWorker) {
let mut rootly_builder = builder
.rootly(RootlyApiKey::new(api_key))
.urgency_id_high(ROOTLY_URGENCY_ID_HIGH.to_string())
.urgency_id_medium(ROOTLY_URGENCY_ID_MEDIUM.to_string())
.urgency_id_low(ROOTLY_URGENCY_ID_LOW.to_string());
let target_type = env_optional_rootly_notification_target_type();
let target_id = env_optional_rootly_notification_target_id();
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
}
rootly_builder.build_with_worker()
}
@@ -0,0 +1 @@
pub mod build_pager;
@@ -55,6 +55,7 @@ error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
grok_api_client.workspace = true
filesys.workspace = true
google_sign_in = { path = "../../../api_clients/google_sign_in" }
hashing = { path = "../../../lib/files/hashing" }
@@ -19,6 +19,42 @@ pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy<OmniGenVideoModelsResponse>
fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::GrokImagineVideo,
model_creator: Some(ModelCreator::Grok),
full_name: Some("Grok Imagine".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(false),
image_references_supported: Some(true),
image_references_max: Some(7),
//video_references_supported: Some(false),
//video_references_max: Some(3),
//video_references_max_total_duration_seconds: Some(15),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::Square,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
//batch_size_options: Some(vec![1, 2, 4]),
//batch_size_default: Some(1),
duration_seconds_min: Some(1),
duration_seconds_max: Some(15),
duration_seconds_max_with_image_references: Some(10),
duration_seconds_default: Some(8),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::HappyHorse1p0,
model_creator: Some(ModelCreator::Alibaba),
@@ -142,6 +142,8 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
};
Ok(router_model)
}
@@ -130,6 +130,7 @@ fn map_provider(
fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerError> {
let router_model = match model {
CommonVideoModel::GrokVideo => RouterVideoModel::GrokVideo,
CommonVideoModel::GrokImagineVideo => RouterVideoModel::GrokImagineVideo,
CommonVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
@@ -4,6 +4,7 @@ use artcraft_router::api::provider::Provider;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient;
use artcraft_router::client::router_grok_api_client::RouterGrokApiClient;
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
@@ -31,6 +32,11 @@ pub fn build_router_client(
server_state.gmicloud.api_key.clone(),
)))
}
Provider::GrokApi => {
Ok(RouterClient::GrokApi(RouterGrokApiClient::new(
server_state.grok_api.api_key.clone(),
)))
}
other => {
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported provider for video generation: {:?}", other),
@@ -0,0 +1,46 @@
use log::warn;
use mysql_queries::queries::generic_inference::api_providers::grok_api::insert_generic_inference_job_for_grok_api::{
insert_generic_inference_job_for_grok_api, InsertGrokApiInferenceJobArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertGrokApiJobArgs<'a, 'tx> {
pub external_request_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_grok_api_job(args: InsertGrokApiJobArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
let InsertGrokApiJobArgs {
external_request_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_grok_api(
InsertGrokApiInferenceJobArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
external_request_id,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting Grok (xAI) API inference job: {:?}", err);
Err(AdvancedCommonWebError::from_error(err))
}
}
}
@@ -1,4 +1,5 @@
pub mod insert_fal_job;
pub mod insert_gmicloud_job;
pub mod insert_grok_api_job;
pub mod insert_seedance2pro_jobs;
pub mod shared_job_args;
@@ -34,6 +34,7 @@ use crate::http_server::endpoints::generate::common::payments_error_test::paymen
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args};
@@ -193,6 +194,7 @@ pub async fn omni_gen_video_generate_handler(
Some(CommonVideoModel::Seedance2p0BytePlusFast) => true,
Some(CommonVideoModel::PreviewModel) => true,
Some(CommonVideoModel::PreviewModelFast) => true,
Some(CommonVideoModel::GrokImagineVideo) => true,
_ => false,
};
@@ -258,6 +260,23 @@ pub async fn omni_gen_video_generate_handler(
}
}
// ==================== DEBUG LOG: GROK API REQUEST ==================== //
if let GenerateVideoResponse::Grok(ref grok_payload) = pipeline_result.response {
if let Some(ref outbound_request) = grok_payload.maybe_outbound_request {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::GrokApiRequest,
message: &format!("{:#?}", outbound_request),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Grok API request debug log: {:?}", err);
}
}
}
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
@@ -418,6 +437,28 @@ pub async fn omni_gen_video_generate_handler(
vec![token],
)
}
GenerateVideoResponse::Grok(payload) => {
info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_grok_api_job(InsertGrokApiJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response"));
@@ -61,6 +61,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
CommonVideoModel::Seedance2p0Fast => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0Ultra => Provider::GmiCloud,
CommonVideoModel::Seedance2p0UltraFast => Provider::GmiCloud,
CommonVideoModel::GrokImagineVideo => Provider::GrokApi,
_ => Provider::Fal,
};
@@ -68,9 +69,9 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
let mut exec_builder = router_builder.clone();
exec_builder.provider = provider;
// GmiCloud takes URLs directly (like Fal), not media file tokens.
// GmiCloud and Grok (xAI) take URLs directly (like Fal), not media file tokens.
// Resolve tokens to URLs before building.
if matches!(provider, Provider::GmiCloud) {
if matches!(provider, Provider::GmiCloud | Provider::GrokApi) {
resolve_media_tokens_to_urls(&mut exec_builder, media_file_to_url_map.as_ref());
}
@@ -41,7 +41,7 @@ use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{
BeebleData, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData,
GmiCloudData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo,
GmiCloudData, GrokApiData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo,
ServerState, TrollBans, WorldLabsData,
};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
@@ -203,6 +203,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
let openai_api_key = easyenv::get_env_string_required("OPENAI_API_KEY")?;
let resend_api_key = easyenv::get_env_string_required("RESEND_API_KEY")?;
let gmicloud_api_key = easyenv::get_env_string_required("GMICLOUD_API_KEY")?;
let grok_api_key = easyenv::get_env_string_required("GROK_API_KEY")?;
let worldlabs_api_key = easyenv::get_env_string_required("WORLDLABS_API_KEY")?;
let startup_time = Utc::now();
@@ -257,6 +258,9 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
gmicloud: GmiCloudData {
api_key: gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey::new(gmicloud_api_key),
},
grok_api: GrokApiData {
api_key: grok_api_client::creds::grok_api_key::GrokApiKey::new(grok_api_key),
},
seedance2pro: Seedance2ProData {
cookies: easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")?,
cookies_byteplus: easyenv::get_env_string_required("SEEDANCE2PRO_WHITELIST_COOKIES")?,
@@ -93,6 +93,8 @@ pub struct ServerState {
pub gmicloud: GmiCloudData,
pub grok_api: GrokApiData,
pub beeble: BeebleData,
pub seedance2pro: Seedance2ProData,
@@ -324,6 +326,12 @@ pub struct GmiCloudData {
pub api_key: gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey,
}
/// Grok (xAI) API integration
#[derive(Clone)]
pub struct GrokApiData {
pub api_key: grok_api_client::creds::grok_api_key::GrokApiKey,
}
/// Beeble SwitchX integration
#[derive(Clone)]
pub struct BeebleData {
+1 -1
View File
@@ -19,7 +19,7 @@ if (import.meta.env.DEV) {
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
//StorytellerApiHostStore.getInstance().setDevelopment();
StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}