Compare commits

...
Author SHA1 Message Date
Brandon Thomas 7c140c00b4 content error message 2026-05-17 00:28:01 -04:00
Brandon Thomas 46a7993bfb timeout for gmiclient 2026-05-17 00:11:12 -04:00
Brandon Thomas 1ac983a871 cleanup 2026-05-16 23:46:05 -04:00
Brandon Thomas 03f7eb2037 cleanup 2026-05-16 23:44:23 -04:00
Brandon Thomas 539a217521 rename 2026-05-16 23:33:55 -04:00
12 changed files with 177 additions and 28 deletions
@@ -9,6 +9,9 @@ pub enum GmiCloudSpecificApiError {
/// The request was rejected due to content policy.
ContentPolicyViolation(String),
/// The input image was rejected because it may contain a real person.
ContentContainsRealPerson(String),
/// The account has insufficient credits or a billing issue.
BillingError { status_code: u16, body: String },
}
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::requests::api::video::seedance_2_0_260128::raw_request::Seedance20Payload;
use crate::requests::common::create_request::{
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
create_gmicloud_request, create_gmicloud_request_with_context,
GmiCloudCreateRequest, GmiCloudCreateResponse,
};
use crate::requests::context::request_context::RequestContext;
const MODEL_ID: &str = "seedance-2-0-260128";
@@ -113,12 +115,23 @@ impl Seedance20Request {
pub async fn send_request(
&self,
api_key: &GmiCloudApiKey,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
self.send_request_with_context(&context).await
}
pub async fn send_request_with_context(
&self,
context: &RequestContext<'_>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let body = GmiCloudCreateRequest {
model: MODEL_ID.to_string(),
payload: self.to_raw_payload(),
};
create_gmicloud_request(api_key, &body).await
create_gmicloud_request_with_context(context, &body).await
}
pub fn model_id() -> &'static str {
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::requests::api::video::seedance_2_0_fast_260128::raw_request::Seedance20FastPayload;
use crate::requests::common::create_request::{
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
create_gmicloud_request, create_gmicloud_request_with_context,
GmiCloudCreateRequest, GmiCloudCreateResponse,
};
use crate::requests::context::request_context::RequestContext;
const MODEL_ID: &str = "seedance-2-0-fast-260128";
@@ -111,12 +113,23 @@ impl Seedance20FastRequest {
pub async fn send_request(
&self,
api_key: &GmiCloudApiKey,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
self.send_request_with_context(&context).await
}
pub async fn send_request_with_context(
&self,
context: &RequestContext<'_>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let body = GmiCloudCreateRequest {
model: MODEL_ID.to_string(),
payload: self.to_raw_payload(),
};
create_gmicloud_request(api_key, &body).await
create_gmicloud_request_with_context(context, &body).await
}
pub fn model_id() -> &'static str {
@@ -1,3 +1,5 @@
use std::time::Duration;
use log::warn;
use serde::{Deserialize, Serialize};
@@ -5,9 +7,14 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError;
use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError;
use crate::requests::context::request_context::RequestContext;
const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey";
/// Default request timeout. Video requests return quickly (async queue),
/// but image requests block until completion and can take 60+ seconds.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
/// The top-level request body sent to `POST /requests`.
#[derive(Debug, Serialize)]
pub struct GmiCloudCreateRequest<P: Serialize> {
@@ -23,17 +30,34 @@ pub struct GmiCloudCreateResponse {
pub status: String,
}
/// Submit a video generation request to GmiCloud.
/// Submit a request to GmiCloud using just an API key (default timeout).
pub async fn create_gmicloud_request<P: Serialize>(
api_key: &GmiCloudApiKey,
body: &GmiCloudCreateRequest<P>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
create_gmicloud_request_with_context(&context, body).await
}
/// Submit a request to GmiCloud with explicit context (custom timeout, etc.).
pub async fn create_gmicloud_request_with_context<P: Serialize>(
context: &RequestContext<'_>,
body: &GmiCloudCreateRequest<P>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let url = format!("{}/requests", BASE_URL);
let timeout = context.maybe_timeout.unwrap_or(DEFAULT_TIMEOUT);
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(GmiCloudGenericApiError::from)?;
let client = reqwest::Client::new();
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key.as_str()))
.header("Authorization", format!("Bearer {}", context.api_key.as_str()))
.json(body)
.send()
.await
@@ -49,6 +73,12 @@ pub async fn create_gmicloud_request<P: Serialize>(
if !status.is_success() {
warn!("GmiCloud API error: status={}, body={}", status, body_text);
// Detect specific error codes embedded in the response body.
if let Some(specific) = classify_error_body(&body_text) {
return Err(specific.into());
}
return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status.as_u16(),
body: body_text,
@@ -60,3 +90,28 @@ pub async fn create_gmicloud_request<P: Serialize>(
Ok(parsed)
}
/// Attempt to classify an error response body into a specific error type.
/// GmiCloud sometimes wraps inner API errors as a JSON string within `{"error": "..."}`.
fn classify_error_body(body: &str) -> Option<GmiCloudSpecificApiError> {
if body.contains("InputImageSensitiveContentDetected") {
let message = extract_inner_message(body)
.unwrap_or_else(|| "Input image may contain a real person".to_string());
return Some(GmiCloudSpecificApiError::ContentContainsRealPerson(message));
}
None
}
/// Extract the inner `message` field from a doubly-encoded GmiCloud error response.
/// Expected shape: `{"error":"API request failed with status 400: {\"error\":{\"message\":\"...\"}}"}`
fn extract_inner_message(body: &str) -> Option<String> {
let outer: serde_json::Value = serde_json::from_str(body).ok()?;
let error_str = outer.get("error")?.as_str()?;
// The inner JSON is embedded after "status NNN: "
let inner_json_start = error_str.find('{')?;
let inner_str = &error_str[inner_json_start..];
let inner: serde_json::Value = serde_json::from_str(inner_str).ok()?;
inner.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
@@ -0,0 +1 @@
pub mod request_context;
@@ -0,0 +1,8 @@
use std::time::Duration;
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
pub struct RequestContext<'a> {
pub api_key: &'a GmiCloudApiKey,
pub maybe_timeout: Option<Duration>,
}
@@ -1,3 +1,4 @@
pub mod api;
pub mod common;
pub mod context;
pub mod poll_request_queue;
@@ -1,7 +1,12 @@
use std::fs;
use std::path::Path;
use anyhow::anyhow;
use clap::Subcommand;
use clap::{Args, Subcommand};
use log::{info, warn};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use super::state::ArtcraftState;
use super::subcommands;
@@ -10,6 +15,24 @@ use super::subcommands;
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"];
const DEFAULT_COOKIE_FILE: &str = "artcraft_cookies.txt";
#[derive(Args)]
pub struct ArtcraftArgs {
/// Path to a file containing the session cookies.
/// If not specified, reads from `artcraft_cookies.txt` in the current directory.
#[arg(long, global = true)]
pub cookie_file: Option<String>,
/// Target environment: "dev" / "development" (localhost:12345) or "prod" / "production" (api.storyteller.ai).
/// [default: dev]
#[arg(long, global = true, default_value = "dev")]
pub environment: String,
#[command(subcommand)]
pub command: ArtcraftCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum ArtcraftCommand {
@@ -17,17 +40,52 @@ pub enum ArtcraftCommand {
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: ArtcraftCommand) -> anyhow::Result<()> {
let cookies_str = easyenv::get_env_string_required("ARTCRAFT_COOKIES")
.map_err(|err| anyhow!("Missing ARTCRAFT_COOKIES env var: {:?}", err))?;
pub async fn dispatch(args: ArtcraftArgs) -> anyhow::Result<()> {
let cookie_path = args.cookie_file.as_deref().unwrap_or(DEFAULT_COOKIE_FILE);
let cookies_str = load_cookies(cookie_path)?;
let creds = StorytellerCredentialSet::parse_multi_cookie_header(&cookies_str)
.map_err(|err| anyhow!("Failed to parse ARTCRAFT_COOKIES: {:?}", err))?
.ok_or_else(|| anyhow!("ARTCRAFT_COOKIES contained no recognized cookies (expected session=... and/or visitor=...)"))?;
.map_err(|err| anyhow!("Failed to parse cookies: {:?}", err))?
.ok_or_else(|| anyhow!("Cookie file contained no recognized cookies (expected session=... and/or visitor=...)"))?;
let state = ArtcraftState { creds };
let api_host = parse_api_host(&args.environment)?;
let state = ArtcraftState { creds, api_host };
match command {
ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
match args.command {
ArtcraftCommand::GenerateVideo(cmd_args) => subcommands::generate_video::run(&state, cmd_args).await,
}
}
fn load_cookies(cookie_path: &str) -> anyhow::Result<String> {
info!("Reading cookies from: {}", cookie_path);
if Path::new(cookie_path).exists() {
let content = fs::read_to_string(cookie_path)
.map_err(|err| anyhow!("Failed to read cookie file {:?}: {}", cookie_path, err))?;
Ok(content.trim().to_string())
} else {
warn!("Cookie file {:?} does not exist.", cookie_path);
Err(anyhow!(
"Cookie file {:?} not found. Use --cookie-file to specify a path, \
or create '{}' in the current directory.",
cookie_path, DEFAULT_COOKIE_FILE
))
}
}
fn parse_api_host(environment: &str) -> anyhow::Result<ApiHost> {
match environment {
"dev" | "development" => {
info!("Environment: development (localhost:12345)");
Ok(ApiHost::Localhost { port: 12345 })
}
"prod" | "production" => {
info!("Environment: production (api.storyteller.ai)");
Ok(ApiHost::Storyteller)
}
other => Err(anyhow!(
"Unknown environment '{}'. Use 'dev', 'development', 'prod', or 'production'.",
other
)),
}
}
@@ -2,5 +2,6 @@ pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::ArtcraftArgs;
pub use dispatch::ArtcraftCommand;
pub use dispatch::run;
pub use dispatch::dispatch;
@@ -1,5 +1,7 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
pub struct ArtcraftState {
pub creds: StorytellerCredentialSet,
pub api_host: ApiHost,
}
@@ -7,7 +7,6 @@ use log::info;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::utils::parse_video_model::parse_video_model;
@@ -51,9 +50,7 @@ pub struct GenerateVideoArgs {
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API.
#[arg(long)]
pub localhost: bool,
/// Video model to use. Accepts canonical names (eg. "seedance_2p0") or aliases
/// (eg. "seedance2", "happyhorse", "veo3fast"). [default: seedance2p0]
@@ -77,7 +74,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
.ok_or_else(|| anyhow!("Unknown model '{}'. Try --help for supported names.", model_str))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
let api_host = &state.api_host;
let duration = args.duration.unwrap_or(5);
info!("Model: {:?}", model);
@@ -131,7 +128,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
let response = omni_gen_video_generate(
&api_host,
api_host,
Some(&state.creds),
request,
).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?;
@@ -29,10 +29,7 @@ pub enum TopLevelCommand {
},
/// ArtCraft support commands (omni API)
Artcraft {
#[command(subcommand)]
command: artcraft::ArtcraftCommand,
},
Artcraft(artcraft::dispatch::ArtcraftArgs),
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
@@ -40,8 +37,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
TopLevelCommand::Artcraft { command } => {
artcraft::run(command).await
TopLevelCommand::Artcraft(args) => {
artcraft::dispatch(args).await
}
}
}