price compute

This commit is contained in:
2026-07-10 20:25:16 +08:00
parent c55a90d23e
commit c623ffed9c
11 changed files with 354 additions and 6 deletions
@@ -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<ImageId, ImageMetadata>,
}
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: &<Self as Tool>::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=NoneBFF 会先归一为 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))
}
@@ -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`
@@ -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(
@@ -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(
@@ -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,
@@ -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 {
@@ -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(
@@ -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(
@@ -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(
@@ -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;
@@ -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
);
}