mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 15:55:54 -04:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a57bc87e0 | ||
|
|
2c2594c482 | ||
|
|
f3afc4b046 | ||
|
|
b91d75b6cc | ||
|
|
417e915882 | ||
|
|
6e8a7bf817 | ||
|
|
110f9327d8 | ||
|
|
19d0813f02 | ||
|
|
06cc380d74 | ||
|
|
d25b206cae | ||
|
|
96b6150051 | ||
|
|
1d6d0ec1c3 | ||
|
|
c0ee2c44f6 | ||
|
|
bc303207f5 | ||
|
|
471a300758 | ||
|
|
4e7fcb5f39 | ||
|
|
88c922314f | ||
|
|
f38098e629 | ||
|
|
bdfa8bc729 |
Generated
+56
-3
@@ -711,7 +711,7 @@ dependencies = [
|
||||
"fal_client",
|
||||
"filesys",
|
||||
"futures 0.3.32",
|
||||
"grok_client",
|
||||
"grok_consumer_client",
|
||||
"image",
|
||||
"images",
|
||||
"indexmap 2.13.0",
|
||||
@@ -803,6 +803,7 @@ dependencies = [
|
||||
"enums",
|
||||
"fal_client",
|
||||
"gmicloud_client",
|
||||
"grok_api_client",
|
||||
"log",
|
||||
"muapi_client",
|
||||
"reqwest 0.12.23",
|
||||
@@ -4864,7 +4865,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"coarsetime",
|
||||
"errors",
|
||||
"jwt-simple",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
@@ -4898,7 +4898,58 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grok_client"
|
||||
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"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"env_logger 0.11.9",
|
||||
"errors",
|
||||
"log",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"test_data",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grok_consumer_client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
@@ -11491,6 +11542,7 @@ dependencies = [
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
"enums",
|
||||
"erased-serde",
|
||||
"error_utils",
|
||||
"errors",
|
||||
"fal_client",
|
||||
@@ -11501,6 +11553,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gmicloud_client",
|
||||
"google_sign_in",
|
||||
"grok_api_client",
|
||||
"hashing",
|
||||
"hostname 0.4.0",
|
||||
"http_server_common",
|
||||
|
||||
+6
-2
@@ -14,7 +14,8 @@ members = [
|
||||
"crates/api_clients/fal_client",
|
||||
"crates/api_clients/google_drive_downloader",
|
||||
"crates/api_clients/google_sign_in",
|
||||
"crates/api_clients/grok_client",
|
||||
"crates/api_clients/grok_api_client",
|
||||
"crates/api_clients/grok_consumer_client",
|
||||
"crates/api_clients/gmicloud_client",
|
||||
"crates/api_clients/midjourney_client",
|
||||
"crates/api_clients/muapi_client",
|
||||
@@ -127,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",
|
||||
@@ -253,7 +255,8 @@ ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
|
||||
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
|
||||
filesys = { path = "crates/lib/files/filesys" }
|
||||
beeble_client = { path = "crates/api_clients/beeble_client" }
|
||||
grok_client = { path = "crates/api_clients/grok_client" }
|
||||
grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
grok_consumer_client = { path = "crates/api_clients/grok_consumer_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
|
||||
@@ -331,6 +334,7 @@ regex = "1.12.3"
|
||||
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
|
||||
reqwest-websocket = { version = "0.5.0" }
|
||||
rust-s3 = { version = "0.36.0-beta.2", default-features = false, features = ["fail-on-err", "tags", "tokio-rustls-tls"] }
|
||||
erased-serde = "0.4"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_derive = "1.0.228"
|
||||
serde_json = "1.0.149"
|
||||
|
||||
@@ -141,6 +141,7 @@ RUN SQLX_OFFLINE=true \
|
||||
--bin seedance2-pro-job \
|
||||
--bin worldlabs-job \
|
||||
--bin video-thumbnail-job \
|
||||
--bin grok-api-job \
|
||||
--bin dummy-service \
|
||||
--bin analytics-job \
|
||||
--bin email-sender-job \
|
||||
@@ -188,6 +189,7 @@ COPY --from=builder /tmp/target/release/dummy-service /
|
||||
COPY --from=builder /tmp/target/release/analytics-job /
|
||||
COPY --from=builder /tmp/target/release/email-sender-job /
|
||||
COPY --from=builder /tmp/target/release/es-update-job /
|
||||
COPY --from=builder /tmp/target/release/grok-api-job /
|
||||
|
||||
# Legacy apps:
|
||||
# COPY --from=builder /tmp/target/release/tts-download-job /
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+9
@@ -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(),
|
||||
}
|
||||
|
||||
+20
@@ -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()),
|
||||
|
||||
+180
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+470
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -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
@@ -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;
|
||||
|
||||
+553
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+111
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod grok_imagine_video;
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
pub mod artcraft;
|
||||
pub mod gmicloud;
|
||||
pub mod grok_api;
|
||||
pub mod kinovi;
|
||||
+18
@@ -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
|
||||
|
||||
@@ -21,7 +21,6 @@ path = "src/lib.rs"
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Google Sign In
|
||||
|
||||
https://developers.google.com/identity/sign-in/web/sign-in
|
||||
|
||||
This has a link to the management console.
|
||||
|
||||
## Frontend Setup
|
||||
|
||||
```html
|
||||
<!-- Load Library -->
|
||||
<script src="https://apis.google.com/js/platform.js" async defer></script>
|
||||
|
||||
<!-- Specify Client ID-->
|
||||
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
|
||||
```
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
use crate::certs::jwk_to_public_key::jwk_to_public_key;
|
||||
use crate::certs::key_map::KeyMap;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
|
||||
/// https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
||||
/// "These keys are regularly rotated; examine the Cache-Control header in
|
||||
/// the response to determine when you should retrieve them again."
|
||||
const GOOGLE_CERTS_URL : &str = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
pub async fn download_cert_key_set() -> AnyhowResult<KeyMap> {
|
||||
pub async fn download_cert_key_set() -> Result<KeyMap, GoogleSignInError> {
|
||||
let certs = download_certs().await?;
|
||||
let key_map = jwk_to_public_key(&certs)?;
|
||||
Ok(key_map)
|
||||
}
|
||||
|
||||
pub async fn download_certs() -> AnyhowResult<String> {
|
||||
let response = reqwest::get(GOOGLE_CERTS_URL).await?;
|
||||
pub async fn download_certs() -> Result<String, GoogleSignInError> {
|
||||
let response = reqwest::get(GOOGLE_CERTS_URL)
|
||||
.await
|
||||
.map_err(GoogleSignInError::CertDownloadHttpError)?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Failed to download Google certs: {:?}", response.status()));
|
||||
return Err(GoogleSignInError::CertDownloadBadStatus(response.status()));
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(GoogleSignInError::CertDownloadHttpError)?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::certs::key_map::KeyMap;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
use jwt_simple::prelude::RS256PublicKey;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
@@ -7,10 +7,11 @@ use serde_derive::Deserialize;
|
||||
/// Grab Google's keys from here: https://www.googleapis.com/oauth2/v3/certs
|
||||
/// These are updated frequently throughout the day and must be re-fetched.
|
||||
/// NB: This implementation is adapted from the crate `signin`.
|
||||
pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
|
||||
pub fn jwk_to_public_key(json_web_key: &str) -> Result<KeyMap, GoogleSignInError> {
|
||||
let mut key_map = KeyMap::new();
|
||||
|
||||
let json_keys: JsonKeys = serde_json::from_str(json_web_key)?;
|
||||
let json_keys: JsonKeys = serde_json::from_str(json_web_key)
|
||||
.map_err(GoogleSignInError::JwkJsonParseFailed)?;
|
||||
|
||||
for key in json_keys.keys {
|
||||
if key.use_ != "sig" {
|
||||
@@ -18,11 +19,14 @@ pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
|
||||
}
|
||||
match key.alg.as_ref() {
|
||||
"RS256" => {
|
||||
let n_decoded = base64_decode_url(&key.n)?;
|
||||
let e_decoded = base64_decode_url(&key.e)?;
|
||||
let n_decoded = base64_decode_url(&key.n)
|
||||
.map_err(GoogleSignInError::JwkBase64DecodeFailed)?;
|
||||
let e_decoded = base64_decode_url(&key.e)
|
||||
.map_err(GoogleSignInError::JwkBase64DecodeFailed)?;
|
||||
|
||||
let public_key = RS256PublicKey::from_components(&n_decoded, &e_decoded)?
|
||||
.with_key_id(&key.kid);
|
||||
let public_key = RS256PublicKey::from_components(&n_decoded, &e_decoded)
|
||||
.map_err(|err| GoogleSignInError::JwkRsaKeyConstructionFailed(format!("{}", err)))?
|
||||
.with_key_id(&key.kid);
|
||||
|
||||
key_map.insert(key.kid, public_key);
|
||||
}
|
||||
@@ -31,7 +35,7 @@ pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
|
||||
}
|
||||
|
||||
if key_map.is_empty() {
|
||||
return Err(anyhow!("No keys found in JWK"));
|
||||
return Err(GoogleSignInError::JwkNoKeysFound);
|
||||
}
|
||||
|
||||
Ok(key_map)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::claims::google_custom_claims::GoogleCustomClaims;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
use jwt_simple::claims::{Audiences, JWTClaims};
|
||||
|
||||
pub struct Claims {
|
||||
@@ -37,11 +37,11 @@ impl Claims {
|
||||
|
||||
/// Determine if the claim audience is as expected.
|
||||
/// This is necessary so third parties don't send claims signed on their behalf by Google.
|
||||
pub fn audience_matches(&self, audience: &str) -> AnyhowResult<bool> {
|
||||
pub fn audience_matches(&self, audience: &str) -> Result<bool, GoogleSignInError> {
|
||||
match self.claims.audiences.as_ref() {
|
||||
Some(Audiences::AsString(claim_audience)) => Ok(claim_audience.eq(audience)),
|
||||
Some(Audiences::AsSet(audiences)) => Ok(audiences.contains(audience)),
|
||||
_ => Err(anyhow!("Invalid audience type")),
|
||||
_ => Err(GoogleSignInError::AudienceMissing),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::certs::key_map::KeyMap;
|
||||
use crate::claims::claims::Claims;
|
||||
use crate::claims::google_custom_claims::GoogleCustomClaims;
|
||||
use crate::errors::VerifyError;
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
use crate::jwt::decode_jwt_header::decode_jwt_header;
|
||||
use errors::anyhow;
|
||||
use jwt_simple::algorithms::RS256PublicKey;
|
||||
use jwt_simple::algorithms::RSAPublicKeyLike;
|
||||
use jwt_simple::common::VerificationOptions;
|
||||
@@ -12,16 +11,15 @@ use std::collections::HashSet;
|
||||
|
||||
/// Decode a Google Sign In JWT.
|
||||
/// Verification options can be supplied to increase clock skew tolerance, etc.
|
||||
pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option<VerificationOptions>) -> Result<Claims, VerifyError> {
|
||||
let header = decode_jwt_header(token)
|
||||
.map_err(|err| VerifyError::JwtDecodeError { source: err })?;
|
||||
pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option<VerificationOptions>) -> Result<Claims, GoogleSignInError> {
|
||||
let header = decode_jwt_header(token)?;
|
||||
|
||||
let key_id = header.kid.as_deref()
|
||||
.or_else(|| keys.keys().next().map(|k|k.as_str()))
|
||||
.ok_or_else(|| anyhow!("No key ID found"))?;
|
||||
.ok_or(GoogleSignInError::JwtNoKeyId)?;
|
||||
|
||||
let key = keys.get(key_id)
|
||||
.ok_or_else(|| VerifyError::JwtKeyMissing { requested_key: key_id.to_string() })?;
|
||||
.ok_or_else(|| GoogleSignInError::JwtKeyMissing { requested_key: key_id.to_string() })?;
|
||||
|
||||
if options.is_none() {
|
||||
options = Some(VerificationOptions {
|
||||
@@ -36,7 +34,7 @@ pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: O
|
||||
decode_and_verify_token_claims_with_key(key, token, options)
|
||||
}
|
||||
|
||||
pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option<VerificationOptions>) -> Result<Claims, VerifyError> {
|
||||
pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option<VerificationOptions>) -> Result<Claims, GoogleSignInError> {
|
||||
let result = key.verify_token::<GoogleCustomClaims>(token, options);
|
||||
|
||||
let claims = match result {
|
||||
@@ -44,22 +42,22 @@ pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str
|
||||
Err(err) => {
|
||||
if let Some(inner_error) = err.downcast_ref::<JWTError>() {
|
||||
match inner_error {
|
||||
JWTError::RequiredAudienceMismatch => return Err(VerifyError::JwtInvalidAudience),
|
||||
JWTError::RequiredAudienceMissing => return Err(VerifyError::JwtInvalidAudience),
|
||||
JWTError::RequiredIssuerMismatch => return Err(VerifyError::JwtInvalidIssuer),
|
||||
JWTError::RequiredIssuerMissing => return Err(VerifyError::JwtInvalidIssuer),
|
||||
JWTError::TokenHasExpired => return Err(VerifyError::JwtExpired),
|
||||
JWTError::RequiredAudienceMismatch => return Err(GoogleSignInError::JwtInvalidAudience),
|
||||
JWTError::RequiredAudienceMissing => return Err(GoogleSignInError::JwtInvalidAudience),
|
||||
JWTError::RequiredIssuerMismatch => return Err(GoogleSignInError::JwtInvalidIssuer),
|
||||
JWTError::RequiredIssuerMissing => return Err(GoogleSignInError::JwtInvalidIssuer),
|
||||
JWTError::TokenHasExpired => return Err(GoogleSignInError::JwtExpired),
|
||||
_ => {}, // Fall-through
|
||||
}
|
||||
}
|
||||
return Err(VerifyError::AnyhowError(err));
|
||||
return Err(GoogleSignInError::JwtVerifyFailed(format!("{}", err)));
|
||||
}
|
||||
};
|
||||
|
||||
match claims.issuer.as_deref() {
|
||||
Some("https://accounts.google.com" | "accounts.google.com") => {} // Permitted
|
||||
_ => {
|
||||
return Err(VerifyError::JwtInvalidIssuer);
|
||||
return Err(GoogleSignInError::JwtInvalidIssuer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +70,7 @@ pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str
|
||||
mod tests {
|
||||
use crate::certs::jwk_to_public_key::jwk_to_public_key;
|
||||
use crate::decode_and_verify_token_claims::decode_and_verify_token_claims;
|
||||
use crate::errors::VerifyError;
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
use coarsetime::Duration;
|
||||
use jwt_simple::claims::Audiences;
|
||||
use jwt_simple::prelude::VerificationOptions;
|
||||
@@ -180,7 +178,7 @@ mod tests {
|
||||
};
|
||||
|
||||
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
|
||||
Err(VerifyError::JwtKeyMissing { requested_key}) => {
|
||||
Err(GoogleSignInError::JwtKeyMissing { requested_key}) => {
|
||||
assert_eq!(requested_key, "b2620d5e7f132b52afe8875cdf3776c064249d04");
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -201,7 +199,7 @@ mod tests {
|
||||
let credential = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyNjIwZDVlN2YxMzJiNTJhZmU4ODc1Y2RmMzc3NmMwNjQyNDlkMDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTMxMDE5Njc2MTIzOTY3OTM3NzciLCJlbWFpbCI6InZvY29kZXMyMDIwQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYmYiOjE3MjY3ODYxMDAsIm5hbWUiOiJWb2NvZGVzIFZvY29kZXMiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTHoyLTJPYUFtME1ReFI2ajhDTnItUG84X1hyLWFyeUFUaUNuNGMwaV9UdURtTF9nPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlZvY29kZXMiLCJmYW1pbHlfbmFtZSI6IlZvY29kZXMiLCJpYXQiOjE3MjY3ODY0MDAsImV4cCI6MTcyNjc5MDAwMCwianRpIjoiNGQ0NGVlYWMwNmNlNzlmYzBhYjIyNzBjZmVlYTMwZDhhY2Y3NzYxMyJ9.EYg71yIkvhxFGc8ZVCXeTOAmPAtLYDphHnkdf1sh8b_Jz4Y7S1DpmiTqQ1ytxu7J1xNixvdwhuIDzSlCvlxaFl8475GvAlyPTNtZtmWbFD5SRM_XHLOynijOp8WQ4nej-CHvT1KjjqMfkZ1EeQMoWk1H72PxPg_RiUgzsklkUs1wOkLAySk7R3EIAl7bIzpoY_WH2pxv9ccFpBtKDHaDqHkxAWBUQX0-G7ZXZBPVz07V28ZfdbzFDapjZaUFbumazh_-J2-9AA6JkcteF4h_gpbBcLYAuxt5bWI5FECWbYe42khwb93WJ5SK12Tt0EPoyzIObJs14NWGAajtHTg3wA";
|
||||
|
||||
match decode_and_verify_token_claims(&key_map, credential, None) {
|
||||
Err(VerifyError::JwtExpired) => {
|
||||
Err(GoogleSignInError::JwtExpired) => {
|
||||
// Expected error case
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -231,7 +229,7 @@ mod tests {
|
||||
};
|
||||
|
||||
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
|
||||
Err(VerifyError::JwtInvalidIssuer) => {
|
||||
Err(GoogleSignInError::JwtInvalidIssuer) => {
|
||||
// Expected error case
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -261,7 +259,7 @@ mod tests {
|
||||
};
|
||||
|
||||
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
|
||||
Err(VerifyError::JwtInvalidAudience) => {
|
||||
Err(GoogleSignInError::JwtInvalidAudience) => {
|
||||
// Expected error case
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// All failure modes surfaced by the `google_sign_in` crate.
|
||||
///
|
||||
/// Kept as a single flat enum on purpose — the crate is small enough that
|
||||
/// splitting into client/api/specific tiers (like beeble_client / gmicloud_client
|
||||
/// do) is overkill here.
|
||||
#[derive(Debug)]
|
||||
pub enum GoogleSignInError {
|
||||
// ── Cert download (Google's JWK endpoint) ──
|
||||
|
||||
/// HTTP error talking to Google's JWK cert endpoint.
|
||||
CertDownloadHttpError(reqwest::Error),
|
||||
|
||||
/// Google's cert endpoint returned a non-2xx status.
|
||||
CertDownloadBadStatus(reqwest::StatusCode),
|
||||
|
||||
// ── JWK parse ──
|
||||
|
||||
/// Failed to parse Google's JWK payload as JSON.
|
||||
JwkJsonParseFailed(serde_json::Error),
|
||||
|
||||
/// Failed to base64-decode a JWK key component (`n` or `e`).
|
||||
JwkBase64DecodeFailed(base64::DecodeError),
|
||||
|
||||
/// Failed to construct an RSA public key from JWK components.
|
||||
/// The wrapped string is `jwt_simple`'s formatted error.
|
||||
JwkRsaKeyConstructionFailed(String),
|
||||
|
||||
/// JWK payload contained no usable signing keys.
|
||||
JwkNoKeysFound,
|
||||
|
||||
// ── JWT header decode ──
|
||||
|
||||
/// JWT was malformed — couldn't split off the header segment.
|
||||
JwtMalformed,
|
||||
|
||||
/// Failed to base64-decode the JWT header segment.
|
||||
/// The wrapped string is `ct_codecs`'s formatted error.
|
||||
JwtHeaderBase64DecodeFailed(String),
|
||||
|
||||
/// Failed to parse the JWT header as JSON.
|
||||
JwtHeaderJsonParseFailed(serde_json::Error),
|
||||
|
||||
// ── JWT claim verification ──
|
||||
|
||||
/// JWT carried no `kid` and the keymap is empty — no key to verify with.
|
||||
JwtNoKeyId,
|
||||
|
||||
/// JWT requested a `kid` not present in the JWK keymap.
|
||||
/// Refreshing the JWK keymap may resolve.
|
||||
JwtKeyMissing { requested_key: String },
|
||||
|
||||
/// JWT has expired (was issued before the current clock + skew tolerance).
|
||||
JwtExpired,
|
||||
|
||||
/// JWT audience claim is absent or doesn't match the configured allowlist.
|
||||
JwtInvalidAudience,
|
||||
|
||||
/// JWT issuer claim is absent or doesn't match the configured allowlist.
|
||||
JwtInvalidIssuer,
|
||||
|
||||
/// JWT signature verification failed for some other reason.
|
||||
/// The wrapped string is `jwt_simple`'s formatted error.
|
||||
JwtVerifyFailed(String),
|
||||
|
||||
// ── Claims helpers ──
|
||||
|
||||
/// `audience_matches()` was called on claims that have no audience field.
|
||||
AudienceMissing,
|
||||
}
|
||||
|
||||
impl Error for GoogleSignInError {}
|
||||
|
||||
impl Display for GoogleSignInError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod google_sign_in_error;
|
||||
@@ -1,59 +0,0 @@
|
||||
use errors::AnyhowError;
|
||||
use std::error::Error;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VerifyError {
|
||||
/// Could not decode a JWT token.
|
||||
JwtDecodeError { source: AnyhowError },
|
||||
|
||||
/// The JWT has expired (was issued before the current
|
||||
/// clock + deadline + skew tolerance)
|
||||
JwtExpired,
|
||||
|
||||
/// The key requested by the JWT was not available.
|
||||
/// This likely means we need to refresh our JWK key set.
|
||||
JwtKeyMissing { requested_key: String },
|
||||
|
||||
/// The audience for the JWT claims was absent or unexpected.
|
||||
JwtInvalidAudience,
|
||||
|
||||
/// The issuer of the JWT claims was absent or unexpected.
|
||||
JwtInvalidIssuer,
|
||||
|
||||
/// Any other error type we haven't wrapped.
|
||||
AnyhowError(AnyhowError),
|
||||
}
|
||||
|
||||
impl Error for VerifyError {}
|
||||
|
||||
impl Display for VerifyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::JwtDecodeError { source } => {
|
||||
write!(f, "JWT decode error: {}", source)
|
||||
}
|
||||
Self::JwtExpired => {
|
||||
write!(f, "JWT token has expired")
|
||||
}
|
||||
Self::JwtKeyMissing { requested_key } => {
|
||||
write!(f, "JWT requested key absent from JWK: {}", requested_key)
|
||||
}
|
||||
Self::JwtInvalidAudience => {
|
||||
write!(f, "Unexpected or missing JWT audience")
|
||||
}
|
||||
Self::JwtInvalidIssuer => {
|
||||
write!(f, "Unexpected or missing JWT issuer")
|
||||
}
|
||||
Self::AnyhowError(err) => {
|
||||
write!(f, "AnyhowError: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for VerifyError {
|
||||
fn from(value: AnyhowError) -> Self {
|
||||
Self::AnyhowError(value)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use crate::error::google_sign_in_error::GoogleSignInError;
|
||||
use jwt_simple::prelude::Base64UrlSafeNoPadding;
|
||||
use jwt_simple::reexports::ct_codecs::Decoder;
|
||||
use serde_derive::Deserialize;
|
||||
@@ -10,13 +10,15 @@ pub(crate) struct JwtHeader {
|
||||
// Other fields absent
|
||||
}
|
||||
|
||||
pub fn decode_jwt_header(jwt: &str) -> AnyhowResult<JwtHeader> {
|
||||
pub fn decode_jwt_header(jwt: &str) -> Result<JwtHeader, GoogleSignInError> {
|
||||
let mut parts = jwt.split('.');
|
||||
let jwt_header_b64 = parts.next().ok_or(anyhow!("could not split jwt"))?;
|
||||
let jwt_header_b64 = parts.next().ok_or(GoogleSignInError::JwtMalformed)?;
|
||||
|
||||
let jwt_header: JwtHeader = serde_json::from_slice(
|
||||
&Base64UrlSafeNoPadding::decode_to_vec(jwt_header_b64, None)?,
|
||||
)?;
|
||||
let decoded = Base64UrlSafeNoPadding::decode_to_vec(jwt_header_b64, None)
|
||||
.map_err(|err| GoogleSignInError::JwtHeaderBase64DecodeFailed(format!("{}", err)))?;
|
||||
|
||||
let jwt_header: JwtHeader = serde_json::from_slice(&decoded)
|
||||
.map_err(GoogleSignInError::JwtHeaderJsonParseFailed)?;
|
||||
|
||||
Ok(jwt_header)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
pub mod certs;
|
||||
pub mod claims;
|
||||
pub mod decode_and_verify_token_claims;
|
||||
pub mod errors;
|
||||
pub mod error;
|
||||
|
||||
pub(crate) mod jwt;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "grok_api_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "grok_api_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
# `multipart` is enabled on top of the workspace feature set so we can use
|
||||
# `reqwest::multipart` for POST /v1/files uploads.
|
||||
reqwest = { workspace = true, features = ["multipart"] }
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
test_data.workspace = true
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod requests;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,115 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::files::delete_file::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`delete_file`]. Borrows the API key separately from
|
||||
/// the request body so callers can log/save [`DeleteFileRequest`] without
|
||||
/// leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeleteFileArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: DeleteFileRequest,
|
||||
}
|
||||
|
||||
/// The material part of a delete-file request. Derives [`Serialize`].
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DeleteFileRequest {
|
||||
pub file_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeleteFileSuccess {
|
||||
pub file_id: Option<String>,
|
||||
/// Should be `true` after a successful delete.
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// DELETE https://api.x.ai/v1/files/{file_id} — delete a previously-uploaded
|
||||
/// file. After deletion the `file_id` becomes invalid and cannot be
|
||||
/// referenced in further requests.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
pub async fn delete_file(args: DeleteFileArgs<'_>) -> Result<DeleteFileSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id);
|
||||
|
||||
info!("Grok delete_file: file_id={}", req.file_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.delete(&url)
|
||||
.header("Authorization", bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok delete_file response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(DeleteFileSuccess {
|
||||
file_id: parsed.id,
|
||||
deleted: parsed.deleted.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_success() {
|
||||
// serde drops the `object` field on parse; only `id` and `deleted` are
|
||||
// load-bearing for our DTO.
|
||||
let json = r#"{ "id": "file_abc", "object": "file", "deleted": true }"#;
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id.as_deref(), Some("file_abc"));
|
||||
assert_eq!(parsed.deleted, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_minimal() {
|
||||
// xAI may omit some fields; only `deleted` is load-bearing.
|
||||
let json = r#"{ "deleted": true }"#;
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.deleted, Some(true));
|
||||
assert!(parsed.id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = DeleteFileArgs {
|
||||
api_key: &key,
|
||||
request: DeleteFileRequest { file_id: "file_abc".to_string() },
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"file_id\":\"file_abc\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod delete_file;
|
||||
pub(crate) mod request_types;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for DELETE /v1/files/{file_id}.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct DeleteFileResponseBody {
|
||||
pub id: Option<String>,
|
||||
|
||||
/// xAI returns `true` on a successful delete.
|
||||
pub deleted: Option<bool>,
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`download_file`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`DownloadFileRequest`]
|
||||
/// without leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadFileArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: DownloadFileRequest,
|
||||
}
|
||||
|
||||
/// The material part of a download-file request. Derives [`Serialize`].
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DownloadFileRequest {
|
||||
pub file_id: String,
|
||||
|
||||
/// Optional `?format=` query — `"original"` returns the bytes as uploaded;
|
||||
/// `"text"` returns a textual transcription where applicable. Omit for the
|
||||
/// xAI default.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<DownloadFormat>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DownloadFormat {
|
||||
Original,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl DownloadFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Original => "original",
|
||||
Self::Text => "text",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("original" or "text").
|
||||
impl serde::Serialize for DownloadFormat {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DownloadFileSuccess {
|
||||
/// Raw file content. The xAI response Content-Type is `application/octet-stream`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/files/{file_id}/content — download the raw bytes
|
||||
/// of a previously-uploaded file.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/download>
|
||||
pub async fn download_file(args: DownloadFileArgs<'_>) -> Result<DownloadFileSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
let mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, req.file_id);
|
||||
if let Some(fmt) = req.format {
|
||||
url.push_str("?format=");
|
||||
url.push_str(fmt.as_str());
|
||||
}
|
||||
|
||||
info!("Grok download_file: file_id={}", req.file_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.get(&url)
|
||||
.header("Authorization", bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
// On error, read as text so the classifier can quote it back.
|
||||
if !status.is_success() {
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
classify_grok_http_error(status, Some(&body))?;
|
||||
// classify_grok_http_error always returns Err on non-success.
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
let bytes = response.bytes()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok download_file response: status={}, bytes={}", status, bytes.len());
|
||||
|
||||
Ok(DownloadFileSuccess { bytes: bytes.to_vec() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn download_format_serializes() {
|
||||
assert_eq!(DownloadFormat::Original.as_str(), "original");
|
||||
assert_eq!(DownloadFormat::Text.as_str(), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_format_serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&DownloadFormat::Original).unwrap(), "\"original\"");
|
||||
assert_eq!(serde_json::to_string(&DownloadFormat::Text).unwrap(), "\"text\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = DownloadFileArgs {
|
||||
api_key: &key,
|
||||
request: DownloadFileRequest {
|
||||
file_id: "file_abc".to_string(),
|
||||
format: Some(DownloadFormat::Original),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"file_id\":\"file_abc\""));
|
||||
assert!(json.contains("\"format\":\"original\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod download_file;
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::files::get_file_metadata::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`get_file_metadata`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`GetFileMetadataRequest`]
|
||||
/// without leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GetFileMetadataArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: GetFileMetadataRequest,
|
||||
}
|
||||
|
||||
/// The material part of a get-file-metadata request. Derives [`Serialize`].
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GetFileMetadataRequest {
|
||||
pub file_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub file_id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/files/{file_id} — fetch metadata for an uploaded
|
||||
/// file (no content; use `download_file` for that).
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
pub async fn get_file_metadata(args: GetFileMetadataArgs<'_>) -> Result<FileMetadata, GrokError> {
|
||||
let req = args.request;
|
||||
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, req.file_id);
|
||||
|
||||
info!("Grok get_file_metadata: file_id={}", req.file_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.get(&url)
|
||||
.header("Authorization", bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok get_file_metadata response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: FileMetadataResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(FileMetadata {
|
||||
file_id: parsed.id,
|
||||
bytes: parsed.bytes,
|
||||
created_at: parsed.created_at,
|
||||
expires_at: parsed.expires_at,
|
||||
filename: parsed.filename,
|
||||
purpose: parsed.purpose,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{
|
||||
"id": "file_abc123",
|
||||
"object": "file",
|
||||
"bytes": 4096,
|
||||
"created_at": 1716489600,
|
||||
"expires_at": 1719081600,
|
||||
"filename": "photo.png",
|
||||
"purpose": "vision"
|
||||
}"#;
|
||||
let parsed: FileMetadataResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "file_abc123");
|
||||
assert_eq!(parsed.expires_at, Some(1719081600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = GetFileMetadataArgs {
|
||||
api_key: &key,
|
||||
request: GetFileMetadataRequest { file_id: "file_abc".to_string() },
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"file_id\":\"file_abc\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key + valid file_id
|
||||
async fn live_test_unknown_file_returns_not_found() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = get_file_metadata(GetFileMetadataArgs {
|
||||
api_key: &api_key,
|
||||
request: GetFileMetadataRequest {
|
||||
file_id: "file_doesnotexist000000".to_string(),
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_file_metadata;
|
||||
pub(crate) mod request_types;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for GET /v1/files/{file_id}. Same envelope as the upload
|
||||
/// response (xAI returns identical file objects from both endpoints).
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct FileMetadataResponseBody {
|
||||
pub id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod delete_file;
|
||||
pub mod download_file;
|
||||
pub mod get_file_metadata;
|
||||
pub mod upload_file;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod upload_file;
|
||||
pub(crate) mod request_types;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for POST /v1/files. Matches OpenAI-style file objects.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct UploadFileResponseBody {
|
||||
pub id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
use std::fmt;
|
||||
|
||||
use log::info;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::files::upload_file::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`upload_file`]. Borrows the API key separately from
|
||||
/// the request body so callers can log/save [`UploadFileRequest`] without
|
||||
/// leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UploadFileArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: UploadFileRequest,
|
||||
}
|
||||
|
||||
/// The material part of a file-upload request.
|
||||
///
|
||||
/// Derives [`Serialize`] for log/audit, BUT `file_bytes` is skipped during
|
||||
/// serialization (a megabyte-scale binary blob shouldn't end up in a JSON
|
||||
/// log). [`Debug`] is implemented manually to print the byte length instead
|
||||
/// of the raw contents, so accidental `{:?}` formatting can't dump the file.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct UploadFileRequest {
|
||||
/// Raw bytes of the file. xAI caps uploads at 50 MB. Skipped from both
|
||||
/// `Serialize` and `Debug` output — log/save the rest of the request and
|
||||
/// recover the bytes from disk or cache out-of-band.
|
||||
#[serde(skip)]
|
||||
pub file_bytes: Vec<u8>,
|
||||
|
||||
/// Original filename. xAI extracts this from the multipart
|
||||
/// `Content-Disposition: filename=...` header and surfaces it back in the
|
||||
/// response.
|
||||
pub filename: String,
|
||||
|
||||
/// Optional MIME type. If `None`, reqwest uses `application/octet-stream`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_content_type: Option<String>,
|
||||
|
||||
/// Time-to-live in seconds (3600 – 2_592_000, i.e. 1 hour to 30 days). If
|
||||
/// `None`, the file persists indefinitely until explicitly deleted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_after_seconds: Option<u32>,
|
||||
|
||||
/// OpenAI-compatibility label. xAI accepts but doesn't enforce this — set
|
||||
/// it if porting code that already uses OpenAI's Files API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for UploadFileRequest {
|
||||
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
|
||||
// the entire file into the log line.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("UploadFileRequest")
|
||||
.field("file_bytes_len", &self.file_bytes.len())
|
||||
.field("filename", &self.filename)
|
||||
.field("maybe_content_type", &self.maybe_content_type)
|
||||
.field("expires_after_seconds", &self.expires_after_seconds)
|
||||
.field("purpose", &self.purpose)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadFileSuccess {
|
||||
/// The xAI file identifier. Use this in the `FileId(_)` variant of
|
||||
/// [`crate::api::requests::images::image_edit::image_edit::ImageSource`],
|
||||
/// [`crate::api::requests::videos::video_generation::video_generation::VideoImageSource`],
|
||||
/// [`crate::api::requests::videos::video_edit::video_edit::VideoSource`], or
|
||||
/// [`crate::api::requests::videos::video_extension::video_extension::VideoExtensionSource`].
|
||||
pub file_id: String,
|
||||
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/files — upload a file and receive a `file_id`
|
||||
/// you can reference in subsequent image/video edit, generation, or
|
||||
/// extension calls.
|
||||
///
|
||||
/// xAI documentation:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
pub async fn upload_file(args: UploadFileArgs<'_>) -> Result<UploadFileSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
if req.file_bytes.is_empty() {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
"upload_file: file_bytes is empty".to_string(),
|
||||
).into());
|
||||
}
|
||||
if req.file_bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", req.file_bytes.len()),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/files", XAI_API_BASE_URL);
|
||||
|
||||
info!(
|
||||
"Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}",
|
||||
req.filename, req.file_bytes.len(), req.expires_after_seconds, req.purpose
|
||||
);
|
||||
|
||||
let mut file_part = Part::bytes(req.file_bytes).file_name(req.filename.clone());
|
||||
if let Some(ct) = req.maybe_content_type.as_deref() {
|
||||
file_part = file_part.mime_str(ct)
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
}
|
||||
|
||||
let mut form = Form::new().part("file", file_part);
|
||||
if let Some(secs) = req.expires_after_seconds {
|
||||
form = form.text("expires_after", secs.to_string());
|
||||
}
|
||||
if let Some(purpose) = req.purpose {
|
||||
form = form.text("purpose", purpose);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok upload_file response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: UploadFileResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(UploadFileSuccess {
|
||||
file_id: parsed.id,
|
||||
bytes: parsed.bytes,
|
||||
created_at: parsed.created_at,
|
||||
expires_at: parsed.expires_at,
|
||||
filename: parsed.filename,
|
||||
purpose: parsed.purpose,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Client-side validation ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_bytes_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: &api_key,
|
||||
request: UploadFileRequest {
|
||||
file_bytes: vec![],
|
||||
filename: "x.png".to_string(),
|
||||
maybe_content_type: None,
|
||||
expires_after_seconds: None,
|
||||
purpose: None,
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_size_limit_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let oversized: Vec<u8> = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: &api_key,
|
||||
request: UploadFileRequest {
|
||||
file_bytes: oversized,
|
||||
filename: "x.png".to_string(),
|
||||
maybe_content_type: None,
|
||||
expires_after_seconds: None,
|
||||
purpose: None,
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
// ── Public Request shape — must not leak api_key OR file_bytes ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key_and_without_file_bytes() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = UploadFileArgs {
|
||||
api_key: &key,
|
||||
request: UploadFileRequest {
|
||||
file_bytes: vec![0x89, 0x50, 0x4E, 0x47], // "PNG" header — recognizable
|
||||
filename: "photo.png".to_string(),
|
||||
maybe_content_type: Some("image/png".to_string()),
|
||||
expires_after_seconds: Some(3600),
|
||||
purpose: None,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"), "api_key leaked in: {}", json);
|
||||
assert!(!json.contains("PNG"), "file_bytes leaked in: {}", json);
|
||||
assert!(!json.contains("file_bytes"), "file_bytes field present in: {}", json);
|
||||
assert!(json.contains("\"filename\":\"photo.png\""));
|
||||
assert!(json.contains("\"maybe_content_type\":\"image/png\""));
|
||||
assert!(json.contains("\"expires_after_seconds\":3600"));
|
||||
assert!(!json.contains("\"purpose\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_does_not_dump_file_bytes() {
|
||||
let req = UploadFileRequest {
|
||||
file_bytes: vec![0u8; 1_000_000],
|
||||
filename: "huge.bin".to_string(),
|
||||
maybe_content_type: None,
|
||||
expires_after_seconds: None,
|
||||
purpose: None,
|
||||
};
|
||||
let debug = format!("{:?}", req);
|
||||
assert!(debug.contains("file_bytes_len: 1000000"), "expected length, got: {}", debug);
|
||||
assert!(!debug.contains(", 0, 0, 0, 0"), "raw bytes leaked into Debug: {}", debug);
|
||||
}
|
||||
|
||||
// ── Response parsing ──
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{
|
||||
"id": "file_abc123",
|
||||
"object": "file",
|
||||
"bytes": 1024,
|
||||
"created_at": 1716489600,
|
||||
"expires_at": null,
|
||||
"filename": "photo.png",
|
||||
"purpose": "vision"
|
||||
}"#;
|
||||
let parsed: UploadFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "file_abc123");
|
||||
assert_eq!(parsed.bytes, Some(1024));
|
||||
assert_eq!(parsed.filename.as_deref(), Some("photo.png"));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn live_test_upload_small_png() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
// 1x1 transparent PNG
|
||||
let png_bytes: Vec<u8> = vec![
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
|
||||
0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: &api_key,
|
||||
request: UploadFileRequest {
|
||||
file_bytes: png_bytes,
|
||||
filename: "test_1x1.png".to_string(),
|
||||
maybe_content_type: Some("image/png".to_string()),
|
||||
expires_after_seconds: Some(3600),
|
||||
purpose: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Uploaded file_id={}, bytes={:?}", result.file_id, result.bytes);
|
||||
assert!(!result.file_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use crate::api::requests::images::image_edit::image_edit::ImageEditRequest;
|
||||
use crate::api::requests::images::image_generation::cost::output_mills_per_image;
|
||||
use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills};
|
||||
use crate::api::types::image_types::image_model::ImageModel;
|
||||
use crate::api::types::image_types::image_resolution::ImageResolution;
|
||||
|
||||
// `image_edit` bills BOTH input (per source image fed in) AND output (per
|
||||
// generated image). Per <https://docs.x.ai/developers/pricing>:
|
||||
//
|
||||
// grok-imagine-image input $0.002/img → 2 mills/img
|
||||
// grok-imagine-image-quality input $0.010/img → 10 mills/img
|
||||
//
|
||||
// Output rates match `image_generation` (shared helper `output_mills_per_image`).
|
||||
|
||||
impl GrokRequestCostCalculator for ImageEditRequest {
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills {
|
||||
let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
let resolution = self.resolution.unwrap_or(ImageResolution::OneK);
|
||||
|
||||
let input_mills_per_img = input_mills_per_image(&model);
|
||||
let output_mills_per_img = output_mills_per_image(&model, resolution);
|
||||
|
||||
let n = self.number_images.unwrap_or(1) as u64;
|
||||
let source_count = self.source_images.len() as u64;
|
||||
|
||||
(input_mills_per_img * source_count) + (output_mills_per_img * n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Input rate in mills per source image, by model.
|
||||
fn input_mills_per_image(model: &ImageModel) -> UsdMills {
|
||||
match model {
|
||||
ImageModel::GrokImagineImage => 2,
|
||||
ImageModel::GrokImagineImageQuality => 10,
|
||||
// Custom: conservatively assume the more expensive `-quality` tier.
|
||||
ImageModel::Custom(_) => 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::requests::images::image_edit::image_edit::ImageSource;
|
||||
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
|
||||
fn make_request(
|
||||
model: Option<ImageModel>,
|
||||
resolution: Option<ImageResolution>,
|
||||
number_images: Option<u32>,
|
||||
source_count: usize,
|
||||
) -> ImageEditRequest {
|
||||
ImageEditRequest {
|
||||
prompt: "test".to_string(),
|
||||
source_images: (0..source_count)
|
||||
.map(|i| ImageSource::Url(format!("https://example.com/{i}.png")))
|
||||
.collect(),
|
||||
model,
|
||||
number_images,
|
||||
aspect_ratio: None,
|
||||
resolution,
|
||||
response_format: None,
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-rate components ──
|
||||
|
||||
mod per_rate_components {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_input_is_2_mills_per_source() {
|
||||
assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImage), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_input_is_10_mills_per_source() {
|
||||
assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImageQuality), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_input_defaults_to_quality_rate() {
|
||||
assert_eq!(input_mills_per_image(&ImageModel::Custom("anything".to_string())), 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single source + single output, every (model, resolution) pair ──
|
||||
|
||||
mod single_source_single_output {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_1k() {
|
||||
// input 2 + output 20 = 22 mills, 3¢ (ceil)
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1), 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 22);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_2k() {
|
||||
// input 2 + output 20 = 22 mills, 3¢
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1), 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 22);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_1k() {
|
||||
// input 10 + output 50 = 60 mills, 6¢
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 60);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_2k() {
|
||||
// input 10 + output 70 = 80 mills, 8¢
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 80);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-source edit (up to 3 per xAI's docs) ──
|
||||
|
||||
mod multi_source {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quality_1k_three_sources_one_output() {
|
||||
// input 10 × 3 = 30, output 50, total 80 mills, 8¢
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 80);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_1k_three_sources_two_outputs() {
|
||||
// input 2 × 3 = 6, output 20 × 2 = 40, total 46 mills, 5¢ (ceil)
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(2), 3);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 46);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch output scaling (n > 1) ──
|
||||
|
||||
mod batch_scaling {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_scales_only_output_not_input() {
|
||||
let single = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1)
|
||||
.calculate_cost_in_mills(); // 10 + 70 = 80
|
||||
let quad = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4), 1)
|
||||
.calculate_cost_in_mills(); // 10 + 70 × 4 = 290
|
||||
assert_eq!(single, 80);
|
||||
assert_eq!(quad, 290);
|
||||
// Input doesn't scale with n.
|
||||
assert_ne!(quad, single * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_1k_scaling_1_through_8() {
|
||||
for n in 1u32..=8 {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(n), 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 10 + 50 * n as u64, "n={n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defaults ──
|
||||
|
||||
mod defaults {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fully_default_with_one_source() {
|
||||
// model=quality, res=1k, n=1, sources=1 → 10 + 50 = 60 mills
|
||||
let req = make_request(None, None, None, 1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 60);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Independence from non-pricing fields ──
|
||||
|
||||
mod independence {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1);
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
for ar in [
|
||||
ImageAspectRatio::Square,
|
||||
ImageAspectRatio::Landscape16x9,
|
||||
ImageAspectRatio::Portrait9x16,
|
||||
ImageAspectRatio::Auto,
|
||||
] {
|
||||
base.aspect_ratio = Some(ar);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_source_url_content() {
|
||||
let mut req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 0);
|
||||
req.source_images = vec![
|
||||
ImageSource::Url("https://example.com/a.png".to_string()),
|
||||
ImageSource::FileId("file_abc".to_string()),
|
||||
];
|
||||
// 10 × 2 + 50 = 70 mills
|
||||
assert_eq!(req.calculate_cost_in_mills(), 70);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exhaustive matrix ──
|
||||
|
||||
mod exhaustive_matrix {
|
||||
use super::*;
|
||||
|
||||
// (model, resolution, n, sources, expected_mills)
|
||||
const CASES: &[(ImageModelKind, ImageResolution, u32, usize, u64)] = &[
|
||||
// grok-imagine-image (standard) — input 2/img, output 20/img regardless of resolution
|
||||
(ImageModelKind::Standard, ImageResolution::OneK, 1, 1, 22),
|
||||
(ImageModelKind::Standard, ImageResolution::OneK, 1, 2, 24),
|
||||
(ImageModelKind::Standard, ImageResolution::OneK, 1, 3, 26),
|
||||
(ImageModelKind::Standard, ImageResolution::OneK, 2, 1, 42),
|
||||
(ImageModelKind::Standard, ImageResolution::OneK, 4, 1, 82),
|
||||
(ImageModelKind::Standard, ImageResolution::TwoK, 1, 1, 22),
|
||||
(ImageModelKind::Standard, ImageResolution::TwoK, 2, 2, 44),
|
||||
(ImageModelKind::Standard, ImageResolution::TwoK, 3, 3, 66),
|
||||
// grok-imagine-image-quality — input 10/img, output 50/img (1k) or 70/img (2k)
|
||||
(ImageModelKind::Quality, ImageResolution::OneK, 1, 1, 60),
|
||||
(ImageModelKind::Quality, ImageResolution::OneK, 1, 2, 70),
|
||||
(ImageModelKind::Quality, ImageResolution::OneK, 1, 3, 80),
|
||||
(ImageModelKind::Quality, ImageResolution::OneK, 2, 1, 110),
|
||||
(ImageModelKind::Quality, ImageResolution::OneK, 4, 1, 210),
|
||||
(ImageModelKind::Quality, ImageResolution::TwoK, 1, 1, 80),
|
||||
(ImageModelKind::Quality, ImageResolution::TwoK, 2, 2, 160),
|
||||
(ImageModelKind::Quality, ImageResolution::TwoK, 3, 3, 240),
|
||||
(ImageModelKind::Quality, ImageResolution::TwoK, 4, 3, 310),
|
||||
];
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum ImageModelKind { Standard, Quality }
|
||||
|
||||
fn to_model(kind: ImageModelKind) -> ImageModel {
|
||||
match kind {
|
||||
ImageModelKind::Standard => ImageModel::GrokImagineImage,
|
||||
ImageModelKind::Quality => ImageModel::GrokImagineImageQuality,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_matrix_cases() {
|
||||
for &(kind, res, n, sources, expected) in CASES {
|
||||
let req = make_request(Some(to_model(kind)), Some(res), Some(n), sources);
|
||||
assert_eq!(req.calculate_cost_in_mills(), expected,
|
||||
"model={:?} res={:?} n={} sources={}", kind, res, n, sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monotonicity ──
|
||||
|
||||
mod monotonicity {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn more_sources_cost_more() {
|
||||
let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1)
|
||||
.calculate_cost_in_mills();
|
||||
let three = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3)
|
||||
.calculate_cost_in_mills();
|
||||
assert!(one < three);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_outputs_cost_more() {
|
||||
let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1)
|
||||
.calculate_cost_in_mills();
|
||||
let four = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(4), 1)
|
||||
.calculate_cost_in_mills();
|
||||
assert!(one < four);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_costs_more_than_standard() {
|
||||
for res in [ImageResolution::OneK, ImageResolution::TwoK] {
|
||||
let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(2), 2).calculate_cost_in_mills();
|
||||
let qua = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(2), 2).calculate_cost_in_mills();
|
||||
assert!(std < qua, "standard ({std}) should be < quality ({qua}) at {res:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::images::image_edit::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
use crate::api::types::image_types::image_model::ImageModel;
|
||||
use crate::api::types::image_types::image_resolution::ImageResolution;
|
||||
use crate::api::types::image_types::image_response_format::ImageResponseFormat;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`image_edit`]. Borrows the API key separately from
|
||||
/// the request body so callers can log/save [`ImageEditRequest`] without
|
||||
/// leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageEditArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: ImageEditRequest,
|
||||
}
|
||||
|
||||
/// The material part of an image-edit request. Derives [`Serialize`] so it
|
||||
/// can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ImageEditRequest {
|
||||
/// Edit instruction. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// One or more source images. xAI supports up to 3.
|
||||
/// The output aspect ratio follows the first input image unless overridden.
|
||||
pub source_images: Vec<ImageSource>,
|
||||
|
||||
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<ImageModel>,
|
||||
|
||||
/// Number of edited images to render. Server default is 1 when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number_images: Option<u32>,
|
||||
|
||||
/// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to
|
||||
/// matching the first input image.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<ImageAspectRatio>,
|
||||
|
||||
/// Output resolution tier. See [`ImageResolution`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<ImageResolution>,
|
||||
|
||||
/// `Url` (default) or `B64Json`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<ImageResponseFormat>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source image — pick by URL or by `file_id` (from a prior upload).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum ImageSource {
|
||||
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
|
||||
/// image bytes.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. The file
|
||||
/// must still exist at request time (it has not been deleted and any
|
||||
/// `expires_after` has not lapsed).
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageEditSuccess {
|
||||
pub images: Vec<EditedImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditedImage {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/images/edits — edit one or more source images
|
||||
/// according to a text prompt.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/multi-image-editing>
|
||||
pub async fn image_edit(args: ImageEditArgs<'_>) -> Result<ImageEditSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
if req.source_images.is_empty() {
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"image_edit requires at least one source image".to_string(),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/images/edits", XAI_API_BASE_URL);
|
||||
let model = req.model.unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
|
||||
info!(
|
||||
"Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}",
|
||||
model.as_str(), req.source_images.len(),
|
||||
req.aspect_ratio.map(|a| a.as_str()),
|
||||
req.resolution.map(|r| r.as_str()),
|
||||
);
|
||||
|
||||
// Single source → `image`. Multiple sources → `images`.
|
||||
let (image, images) = if req.source_images.len() == 1 {
|
||||
(Some(to_image_ref(&req.source_images[0])), None)
|
||||
} else {
|
||||
(None, Some(req.source_images.iter().map(to_image_ref).collect()))
|
||||
};
|
||||
|
||||
let request_body = ImageEditRequestBody {
|
||||
prompt: req.prompt,
|
||||
image,
|
||||
images,
|
||||
model: Some(model.as_str().to_string()),
|
||||
n: req.number_images,
|
||||
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
|
||||
resolution: req.resolution.map(|r| r.as_str().to_string()),
|
||||
response_format: req.response_format.map(|f| f.as_str().to_string()),
|
||||
user: req.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok image_edit response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: ImageEditResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(ImageEditSuccess {
|
||||
images: parsed.data.into_iter().map(|d| EditedImage {
|
||||
url: d.url,
|
||||
b64_json: d.b64_json,
|
||||
revised_prompt: d.revised_prompt,
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_image_ref(source: &ImageSource) -> ImageRef {
|
||||
match source {
|
||||
ImageSource::Url(u) => ImageRef { url: Some(u.clone()), file_id: None },
|
||||
ImageSource::FileId(id) => ImageRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Wire-format shape tests (against the internal ImageEditRequestBody) ──
|
||||
|
||||
#[test]
|
||||
fn wire_body_single_source_uses_image_field() {
|
||||
let body = ImageEditRequestBody {
|
||||
prompt: "make it sepia".to_string(),
|
||||
image: Some(ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }),
|
||||
images: None,
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"image\":{"));
|
||||
assert!(!json.contains("\"images\""));
|
||||
assert!(json.contains("\"url\":\"https://example.com/a.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_multi_source_uses_images_array() {
|
||||
let body = ImageEditRequestBody {
|
||||
prompt: "combine these".to_string(),
|
||||
image: None,
|
||||
images: Some(vec![
|
||||
ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None },
|
||||
ImageRef { url: None, file_id: Some("file_xyz".to_string()) },
|
||||
]),
|
||||
model: None,
|
||||
n: None,
|
||||
aspect_ratio: Some("1:1".to_string()),
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"images\":["));
|
||||
assert!(!json.contains("\"image\":{"));
|
||||
assert!(json.contains("\"file_id\":\"file_xyz\""));
|
||||
assert!(json.contains("\"aspect_ratio\":\"1:1\""));
|
||||
}
|
||||
|
||||
// ── Public-API shape tests: ImageEditRequest must serialize cleanly ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_with_typed_enums() {
|
||||
let req = ImageEditRequest {
|
||||
prompt: "edit me".to_string(),
|
||||
source_images: vec![ImageSource::Url("https://example.com/a.png".to_string())],
|
||||
model: Some(ImageModel::GrokImagineImageQuality),
|
||||
number_images: Some(2),
|
||||
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
|
||||
resolution: Some(ImageResolution::TwoK),
|
||||
response_format: Some(ImageResponseFormat::B64Json),
|
||||
user: Some("user_xyz".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"edit me\""));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
|
||||
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
|
||||
assert!(json.contains("\"resolution\":\"2k\""));
|
||||
assert!(json.contains("\"response_format\":\"b64_json\""));
|
||||
assert!(json.contains("\"number_images\":2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_omits_none_fields() {
|
||||
let req = ImageEditRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_images: vec![ImageSource::FileId("file_abc".to_string())],
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("\"model\""));
|
||||
assert!(!json.contains("\"aspect_ratio\""));
|
||||
assert!(!json.contains("\"resolution\""));
|
||||
assert!(!json.contains("\"user\""));
|
||||
assert!(!json.contains("\"number_images\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_does_not_serialize_api_key() {
|
||||
let key = GrokApiKey::new("secret_value_must_not_leak".to_string());
|
||||
let args = ImageEditArgs {
|
||||
api_key: &key,
|
||||
request: ImageEditRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_images: vec![ImageSource::Url("u".to_string())],
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_value_must_not_leak"),
|
||||
"serialized request must not contain the API key. got: {}", json);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_sources_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let result = image_edit(ImageEditArgs {
|
||||
api_key: &api_key,
|
||||
request: ImageEditRequest {
|
||||
prompt: "edit".to_string(),
|
||||
source_images: vec![],
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_url() {
|
||||
let json = r#"{ "data": [ { "url": "https://imagine.x.ai/edit.png" } ] }"#;
|
||||
let parsed: ImageEditResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/edit.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ref_url_only() {
|
||||
let r = ImageRef { url: Some("u".to_string()), file_id: None };
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"url\":\"u\""));
|
||||
assert!(!json.contains("\"file_id\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ref_file_id_only() {
|
||||
let r = ImageRef { url: None, file_id: Some("f".to_string()) };
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"file_id\":\"f\""));
|
||||
assert!(!json.contains("\"url\""));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_image_edit_single() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = image_edit(ImageEditArgs {
|
||||
api_key: &api_key,
|
||||
request: ImageEditRequest {
|
||||
prompt: "Render this as a pencil sketch with detailed shading".to_string(),
|
||||
source_images: vec![ImageSource::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())],
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Edited {} image(s)", result.images.len());
|
||||
for (i, img) in result.images.iter().enumerate() {
|
||||
println!(" [{}] url={:?}", i, img.url);
|
||||
}
|
||||
assert!(!result.images.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod image_edit;
|
||||
pub(crate) mod request_types;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct ImageEditRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
/// Exactly one of `image` or `images` is sent (single vs. multi-image edit).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<ImageRef>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<Vec<ImageRef>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// xAI image reference — exactly one of `url` or `file_id` is set.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct ImageRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageEditResponseBody {
|
||||
pub data: Vec<ImageEditResponseDatum>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageEditResponseDatum {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use crate::api::requests::images::image_generation::image_generation::ImageGenerationRequest;
|
||||
use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills};
|
||||
use crate::api::types::image_types::image_model::ImageModel;
|
||||
use crate::api::types::image_types::image_resolution::ImageResolution;
|
||||
|
||||
// xAI published rates as of these docs: <https://docs.x.ai/developers/pricing>
|
||||
//
|
||||
// grok-imagine-image 1K $0.02/img, 2K $0.02/img (input $0.002/img)
|
||||
// grok-imagine-image-quality 1K $0.05/img, 2K $0.07/img (input $0.010/img)
|
||||
//
|
||||
// In mills (1¢ = 10 mills):
|
||||
//
|
||||
// grok-imagine-image: 1K 20 mills/img, 2K 20 mills/img
|
||||
// grok-imagine-image-quality: 1K 50 mills/img, 2K 70 mills/img
|
||||
//
|
||||
// `image_generation` has no INPUT cost (it's pure text → image) — only output.
|
||||
|
||||
impl GrokRequestCostCalculator for ImageGenerationRequest {
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills {
|
||||
let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
let resolution = self.resolution.unwrap_or(ImageResolution::OneK);
|
||||
|
||||
let mills_per_image = output_mills_per_image(&model, resolution);
|
||||
|
||||
// Server default for `n` is 1.
|
||||
let n = self.number_images.unwrap_or(1) as u64;
|
||||
|
||||
mills_per_image * n
|
||||
}
|
||||
}
|
||||
|
||||
/// Output rate in mills per generated image. Used by both
|
||||
/// `image_generation` and `image_edit` (same output side).
|
||||
pub(crate) fn output_mills_per_image(model: &ImageModel, resolution: ImageResolution) -> UsdMills {
|
||||
match (model, resolution) {
|
||||
(ImageModel::GrokImagineImage, ImageResolution::OneK) => 20,
|
||||
(ImageModel::GrokImagineImage, ImageResolution::TwoK) => 20,
|
||||
(ImageModel::GrokImagineImageQuality, ImageResolution::OneK) => 50,
|
||||
(ImageModel::GrokImagineImageQuality, ImageResolution::TwoK) => 70,
|
||||
// Custom: conservatively assume the more expensive `-quality` tier.
|
||||
(ImageModel::Custom(_), ImageResolution::OneK) => 50,
|
||||
(ImageModel::Custom(_), ImageResolution::TwoK) => 70,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
use crate::api::types::image_types::image_response_format::ImageResponseFormat;
|
||||
|
||||
fn make_request(
|
||||
model: Option<ImageModel>,
|
||||
resolution: Option<ImageResolution>,
|
||||
number_images: Option<u32>,
|
||||
) -> ImageGenerationRequest {
|
||||
ImageGenerationRequest {
|
||||
prompt: "test".to_string(),
|
||||
model,
|
||||
number_images,
|
||||
aspect_ratio: None,
|
||||
resolution,
|
||||
response_format: None,
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-image rate at each (model, resolution) combo ──
|
||||
|
||||
mod per_image_rates {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_1k_is_20_mills() {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 20);
|
||||
// 20 mills = 2¢ (ceiling)
|
||||
assert_eq!(req.calculate_cost_in_cents(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_2k_is_20_mills() {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 20);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_1k_is_50_mills() {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 50);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_2k_is_70_mills() {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 70);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 7);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defaults ──
|
||||
|
||||
mod defaults {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_model_is_quality() {
|
||||
let r1 = make_request(None, Some(ImageResolution::OneK), Some(1));
|
||||
let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_one_k() {
|
||||
let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), None, Some(1));
|
||||
let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_n_is_one() {
|
||||
let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), None);
|
||||
let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_default_request_costs_50_mills() {
|
||||
// All defaults = grok-imagine-image-quality, 1k, n=1 → 50 mills
|
||||
let req = make_request(None, None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 50);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch scaling ──
|
||||
|
||||
mod batch_scaling {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn n_scales_linearly_standard_1k() {
|
||||
for n in 1u32..=10 {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(n));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 20 * n as u64, "n={n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_scales_linearly_quality_2k() {
|
||||
for n in 1u32..=10 {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(n));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 70 * n as u64, "n={n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_of_four_quality_2k() {
|
||||
let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4));
|
||||
// 70 mills × 4 = 280 mills = 28¢
|
||||
assert_eq!(req.calculate_cost_in_mills(), 280);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 28);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom model (forward-compat) ──
|
||||
|
||||
mod custom_model {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_model_uses_quality_rate() {
|
||||
let custom = ImageModel::Custom("grok-imagine-image-future".to_string());
|
||||
let r_custom_1k = make_request(Some(custom.clone()), Some(ImageResolution::OneK), Some(1));
|
||||
let r_quality_1k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
assert_eq!(r_custom_1k.calculate_cost_in_mills(), r_quality_1k.calculate_cost_in_mills());
|
||||
|
||||
let r_custom_2k = make_request(Some(custom), Some(ImageResolution::TwoK), Some(1));
|
||||
let r_quality_2k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1));
|
||||
assert_eq!(r_custom_2k.calculate_cost_in_mills(), r_quality_2k.calculate_cost_in_mills());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cost doesn't depend on irrelevant fields ──
|
||||
|
||||
mod independence {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
for ar in [
|
||||
ImageAspectRatio::Square,
|
||||
ImageAspectRatio::Landscape16x9,
|
||||
ImageAspectRatio::Portrait9x16,
|
||||
ImageAspectRatio::Landscape20x9,
|
||||
ImageAspectRatio::Auto,
|
||||
] {
|
||||
base.aspect_ratio = Some(ar);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_response_format() {
|
||||
let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
base.response_format = Some(ImageResponseFormat::Url);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
base.response_format = Some(ImageResponseFormat::B64Json);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_user_field() {
|
||||
let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1));
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
base.user = Some("any-user-id".to_string());
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monotonicity sanity checks ──
|
||||
|
||||
mod monotonicity {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quality_is_at_least_as_expensive_as_standard() {
|
||||
for res in [ImageResolution::OneK, ImageResolution::TwoK] {
|
||||
let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(1)).calculate_cost_in_mills();
|
||||
let q = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(1)).calculate_cost_in_mills();
|
||||
assert!(std <= q, "standard ({std}) should be <= quality ({q}) at {res:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_k_is_at_least_as_expensive_as_one_k() {
|
||||
for model in [ImageModel::GrokImagineImage, ImageModel::GrokImagineImageQuality] {
|
||||
let lo = make_request(Some(model.clone()), Some(ImageResolution::OneK), Some(1)).calculate_cost_in_mills();
|
||||
let hi = make_request(Some(model.clone()), Some(ImageResolution::TwoK), Some(1)).calculate_cost_in_mills();
|
||||
assert!(lo <= hi, "1k ({lo}) should be <= 2k ({hi}) for {:?}", model.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::images::image_generation::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
use crate::api::types::image_types::image_model::ImageModel;
|
||||
use crate::api::types::image_types::image_resolution::ImageResolution;
|
||||
use crate::api::types::image_types::image_response_format::ImageResponseFormat;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`image_generation`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`ImageGenerationRequest`]
|
||||
/// without leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageGenerationArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: ImageGenerationRequest,
|
||||
}
|
||||
|
||||
/// The material part of an image-generation request. Derives [`Serialize`] so
|
||||
/// it can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ImageGenerationRequest {
|
||||
/// Text prompt describing the image. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`]
|
||||
/// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed
|
||||
/// in the enum.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<ImageModel>,
|
||||
|
||||
/// Number of images to render in this request. xAI's docs don't state a
|
||||
/// hard maximum; server default is 1 when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number_images: Option<u32>,
|
||||
|
||||
/// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted
|
||||
/// values. Server default when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<ImageAspectRatio>,
|
||||
|
||||
/// Output resolution tier. See [`ImageResolution`]. Server default when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<ImageResolution>,
|
||||
|
||||
/// Url (default) or b64 inline. See [`ImageResponseFormat`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<ImageResponseFormat>,
|
||||
|
||||
/// Optional opaque user identifier for usage attribution.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageGenerationSuccess {
|
||||
/// One entry per generated image. Order matches the order xAI returned.
|
||||
pub images: Vec<GeneratedImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedImage {
|
||||
/// URL to the rendered image. Present when `response_format` was `"url"`
|
||||
/// (the default). xAI URLs are time-limited.
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Base64-encoded image bytes. Present when `response_format` was `"b64_json"`.
|
||||
pub b64_json: Option<String>,
|
||||
|
||||
/// xAI's revised version of the input prompt, if it edited it.
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/images/generations — generate one or more images
|
||||
/// from a text prompt using xAI's Imagine API.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
pub async fn image_generation(args: ImageGenerationArgs<'_>) -> Result<ImageGenerationSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
let url = format!("{}/v1/images/generations", XAI_API_BASE_URL);
|
||||
let model = req.model.unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
|
||||
info!(
|
||||
"Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}",
|
||||
model.as_str(), req.number_images,
|
||||
req.aspect_ratio.map(|a| a.as_str()),
|
||||
req.resolution.map(|r| r.as_str()),
|
||||
);
|
||||
|
||||
let request_body = ImageGenerationRequestBody {
|
||||
prompt: req.prompt,
|
||||
model: Some(model.as_str().to_string()),
|
||||
n: req.number_images,
|
||||
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
|
||||
resolution: req.resolution.map(|r| r.as_str().to_string()),
|
||||
response_format: req.response_format.map(|f| f.as_str().to_string()),
|
||||
user: req.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok image_generation response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(ImageGenerationSuccess {
|
||||
images: parsed.data.into_iter().map(|d| GeneratedImage {
|
||||
url: d.url,
|
||||
b64_json: d.b64_json,
|
||||
revised_prompt: d.revised_prompt,
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Wire-format shape tests (internal ImageGenerationRequestBody) ──
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_minimal() {
|
||||
let body = ImageGenerationRequestBody {
|
||||
prompt: "a cat".to_string(),
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"a cat\""));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
|
||||
assert!(!json.contains("\"n\""));
|
||||
assert!(!json.contains("\"aspect_ratio\""));
|
||||
assert!(!json.contains("\"user\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_full() {
|
||||
let body = ImageGenerationRequestBody {
|
||||
prompt: "a cat".to_string(),
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: Some(2),
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
resolution: Some("2k".to_string()),
|
||||
response_format: Some("b64_json".to_string()),
|
||||
user: Some("user_abc".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"n\":2"));
|
||||
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
|
||||
assert!(json.contains("\"resolution\":\"2k\""));
|
||||
assert!(json.contains("\"response_format\":\"b64_json\""));
|
||||
assert!(json.contains("\"user\":\"user_abc\""));
|
||||
}
|
||||
|
||||
// ── Public-API shape tests: ImageGenerationRequest must serialize cleanly ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_with_typed_enums() {
|
||||
let req = ImageGenerationRequest {
|
||||
prompt: "a serene lake".to_string(),
|
||||
model: Some(ImageModel::GrokImagineImageQuality),
|
||||
number_images: Some(3),
|
||||
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
|
||||
resolution: Some(ImageResolution::OneK),
|
||||
response_format: Some(ImageResponseFormat::Url),
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"a serene lake\""));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
|
||||
assert!(json.contains("\"number_images\":3"));
|
||||
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
|
||||
assert!(json.contains("\"resolution\":\"1k\""));
|
||||
assert!(json.contains("\"response_format\":\"url\""));
|
||||
assert!(!json.contains("\"user\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_does_not_serialize_api_key() {
|
||||
let key = GrokApiKey::new("secret_value_must_not_leak".to_string());
|
||||
let args = ImageGenerationArgs {
|
||||
api_key: &key,
|
||||
request: ImageGenerationRequest {
|
||||
prompt: "p".to_string(),
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_value_must_not_leak"),
|
||||
"serialized request must not contain the API key. got: {}", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_url() {
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{ "url": "https://imagine.x.ai/abc.png", "revised_prompt": "a cat" }
|
||||
]
|
||||
}"#;
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data.len(), 1);
|
||||
assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/abc.png"));
|
||||
assert!(parsed.data[0].b64_json.is_none());
|
||||
assert_eq!(parsed.data[0].revised_prompt.as_deref(), Some("a cat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_b64_json() {
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{ "b64_json": "iVBORw0KGgo=" }
|
||||
]
|
||||
}"#;
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data[0].b64_json.as_deref(), Some("iVBORw0KGgo="));
|
||||
assert!(parsed.data[0].url.is_none());
|
||||
}
|
||||
|
||||
// ── Live API tests (ignored — incur cost) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_image_generation_simple() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = image_generation(ImageGenerationArgs {
|
||||
api_key: &api_key,
|
||||
request: ImageGenerationRequest {
|
||||
prompt: "A serene mountain lake at sunrise, photorealistic".to_string(),
|
||||
model: None,
|
||||
number_images: None,
|
||||
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
|
||||
resolution: Some(ImageResolution::OneK),
|
||||
response_format: None,
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Generated {} image(s)", result.images.len());
|
||||
for (i, img) in result.images.iter().enumerate() {
|
||||
println!(" [{}] url={:?} revised_prompt={:?}", i, img.url, img.revised_prompt);
|
||||
}
|
||||
assert!(!result.images.is_empty());
|
||||
assert!(result.images[0].url.is_some(), "expected url-format response by default");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod image_generation;
|
||||
pub(crate) mod request_types;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct ImageGenerationRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageGenerationResponseBody {
|
||||
pub data: Vec<ImageGenerationResponseDatum>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageGenerationResponseDatum {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
|
||||
/// xAI may revise the prompt (their docs call this "revised_prompt" in
|
||||
/// OpenAI-compat responses). Optional, included if present.
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod image_edit;
|
||||
pub mod image_generation;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod files;
|
||||
pub mod images;
|
||||
pub mod videos;
|
||||
|
||||
pub(crate) mod xai_host;
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod video_edit;
|
||||
pub mod video_extension;
|
||||
pub mod video_generation;
|
||||
pub mod video_status;
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::api::requests::videos::video_edit::video_edit::VideoEditRequest;
|
||||
use crate::api::requests::videos::video_generation::cost::{
|
||||
output_mills_per_second,
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO,
|
||||
};
|
||||
use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills};
|
||||
use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
|
||||
/// Resolution we assume for both input and output. Video edits inherit
|
||||
/// source resolution capped at 720p, so 720p is also the worst case
|
||||
/// (output rate is higher at 720p than 480p).
|
||||
const ASSUMED_RESOLUTION: VideoResolution = VideoResolution::SevenTwentyP;
|
||||
|
||||
// `video_edit` cost depends entirely on the source video's duration:
|
||||
//
|
||||
// Input: 10 mills/sec × source_duration (resolution-independent)
|
||||
// Output: 70 mills/sec × source_duration (720p, the assumed cap)
|
||||
// Total: 80 mills/sec × source_duration
|
||||
//
|
||||
// xAI documents that video edits re-render the ENTIRE source video — output
|
||||
// duration mirrors source duration, output resolution mirrors source
|
||||
// resolution (capped at 720p, which is also our max-supported variant).
|
||||
//
|
||||
// Source duration isn't in the request body, but callers can populate the
|
||||
// `source_video_duration_seconds_hint` field on `VideoEditRequest` for an
|
||||
// accurate estimate. Without it we fall back to xAI's 8-second generation
|
||||
// default so callers don't get a misleadingly-free quote.
|
||||
|
||||
impl GrokRequestCostCalculator for VideoEditRequest {
|
||||
/// Uses [`VideoEditRequest::source_video_duration_seconds_hint`] when set
|
||||
/// (the caller-supplied source duration drives both the input AND output
|
||||
/// billing); otherwise falls back to [`DEFAULT_VIDEO_DURATION_SECONDS`]
|
||||
/// (8s, matching xAI's `/v1/videos/generations` default).
|
||||
///
|
||||
/// Resolution is always assumed to be 720p — the request body can't
|
||||
/// carry the source's actual resolution and 720p is both xAI's cap and
|
||||
/// the worst case for cost.
|
||||
///
|
||||
/// Formula: `secs × (10 input + 70 output@720p) = 80 × secs` mills.
|
||||
///
|
||||
/// At the 8-second default: 8 × 80 = **640 mills (64¢)**.
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills {
|
||||
let secs = self
|
||||
.source_video_duration_seconds_hint
|
||||
.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64;
|
||||
let per_second = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO + output_mills_per_second(ASSUMED_RESOLUTION);
|
||||
per_second * secs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::requests::videos::video_edit::video_edit::{VideoEditRequest, VideoSource};
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
|
||||
fn make_request(source_video: VideoSource, model: Option<VideoModel>) -> VideoEditRequest {
|
||||
VideoEditRequest {
|
||||
prompt: "test edit".to_string(),
|
||||
source_video,
|
||||
source_video_duration_seconds_hint: None,
|
||||
model,
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Default base-trait estimate assumes 8s source @ 720p, so:
|
||||
// 8 × (10 input + 70 output) = 640 mills = 64¢
|
||||
const EXPECTED_BASE_ESTIMATE_MILLS: u64 = 640;
|
||||
const EXPECTED_BASE_ESTIMATE_CENTS: u64 = 64;
|
||||
|
||||
#[test]
|
||||
fn base_estimate_is_conservative_eight_seconds_seven_twenty_p() {
|
||||
let cases = [
|
||||
VideoSource::Url("https://example.com/v.mp4".to_string()),
|
||||
VideoSource::FileId("file_abc".to_string()),
|
||||
];
|
||||
for source in cases {
|
||||
let req = make_request(source, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS);
|
||||
assert_eq!(req.calculate_cost_in_cents(), EXPECTED_BASE_ESTIMATE_CENTS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_estimate_is_independent_of_model() {
|
||||
let r_default = make_request(VideoSource::Url("u".to_string()), None).calculate_cost_in_mills();
|
||||
let r_known = make_request(VideoSource::Url("u".to_string()), Some(VideoModel::GrokImagineVideo)).calculate_cost_in_mills();
|
||||
let r_custom = make_request(VideoSource::Url("u".to_string()),
|
||||
Some(VideoModel::Custom("future".to_string()))).calculate_cost_in_mills();
|
||||
assert_eq!(r_default, EXPECTED_BASE_ESTIMATE_MILLS);
|
||||
assert_eq!(r_known, EXPECTED_BASE_ESTIMATE_MILLS);
|
||||
assert_eq!(r_custom, EXPECTED_BASE_ESTIMATE_MILLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_estimate_is_independent_of_prompt_or_user() {
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.prompt = "an enormously long prompt with many tokens".repeat(100);
|
||||
req.user = Some("user-id".to_string());
|
||||
assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_estimate_is_nonzero() {
|
||||
// Sanity guard against accidentally regressing to the old return-0 footgun.
|
||||
let req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
assert!(req.calculate_cost_in_mills() > 0);
|
||||
assert!(req.calculate_cost_in_cents() > 0);
|
||||
}
|
||||
|
||||
// ── source_video_duration_seconds_hint hint ──
|
||||
|
||||
mod duration_hint {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hint_overrides_default_duration() {
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.source_video_duration_seconds_hint = Some(5);
|
||||
// 5 × 80 = 400 mills (vs the 640 mills default)
|
||||
assert_eq!(req.calculate_cost_in_mills(), 400);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_second_hint() {
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.source_video_duration_seconds_hint = Some(1);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 80); // 1 × 80
|
||||
assert_eq!(req.calculate_cost_in_cents(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifteen_second_hint() {
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.source_video_duration_seconds_hint = Some(15);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1200); // 15 × 80
|
||||
assert_eq!(req.calculate_cost_in_cents(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_hint_falls_back_to_default() {
|
||||
let req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
assert_eq!(req.source_video_duration_seconds_hint, None);
|
||||
// Default = 8s @ 720p = 640 mills.
|
||||
assert_eq!(req.calculate_cost_in_mills(), 640);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_second_hint_costs_zero() {
|
||||
// Edge case: 0-second source is meaningless but shouldn't panic.
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.source_video_duration_seconds_hint = Some(0);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_scales_linearly() {
|
||||
for secs in 1u32..=20 {
|
||||
let mut req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
req.source_video_duration_seconds_hint = Some(secs);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 80 * secs as u64, "secs={secs}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_is_independent_of_source_kind() {
|
||||
let mut url_req = make_request(VideoSource::Url("u".to_string()), None);
|
||||
let mut file_req = make_request(VideoSource::FileId("f".to_string()), None);
|
||||
url_req.source_video_duration_seconds_hint = Some(7);
|
||||
file_req.source_video_duration_seconds_hint = Some(7);
|
||||
assert_eq!(url_req.calculate_cost_in_mills(), file_req.calculate_cost_in_mills());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_edit;
|
||||
pub(crate) mod request_types;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoEditRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
pub video: VideoSourceRef,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoSourceRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoEditResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_edit::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`video_edit`]. Borrows the API key separately from
|
||||
/// the request body so callers can log/save [`VideoEditRequest`] without
|
||||
/// leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoEditArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: VideoEditRequest,
|
||||
}
|
||||
|
||||
/// The material part of a video-edit request. Derives [`Serialize`] so it
|
||||
/// can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VideoEditRequest {
|
||||
/// Edit instruction. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Source video to modify (xAI reads this as the input).
|
||||
pub source_video: VideoSource,
|
||||
|
||||
/// **Cost-estimation hint — NOT sent to xAI, has zero effect on the
|
||||
/// actual HTTP request.** Duration in whole seconds of the video at
|
||||
/// [`source_video`](Self::source_video). When set, the
|
||||
/// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`]
|
||||
/// impl uses it for an accurate cost estimate; when `None`, the impl
|
||||
/// falls back to a conservative default (xAI's 8-second generation
|
||||
/// default). Populate this field if you already know the source's
|
||||
/// runtime (e.g. from ffprobe or an upstream generate call) so internal
|
||||
/// cost tracking stays correct.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_video_duration_seconds_hint: Option<u32>,
|
||||
|
||||
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
|
||||
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<VideoModel>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source video to edit. Pick by public URL or by a previously-uploaded
|
||||
/// xAI file_id.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum VideoSource {
|
||||
/// Public HTTPS URL pointing to the source video. xAI fetches the bytes
|
||||
/// on its end.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::api::requests::files::upload_file::upload_file::upload_file`].
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoEditSuccess {
|
||||
/// Use this with `video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/edits — modify an existing video based on
|
||||
/// a text prompt. Asynchronous; poll `video_status` for completion.
|
||||
///
|
||||
/// xAI states the `duration`, `aspectRatio`, and `resolution` parameters are
|
||||
/// ignored for video edits — the output mirrors the source video.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/editing>
|
||||
pub async fn video_edit(args: VideoEditArgs<'_>) -> Result<VideoEditSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
let url = format!("{}/v1/videos/edits", XAI_API_BASE_URL);
|
||||
let model = req.model.unwrap_or(VideoModel::GrokImagineVideo);
|
||||
|
||||
info!("Grok video_edit: model={}", model.as_str());
|
||||
|
||||
let request_body = VideoEditRequestBody {
|
||||
prompt: req.prompt,
|
||||
video: to_video_source_ref(&req.source_video),
|
||||
model: Some(model.as_str().to_string()),
|
||||
user: req.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_edit response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoEditResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoEditSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_video_source_ref(source: &VideoSource) -> VideoSourceRef {
|
||||
match source {
|
||||
VideoSource::Url(u) => VideoSourceRef { url: Some(u.clone()), file_id: None },
|
||||
VideoSource::FileId(id) => VideoSourceRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Wire-format shape tests ──
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_url_source() {
|
||||
let body = VideoEditRequestBody {
|
||||
prompt: "make it stormy".to_string(),
|
||||
video: VideoSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None },
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"make it stormy\""));
|
||||
assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}"));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-video\""));
|
||||
assert!(!json.contains("\"file_id\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_file_id_source() {
|
||||
let body = VideoEditRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoSourceRef { url: None, file_id: Some("file_v".to_string()) },
|
||||
model: None,
|
||||
user: Some("u".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
|
||||
assert!(json.contains("\"user\":\"u\""));
|
||||
}
|
||||
|
||||
// ── Public Request shape ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = VideoEditArgs {
|
||||
api_key: &key,
|
||||
request: VideoEditRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_video: VideoSource::FileId("file_abc".to_string()),
|
||||
source_video_duration_seconds_hint: None,
|
||||
model: None,
|
||||
user: None,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"source_video\":{\"FileId\":\"file_abc\"}"));
|
||||
// Hint omitted when None.
|
||||
assert!(!json.contains("source_video_duration_seconds_hint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_duration_hint_serializes_when_set() {
|
||||
let req = VideoEditRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_video: VideoSource::Url("u".to_string()),
|
||||
source_video_duration_seconds_hint: Some(12),
|
||||
model: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"source_video_duration_seconds_hint\":12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_duration_hint_is_not_in_wire_body() {
|
||||
// The internal wire body (what actually gets POSTed to xAI) doesn't
|
||||
// carry the hint — it's intentionally absent from VideoEditRequestBody.
|
||||
let body = VideoEditRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoSourceRef { url: Some("u".to_string()), file_id: None },
|
||||
model: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(!json.contains("source_video_duration_seconds_hint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "0199c33d-3afa-7000-b400-deadbeefcafe" }"#;
|
||||
let parsed: VideoEditResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "0199c33d-3afa-7000-b400-deadbeefcafe");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_edit() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_edit(VideoEditArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoEditRequest {
|
||||
prompt: "Change the lighting to midnight. Starry sky with the mily way overhead. Add some shooting stars. Everything is glowing under the starlight.".to_string(),
|
||||
source_video: VideoSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()),
|
||||
source_video_duration_seconds_hint: None,
|
||||
model: None,
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Edit request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use crate::api::requests::videos::video_extension::video_extension::VideoExtensionRequest;
|
||||
use crate::api::requests::videos::video_generation::cost::{
|
||||
output_mills_per_second,
|
||||
INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO,
|
||||
};
|
||||
use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills};
|
||||
use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
|
||||
// `video_extension` charges:
|
||||
//
|
||||
// Output: 70 mills/sec × extension_duration (assumed 720p, since output
|
||||
// inherits the source's resolution capped at 720p)
|
||||
// Input: 10 mills/sec × source_duration (only billed when caller
|
||||
// supplies a duration hint)
|
||||
//
|
||||
// `extension_duration` is the `duration` field on the request (xAI default
|
||||
// is 6 seconds; range 1–10). `source_duration` isn't in the wire body, but
|
||||
// callers can populate `source_video_duration_seconds_hint` on the public
|
||||
// Request to drive the input-side billing.
|
||||
|
||||
/// xAI default duration for video_extension when `duration` is omitted.
|
||||
const DEFAULT_EXTENSION_DURATION_SECONDS: u32 = 6;
|
||||
|
||||
/// Assumed output resolution. xAI caps extension output at 720p, so this
|
||||
/// is the worst case for cost.
|
||||
const ASSUMED_RESOLUTION: VideoResolution = VideoResolution::SevenTwentyP;
|
||||
|
||||
impl GrokRequestCostCalculator for VideoExtensionRequest {
|
||||
/// Output portion (always billed): extension duration × 70 mills/sec at
|
||||
/// the assumed 720p output.
|
||||
///
|
||||
/// Input portion (billed only when
|
||||
/// [`VideoExtensionRequest::source_video_duration_seconds_hint`] is set):
|
||||
/// 10 mills/sec × hinted source duration.
|
||||
///
|
||||
/// Formula:
|
||||
/// - hint unset: `extension_duration × 70`
|
||||
/// - hint set: `extension_duration × 70 + source_duration × 10`
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills {
|
||||
let extension_duration = self.duration.unwrap_or(DEFAULT_EXTENSION_DURATION_SECONDS) as u64;
|
||||
let output_mills = output_mills_per_second(ASSUMED_RESOLUTION) * extension_duration;
|
||||
let input_mills = self
|
||||
.source_video_duration_seconds_hint
|
||||
.map(|secs| INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO * secs as u64)
|
||||
.unwrap_or(0);
|
||||
output_mills + input_mills
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::requests::videos::video_extension::video_extension::{
|
||||
VideoExtensionRequest, VideoExtensionSource,
|
||||
};
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u32>,
|
||||
source: VideoExtensionSource,
|
||||
model: Option<VideoModel>,
|
||||
) -> VideoExtensionRequest {
|
||||
VideoExtensionRequest {
|
||||
prompt: "test".to_string(),
|
||||
source_video: source,
|
||||
source_video_duration_seconds_hint: None,
|
||||
model,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
fn url_source() -> VideoExtensionSource {
|
||||
VideoExtensionSource::Url("https://example.com/v.mp4".to_string())
|
||||
}
|
||||
|
||||
// ── Output portion (the part we CAN compute) ──
|
||||
|
||||
mod output_portion {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_6_seconds() {
|
||||
// 70 × 6 = 420 mills = 42¢
|
||||
let req = make_request(None, url_source(), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 420);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_second_extension() {
|
||||
// 70 × 1 = 70 mills = 7¢
|
||||
let req = make_request(Some(1), url_source(), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 70);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_second_extension() {
|
||||
// 70 × 5 = 350 mills = 35¢
|
||||
let req = make_request(Some(5), url_source(), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 350);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_second_extension_is_max() {
|
||||
// 70 × 10 = 700 mills = 70¢
|
||||
let req = make_request(Some(10), url_source(), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 700);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 70);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scaling ──
|
||||
|
||||
mod scaling {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_scales_linearly() {
|
||||
for d in 1u32..=10 {
|
||||
let req = make_request(Some(d), url_source(), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 70 * d as u64, "d={d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Independence from non-pricing fields ──
|
||||
|
||||
mod independence {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_source_kind() {
|
||||
let url = make_request(Some(5), VideoExtensionSource::Url("u".to_string()), None);
|
||||
let file = make_request(Some(5), VideoExtensionSource::FileId("f".to_string()), None);
|
||||
assert_eq!(url.calculate_cost_in_mills(), file.calculate_cost_in_mills());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_model_variant() {
|
||||
let mut base = make_request(Some(5), url_source(), None);
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
base.model = Some(VideoModel::GrokImagineVideo);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
base.model = Some(VideoModel::Custom("future".to_string()));
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_prompt_length() {
|
||||
let mut base = make_request(Some(5), url_source(), None);
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
base.prompt = "a much longer prompt with many more words to influence pricing... or not".to_string();
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── source_video_duration_seconds_hint hint ──
|
||||
|
||||
mod duration_hint {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_hint_returns_output_only() {
|
||||
// ext=5s @ 720p, no hint → just output portion: 70 × 5 = 350
|
||||
let req = make_request(Some(5), url_source(), None);
|
||||
assert_eq!(req.source_video_duration_seconds_hint, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_hint_adds_input_portion() {
|
||||
// ext=5s @ 720p, source=10s → output 350 + input 100 = 450
|
||||
let mut req = make_request(Some(5), url_source(), None);
|
||||
req.source_video_duration_seconds_hint = Some(10);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 450);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_zero_is_treated_as_zero_input() {
|
||||
// ext=5s @ 720p, source=0 → output 350 + 0 = 350
|
||||
let mut req = make_request(Some(5), url_source(), None);
|
||||
req.source_video_duration_seconds_hint = Some(0);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_extension_with_hint() {
|
||||
// ext default=6s @ 720p (output 420), source=5s (input 50) → 470
|
||||
let mut req = make_request(None, url_source(), None);
|
||||
req.source_video_duration_seconds_hint = Some(5);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 470);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 47);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_scales_linearly() {
|
||||
// Fix ext=5s (output 350); vary hint from 0..30.
|
||||
for source_secs in 0u32..=30 {
|
||||
let mut req = make_request(Some(5), url_source(), None);
|
||||
req.source_video_duration_seconds_hint = Some(source_secs);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 350 + 10 * source_secs as u64, "source_secs={source_secs}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_extension;
|
||||
pub(crate) mod request_types;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoExtensionRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
pub video: VideoExtensionSourceRef,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Length of the *extension only*, not the total output. xAI default is 6
|
||||
/// seconds; range 1–10.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoExtensionSourceRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoExtensionResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_extension::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`video_extension`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`VideoExtensionRequest`]
|
||||
/// without leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoExtensionArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: VideoExtensionRequest,
|
||||
}
|
||||
|
||||
/// The material part of a video-extension request. Derives [`Serialize`] so
|
||||
/// it can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VideoExtensionRequest {
|
||||
/// Prompt describing what should happen in the extension.
|
||||
pub prompt: String,
|
||||
|
||||
/// Source video to extend.
|
||||
pub source_video: VideoExtensionSource,
|
||||
|
||||
/// **Cost-estimation hint — NOT sent to xAI, has zero effect on the
|
||||
/// actual HTTP request.** Duration in whole seconds of the video at
|
||||
/// [`source_video`](Self::source_video). When set, the
|
||||
/// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`]
|
||||
/// impl includes the input-side billing (10 mills/sec × source duration)
|
||||
/// in the estimate; when `None`, the impl returns the output portion
|
||||
/// only (extension duration × output rate). Populate this field if you
|
||||
/// already know the source's runtime (e.g. from ffprobe or an upstream
|
||||
/// generate call) so internal cost tracking reflects the true total.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_video_duration_seconds_hint: Option<u32>,
|
||||
|
||||
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
|
||||
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<VideoModel>,
|
||||
|
||||
/// Length of the *extension only*, not the total output. xAI default is 6
|
||||
/// seconds; range 1–10.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
}
|
||||
|
||||
/// Source video to extend. Pick by public URL or by a previously-uploaded
|
||||
/// xAI file_id.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum VideoExtensionSource {
|
||||
/// Public HTTPS URL pointing to the source video.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::api::requests::files::upload_file::upload_file::upload_file`].
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoExtensionSuccess {
|
||||
/// Use this with `video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/extensions — extend an existing video with
|
||||
/// additional generated content.
|
||||
///
|
||||
/// Asynchronous; poll `video_status` for completion.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/extension>
|
||||
pub async fn video_extension(args: VideoExtensionArgs<'_>) -> Result<VideoExtensionSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
let url = format!("{}/v1/videos/extensions", XAI_API_BASE_URL);
|
||||
let model = req.model.unwrap_or(VideoModel::GrokImagineVideo);
|
||||
|
||||
info!("Grok video_extension: model={}, duration={:?}", model.as_str(), req.duration);
|
||||
|
||||
let request_body = VideoExtensionRequestBody {
|
||||
prompt: req.prompt,
|
||||
video: to_extension_source_ref(&req.source_video),
|
||||
model: Some(model.as_str().to_string()),
|
||||
duration: req.duration,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_extension response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoExtensionResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoExtensionSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_extension_source_ref(source: &VideoExtensionSource) -> VideoExtensionSourceRef {
|
||||
match source {
|
||||
VideoExtensionSource::Url(u) => VideoExtensionSourceRef { url: Some(u.clone()), file_id: None },
|
||||
VideoExtensionSource::FileId(id) => VideoExtensionSourceRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Wire-format shape tests ──
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_minimal() {
|
||||
let body = VideoExtensionRequestBody {
|
||||
prompt: "keep walking".to_string(),
|
||||
video: VideoExtensionSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None },
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
duration: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"keep walking\""));
|
||||
assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}"));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-video\""));
|
||||
assert!(!json.contains("\"duration\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_with_duration() {
|
||||
let body = VideoExtensionRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoExtensionSourceRef { url: None, file_id: Some("file_v".to_string()) },
|
||||
model: None,
|
||||
duration: Some(8),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"duration\":8"));
|
||||
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
|
||||
}
|
||||
|
||||
// ── Public Request shape ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = VideoExtensionArgs {
|
||||
api_key: &key,
|
||||
request: VideoExtensionRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_video: VideoExtensionSource::Url("u".to_string()),
|
||||
source_video_duration_seconds_hint: None,
|
||||
model: None,
|
||||
duration: Some(5),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"duration\":5"));
|
||||
assert!(json.contains("\"source_video\":{\"Url\":\"u\"}"));
|
||||
// Hint omitted when None.
|
||||
assert!(!json.contains("source_video_duration_seconds_hint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_duration_hint_serializes_when_set() {
|
||||
let req = VideoExtensionRequest {
|
||||
prompt: "p".to_string(),
|
||||
source_video: VideoExtensionSource::Url("u".to_string()),
|
||||
source_video_duration_seconds_hint: Some(7),
|
||||
model: None,
|
||||
duration: Some(5),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"source_video_duration_seconds_hint\":7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_duration_hint_is_not_in_wire_body() {
|
||||
// The internal wire body (what actually gets POSTed to xAI) doesn't
|
||||
// carry the hint — it's intentionally absent from VideoExtensionRequestBody.
|
||||
let body = VideoExtensionRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoExtensionSourceRef { url: Some("u".to_string()), file_id: None },
|
||||
model: None,
|
||||
duration: Some(5),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(!json.contains("source_video_duration_seconds_hint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "ext-abc-123" }"#;
|
||||
let parsed: VideoExtensionResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "ext-abc-123");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_extension() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_extension(VideoExtensionArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoExtensionRequest {
|
||||
prompt: "Continue the walk down the same street".to_string(),
|
||||
source_video: VideoExtensionSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()),
|
||||
source_video_duration_seconds_hint: None,
|
||||
model: None,
|
||||
duration: Some(5),
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Extension request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use crate::api::requests::videos::video_generation::video_generation::VideoGenerationRequest;
|
||||
use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills};
|
||||
use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
|
||||
// xAI pricing per <https://docs.x.ai/developers/pricing>:
|
||||
//
|
||||
// grok-imagine-video
|
||||
// Output: 480p $0.05/sec, 720p $0.07/sec
|
||||
// Input: $0.01/sec (source video) + $0.002/img (source images)
|
||||
//
|
||||
// In mills (1¢ = 10 mills):
|
||||
//
|
||||
// Output: 480p 50 mills/sec, 720p 70 mills/sec
|
||||
// Input: 10 mills/sec (video), 2 mills/img (per source image)
|
||||
//
|
||||
// video_generation has NO input video — only optional input images. xAI
|
||||
// defaults: `duration` = 8 seconds, `resolution` = "480p".
|
||||
|
||||
/// xAI default duration for video_generation when `duration` is omitted.
|
||||
pub(crate) const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 8;
|
||||
|
||||
/// Per-source-image input rate for video endpoints, in mills.
|
||||
pub(crate) const INPUT_MILLS_PER_IMAGE: UsdMills = 2;
|
||||
|
||||
/// Per-source-second input rate (when a source video is supplied) for video
|
||||
/// endpoints, in mills. Not used by `video_generation` (which has no source
|
||||
/// video) but referenced by `video_edit` and `video_extension`.
|
||||
pub(crate) const INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO: UsdMills = 10;
|
||||
|
||||
impl GrokRequestCostCalculator for VideoGenerationRequest {
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills {
|
||||
let duration = self.duration.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64;
|
||||
let resolution = self.resolution.unwrap_or(VideoResolution::SevenTwentyP);
|
||||
|
||||
let output_mills = output_mills_per_second(resolution) * duration;
|
||||
|
||||
// Source image count = `image` (image-to-video) + len(`reference_images`)
|
||||
// (reference-to-video). These are mutually exclusive at call time but the
|
||||
// cost math doesn't need to enforce that — it just counts.
|
||||
let source_image_count: u64 =
|
||||
(self.image.is_some() as u64)
|
||||
+ (self.reference_images.as_ref().map(|v| v.len() as u64).unwrap_or(0));
|
||||
|
||||
let input_mills = INPUT_MILLS_PER_IMAGE * source_image_count;
|
||||
|
||||
output_mills + input_mills
|
||||
}
|
||||
}
|
||||
|
||||
/// Output rate in mills per second of generated video, by resolution.
|
||||
pub(crate) fn output_mills_per_second(resolution: VideoResolution) -> UsdMills {
|
||||
match resolution {
|
||||
VideoResolution::FourEightyP => 50,
|
||||
VideoResolution::SevenTwentyP => 70,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::requests::videos::video_generation::video_generation::VideoImageSource;
|
||||
use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio;
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u32>,
|
||||
resolution: Option<VideoResolution>,
|
||||
image: Option<VideoImageSource>,
|
||||
reference_images: Option<Vec<VideoImageSource>>,
|
||||
) -> VideoGenerationRequest {
|
||||
VideoGenerationRequest {
|
||||
prompt: "test".to_string(),
|
||||
model: None,
|
||||
image,
|
||||
reference_images,
|
||||
aspect_ratio: None,
|
||||
duration,
|
||||
resolution,
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn url_source() -> VideoImageSource {
|
||||
VideoImageSource::Url("https://example.com/img.png".to_string())
|
||||
}
|
||||
|
||||
// ── Output rate sanity ──
|
||||
|
||||
mod output_rate {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_480p_is_50_mills_per_sec() {
|
||||
assert_eq!(output_mills_per_second(VideoResolution::FourEightyP), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_720p_is_70_mills_per_sec() {
|
||||
assert_eq!(output_mills_per_second(VideoResolution::SevenTwentyP), 70);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure text-to-video (no input images) ──
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fully_default_is_8s_720p() {
|
||||
// Default duration 8s, default resolution 720p (per our defaults in cost.rs).
|
||||
// 70 × 8 = 560 mills = 56¢
|
||||
let req = make_request(None, None, None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 560);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_480p() {
|
||||
// 50 × 5 = 250 mills = 25¢
|
||||
let req = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 250);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p() {
|
||||
// 70 × 5 = 350 mills = 35¢
|
||||
let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 350);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifteen_seconds_720p_is_max_duration() {
|
||||
// 70 × 15 = 1050 mills = $1.05
|
||||
let req = make_request(Some(15), Some(VideoResolution::SevenTwentyP), None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1050);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 105);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image-to-video (single source image) ──
|
||||
|
||||
mod image_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn five_seconds_480p_one_source_image() {
|
||||
// 50 × 5 + 2 × 1 = 252 mills = 26¢ (ceil)
|
||||
let req = make_request(Some(5), Some(VideoResolution::FourEightyP), Some(url_source()), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 252);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p_one_source_image() {
|
||||
// 70 × 5 + 2 = 352 mills = 36¢ (ceil)
|
||||
let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 352);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 36);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reference-to-video (multiple source images) ──
|
||||
|
||||
mod reference_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p_two_reference_images() {
|
||||
// 70 × 5 + 2 × 2 = 354 mills = 36¢ (ceil)
|
||||
let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(vec![url_source(), url_source()]));
|
||||
assert_eq!(req.calculate_cost_in_mills(), 354);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p_three_reference_images() {
|
||||
// 70 × 5 + 2 × 3 = 356 mills
|
||||
let req = make_request(
|
||||
Some(5), Some(VideoResolution::SevenTwentyP),
|
||||
None, Some(vec![url_source(), url_source(), url_source()]),
|
||||
);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 356);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_image_count_scales_linearly() {
|
||||
let base = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills();
|
||||
for n in 1usize..=5 {
|
||||
let imgs: Vec<_> = (0..n).map(|_| url_source()).collect();
|
||||
let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(imgs));
|
||||
assert_eq!(req.calculate_cost_in_mills(), base + INPUT_MILLS_PER_IMAGE * n as u64, "n={n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defaults ──
|
||||
|
||||
mod defaults {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_8_seconds() {
|
||||
let r1 = make_request(None, Some(VideoResolution::FourEightyP), None, None);
|
||||
let r2 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None);
|
||||
assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
let r1 = make_request(Some(5), None, None, None);
|
||||
let r2 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None);
|
||||
assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Independence from non-pricing fields ──
|
||||
|
||||
mod independence {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None);
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
for ar in [
|
||||
VideoAspectRatio::Square,
|
||||
VideoAspectRatio::Landscape16x9,
|
||||
VideoAspectRatio::Portrait9x16,
|
||||
VideoAspectRatio::Portrait2x3,
|
||||
] {
|
||||
base.aspect_ratio = Some(ar);
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_model_variant() {
|
||||
// All `model` enum variants map to the same pricing (only one video model).
|
||||
let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None);
|
||||
let base_cost = base.calculate_cost_in_mills();
|
||||
for m in [
|
||||
VideoModel::GrokImagineVideo,
|
||||
VideoModel::Custom("grok-imagine-video-future".to_string()),
|
||||
] {
|
||||
base.model = Some(m.clone());
|
||||
assert_eq!(base.calculate_cost_in_mills(), base_cost, "{:?}", m.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exhaustive matrix ──
|
||||
|
||||
mod exhaustive_matrix {
|
||||
use super::*;
|
||||
|
||||
// (duration_s, resolution, image_to_video_source, reference_image_count, expected_mills)
|
||||
const CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[
|
||||
// 480p, no input images
|
||||
( 1, VideoResolution::FourEightyP, false, 0, 50),
|
||||
( 5, VideoResolution::FourEightyP, false, 0, 250),
|
||||
( 8, VideoResolution::FourEightyP, false, 0, 400),
|
||||
(15, VideoResolution::FourEightyP, false, 0, 750),
|
||||
// 720p, no input images
|
||||
( 1, VideoResolution::SevenTwentyP, false, 0, 70),
|
||||
( 5, VideoResolution::SevenTwentyP, false, 0, 350),
|
||||
( 8, VideoResolution::SevenTwentyP, false, 0, 560),
|
||||
(15, VideoResolution::SevenTwentyP, false, 0, 1050),
|
||||
// 480p with image-to-video source (1 input image)
|
||||
( 5, VideoResolution::FourEightyP, true, 0, 252),
|
||||
(10, VideoResolution::FourEightyP, true, 0, 502),
|
||||
// 720p with image-to-video source (1 input image)
|
||||
( 5, VideoResolution::SevenTwentyP, true, 0, 352),
|
||||
(10, VideoResolution::SevenTwentyP, true, 0, 702),
|
||||
// 480p with reference_images (no image-to-video)
|
||||
( 5, VideoResolution::FourEightyP, false, 1, 252),
|
||||
( 5, VideoResolution::FourEightyP, false, 2, 254),
|
||||
( 5, VideoResolution::FourEightyP, false, 3, 256),
|
||||
// 720p with reference_images
|
||||
( 5, VideoResolution::SevenTwentyP, false, 1, 352),
|
||||
( 5, VideoResolution::SevenTwentyP, false, 2, 354),
|
||||
( 5, VideoResolution::SevenTwentyP, false, 3, 356),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn all_matrix_cases() {
|
||||
for &(duration, res, has_image, ref_count, expected) in CASES {
|
||||
let image = if has_image { Some(url_source()) } else { None };
|
||||
let refs = if ref_count > 0 {
|
||||
Some((0..ref_count).map(|_| url_source()).collect())
|
||||
} else { None };
|
||||
let req = make_request(Some(duration), Some(res), image, refs);
|
||||
assert_eq!(
|
||||
req.calculate_cost_in_mills(), expected,
|
||||
"duration={duration} res={res:?} has_image={has_image} ref_count={ref_count}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monotonicity ──
|
||||
|
||||
mod monotonicity {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn longer_duration_costs_more() {
|
||||
let short = make_request(Some(1), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills();
|
||||
let long = make_request(Some(15), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills();
|
||||
assert!(short < long);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_costs_more() {
|
||||
let p480 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills();
|
||||
let p720 = make_request(Some(8), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills();
|
||||
assert!(p480 < p720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_reference_images_cost_more() {
|
||||
let zero = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills();
|
||||
let three = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None,
|
||||
Some(vec![url_source(), url_source(), url_source()])).calculate_cost_in_mills();
|
||||
assert!(zero < three);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_generation;
|
||||
pub(crate) mod request_types;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoGenerationRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Image-to-video: single source image. Mutually exclusive with
|
||||
/// `reference_images`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<VideoImageRef>,
|
||||
|
||||
/// Reference-to-video: array of reference images.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_images: Option<Vec<VideoImageRef>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// xAI image/video reference — exactly one of `url` or `file_id` is set.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoImageRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoGenerationResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_generation::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio;
|
||||
use crate::api::types::video_types::video_model::VideoModel;
|
||||
use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`video_generation`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`VideoGenerationRequest`]
|
||||
/// without leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoGenerationArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: VideoGenerationRequest,
|
||||
}
|
||||
|
||||
/// The material part of a video-generation request. Derives [`Serialize`] so
|
||||
/// it can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VideoGenerationRequest {
|
||||
/// Text prompt. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`.
|
||||
/// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<VideoModel>,
|
||||
|
||||
/// Image-to-video: a single source image. Mutually exclusive with
|
||||
/// `reference_images` — supplying both returns a `BadRequest` before the
|
||||
/// HTTP call.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<VideoImageSource>,
|
||||
|
||||
/// Reference-to-video: zero or more reference images.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_images: Option<Vec<VideoImageSource>>,
|
||||
|
||||
/// Aspect ratio. See [`VideoAspectRatio`] for the closed set of accepted
|
||||
/// values (7 ratios — note the video endpoint accepts fewer than image
|
||||
/// endpoints). Server default is `16:9` when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<VideoAspectRatio>,
|
||||
|
||||
/// Duration in seconds (1–15). xAI default is 8.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
|
||||
/// Output resolution tier. See [`VideoResolution`] (`480p` or `720p`).
|
||||
/// Server default is `480p` when `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<VideoResolution>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source image for `image` (image-to-video) or `reference_images`
|
||||
/// (reference-to-video).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum VideoImageSource {
|
||||
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
|
||||
/// image bytes.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::api::requests::files::upload_file::upload_file::upload_file`]. The file
|
||||
/// must still exist at request time.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoGenerationSuccess {
|
||||
/// Use this with `video_status::video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/generations — start a video generation
|
||||
/// job. The video is rendered asynchronously; poll
|
||||
/// `video_status::video_status(request_id)` until it returns `done` or
|
||||
/// `failed`.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/image-to-video>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/reference-to-video>
|
||||
pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGenerationSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
|
||||
if req.image.is_some() && req.reference_images.as_ref().is_some_and(|v| !v.is_empty()) {
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/videos/generations", XAI_API_BASE_URL);
|
||||
let model = req.model.unwrap_or(VideoModel::GrokImagineVideo);
|
||||
|
||||
info!(
|
||||
"Grok video_generation: model={}, has_image={}, ref_imgs={}, aspect_ratio={:?}, duration={:?}, resolution={:?}",
|
||||
model.as_str(),
|
||||
req.image.is_some(),
|
||||
req.reference_images.as_ref().map(|v| v.len()).unwrap_or(0),
|
||||
req.aspect_ratio.map(|a| a.as_str()),
|
||||
req.duration,
|
||||
req.resolution.map(|r| r.as_str()),
|
||||
);
|
||||
|
||||
let request_body = VideoGenerationRequestBody {
|
||||
prompt: req.prompt,
|
||||
model: Some(model.as_str().to_string()),
|
||||
image: req.image.as_ref().map(to_video_image_ref),
|
||||
reference_images: req.reference_images.map(|v| v.iter().map(to_video_image_ref).collect()),
|
||||
aspect_ratio: req.aspect_ratio.map(|a| a.as_str().to_string()),
|
||||
duration: req.duration,
|
||||
resolution: req.resolution.map(|r| r.as_str().to_string()),
|
||||
user: req.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_generation response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoGenerationSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_video_image_ref(source: &VideoImageSource) -> VideoImageRef {
|
||||
match source {
|
||||
VideoImageSource::Url(u) => VideoImageRef { url: Some(u.clone()), file_id: None },
|
||||
VideoImageSource::FileId(id) => VideoImageRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use test_data::web::image_urls::{SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
|
||||
|
||||
// ── Wire-format shape tests ──
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_text_only() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "a cat dancing".to_string(),
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
duration: Some(5),
|
||||
resolution: Some("720p".to_string()),
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"a cat dancing\""));
|
||||
assert!(json.contains("\"duration\":5"));
|
||||
assert!(json.contains("\"resolution\":\"720p\""));
|
||||
assert!(!json.contains("\"image\""));
|
||||
assert!(!json.contains("\"reference_images\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_image_to_video() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "animate this".to_string(),
|
||||
model: None,
|
||||
image: Some(VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }),
|
||||
reference_images: None,
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"image\":{"));
|
||||
assert!(json.contains("\"url\":\"https://example.com/a.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_body_serializes_reference_to_video() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "<IMAGE_1> walking".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: Some(vec![
|
||||
VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None },
|
||||
VideoImageRef { url: None, file_id: Some("file_xyz".to_string()) },
|
||||
]),
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"reference_images\":["));
|
||||
assert!(json.contains("\"file_id\":\"file_xyz\""));
|
||||
}
|
||||
|
||||
// ── Public Request shape ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = VideoGenerationArgs {
|
||||
api_key: &key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "p".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo),
|
||||
image: Some(VideoImageSource::Url("u".to_string())),
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
duration: Some(8),
|
||||
resolution: Some(VideoResolution::SevenTwentyP),
|
||||
user: None,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"),
|
||||
"serialized request must not contain the API key. got: {}", json);
|
||||
assert!(json.contains("\"prompt\":\"p\""));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-video\""));
|
||||
assert!(json.contains("\"image\":{\"Url\":\"u\"}"));
|
||||
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
|
||||
assert!(json.contains("\"resolution\":\"720p\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_plus_reference_images_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "x".to_string(),
|
||||
model: None,
|
||||
image: Some(VideoImageSource::Url("u".to_string())),
|
||||
reference_images: Some(vec![VideoImageSource::Url("v".to_string())]),
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
user: None,
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "d97415a1-5796-b7ec-379f-4e6819e08fdf" }"#;
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "d97415a1-5796-b7ec-379f-4e6819e08fdf");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_generation_text_only() -> AnyhowResult<()> {
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "A glowing crystal rocket launching from Mars".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
duration: Some(5),
|
||||
resolution: Some(VideoResolution::FourEightyP),
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Video request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-frame-to-video: a single source image becomes the opening frame
|
||||
/// and the prompt drives the animation. Uses `image` (NOT
|
||||
/// `reference_images`) — that's the xAI distinction between
|
||||
/// image-to-video and reference-to-video.
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_generation_first_frame_to_video() -> AnyhowResult<()> {
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(),
|
||||
model: None,
|
||||
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
duration: Some(5),
|
||||
resolution: Some(VideoResolution::SevenTwentyP),
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("First-frame video request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reference-to-video: multiple source images influence the generated
|
||||
/// video. xAI accepts up to 3. The prompt references them by index using
|
||||
/// `<IMAGE_1>`, `<IMAGE_2>`, … placeholders per xAI's reference-to-video
|
||||
/// docs.
|
||||
///
|
||||
/// Different aspect ratio than the first-frame test (portrait 9:16
|
||||
/// instead of landscape 16:9) so the two live tests don't accidentally
|
||||
/// share a code path.
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_generation_reference_images() -> AnyhowResult<()> {
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The dogs from <IMAGE_1> in the scene from <IMAGE_2>. Make them play together.".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: Some(vec![
|
||||
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
|
||||
VideoImageSource::Url(SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL.to_string()),
|
||||
]),
|
||||
aspect_ratio: Some(VideoAspectRatio::Portrait9x16),
|
||||
duration: Some(5),
|
||||
resolution: Some(VideoResolution::SevenTwentyP),
|
||||
user: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Reference-to-video request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod video_status;
|
||||
pub(crate) mod request_types;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusResponseBody {
|
||||
pub status: String,
|
||||
|
||||
pub progress: Option<u8>,
|
||||
|
||||
pub model: Option<String>,
|
||||
|
||||
pub video: Option<VideoStatusVideo>,
|
||||
|
||||
pub error: Option<VideoStatusError>,
|
||||
|
||||
pub usage: Option<VideoStatusUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusVideo {
|
||||
pub url: Option<String>,
|
||||
pub duration: Option<u32>,
|
||||
pub respect_moderation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusUsage {
|
||||
pub cost_in_usd_ticks: Option<u64>,
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_status::request_types::*;
|
||||
use crate::api::requests::xai_host::XAI_API_BASE_URL;
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`video_status`]. Borrows the API key separately from
|
||||
/// the request body so callers can log/save [`VideoStatusRequest`] without
|
||||
/// leaking the credential.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoStatusArgs<'a> {
|
||||
pub api_key: &'a GrokApiKey,
|
||||
pub request: VideoStatusRequest,
|
||||
}
|
||||
|
||||
/// The material part of a video-status poll. Derives [`Serialize`] so it
|
||||
/// can be persisted to a log or audit store independently of the API key.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VideoStatusRequest {
|
||||
/// The `request_id` returned by a prior `video_generation`,
|
||||
/// `video_edit`, or `video_extension` call.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VideoStatusState {
|
||||
/// Still working.
|
||||
Pending,
|
||||
/// Finished successfully — `video.url` is populated.
|
||||
Done,
|
||||
/// xAI couldn't render this — see `VideoJobFailed` in the error path.
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoStatusSuccess {
|
||||
pub state: VideoStatusState,
|
||||
|
||||
/// 0–100, may be `None` while still queued.
|
||||
pub progress: Option<u8>,
|
||||
|
||||
/// The model that fulfilled the request.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Populated when `state == Done`.
|
||||
pub video: Option<VideoOutputInfo>,
|
||||
|
||||
/// xAI cost reporting (in micro-dollars / "USD ticks").
|
||||
pub cost_in_usd_ticks: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoOutputInfo {
|
||||
/// Temporary xAI-hosted URL, or the caller-supplied upload URL once xAI has
|
||||
/// finished uploading.
|
||||
pub url: Option<String>,
|
||||
pub duration: Option<u32>,
|
||||
pub respect_moderation: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation /
|
||||
/// edit / extension job for its current status.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(VideoStatusSuccess { state: Pending, .. })` while the job is in flight
|
||||
/// - `Ok(VideoStatusSuccess { state: Done, video: Some(..), .. })` when finished
|
||||
/// - `Err(GrokSpecificApiError::VideoJobFailed { code, message })` when xAI
|
||||
/// reports the job as failed
|
||||
/// - `Err(GrokSpecificApiError::VideoJobExpired)` when the job expired
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSuccess, GrokError> {
|
||||
let req = args.request;
|
||||
let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, req.request_id);
|
||||
|
||||
info!("Grok video_status: request_id={}", req.request_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.get(&url)
|
||||
.header("Authorization", bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_status response: http_status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
classify_status_field(&parsed)
|
||||
}
|
||||
|
||||
fn classify_status_field(parsed: &VideoStatusResponseBody) -> Result<VideoStatusSuccess, GrokError> {
|
||||
let state = match parsed.status.as_str() {
|
||||
"pending" | "queued" | "in_progress" | "processing" => VideoStatusState::Pending,
|
||||
"done" | "completed" | "succeeded" => VideoStatusState::Done,
|
||||
"failed" => {
|
||||
let (code, message) = parsed.error.as_ref()
|
||||
.map(|e| (e.code.clone(), e.message.clone()))
|
||||
.unwrap_or_else(|| ("unknown".to_string(), "video job failed without details".to_string()));
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobFailed { code, message }.into());
|
||||
}
|
||||
"expired" => {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobExpired.into());
|
||||
}
|
||||
other => {
|
||||
return Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: reqwest::StatusCode::OK,
|
||||
body: format!("unknown video status: {:?}", other),
|
||||
}.into());
|
||||
}
|
||||
};
|
||||
|
||||
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
|
||||
url: v.url.clone(),
|
||||
duration: v.duration,
|
||||
respect_moderation: v.respect_moderation,
|
||||
});
|
||||
|
||||
Ok(VideoStatusSuccess {
|
||||
state,
|
||||
progress: parsed.progress,
|
||||
model: parsed.model.clone(),
|
||||
video,
|
||||
cost_in_usd_ticks: parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn pending_response_classifies_as_pending() {
|
||||
let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed).unwrap();
|
||||
assert_eq!(result.state, VideoStatusState::Pending);
|
||||
assert_eq!(result.progress, Some(12));
|
||||
assert_eq!(result.model.as_deref(), Some("grok-imagine-video"));
|
||||
assert!(result.video.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_response_classifies_as_done_with_video() {
|
||||
let json = r#"{
|
||||
"status": "done",
|
||||
"progress": 100,
|
||||
"model": "grok-imagine-video",
|
||||
"video": { "url": "https://imagine.x.ai/v.mp4", "duration": 5, "respect_moderation": true },
|
||||
"usage": { "cost_in_usd_ticks": 42 }
|
||||
}"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed).unwrap();
|
||||
assert_eq!(result.state, VideoStatusState::Done);
|
||||
let video = result.video.unwrap();
|
||||
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
|
||||
assert_eq!(video.duration, Some(5));
|
||||
assert_eq!(video.respect_moderation, Some(true));
|
||||
assert_eq!(result.cost_in_usd_ticks, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_response_returns_video_job_failed_error() {
|
||||
let json = r#"{
|
||||
"status": "failed",
|
||||
"progress": 50,
|
||||
"error": { "code": "invalid_argument", "message": "duration too long" }
|
||||
}"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
|
||||
assert_eq!(code, "invalid_argument");
|
||||
assert_eq!(message, "duration too long");
|
||||
}
|
||||
other => panic!("expected VideoJobFailed, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_response_without_error_object_uses_unknown() {
|
||||
let json = r#"{ "status": "failed", "progress": 0 }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, .. }) => {
|
||||
assert_eq!(code, "unknown");
|
||||
}
|
||||
other => panic!("expected VideoJobFailed(unknown), got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_response_returns_expired_error() {
|
||||
let json = r#"{ "status": "expired" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_field_returns_generic_error() {
|
||||
let json = r#"{ "status": "magnificent" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(_)));
|
||||
}
|
||||
|
||||
// ── Public Request shape ──
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_api_key() {
|
||||
let key = GrokApiKey::new("secret_must_not_leak".to_string());
|
||||
let args = VideoStatusArgs {
|
||||
api_key: &key,
|
||||
request: VideoStatusRequest {
|
||||
request_id: "req_abc".to_string(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&args.request).unwrap();
|
||||
assert!(!json.contains("secret_must_not_leak"));
|
||||
assert!(json.contains("\"request_id\":\"req_abc\""));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key + an existing request_id
|
||||
async fn live_test_video_status_unknown_id_is_not_found() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
// Random UUID — should yield 404.
|
||||
let result = video_status(VideoStatusArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoStatusRequest {
|
||||
request_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||
},
|
||||
}).await;
|
||||
|
||||
println!("Result: {:?}", result.as_ref().map(|s| (&s.state, s.progress)));
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls a known request_id and prints the result. Doesn't assert a
|
||||
/// specific state — depending on when the test runs, the job may be
|
||||
/// pending, done, failed, or expired. The point is to exercise the
|
||||
/// happy-path against the real xAI API.
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn live_test_video_status_known_id() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_status(VideoStatusArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoStatusRequest {
|
||||
request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
|
||||
},
|
||||
}).await;
|
||||
|
||||
match &result {
|
||||
Ok(s) => println!(
|
||||
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
|
||||
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
|
||||
),
|
||||
Err(e) => println!("status error: {:?}", e),
|
||||
}
|
||||
// No assertion on the specific state — the job may have completed,
|
||||
// failed, or expired by the time this runs. We just want to confirm
|
||||
// the request_id was accepted and parsed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls another known request_id. Same lax assertions as the test above
|
||||
/// — state varies with time.
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn live_test_video_status_known_id_2() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_status(VideoStatusArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoStatusRequest {
|
||||
request_id: "7b2fa3dc-8e63-9100-b117-faffb3178773".to_string(),
|
||||
},
|
||||
}).await;
|
||||
|
||||
match &result {
|
||||
Ok(s) => println!(
|
||||
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
|
||||
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
|
||||
),
|
||||
Err(e) => println!("status error: {:?}", e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls yet another known request_id.
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn live_test_video_status_known_id_3() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_status(VideoStatusArgs {
|
||||
api_key: &api_key,
|
||||
request: VideoStatusRequest {
|
||||
//request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
|
||||
request_id: "4eea215a-9d21-9261-be1a-324409ec22c5".to_string(),
|
||||
},
|
||||
}).await;
|
||||
|
||||
match &result {
|
||||
Ok(s) => println!(
|
||||
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
|
||||
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
|
||||
),
|
||||
Err(e) => println!("status error: {:?}", e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Base URL for the xAI Imagine API. Every request is `{XAI_API_BASE_URL}/v1/...`.
|
||||
pub(crate) const XAI_API_BASE_URL: &str = "https://api.x.ai";
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
//! Cost-estimation trait for xAI (Grok) API requests.
|
||||
//!
|
||||
//! Implementations live next to each Request type in
|
||||
//! `api/requests/<category>/<endpoint>/cost.rs`. Implemented on the public
|
||||
//! `*Request` type (NOT the `*Args` wrapper), since the request body is what
|
||||
//! drives the bill.
|
||||
//!
|
||||
//! All math is performed in mills (tenths of a US cent), which is the
|
||||
//! natural unit for xAI's published rates — an image edit at the cheap tier
|
||||
//! is $0.002/img = 2 mills, well below cent resolution.
|
||||
|
||||
/// Cost in US cents (1¢ = 1 unit).
|
||||
pub type UsdCents = u64;
|
||||
|
||||
/// Cost in mills (1¢ = 10 mills). Use this when sub-cent precision matters
|
||||
/// — many xAI rates are below 1 cent.
|
||||
pub type UsdMills = u64;
|
||||
|
||||
/// Implemented on `*Request` types to estimate the cost of a Grok API call
|
||||
/// without actually making it.
|
||||
///
|
||||
/// The estimate is derived from fields present in the request body. Some
|
||||
/// endpoints (notably `video_edit` and the input-cost portion of
|
||||
/// `video_extension`) depend on the source video's duration, which is NOT
|
||||
/// part of the request. Those impls document their limitations.
|
||||
pub trait GrokRequestCostCalculator {
|
||||
/// Estimated cost rounded up to the nearest whole cent.
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Default impl: derive from mills via ceiling division.
|
||||
self.calculate_cost_in_mills().div_ceil(10)
|
||||
}
|
||||
|
||||
/// Estimated cost in mills (tenths of a cent). This is the canonical
|
||||
/// method — implementations should override this one.
|
||||
fn calculate_cost_in_mills(&self) -> UsdMills;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod grok_request_cost_calculator_trait;
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Aspect ratios accepted by the xAI image endpoints.
|
||||
///
|
||||
/// The wire format is `"<width>:<height>"` — e.g. `Square` serialises as
|
||||
/// `"1:1"`, `Cinematic20x9` as `"20:9"`. `Auto` defers to xAI to pick.
|
||||
///
|
||||
/// xAI documents this closed set; see
|
||||
/// <https://docs.x.ai/developers/rest-api-reference/inference/images>.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageAspectRatio {
|
||||
/// 1:1 square.
|
||||
Square,
|
||||
|
||||
/// 3:4 portrait (slight tall).
|
||||
Portrait3x4,
|
||||
|
||||
/// 4:3 landscape (slight wide).
|
||||
Landscape4x3,
|
||||
|
||||
/// 9:16 portrait (phone/vertical-video).
|
||||
Portrait9x16,
|
||||
|
||||
/// 16:9 landscape (widescreen / YouTube).
|
||||
Landscape16x9,
|
||||
|
||||
/// 2:3 portrait (photo).
|
||||
Portrait2x3,
|
||||
|
||||
/// 3:2 landscape (photo).
|
||||
Landscape3x2,
|
||||
|
||||
/// 9:19.5 portrait (modern phone screens).
|
||||
Portrait9x19_5,
|
||||
|
||||
/// 19.5:9 landscape (modern phone screens, rotated).
|
||||
Landscape19_5x9,
|
||||
|
||||
/// 9:20 portrait (taller phone).
|
||||
Portrait9x20,
|
||||
|
||||
/// 20:9 landscape (taller phone, rotated).
|
||||
Landscape20x9,
|
||||
|
||||
/// 1:2 ultra-portrait.
|
||||
Portrait1x2,
|
||||
|
||||
/// 2:1 ultra-landscape.
|
||||
Landscape2x1,
|
||||
|
||||
/// Let xAI pick the aspect ratio (typically matches the source image for edits).
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl ImageAspectRatio {
|
||||
/// Wire representation — the exact string xAI expects in the
|
||||
/// `"aspect_ratio"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Square => "1:1",
|
||||
Self::Portrait3x4 => "3:4",
|
||||
Self::Landscape4x3 => "4:3",
|
||||
Self::Portrait9x16 => "9:16",
|
||||
Self::Landscape16x9 => "16:9",
|
||||
Self::Portrait2x3 => "2:3",
|
||||
Self::Landscape3x2 => "3:2",
|
||||
Self::Portrait9x19_5 => "9:19.5",
|
||||
Self::Landscape19_5x9 => "19.5:9",
|
||||
Self::Portrait9x20 => "9:20",
|
||||
Self::Landscape20x9 => "20:9",
|
||||
Self::Portrait1x2 => "1:2",
|
||||
Self::Landscape2x1 => "2:1",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("1:1", "16:9", "auto", …) rather than the
|
||||
// default external-tag enum form. Lets request structs round-trip cleanly
|
||||
// through a log/audit pipeline.
|
||||
impl serde::Serialize for ImageAspectRatio {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_variant_maps_to_a_distinct_string() {
|
||||
let all = [
|
||||
ImageAspectRatio::Square,
|
||||
ImageAspectRatio::Portrait3x4,
|
||||
ImageAspectRatio::Landscape4x3,
|
||||
ImageAspectRatio::Portrait9x16,
|
||||
ImageAspectRatio::Landscape16x9,
|
||||
ImageAspectRatio::Portrait2x3,
|
||||
ImageAspectRatio::Landscape3x2,
|
||||
ImageAspectRatio::Portrait9x19_5,
|
||||
ImageAspectRatio::Landscape19_5x9,
|
||||
ImageAspectRatio::Portrait9x20,
|
||||
ImageAspectRatio::Landscape20x9,
|
||||
ImageAspectRatio::Portrait1x2,
|
||||
ImageAspectRatio::Landscape2x1,
|
||||
ImageAspectRatio::Auto,
|
||||
];
|
||||
let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
|
||||
strs.sort();
|
||||
strs.dedup();
|
||||
assert_eq!(strs.len(), 14, "expected 14 distinct wire values, got {:?}", strs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageAspectRatio::Square.as_str(), "1:1");
|
||||
assert_eq!(ImageAspectRatio::Landscape16x9.as_str(), "16:9");
|
||||
assert_eq!(ImageAspectRatio::Portrait9x19_5.as_str(), "9:19.5");
|
||||
assert_eq!(ImageAspectRatio::Landscape19_5x9.as_str(), "19.5:9");
|
||||
assert_eq!(ImageAspectRatio::Auto.as_str(), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&ImageAspectRatio::Square).unwrap(), "\"1:1\"");
|
||||
assert_eq!(serde_json::to_string(&ImageAspectRatio::Portrait9x19_5).unwrap(), "\"9:19.5\"");
|
||||
assert_eq!(serde_json::to_string(&ImageAspectRatio::Auto).unwrap(), "\"auto\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/// Models accepted by the xAI image endpoints (`/v1/images/generations` and
|
||||
/// `/v1/images/edits`). Both endpoints accept all variants here.
|
||||
///
|
||||
/// xAI may add or deprecate models faster than this crate ships releases, so
|
||||
/// the [`ImageModel::Custom`] escape hatch lets callers pass an arbitrary
|
||||
/// identifier without waiting for a code change.
|
||||
///
|
||||
/// Docs:
|
||||
/// - Models list: <https://docs.x.ai/docs/models>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageModel {
|
||||
/// `grok-imagine-image` — the standard image model. $0.02 / image as of
|
||||
/// the docs at time of writing; lower cost and faster than the `-quality`
|
||||
/// variant, at the expense of fidelity.
|
||||
GrokImagineImage,
|
||||
|
||||
/// `grok-imagine-image-quality` — the higher-quality image model. $0.05 /
|
||||
/// image as of the docs at time of writing. xAI recommends this as the
|
||||
/// default for new requests.
|
||||
GrokImagineImageQuality,
|
||||
|
||||
/// Escape hatch for model identifiers not yet enumerated here.
|
||||
///
|
||||
/// Useful for forward compat and for legacy/deprecated identifiers (e.g.
|
||||
/// `grok-imagine-image-pro`, deprecated as of 2026-05-15) that still
|
||||
/// function until xAI removes them.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
/// Wire representation — the exact string xAI expects in the `"model"` field.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GrokImagineImage => "grok-imagine-image",
|
||||
Self::GrokImagineImageQuality => "grok-imagine-image-quality",
|
||||
Self::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("grok-imagine-image", "grok-imagine-image-quality",
|
||||
// or the Custom inner string) rather than the default external-tag enum format.
|
||||
// Lets `ImageGenerationRequest` / `ImageEditRequest` round-trip through any
|
||||
// log/audit pipeline as readable JSON.
|
||||
impl serde::Serialize for ImageModel {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_model_serializes() {
|
||||
assert_eq!(ImageModel::GrokImagineImage.as_str(), "grok-imagine-image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_model_serializes() {
|
||||
assert_eq!(ImageModel::GrokImagineImageQuality.as_str(), "grok-imagine-image-quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_model_passes_through() {
|
||||
let m = ImageModel::Custom("grok-imagine-image-future".to_string());
|
||||
assert_eq!(m.as_str(), "grok-imagine-image-future");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_pro_model_still_usable_via_custom() {
|
||||
let m = ImageModel::Custom("grok-imagine-image-pro".to_string());
|
||||
assert_eq!(m.as_str(), "grok-imagine-image-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_and_quality_are_distinct_strings() {
|
||||
assert_ne!(
|
||||
ImageModel::GrokImagineImage.as_str(),
|
||||
ImageModel::GrokImagineImageQuality.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
let m = ImageModel::GrokImagineImageQuality;
|
||||
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-quality\"");
|
||||
let m = ImageModel::GrokImagineImage;
|
||||
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image\"");
|
||||
let m = ImageModel::Custom("grok-imagine-image-pro".to_string());
|
||||
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-image-pro\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Output resolution for xAI image endpoints.
|
||||
///
|
||||
/// xAI's actual pixel counts depend on the chosen `aspect_ratio` — the `1k`
|
||||
/// and `2k` labels denote the *target* size class (roughly 1024-px and
|
||||
/// 2048-px on the longest edge). When omitted, xAI falls back to its server
|
||||
/// default (currently 1k as of these docs).
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageResolution {
|
||||
/// `"1k"` — ~1024-px class (faster, cheaper).
|
||||
OneK,
|
||||
/// `"2k"` — ~2048-px class (higher fidelity, slower, more expensive).
|
||||
TwoK,
|
||||
}
|
||||
|
||||
impl ImageResolution {
|
||||
/// Wire representation — the exact string xAI expects in the `"resolution"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OneK => "1k",
|
||||
Self::TwoK => "2k",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("1k", "2k").
|
||||
impl serde::Serialize for ImageResolution {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageResolution::OneK.as_str(), "1k");
|
||||
assert_eq!(ImageResolution::TwoK.as_str(), "2k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&ImageResolution::OneK).unwrap(), "\"1k\"");
|
||||
assert_eq!(serde_json::to_string(&ImageResolution::TwoK).unwrap(), "\"2k\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Wire format for returned images.
|
||||
///
|
||||
/// - `Url` (default) — xAI returns a temporary CDN URL in `data[].url`.
|
||||
/// - `B64Json` — xAI returns the raw image bytes inlined as base64 in
|
||||
/// `data[].b64_json`. Use this when you can't / don't want to make a
|
||||
/// follow-up HTTP request to fetch the image.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageResponseFormat {
|
||||
Url,
|
||||
B64Json,
|
||||
}
|
||||
|
||||
impl ImageResponseFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::B64Json => "b64_json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("url" or "b64_json").
|
||||
impl serde::Serialize for ImageResponseFormat {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageResponseFormat::Url.as_str(), "url");
|
||||
assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&ImageResponseFormat::Url).unwrap(), "\"url\"");
|
||||
assert_eq!(serde_json::to_string(&ImageResponseFormat::B64Json).unwrap(), "\"b64_json\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Shared image-endpoint enums (model, aspect ratio, resolution, response format).
|
||||
//!
|
||||
//! These are reused by both [`crate::api::requests::image_generation`] and
|
||||
//! [`crate::api::requests::image_edit`] so callers can pass type-safe values
|
||||
//! instead of memorising the allowed strings.
|
||||
|
||||
pub mod image_aspect_ratio;
|
||||
pub mod image_model;
|
||||
pub mod image_resolution;
|
||||
pub mod image_response_format;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod image_types;
|
||||
pub mod video_types;
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Shared video-endpoint enums (model, aspect ratio, resolution).
|
||||
//!
|
||||
//! `VideoModel` is reused by every video endpoint
|
||||
//! ([`crate::api::requests::videos::video_generation`],
|
||||
//! [`crate::api::requests::videos::video_edit`],
|
||||
//! [`crate::api::requests::videos::video_extension`]).
|
||||
//!
|
||||
//! `VideoAspectRatio` and `VideoResolution` are accepted only by
|
||||
//! [`crate::api::requests::videos::video_generation`] — the edit and
|
||||
//! extension endpoints inherit dimensions from the source video.
|
||||
|
||||
pub mod video_aspect_ratio;
|
||||
pub mod video_model;
|
||||
pub mod video_resolution;
|
||||
@@ -0,0 +1,101 @@
|
||||
/// Aspect ratios accepted by `/v1/videos/generations`.
|
||||
///
|
||||
/// The wire format is `"<width>:<height>"` — e.g. [`VideoAspectRatio::Square`]
|
||||
/// serialises as `"1:1"`, [`VideoAspectRatio::Landscape16x9`] as `"16:9"`.
|
||||
///
|
||||
/// Note: the video endpoint accepts FEWER aspect ratios than image endpoints
|
||||
/// — there's no `auto`, no ultra-portrait `9:19.5` or `9:20`, no `1:2`/`2:1`.
|
||||
/// xAI documents this closed set of 7.
|
||||
///
|
||||
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
|
||||
/// don't accept this field — they inherit the source video's dimensions.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/videos>
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VideoAspectRatio {
|
||||
/// 1:1 square.
|
||||
Square,
|
||||
|
||||
/// 16:9 landscape (widescreen / YouTube). xAI default.
|
||||
Landscape16x9,
|
||||
|
||||
/// 9:16 portrait (phone / vertical video).
|
||||
Portrait9x16,
|
||||
|
||||
/// 4:3 landscape (classic TV).
|
||||
Landscape4x3,
|
||||
|
||||
/// 3:4 portrait.
|
||||
Portrait3x4,
|
||||
|
||||
/// 3:2 landscape (photo).
|
||||
Landscape3x2,
|
||||
|
||||
/// 2:3 portrait (photo).
|
||||
Portrait2x3,
|
||||
}
|
||||
|
||||
impl VideoAspectRatio {
|
||||
/// Wire representation — the exact string xAI expects in the
|
||||
/// `"aspect_ratio"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Square => "1:1",
|
||||
Self::Landscape16x9 => "16:9",
|
||||
Self::Portrait9x16 => "9:16",
|
||||
Self::Landscape4x3 => "4:3",
|
||||
Self::Portrait3x4 => "3:4",
|
||||
Self::Landscape3x2 => "3:2",
|
||||
Self::Portrait2x3 => "2:3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("1:1", "16:9", …) rather than the default
|
||||
// external-tag enum form.
|
||||
impl serde::Serialize for VideoAspectRatio {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_variant_maps_to_a_distinct_string() {
|
||||
let all = [
|
||||
VideoAspectRatio::Square,
|
||||
VideoAspectRatio::Landscape16x9,
|
||||
VideoAspectRatio::Portrait9x16,
|
||||
VideoAspectRatio::Landscape4x3,
|
||||
VideoAspectRatio::Portrait3x4,
|
||||
VideoAspectRatio::Landscape3x2,
|
||||
VideoAspectRatio::Portrait2x3,
|
||||
];
|
||||
let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
|
||||
strs.sort();
|
||||
strs.dedup();
|
||||
assert_eq!(strs.len(), 7, "expected 7 distinct wire values, got {:?}", strs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(VideoAspectRatio::Square.as_str(), "1:1");
|
||||
assert_eq!(VideoAspectRatio::Landscape16x9.as_str(), "16:9");
|
||||
assert_eq!(VideoAspectRatio::Portrait9x16.as_str(), "9:16");
|
||||
assert_eq!(VideoAspectRatio::Landscape4x3.as_str(), "4:3");
|
||||
assert_eq!(VideoAspectRatio::Portrait3x4.as_str(), "3:4");
|
||||
assert_eq!(VideoAspectRatio::Landscape3x2.as_str(), "3:2");
|
||||
assert_eq!(VideoAspectRatio::Portrait2x3.as_str(), "2:3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&VideoAspectRatio::Landscape16x9).unwrap(), "\"16:9\"");
|
||||
assert_eq!(serde_json::to_string(&VideoAspectRatio::Square).unwrap(), "\"1:1\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Models accepted by the xAI video endpoints (`/v1/videos/generations`,
|
||||
/// `/v1/videos/edits`, `/v1/videos/extensions`). All three endpoints accept
|
||||
/// the same model identifier.
|
||||
///
|
||||
/// xAI may add or deprecate models faster than this crate ships releases, so
|
||||
/// the [`VideoModel::Custom`] escape hatch lets callers pass an arbitrary
|
||||
/// identifier without waiting for a code change.
|
||||
///
|
||||
/// Docs:
|
||||
/// - Models list: <https://docs.x.ai/docs/models>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/editing>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/extension>
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VideoModel {
|
||||
/// `grok-imagine-video` — the (currently sole) video model. Used by
|
||||
/// generation, editing, and extension endpoints.
|
||||
GrokImagineVideo,
|
||||
|
||||
/// Escape hatch for model identifiers not yet enumerated here.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl VideoModel {
|
||||
/// Wire representation — the exact string xAI expects in the `"model"` field.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GrokImagineVideo => "grok-imagine-video",
|
||||
Self::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
|
||||
// string) rather than the default external-tag enum format. Lets the public
|
||||
// VideoGeneration/Edit/ExtensionRequest types round-trip through any
|
||||
// log/audit pipeline as readable JSON.
|
||||
impl serde::Serialize for VideoModel {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_model_serializes() {
|
||||
assert_eq!(VideoModel::GrokImagineVideo.as_str(), "grok-imagine-video");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_model_passes_through() {
|
||||
let m = VideoModel::Custom("grok-imagine-video-future".to_string());
|
||||
assert_eq!(m.as_str(), "grok-imagine-video-future");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&VideoModel::GrokImagineVideo).unwrap(), "\"grok-imagine-video\"");
|
||||
let m = VideoModel::Custom("grok-imagine-video-v2".to_string());
|
||||
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-video-v2\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/// Output resolution for `/v1/videos/generations`.
|
||||
///
|
||||
/// Currently `480p` (server default) and `720p` are the only supported output
|
||||
/// resolutions. The xAI REST schema also lists `"1080p"`, but the capability
|
||||
/// page contradicts this — it documents that "a 1080p input will be downsized
|
||||
/// to 720p", indicating 720p is the actual output cap. We follow the
|
||||
/// capability page since it describes observed behavior rather than just the
|
||||
/// wire schema.
|
||||
///
|
||||
/// If/when xAI removes the cap and 1080p is genuinely produced as output,
|
||||
/// add a `TenEightyP` variant here.
|
||||
///
|
||||
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
|
||||
/// don't accept this field — they inherit the source video's resolution
|
||||
/// (capped at 720p).
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/videos>
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VideoResolution {
|
||||
/// `"480p"` — standard definition, faster processing. Server default.
|
||||
FourEightyP,
|
||||
|
||||
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
impl VideoResolution {
|
||||
/// Wire representation — the exact string xAI expects in the
|
||||
/// `"resolution"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FourEightyP => "480p",
|
||||
Self::SevenTwentyP => "720p",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("480p", "720p", "1080p").
|
||||
impl serde::Serialize for VideoResolution {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(VideoResolution::FourEightyP.as_str(), "480p");
|
||||
assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::FourEightyP).unwrap(), "\"480p\"");
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::SevenTwentyP).unwrap(), "\"720p\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Holds the API key for authenticating with the Grok (xAI Imagine) API.
|
||||
///
|
||||
/// Sent on every request as `Authorization: Bearer <api_key>`.
|
||||
#[derive(Clone)]
|
||||
pub struct GrokApiKey {
|
||||
pub(crate) api_key: String,
|
||||
}
|
||||
|
||||
impl GrokApiKey {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self { api_key }
|
||||
}
|
||||
}
|
||||
|
||||
// Debug is redacted so the key can't end up in a log line via accidental
|
||||
// `{:?}` on an enclosing args struct.
|
||||
impl std::fmt::Debug for GrokApiKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "GrokApiKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod grok_api_key;
|
||||
@@ -0,0 +1,196 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
/// Convert a non-2xx HTTP response into a `GrokError`.
|
||||
///
|
||||
/// On 2xx returns `Ok(())`; on any error status returns the most-specific
|
||||
/// `GrokError` variant we can classify from the status code and (optional)
|
||||
/// response body.
|
||||
pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), GrokError> {
|
||||
if status_code.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match status_code {
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited.into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 403 is Forbidden unless the body indicates content moderation.
|
||||
if status_code == StatusCode::FORBIDDEN {
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
}
|
||||
return Err(GrokSpecificApiError::Forbidden.into());
|
||||
}
|
||||
|
||||
// 400 Bad Request is usually a malformed body or moderation rejection.
|
||||
if status_code == StatusCode::BAD_REQUEST {
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::BadRequest(message).into());
|
||||
}
|
||||
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
|
||||
}
|
||||
|
||||
// Everything else falls through to the generic catch-all.
|
||||
Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body: maybe_body.unwrap_or("").to_string(),
|
||||
}.into())
|
||||
}
|
||||
|
||||
/// xAI returns OpenAI-compatible error envelopes:
|
||||
///
|
||||
/// ```json
|
||||
/// { "error": { "code": "...", "message": "..." } }
|
||||
/// ```
|
||||
///
|
||||
/// Extract `error.message` if present.
|
||||
fn extract_xai_error_message(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Heuristic: does this response body mention content moderation? xAI's
|
||||
/// safety filter rejections include words like "moderation", "safety",
|
||||
/// "content_policy", or "blocked".
|
||||
fn body_indicates_moderation(body: &str) -> bool {
|
||||
let lower = body.to_lowercase();
|
||||
lower.contains("moderat") // moderation / moderated
|
||||
|| lower.contains("safety")
|
||||
|| lower.contains("content_policy")
|
||||
|| lower.contains("content policy")
|
||||
|| lower.contains("blocked by")
|
||||
|| lower.contains("disallowed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Success ──
|
||||
|
||||
#[test]
|
||||
fn ok_200_returns_ok() {
|
||||
assert!(classify_grok_http_error(StatusCode::OK, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_201_returns_ok() {
|
||||
assert!(classify_grok_http_error(StatusCode::CREATED, Some("{}")).is_ok());
|
||||
}
|
||||
|
||||
// ── Direct status mappings ──
|
||||
|
||||
#[test]
|
||||
fn unauthorized_401() {
|
||||
let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_402() {
|
||||
let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_404() {
|
||||
let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_429() {
|
||||
let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited)));
|
||||
}
|
||||
|
||||
// ── 403 Forbidden ──
|
||||
|
||||
#[test]
|
||||
fn plain_403_is_forbidden() {
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_403_is_prompt_moderated() {
|
||||
let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── 400 Bad Request ──
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_extracts_message() {
|
||||
let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "unknown field 'foo'");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_unparseable_body_falls_back_to_raw() {
|
||||
let body = "definitely not json";
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "definitely not json");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_no_body_is_empty_string() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert!(msg.is_empty());
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_400_is_prompt_moderated() {
|
||||
// Some APIs return moderation as 400 rather than 403.
|
||||
let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── Generic fallback ──
|
||||
|
||||
#[test]
|
||||
fn server_error_500_is_generic() {
|
||||
let err = classify_grok_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some("oops")).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_gateway_502_is_generic() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_GATEWAY, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Errors that happen entirely on the client side — before or independent of
|
||||
/// the HTTP request reaching xAI's servers.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokClientError {
|
||||
/// No API key is present.
|
||||
NoApiKeyPresent,
|
||||
|
||||
/// An error was encountered building the reqwest client.
|
||||
ReqwestClientError(reqwest::Error),
|
||||
|
||||
/// The request body could not be serialized to JSON.
|
||||
RequestSerializationError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error for GrokClientError {}
|
||||
|
||||
impl Display for GrokClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoApiKeyPresent => write!(f, "No API key present."),
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
|
||||
Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::error::Error;
|
||||
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
/// Top-level error for the Grok API client. Sum of every failure mode any
|
||||
/// endpoint can produce.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokError {
|
||||
Client(GrokClientError),
|
||||
ApiSpecific(GrokSpecificApiError),
|
||||
ApiGeneric(GrokGenericApiError),
|
||||
}
|
||||
|
||||
impl Error for GrokError {}
|
||||
|
||||
impl std::fmt::Display for GrokError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "GrokClientError: {:?}", e),
|
||||
Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e),
|
||||
Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokClientError> for GrokError {
|
||||
fn from(error: GrokClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokSpecificApiError> for GrokError {
|
||||
fn from(error: GrokSpecificApiError) -> Self {
|
||||
Self::ApiSpecific(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokGenericApiError> for GrokError {
|
||||
fn from(error: GrokGenericApiError) -> Self {
|
||||
Self::ApiGeneric(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Grab-bag for server-side responses that don't map to a `GrokSpecificApiError`.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokGenericApiError {
|
||||
/// Response body didn't deserialize against the expected schema. Includes
|
||||
/// the original body so we can diagnose drift.
|
||||
SerdeResponseParseErrorWithBody(serde_json::Error, String),
|
||||
|
||||
/// An uncategorized bad HTTP response with status code and body.
|
||||
UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// An uncaught error from the HTTP client (e.g. network/IO).
|
||||
ReqwestError(reqwest::Error),
|
||||
}
|
||||
|
||||
impl Error for GrokGenericApiError {}
|
||||
|
||||
impl Display for GrokGenericApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SerdeResponseParseErrorWithBody(err, body) => {
|
||||
write!(f, "Failed to parse Grok API response body: {:?}. Body: {}", err, body)
|
||||
}
|
||||
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
|
||||
write!(f, "Uncategorized Grok API response: status code {}, body: {}", status_code, body)
|
||||
}
|
||||
Self::ReqwestError(err) => write!(f, "Reqwest error during Grok API call: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Well-known, classifiable error responses from the xAI Imagine API.
|
||||
///
|
||||
/// Distinguishes things like "your key is bad" from generic 5xx so callers can
|
||||
/// react appropriately (retry vs. surface to user vs. alert).
|
||||
#[derive(Debug)]
|
||||
pub enum GrokSpecificApiError {
|
||||
/// 401 Unauthorized — API key is missing, invalid, or revoked.
|
||||
Unauthorized,
|
||||
|
||||
/// 402 Payment Required / insufficient credits to fulfill the request.
|
||||
InsufficientCredits,
|
||||
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
|
||||
Forbidden,
|
||||
|
||||
/// 404 Not Found — typically returned for an unknown `request_id` on the
|
||||
/// video status endpoint.
|
||||
NotFound,
|
||||
|
||||
/// 429 Too Many Requests — rate limit exceeded.
|
||||
RateLimited,
|
||||
|
||||
/// The prompt was rejected by xAI's content moderation.
|
||||
PromptModerated(String),
|
||||
|
||||
/// 400 Bad Request — the request body shape was invalid (e.g. missing
|
||||
/// required field, unsupported model, invalid aspect ratio).
|
||||
BadRequest(String),
|
||||
|
||||
/// The video generation/edit/extension job finished with `status:"failed"`.
|
||||
///
|
||||
/// The `code` mirrors xAI's machine-readable error code
|
||||
/// (`invalid_argument`, `permission_denied`, `failed_precondition`,
|
||||
/// `internal_error`); `message` is the human-readable description.
|
||||
VideoJobFailed {
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The video job expired (poll status was `"expired"`).
|
||||
VideoJobExpired,
|
||||
}
|
||||
|
||||
impl Error for GrokSpecificApiError {}
|
||||
|
||||
impl Display for GrokSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unauthorized => write!(f, "Grok API: Unauthorized (invalid or missing API key)"),
|
||||
Self::InsufficientCredits => write!(f, "Grok API: Insufficient credits"),
|
||||
Self::Forbidden => write!(f, "Grok API: Forbidden"),
|
||||
Self::NotFound => write!(f, "Grok API: Not found"),
|
||||
Self::RateLimited => write!(f, "Grok API: Rate limited"),
|
||||
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
|
||||
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
|
||||
Self::VideoJobFailed { code, message } => {
|
||||
write!(f, "Grok API: Video job failed ({}): {}", code, message)
|
||||
}
|
||||
Self::VideoJobExpired => write!(f, "Grok API: Video job expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user