From c623ffed9c4ad9c4c5413009690bd4138747fa81 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Fri, 10 Jul 2026 20:25:16 +0800 Subject: [PATCH] price compute --- .../src/editor_agent/editor_tools/common.rs | 28 ++- .../editor_agent/editor_tools/edit_image.rs | 11 +- .../editor_tools/generate_background_music.rs | 14 ++ .../editor_tools/generate_character.rs | 15 +- .../editor_tools/generate_icon_spritesheet.rs | 15 +- .../editor_tools/generate_image.rs | 15 +- .../editor_tools/generate_sound_effect.rs | 20 ++ .../editor_tools/generate_ui_design.rs | 15 +- .../editor_tools/generate_video.rs | 34 ++++ .../src/editor_agent/editor_tools/mod.rs | 3 + .../editor_tools/pricing_tests.rs | 190 ++++++++++++++++++ 11 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 server-rs/crates/api-server/src/editor_agent/editor_tools/pricing_tests.rs diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/common.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/common.rs index 3cbdfc000..06803a14b 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/common.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/common.rs @@ -1,4 +1,7 @@ use crate::editor_agent::utils::{ImageId, ImageMetadata}; +use crate::editor_generation_config::EditorGenerationPricingConfig; +use crate::openai_image_generation::GPT_IMAGE_2_MODEL; +use module_editor_agent::agent::tool::Tool; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -7,7 +10,6 @@ pub struct EditorToolContext { pub images: HashMap, } - impl EditorToolContext { /// Check if an image with the given ID exists in the context. pub fn contains_image(&self, image_id: &ImageId) -> bool { @@ -20,3 +22,27 @@ impl EditorToolContext { .map(|metadata| metadata.data_key.as_str()) } } + +/// api-server 侧的画布 Agent 工具计价扩展。 +/// +/// 通用 `Tool` 仍只负责参数校验;价格依赖 api-server 的运行时配置,不能下沉到 +/// `module-editor-agent`。实际执行和扣费仍由既有生成 BFF 负责。 +#[allow(dead_code)] +pub(crate) trait EditorAgentPricedTool: Tool { + fn pricing(&self, pricing: &EditorGenerationPricingConfig, args: &::Args) -> u32; +} + +#[allow(dead_code)] +pub(crate) fn editor_agent_image_mud_points( + pricing: &EditorGenerationPricingConfig, + kind: Option<&str>, + image_size: Option<&str>, +) -> u32 { + // 这些 Agent 工具当前向既有 BFF 传 model=None;BFF 会先归一为 gpt-image-2。 + // 尺寸同样只把精确的 2K 识别为 2K,其余值回落到 1K。 + let normalized_image_size = match image_size.map(str::trim) { + Some("2K") => "2K", + _ => "1K", + }; + pricing.image_generation_mud_points(kind, Some(GPT_IMAGE_2_MODEL), Some(normalized_image_size)) +} diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs index d9fd2f5bb..c10de7498 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs @@ -1,5 +1,8 @@ -use crate::editor_agent::editor_tools::common::EditorToolContext; +use crate::editor_agent::editor_tools::common::{ + EditorAgentPricedTool, EditorToolContext, editor_agent_image_mud_points, +}; use crate::editor_agent::utils::ImageId; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_project::{ EditorGenerationCaller, EditorImageEditRequest, edit_editor_image_for_owner, }; @@ -122,6 +125,12 @@ impl Tool for EditImageTool { } } +impl EditorAgentPricedTool for EditImageTool { + fn pricing(&self, pricing: &EditorGenerationPricingConfig, _args: &EditImageToolArgs) -> u32 { + editor_agent_image_mud_points(pricing, Some("quick-edit"), Some("1K")) + } +} + /// Typed result deserialized from `edit_editor_image_for_owner` response. /// /// Mirrors `EditorImageGenerationResponse` but uses `String` instead of `&'static str` diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_background_music.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_background_music.rs index a76a965c6..5d59b4721 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_background_music.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_background_music.rs @@ -1,6 +1,10 @@ +use crate::editor_agent::editor_tools::common::EditorAgentPricedTool; use crate::editor_agent::editor_tools::generate_sound_effect::{ map_media_response_error, parse_media_response, pending_message, }; +use crate::editor_generation_config::{ + EDITOR_BACKGROUND_MUSIC_MODEL_SUNO, EditorGenerationPricingConfig, +}; use crate::http_error::AppError; use crate::request_context::RequestContext; use crate::state::AppState; @@ -79,6 +83,16 @@ impl Tool for GenerateBackgroundMusicTool { } } +impl EditorAgentPricedTool for GenerateBackgroundMusicTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + _args: &GenerateBackgroundMusicToolArgs, + ) -> u32 { + pricing.background_music_model_mud_points(Some(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO)) + } +} + impl GenerateBackgroundMusicTool { #[allow(clippy::too_many_arguments)] pub async fn execute( diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_character.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_character.rs index d088f4980..d1ad2a541 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_character.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_character.rs @@ -1,8 +1,11 @@ -use crate::editor_agent::editor_tools::common::EditorToolContext; +use crate::editor_agent::editor_tools::common::{ + EditorAgentPricedTool, EditorToolContext, editor_agent_image_mud_points, +}; use crate::editor_agent::editor_tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, GenerateImageToolOutput, }; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_project::EditorGenerationCaller; use crate::http_error::AppError; use crate::request_context::RequestContext; @@ -61,6 +64,16 @@ impl Tool for GenerateCharacterTool { } } +impl EditorAgentPricedTool for GenerateCharacterTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("character"), args.image_size.as_deref()) + } +} + impl GenerateCharacterTool { #[allow(clippy::too_many_arguments)] pub async fn execute( diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_icon_spritesheet.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_icon_spritesheet.rs index cde4c38f5..3916b24bf 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_icon_spritesheet.rs @@ -1,5 +1,8 @@ -use crate::editor_agent::editor_tools::common::EditorToolContext; +use crate::editor_agent::editor_tools::common::{ + EditorAgentPricedTool, EditorToolContext, editor_agent_image_mud_points, +}; use crate::editor_agent::utils::ImageId; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_project::{ EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, generate_editor_icon_spritesheet_for_owner, @@ -118,6 +121,16 @@ impl Tool for GenerateIconSpritesheetTool { } } +impl EditorAgentPricedTool for GenerateIconSpritesheetTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateIconSpritesheetToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("icon"), args.image_size.as_deref()) + } +} + impl GenerateIconSpritesheetTool { fn validate_args( &self, diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_image.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_image.rs index ea0272f6a..0b18b59c5 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_image.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_image.rs @@ -1,5 +1,8 @@ -use crate::editor_agent::editor_tools::common::EditorToolContext; +use crate::editor_agent::editor_tools::common::{ + EditorAgentPricedTool, EditorToolContext, editor_agent_image_mud_points, +}; use crate::editor_agent::utils::ImageId; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_project::{ EditorGenerationCaller, EditorImageGenerationRequest, generate_editor_image_for_owner, }; @@ -113,6 +116,16 @@ impl Tool for GenerateImageTool { } } +impl EditorAgentPricedTool for GenerateImageTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, None, args.image_size.as_deref()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResult { diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_sound_effect.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_sound_effect.rs index 895672c40..893970c35 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_sound_effect.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_sound_effect.rs @@ -1,3 +1,7 @@ +use crate::editor_agent::editor_tools::common::EditorAgentPricedTool; +use crate::editor_generation_config::{ + EDITOR_SOUND_EFFECT_MODEL_VIDU, EditorGenerationPricingConfig, +}; use crate::http_error::AppError; use crate::request_context::RequestContext; use crate::state::AppState; @@ -87,6 +91,22 @@ impl Tool for GenerateSoundEffectTool { } } +impl EditorAgentPricedTool for GenerateSoundEffectTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateSoundEffectToolArgs, + ) -> u32 { + let model = args + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(EDITOR_SOUND_EFFECT_MODEL_VIDU); + pricing.sound_effect_model_mud_points(Some(model)) + } +} + impl GenerateSoundEffectTool { #[allow(clippy::too_many_arguments)] pub async fn execute( diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_ui_design.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_ui_design.rs index b2a36e55d..b6b45b85c 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_ui_design.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_ui_design.rs @@ -1,9 +1,12 @@ -use crate::editor_agent::editor_tools::common::EditorToolContext; +use crate::editor_agent::editor_tools::common::{ + EditorAgentPricedTool, EditorToolContext, editor_agent_image_mud_points, +}; use crate::editor_agent::editor_tools::generate_character::format_tool_message; use crate::editor_agent::editor_tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, GenerateImageToolOutput, }; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_project::EditorGenerationCaller; use crate::http_error::AppError; use crate::request_context::RequestContext; @@ -61,6 +64,16 @@ impl Tool for GenerateUiDesignTool { } } +impl EditorAgentPricedTool for GenerateUiDesignTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("ui-design"), args.image_size.as_deref()) + } +} + impl GenerateUiDesignTool { #[allow(clippy::too_many_arguments)] pub async fn execute( diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_video.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_video.rs index 27d26609e..0f8505a9e 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_video.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/generate_video.rs @@ -1,8 +1,10 @@ use crate::character_animation_assets::generate_editor_video_for_owner; +use crate::editor_agent::editor_tools::common::EditorAgentPricedTool; use crate::editor_agent::editor_tools::generate_sound_effect::{ map_media_response_error, parse_media_response, pending_message, }; use crate::editor_agent::utils::ImageId; +use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::http_error::AppError; use crate::request_context::RequestContext; use crate::state::AppState; @@ -22,6 +24,13 @@ pub struct GenerateVideoTool { pub context: crate::editor_agent::editor_tools::common::EditorToolContext, } +#[allow(dead_code)] +const DEFAULT_VIDEO_MODEL: &str = "seedance2.0-fast"; +#[allow(dead_code)] +const DEFAULT_VIDEO_RESOLUTION: &str = "720p"; +#[allow(dead_code)] +const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 4; + #[derive(Debug, Clone)] pub enum GenerateVideoError { PromptNotProvided, @@ -107,6 +116,31 @@ impl Tool for GenerateVideoTool { } } +impl EditorAgentPricedTool for GenerateVideoTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateVideoToolArgs, + ) -> u32 { + let model = args + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_VIDEO_MODEL); + let resolution = args + .resolution + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_VIDEO_RESOLUTION); + let duration_seconds = args + .duration_seconds + .unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS); + pricing.video_model_mud_points(Some(model), resolution, duration_seconds) + } +} + impl GenerateVideoTool { #[allow(clippy::too_many_arguments)] pub async fn execute( diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/mod.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/mod.rs index add644b07..ec3c580d2 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/mod.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/mod.rs @@ -7,3 +7,6 @@ pub mod generate_image; pub mod generate_sound_effect; pub mod generate_ui_design; pub mod generate_video; + +#[cfg(test)] +mod pricing_tests; diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/pricing_tests.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/pricing_tests.rs new file mode 100644 index 000000000..8c66aa585 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/pricing_tests.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; + +use crate::editor_agent::editor_tools::common::{EditorAgentPricedTool, EditorToolContext}; +use crate::editor_agent::editor_tools::edit_image::{EditImageTool, EditImageToolArgs}; +use crate::editor_agent::editor_tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use crate::editor_agent::editor_tools::generate_character::GenerateCharacterTool; +use crate::editor_agent::editor_tools::generate_icon_spritesheet::{ + GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use crate::editor_agent::editor_tools::generate_image::{GenerateImageTool, GenerateImageToolArgs}; +use crate::editor_agent::editor_tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use crate::editor_agent::editor_tools::generate_ui_design::GenerateUiDesignTool; +use crate::editor_agent::editor_tools::generate_video::{GenerateVideoTool, GenerateVideoToolArgs}; +use crate::editor_agent::utils::ImageId; +use crate::editor_generation_config::load_editor_generation_pricing_from_paths; + +fn context() -> EditorToolContext { + EditorToolContext { + images: HashMap::new(), + } +} + +fn image_args(image_size: Option<&str>) -> GenerateImageToolArgs { + GenerateImageToolArgs { + prompt: "生成图片".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("1:1".to_string()), + image_size: image_size.map(ToOwned::to_owned), + } +} + +#[test] +fn every_editor_agent_tool_exposes_argument_based_pricing() { + let pricing = load_editor_generation_pricing_from_paths(None) + .expect("default editor pricing should load"); + let context = context(); + + assert_eq!( + EditImageTool { + context: context.clone(), + } + .pricing( + &pricing, + &EditImageToolArgs { + object_image_id: ImageId { + id: "image-1".to_string(), + }, + reference_image_ids: Vec::new(), + prompt: "改成蓝色".to_string(), + }, + ), + 3 + ); + assert_eq!( + GenerateImageTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(Some("2K"))), + 5 + ); + assert_eq!( + GenerateCharacterTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(None)), + 3 + ); + assert_eq!( + GenerateUiDesignTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(Some("2K"))), + 5 + ); + assert_eq!( + GenerateIconSpritesheetTool { + context: context.clone(), + } + .pricing( + &pricing, + &GenerateIconSpritesheetToolArgs { + reference_image_id: ImageId { + id: "image-1".to_string(), + }, + reference_image_ids: Vec::new(), + icon_descriptions: vec!["背包".to_string(), "地图".to_string()], + aspect_ratio: Some("1:1".to_string()), + image_size: Some("2K".to_string()), + }, + ), + 5 + ); + assert_eq!( + GenerateVideoTool { + context: context.clone(), + } + .pricing( + &pricing, + &GenerateVideoToolArgs { + prompt: "镜头缓慢推进".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: None, + duration_seconds: Some(6), + model: Some("seedance2.0".to_string()), + resolution: Some("720p".to_string()), + sound: None, + }, + ), + 144 + ); + assert_eq!( + GenerateSoundEffectTool.pricing( + &pricing, + &GenerateSoundEffectToolArgs { + prompt: "按钮点击声".to_string(), + duration: None, + model: None, + }, + ), + 5 + ); + assert_eq!( + GenerateBackgroundMusicTool.pricing( + &pricing, + &GenerateBackgroundMusicToolArgs { + prompt: "轻松背景音乐".to_string(), + make_instrumental: true, + }, + ), + 12 + ); +} + +#[test] +fn pricing_uses_the_supplied_runtime_snapshot() { + let mut pricing = load_editor_generation_pricing_from_paths(None) + .expect("default editor pricing should load"); + pricing + .models + .get_mut("gpt-image-2") + .expect("gpt image pricing should exist") + .prices + .insert("2K".to_string(), 37); + pricing + .models + .get_mut("seedance2.0-fast") + .expect("video pricing should exist") + .prices + .insert("720p".to_string(), 7); + pricing + .models + .get_mut("audio1.0") + .expect("sound pricing should exist") + .price = Some(19); + + assert_eq!( + GenerateImageTool { context: context() }.pricing(&pricing, &image_args(Some("2K"))), + 37 + ); + assert_eq!( + GenerateVideoTool { context: context() }.pricing( + &pricing, + &GenerateVideoToolArgs { + prompt: "four seconds".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: None, + duration_seconds: None, + model: None, + resolution: None, + sound: None, + }, + ), + 28 + ); + assert_eq!( + GenerateSoundEffectTool.pricing( + &pricing, + &GenerateSoundEffectToolArgs { + prompt: "sound".to_string(), + duration: None, + model: None, + }, + ), + 19 + ); +}