router wip, need to change artcraft_router

This commit is contained in:
Brandon Thomas committed 2026-05-11 21:14:13 -04:00
1 parent 5f1ade7600
commit 86bdd4bd4f
11 files changed
+352 -23

No files matched your search

@@ -9,4 +9,11 @@ impl RouterFalClient {
pub fn new(api_key: FalApiKey, webhook_url: String) -> Self {
Self { api_key, webhook_url }
}
pub fn new_from_raw_key(api_key: &str, webhook_url: String) -> Self {
Self {
api_key: FalApiKey::from_str(api_key),
webhook_url,
}
}
}
@@ -1,7 +1,8 @@
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft;
use crate::core::commands::generate::generate_image::providers::router::handle_router::handle_router;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::{
TauriGenerateImageErrorType, TauriGenerateImageRequest, TauriGenerateImageResponse,
};
@@ -10,6 +11,7 @@ use crate::core::commands::response::shorthand::Response;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -24,32 +26,37 @@ pub async fn generate_image_command(
app_env_configs: State<'_, AppEnvConfigs>,
task_database: State<'_, TaskDatabase>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
credential_cache: State<'_, ProviderCredentialLoadingCache>,
) -> Response<TauriGenerateImageResponse, TauriGenerateImageErrorType, ()> {
info!("generate_image_command called, request: {:?}", request);
// Only Artcraft provider is supported via this command.
// Other providers (Grok, Sora, Midjourney) should use the legacy commands.
let provider = request.provider.unwrap_or(GenerationProvider::Artcraft);
let is_artcraft_provider = matches!(
request.provider,
None | Some(GenerationProvider::Artcraft)
);
if !is_artcraft_provider {
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::BadRequest,
error_message: Some("Only Artcraft is supported.".to_string()),
error_type: Some(TauriGenerateImageErrorType::NoProviderAvailable),
error_details: None,
});
}
let result = handle_artcraft(
&request,
&app_env_configs,
&storyteller_creds_manager,
).await;
let result = match provider {
GenerationProvider::Artcraft => {
handle_artcraft(
&request,
&app_env_configs,
&storyteller_creds_manager,
).await
}
// Midjourney uses its own legacy command path, not this one.
GenerationProvider::Midjourney => {
Err(GenerateError::NotYetImplemented(
"Midjourney should use its dedicated command".to_string(),
))
}
// All other providers go through the router.
other => {
handle_router(
&request,
other,
&app_env_configs,
&credential_cache,
).await
}
};
match result {
Ok(success) => handle_success_behavior(&app, &task_database, &request, success).await,
@@ -119,6 +126,8 @@ async fn handle_error_behavior(
let error_type = match &err {
GenerateError::BadInput(_) => TauriGenerateImageErrorType::BadInput,
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => TauriGenerateImageErrorType::NeedsFalApiKey,
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) => TauriGenerateImageErrorType::NeedsGrokCredentials,
GenerateError::MissingCredentials(_) => TauriGenerateImageErrorType::NeedsStorytellerCredentials,
GenerateError::NoProviderAvailable => TauriGenerateImageErrorType::NoProviderAvailable,
GenerateError::BillingIssue(_) => TauriGenerateImageErrorType::BillingIssue,
@@ -1 +1,2 @@
pub mod artcraft;
pub mod artcraft;
pub mod router;
@@ -0,0 +1,126 @@
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::info;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model;
use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::generate_image::providers::router::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution};
use crate::core::commands::generate::generate_image::providers::router::utils::map_media_files_to_urls::map_media_file_tokens_to_cdn_urls;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
/// Handle image generation via FAL using the user's own API key.
pub async fn handle_fal(
request: &TauriGenerateImageRequest,
api_key: &str,
api_host: &ApiHost,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?;
let router_model = tauri_image_model_to_router_model(tauri_model)
.ok_or(GenerateError::NotYetImplemented(
format!("Model {:?} is not supported via the FAL router path", tauri_model),
))?;
// Collect all media file tokens that need resolving.
let image_inputs = resolve_image_inputs(request, api_host).await?;
let webhook_url = build_fal_webhook_url(api_host);
let router_request = GenerateImageRequest {
model: router_model,
provider: Provider::Fal,
prompt: request.prompt.clone(),
image_inputs,
resolution: request.resolution.map(convert_resolution),
aspect_ratio: request.aspect_ratio.map(convert_aspect_ratio),
quality: request.quality.map(convert_quality),
image_batch_count: request.batch_size.map(|n| n as u16),
horizontal_angle: request.adjust_horizontal_angle,
vertical_angle: request.adjust_vertical_angle,
zoom: request.adjust_zoom,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
info!("Building FAL image generation plan: model={:?}", router_model);
let plan = router_request.build()?;
let fal_client = RouterFalClient::new_from_raw_key(api_key, webhook_url);
let client = RouterClient::Fal(fal_client);
info!("Executing FAL image generation...");
let response = plan.generate_image(&client).await?;
build_task_enqueue_success(tauri_model, response)
}
// ── Helpers ──
/// Collect media file tokens from the request and resolve them to CDN URLs.
///
/// FAL accepts image URLs directly, so we map our media file tokens to their
/// CDN URLs rather than downloading the bytes locally.
async fn resolve_image_inputs(
request: &TauriGenerateImageRequest,
api_host: &ApiHost,
) -> Result<Option<ImageListRef>, GenerateError> {
let mut tokens: Vec<MediaFileToken> = Vec::new();
if let Some(canvas_token) = &request.canvas_image_media_token {
tokens.push(canvas_token.clone());
}
if let Some(scene_token) = &request.scene_image_media_token {
tokens.push(scene_token.clone());
}
if let Some(media_tokens) = &request.image_media_tokens {
tokens.extend(media_tokens.clone());
}
if tokens.is_empty() {
return Ok(None);
}
let urls = map_media_file_tokens_to_cdn_urls(&tokens, api_host).await?;
Ok(Some(ImageListRef::Urls(urls)))
}
fn build_fal_webhook_url(api_host: &ApiHost) -> String {
let base = api_host.to_api_hostname_and_scheme();
format!("{}/v1/webhooks/fal", base)
}
fn build_task_enqueue_success(
tauri_model: TauriImageModel,
response: GenerateImageResponse,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let fal_payload = response.get_fal_payload()
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
let provider_job_id = fal_payload.request_id
.or(fal_payload.gateway_request_id)
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
let generation_model = tauri_image_model_to_generation_model(tauri_model);
Ok(TaskEnqueueSuccess {
task_type: TaskType::ImageGeneration,
model: Some(generation_model),
provider: GenerationProvider::Fal,
provider_job_id: Some(provider_job_id),
})
}
@@ -0,0 +1,84 @@
use enums::common::generation_provider::GenerationProvider;
use log::info;
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::generate_image::providers::router::handle_api_providers::handle_fal;
use crate::core::commands::generate::generate_image::providers::router::handle_web_login_providers::handle_web_login_provider;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
use crate::core::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload;
use crate::core::providers::credentials::provider_credential_key::ProviderCredentialKey;
use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
/// Dispatch an image generation request to a third-party provider via the router.
///
/// This handles providers that authenticate with the user's own credentials
/// (API key or web login) rather than going through the Artcraft backend.
pub async fn handle_router(
request: &TauriGenerateImageRequest,
provider: GenerationProvider,
app_env_configs: &AppEnvConfigs,
credential_cache: &ProviderCredentialLoadingCache,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let credential_key = map_provider_to_credential_key(provider)?;
info!("handle_router: provider={:?}, credential_key={:?}", provider, credential_key);
let payload = credential_cache.get_credentials(credential_key)
.map_err(|err| {
GenerateError::AnyhowError(anyhow::anyhow!("Failed to load credentials: {:?}", err))
})?
.ok_or_else(|| map_provider_to_missing_credentials_error(provider))?;
match payload {
ProviderCredentialPayload::ApiKey(api_key_data) => {
handle_api_key_provider(request, provider, api_key_data.as_str(), app_env_configs).await
}
ProviderCredentialPayload::WebLogin(web_login_data) => {
handle_web_login_provider(request, provider, &web_login_data).await
}
}
}
// ── Helpers ──
async fn handle_api_key_provider(
request: &TauriGenerateImageRequest,
provider: GenerationProvider,
api_key: &str,
app_env_configs: &AppEnvConfigs,
) -> Result<TaskEnqueueSuccess, GenerateError> {
match provider {
GenerationProvider::Fal => {
handle_fal(request, api_key, &app_env_configs.storyteller_host).await
}
_ => {
Err(GenerateError::NotYetImplemented(
format!("API key provider {:?} is not yet supported via the router path", provider),
))
}
}
}
fn map_provider_to_credential_key(
provider: GenerationProvider,
) -> Result<ProviderCredentialKey, GenerateError> {
match provider {
GenerationProvider::Fal => Ok(ProviderCredentialKey::FalApiKey),
_ => Err(GenerateError::NotYetImplemented(
format!("Provider {:?} does not have a mapped credential key yet", provider),
)),
}
}
fn map_provider_to_missing_credentials_error(provider: GenerationProvider) -> GenerateError {
match provider {
GenerationProvider::Fal => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey),
GenerationProvider::Grok => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials),
GenerationProvider::Midjourney => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials),
GenerationProvider::Sora => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials),
GenerationProvider::WorldLabs => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials),
_ => GenerateError::NoProviderAvailable,
}
}
@@ -0,0 +1,17 @@
use enums::common::generation_provider::GenerationProvider;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
use crate::core::providers::credentials::payload::web_login::WebLoginData;
/// Handle image generation for providers that authenticate via web login.
///
/// Not yet implemented — placeholder for Grok, Runway, Higgsfield, etc.
pub async fn handle_web_login_provider(
_request: &TauriGenerateImageRequest,
_provider: GenerationProvider,
_web_login: &WebLoginData,
) -> Result<TaskEnqueueSuccess, GenerateError> {
unimplemented!("Web login provider image generation is not yet supported via the router path")
}
@@ -0,0 +1,4 @@
pub mod handle_router;
mod handle_api_providers;
mod handle_web_login_providers;
mod utils;
@@ -0,0 +1,50 @@
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio;
use artcraft_router::api::common_quality::CommonQuality as RouterQuality;
use artcraft_router::api::common_resolution::CommonResolution as RouterResolution;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio;
use enums::common::generation::common_quality::CommonQuality as EnumsQuality;
use enums::common::generation::common_resolution::CommonResolution as EnumsResolution;
pub fn convert_aspect_ratio(value: EnumsAspectRatio) -> RouterAspectRatio {
match value {
EnumsAspectRatio::Auto => RouterAspectRatio::Auto,
EnumsAspectRatio::Square => RouterAspectRatio::Square,
EnumsAspectRatio::WideThreeByTwo => RouterAspectRatio::WideThreeByTwo,
EnumsAspectRatio::WideFourByThree => RouterAspectRatio::WideFourByThree,
EnumsAspectRatio::WideFiveByFour => RouterAspectRatio::WideFiveByFour,
EnumsAspectRatio::WideSixteenByNine => RouterAspectRatio::WideSixteenByNine,
EnumsAspectRatio::WideTwentyOneByNine => RouterAspectRatio::WideTwentyOneByNine,
EnumsAspectRatio::TallTwoByThree => RouterAspectRatio::TallTwoByThree,
EnumsAspectRatio::TallThreeByFour => RouterAspectRatio::TallThreeByFour,
EnumsAspectRatio::TallFourByFive => RouterAspectRatio::TallFourByFive,
EnumsAspectRatio::TallNineBySixteen => RouterAspectRatio::TallNineBySixteen,
EnumsAspectRatio::TallNineByTwentyOne => RouterAspectRatio::TallNineByTwentyOne,
EnumsAspectRatio::Wide => RouterAspectRatio::Wide,
EnumsAspectRatio::Tall => RouterAspectRatio::Tall,
EnumsAspectRatio::Auto2k => RouterAspectRatio::Auto2k,
EnumsAspectRatio::Auto3k => RouterAspectRatio::Auto3k,
EnumsAspectRatio::Auto4k => RouterAspectRatio::Auto4k,
EnumsAspectRatio::SquareHd => RouterAspectRatio::SquareHd,
}
}
pub fn convert_quality(value: EnumsQuality) -> RouterQuality {
match value {
EnumsQuality::High => RouterQuality::High,
EnumsQuality::Medium => RouterQuality::Medium,
EnumsQuality::Low => RouterQuality::Low,
}
}
pub fn convert_resolution(value: EnumsResolution) -> RouterResolution {
match value {
EnumsResolution::OneK => RouterResolution::OneK,
EnumsResolution::TwoK => RouterResolution::TwoK,
EnumsResolution::ThreeK => RouterResolution::ThreeK,
EnumsResolution::FourK => RouterResolution::FourK,
EnumsResolution::HalfK => RouterResolution::HalfK,
EnumsResolution::FourEightyP => RouterResolution::FourEightyP,
EnumsResolution::SevenTwentyP => RouterResolution::SevenTwentyP,
EnumsResolution::TenEightyP => RouterResolution::TenEightyP,
}
}
@@ -0,0 +1,27 @@
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::utils::api_host::ApiHost;
use log::info;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::commands::enqueue::generate_error::GenerateError;
/// Resolve a list of media file tokens to their CDN URLs.
///
/// FAL (and other third-party providers) can fetch images by URL directly,
/// so we just need the CDN link — no need to download the bytes locally.
pub async fn map_media_file_tokens_to_cdn_urls(
tokens: &[MediaFileToken],
api_host: &ApiHost,
) -> Result<Vec<String>, GenerateError> {
let mut urls = Vec::with_capacity(tokens.len());
for token in tokens {
info!("Resolving media file token {} to CDN URL...", token.as_str());
let response = get_media_file(api_host, token).await?;
let cdn_url = response.media_file.media_links.cdn_url.to_string();
info!("Resolved {} -> {}", token.as_str(), &cdn_url);
urls.push(cdn_url);
}
Ok(urls)
}
@@ -0,0 +1,2 @@
pub mod convert_enums_to_router;
pub mod map_media_files_to_urls;
@@ -116,6 +116,8 @@ pub enum TauriGenerateImageErrorType {
ServerError,
/// Needs to be logged into Artcraft
NeedsStorytellerCredentials,
/// Needs a FAL API key
NeedsFalApiKey,
/// Needs Grok credentials
NeedsGrokCredentials,
/// Billing issue