mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8fe7f3db | ||
|
|
54b172bf6f | ||
|
|
d7868c7da0 |
+1
-1
@@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response body for the omni-gen video cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenVideoCostResponse {
|
||||
pub success: bool,
|
||||
|
||||
|
||||
+13
-18
@@ -1,7 +1,6 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
@@ -27,17 +26,10 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
|
||||
// xAI's v1.5 model rejects text-to-video at the server. Bounce here rather
|
||||
// than spending an upstream call to learn the same thing.
|
||||
if request.start_frame_image_media_token.is_none()
|
||||
&& request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// NB: xAI's v1.5 model rejects text-to-video at the server, but that is
|
||||
// NOT enforced here — the cost path builds image-less requests to price
|
||||
// them while the user is still composing. The generate endpoints reject
|
||||
// image-less v1.5 requests at the handler layer (validate_when_image_required).
|
||||
let state = ArtcraftGrokImagineVideo1p5RequestState { request };
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(
|
||||
@@ -129,6 +121,15 @@ mod tests {
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_without_any_image_inputs() {
|
||||
// The cost path prices image-less requests while the user is still
|
||||
// composing; the generate endpoints enforce the image requirement.
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.start_frame_image_media_token.is_none());
|
||||
assert!(req.request.reference_image_media_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
@@ -180,12 +181,6 @@ mod tests {
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
// v1.5 requires an input image; tests that don't explicitly exercise the
|
||||
// image-handling paths get a default MediaFileToken start_frame so they
|
||||
// sail past the no-image guard in `build()`.
|
||||
if builder.start_frame.is_none() && builder.reference_images.is_none() {
|
||||
builder.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string())));
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
|
||||
+37
-24
@@ -9,7 +9,6 @@ use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVid
|
||||
use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
|
||||
|
||||
@@ -65,16 +64,10 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
// Defense in depth: `build()` already enforces this, but a state
|
||||
// constructed by hand must not get a quote for an operation xAI will
|
||||
// reject.
|
||||
if self.input_image_count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// NB: Image-less requests DO get a quote even though xAI's v1.5 rejects
|
||||
// text-to-video — the cost path prices requests the user is still
|
||||
// composing. The image requirement is enforced at send time (request.rs)
|
||||
// and by the generate endpoints.
|
||||
let base = self.base_cost_in_cents_for_batch();
|
||||
// 5% markup, ceil so the user is always charged enough to cover the
|
||||
// upstream grok cost regardless of rounding.
|
||||
@@ -204,24 +197,23 @@ mod tests {
|
||||
assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 150);
|
||||
}
|
||||
|
||||
/// v1.5 doesn't support text-to-video. The cost calculator must refuse
|
||||
/// to quote an image-less request even if a state is constructed
|
||||
/// directly.
|
||||
/// v1.5 doesn't support text-to-video at generation time, but the cost
|
||||
/// calculator still quotes an image-less request — the cost UI prices
|
||||
/// requests before the user has attached an image. The requirement is
|
||||
/// enforced at send time and by the generate endpoints.
|
||||
#[test]
|
||||
fn estimate_cost_rejects_text_only_request() {
|
||||
fn estimate_cost_quotes_text_only_request() {
|
||||
let state = ArtcraftGrokImagineVideo1p5CostState {
|
||||
resolution: CommonResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: 1,
|
||||
input_image_count: 0,
|
||||
};
|
||||
let err = state.estimate_cost().expect_err("text-only should be rejected");
|
||||
match err {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
|
||||
assert_eq!(field, "image_inputs");
|
||||
}
|
||||
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
|
||||
}
|
||||
let base = state.base_cost_in_cents_for_batch();
|
||||
let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
let got = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
|
||||
assert_eq!(got, want);
|
||||
assert!(got > 0);
|
||||
}
|
||||
|
||||
// (resolution, duration, image_count, batch) — every case has at least
|
||||
@@ -326,6 +318,28 @@ mod tests {
|
||||
.cost_in_usd_cents.unwrap();
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
}
|
||||
|
||||
/// Regression: an image-less v1.5 request must get a quote through the
|
||||
/// full public builder path (build2 → estimate_cost). The cost UI polls
|
||||
/// for a price before the user has attached an image; the image
|
||||
/// requirement is enforced at send time and by the generate endpoints,
|
||||
/// NOT here.
|
||||
#[test]
|
||||
fn image_less_request_gets_a_quote_through_the_builder() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo1p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let cost = builder.build2()
|
||||
.expect("build2 should succeed without an image")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed without an image");
|
||||
assert!(cost.cost_in_usd_cents.unwrap() > 0);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -390,8 +404,7 @@ mod tests {
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
// v1.5 requires an input image — the no-image guard in build()
|
||||
// rejects T2V.
|
||||
// These cases price the image-to-video tier (one input image).
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+57
-2
@@ -13,8 +13,11 @@ pub struct ArtcraftGrokImagineVideo1p5RequestState {
|
||||
|
||||
impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
// Defense in depth: `build()` already enforces this. Bouncing here costs
|
||||
// nothing and avoids an upstream call we know will fail.
|
||||
// xAI's v1.5 model rejects text-to-video at the server. This is the
|
||||
// GENERATION-time guard — `build()` and `estimate_cost()` deliberately
|
||||
// allow image-less states so the cost path can quote a request the user
|
||||
// is still composing. Bouncing here costs nothing and avoids an upstream
|
||||
// call we know will fail.
|
||||
if self.request.start_frame_image_media_token.is_none()
|
||||
&& self.request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
|
||||
{
|
||||
@@ -27,3 +30,55 @@ impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Generation must never reach xAI with an image-less v1.5 request. The
|
||||
/// build/cost layers allow that state (the cost path quotes it), so the
|
||||
/// send-time guard is the router's last line of defense.
|
||||
#[tokio::test]
|
||||
async fn send_rejects_image_less_request_before_any_network_call() {
|
||||
let state = ArtcraftGrokImagineVideo1p5RequestState {
|
||||
request: OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
|
||||
model: Some(CommonVideoModelEnum::GrokImagineVideo1p5),
|
||||
prompt: Some("a corgi running through a field".to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: None,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
},
|
||||
};
|
||||
|
||||
// The guard fires before the client is used, so an unroutable localhost
|
||||
// client with empty credentials proves no network call happens.
|
||||
let client = RouterArtcraftClient::new(
|
||||
ApiHost::Localhost { port: 1 },
|
||||
StorytellerCredentialSet::empty(),
|
||||
);
|
||||
|
||||
let err = state.send(&client).await.expect_err("image-less send should be rejected");
|
||||
match err {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
|
||||
assert_eq!(field, "image_inputs");
|
||||
}
|
||||
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-13
@@ -5,11 +5,10 @@ use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::OmniGenAudioCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_audio_cost_response::OmniGenAudioCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::audio::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of an audio generation.
|
||||
@@ -29,22 +28,18 @@ pub async fn omni_gen_audio_cost_handler(
|
||||
request: Json<OmniGenAudioCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenAudioCostResponse>, CommonWebError> {
|
||||
validate_audio_request(&request)?;
|
||||
|
||||
// NB: Deliberately no input validation here. The UI polls this endpoint
|
||||
// while the user is still composing the request (no prompt typed, nothing
|
||||
// attached), and pricing is a total function of the model and options.
|
||||
// Bad requests are rejected by the generate endpoint.
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft;
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build audio cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate audio cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
.map_err(map_router_cost_error)?;
|
||||
|
||||
Ok(Json(OmniGenAudioCostResponse {
|
||||
success: true,
|
||||
|
||||
+4
-10
@@ -8,9 +8,9 @@ use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use artcraft_router::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
@@ -33,18 +33,12 @@ pub async fn omni_gen_image_cost_handler(
|
||||
) -> Result<Json<OmniGenImageCostResponse>, CommonWebError> {
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft; // NB: Explicitly spell this out.
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
.map_err(map_router_cost_error)?;
|
||||
|
||||
Ok(Json(OmniGenImageCostResponse {
|
||||
success: true,
|
||||
|
||||
+8
-13
@@ -5,11 +5,10 @@ use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_mesh_cost_response::OmniGenMeshCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a mesh (3D object) generation.
|
||||
@@ -29,22 +28,18 @@ pub async fn omni_gen_mesh_cost_handler(
|
||||
request: Json<OmniGenMeshCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenMeshCostResponse>, CommonWebError> {
|
||||
validate_mesh_request(&request)?;
|
||||
|
||||
// NB: Deliberately no input validation here. The UI polls this endpoint
|
||||
// while the user is still composing the request (no prompt typed, nothing
|
||||
// attached), and pricing is a total function of the model and options.
|
||||
// Bad requests are rejected by the generate endpoint.
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft;
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build mesh cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate mesh cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
.map_err(map_router_cost_error)?;
|
||||
|
||||
Ok(Json(OmniGenMeshCostResponse {
|
||||
success: true,
|
||||
|
||||
+8
-13
@@ -5,11 +5,10 @@ use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_splat_cost_response::OmniGenSplatCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::splat::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a splat (3D world) generation.
|
||||
@@ -29,22 +28,18 @@ pub async fn omni_gen_splat_cost_handler(
|
||||
request: Json<OmniGenSplatCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenSplatCostResponse>, CommonWebError> {
|
||||
validate_splat_request(&request)?;
|
||||
|
||||
// NB: Deliberately no input validation here. The UI polls this endpoint
|
||||
// while the user is still composing the request (no prompt typed, nothing
|
||||
// attached), and pricing is a total function of the model and options.
|
||||
// Bad requests are rejected by the generate endpoint.
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft;
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build splat cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate splat cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
.map_err(map_router_cost_error)?;
|
||||
|
||||
Ok(Json(OmniGenSplatCostResponse {
|
||||
success: true,
|
||||
|
||||
+102
-18
@@ -1,15 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use actix_web::HttpRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use log::warn;
|
||||
|
||||
/// Estimate the cost of a video generation.
|
||||
#[utoipa::path(
|
||||
@@ -26,24 +22,19 @@ use log::warn;
|
||||
pub async fn omni_gen_video_cost_handler(
|
||||
_http_request: HttpRequest,
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenVideoCostResponse>, CommonWebError> {
|
||||
validate_video_request(&request)?;
|
||||
|
||||
// NB: Deliberately no input validation here. The UI polls this endpoint
|
||||
// while the user is still composing the request (no prompt typed, nothing
|
||||
// attached), and pricing is a total function of the model and options.
|
||||
// Bad requests are rejected by the generate endpoint.
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
builder.provider = RouterProvider::Artcraft;
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build cost estimate: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost: {}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
.map_err(map_router_cost_error)?;
|
||||
|
||||
Ok(Json(OmniGenVideoCostResponse {
|
||||
success: true,
|
||||
@@ -56,3 +47,96 @@ pub async fn omni_gen_video_cost_handler(
|
||||
failures_are_refunded: estimate.failures_are_refunded,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test::TestRequest;
|
||||
use actix_web::ResponseError;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod cost_without_inputs_tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression: xAI's v1.5 rejects text-to-video at generation time, but
|
||||
/// the cost endpoint must still quote an image-less request — the cost
|
||||
/// UI polls for a price before the user attaches an image. This request
|
||||
/// shape used to be rejected (handler validation, then a router build
|
||||
/// error that surfaced as a 500).
|
||||
#[tokio::test]
|
||||
async fn grok_1p5_without_an_image_gets_a_quote() {
|
||||
let response = post_cost_request(base_request(CommonVideoModel::GrokImagineVideo1p5))
|
||||
.await
|
||||
.expect("image-less grok 1.5 cost estimate should succeed");
|
||||
assert!(response.success);
|
||||
assert!(response.cost_in_credits.unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_model_gets_a_quote_for_representative_models() {
|
||||
for model in [
|
||||
CommonVideoModel::GrokImagineVideo,
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::Seedance2p0,
|
||||
CommonVideoModel::Sora2,
|
||||
CommonVideoModel::Veo3p1,
|
||||
CommonVideoModel::ViduQ3,
|
||||
] {
|
||||
let response = post_cost_request(base_request(model))
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("bare-model cost estimate should succeed for {model:?}: {e:?}"));
|
||||
assert!(response.cost_in_credits.unwrap() > 0, "no cost for {model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod error_mapping_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unroutable_model_returns_400_not_500() {
|
||||
// grok_video has no Artcraft route in the router; this must surface
|
||||
// as a 400, not a 500 (it 500'd in production before the
|
||||
// map_router_cost_error mapping).
|
||||
let err = post_cost_request(base_request(CommonVideoModel::GrokVideo))
|
||||
.await
|
||||
.expect_err("unroutable model should be rejected");
|
||||
assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_cost_request(
|
||||
body: OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<OmniGenVideoCostResponse, CommonWebError> {
|
||||
let http_request = TestRequest::post()
|
||||
.uri("/v1/omni_gen/cost/video")
|
||||
.to_http_request();
|
||||
omni_gen_video_cost_handler(http_request, Json(body))
|
||||
.await
|
||||
.map(Json::into_inner)
|
||||
}
|
||||
|
||||
fn base_request(model: CommonVideoModel) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: None,
|
||||
model: Some(model),
|
||||
prompt: None,
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: None,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
quality: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -7,7 +7,11 @@ const SEED_AUDIO_SAMPLE_RATES_HZ: [u32; 6] = [8000, 16000, 24000, 32000, 44100,
|
||||
const SEED_AUDIO_MAX_AUDIO_REFERENCES: usize = 3;
|
||||
const SEED_AUDIO_MAX_IMAGE_REFERENCES: usize = 1;
|
||||
|
||||
/// Validate requests before they incur user costs or send API requests.
|
||||
/// Validate generation requests before they incur user costs or send API
|
||||
/// requests.
|
||||
///
|
||||
/// NB: The cost endpoint deliberately does NOT call this — the UI polls for
|
||||
/// a price while the user is still composing the request.
|
||||
pub fn validate_audio_request(
|
||||
request: &OmniGenAudioCostAndGenerateRequest,
|
||||
) -> Result<(), CommonWebError> {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Map a router error from a cost-estimation path to an HTTP error.
|
||||
///
|
||||
/// Caller mistakes — a model the router can't price (e.g. no Artcraft
|
||||
/// route), bad option values — surface as 400s with the router's message.
|
||||
/// Infrastructure-class errors stay 500s.
|
||||
pub fn map_router_cost_error(error: ArtcraftRouterError) -> CommonWebError {
|
||||
match &error {
|
||||
ArtcraftRouterError::InvalidInput(reason) => {
|
||||
warn!("Cost estimate rejected: {}", reason);
|
||||
CommonWebError::BadInputWithSimpleMessage(reason.to_string())
|
||||
}
|
||||
ArtcraftRouterError::Client(_)
|
||||
| ArtcraftRouterError::UnsupportedModel(_)
|
||||
| ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_) => {
|
||||
// NB: Don't leak configuration details.
|
||||
warn!("Cost estimate rejected: {}", error);
|
||||
CommonWebError::BadInputWithSimpleMessage("Bad input for model".to_string())
|
||||
}
|
||||
_ => {
|
||||
warn!("Cost estimate failed: {}", error);
|
||||
CommonWebError::from_error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -18,7 +18,11 @@ const MESHY_MAX_FACE_COUNT: u64 = 300_000;
|
||||
/// Rodin v2.5 Fast accepts up to five input images.
|
||||
const RODIN_MAX_REFERENCE_IMAGES: usize = 5;
|
||||
|
||||
/// Validate requests before they incur user costs or send API requests.
|
||||
/// Validate generation requests before they incur user costs or send API
|
||||
/// requests.
|
||||
///
|
||||
/// NB: The cost endpoint deliberately does NOT call this — the UI polls for
|
||||
/// a price while the user is still composing the request.
|
||||
pub fn validate_mesh_request(
|
||||
request: &OmniGenMeshCostAndGenerateRequest,
|
||||
) -> Result<(), CommonWebError> {
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
pub mod audio;
|
||||
pub mod map_router_cost_error;
|
||||
pub mod map_seedance2pro_router_error;
|
||||
pub mod mesh;
|
||||
pub mod splat;
|
||||
|
||||
+5
-1
@@ -5,7 +5,11 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
/// World Labs MultiImage input accepts up to this many reference images.
|
||||
const MAX_IMAGE_REFERENCES: usize = 4;
|
||||
|
||||
/// Validate requests before they incur user costs or send API requests.
|
||||
/// Validate generation requests before they incur user costs or send API
|
||||
/// requests.
|
||||
///
|
||||
/// NB: The cost endpoint deliberately does NOT call this — the UI polls for
|
||||
/// a price while the user is still composing the request.
|
||||
///
|
||||
/// Model presence is enforced later during hydration; the input-shape rules
|
||||
/// below apply uniformly to all Marble models.
|
||||
|
||||
Reference in New Issue
Block a user