From c38e4b41752a6d2116a2277aa37b7a790344afdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:00:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90=E5=B8=A6=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=9B=BE=E7=BB=9F=E4=B8=80=E6=8C=89=E7=BC=96=E8=BE=91=20concre?= =?UTF-8?q?te=20model=20=E6=89=A7=E8=A1=8C=E4=B8=8E=E8=AE=A1=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 editor_image_generation_concrete_model 作为生成类任务选择 concrete model 的唯一入口,dispatch 与计价共用同一判据 - image_generation_mud_points 增加 has_reference_images,命中编辑 concrete model 时改走 image_edit_model_mud_points - editor_project/editor_project_icon 的 provider_model 改用共享解析函数,替换三处重复的带参考图 if 表达式 - character_visual_assets 的角色主形象生成改用共享解析函数 - 图标素材图集、UI 素材提取、图标规范带 referenceId、Agent 生成类工具带 referenceImageIds 的计价同步切到编辑档 - EditImageTool 计价从生成档改为编辑档,与直连快速编辑、raw edit 对齐 - normalize_editor_generation_options 的具体 provider key 改用常量替换字符串字面量 - 补充参考图档位路由与 Agent 工具档位路由单测 --- .../api-server/src/character_visual_assets.rs | 18 +- .../api-server/src/editor_agent/tool.rs | 76 ++++++++- .../src/editor_generation_config.rs | 154 +++++++++++++++++- .../crates/api-server/src/editor_project.rs | 31 ++-- .../api-server/src/editor_project_icon.rs | 25 +-- 5 files changed, 255 insertions(+), 49 deletions(-) diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 2eaa89e74..b266e3326 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -34,11 +34,12 @@ use crate::{ build_character_visual_negative_prompt, build_character_visual_prompt, build_fallback_moderation_safe_character_visual_prompt, }, + editor_generation_config::editor_image_generation_concrete_model, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, - GPT_IMAGE_2_5_GENERATION_MODEL, OpenAiImageSettings, build_openai_image_http_client, - create_openai_image_generation_with_model, require_openai_image_settings, + DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, OpenAiImageSettings, + build_openai_image_http_client, create_openai_image_generation_with_model, + require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, @@ -940,18 +941,15 @@ async fn create_character_visual_generation( async fn create_character_visual_generation_once( http_client: &reqwest::Client, settings: &OpenAiImageSettings, - _model: &str, + model: &str, prompt: &str, size: &str, candidate_count: u32, reference_images: &[String], ) -> Result { - // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑模型而不是生成模型。 - let provider_model = if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }; + // 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑 concrete model 而不是生成模型。 + let provider_model = + editor_image_generation_concrete_model(Some(model), !reference_images.is_empty()); let generated = create_openai_image_generation_with_model( http_client, settings, diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index e5e3b69f2..4255b0b62 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -347,8 +347,9 @@ fn editor_agent_image_mud_points( kind: Option<&str>, model: &str, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - pricing.image_generation_mud_points(kind, Some(model), image_size) + pricing.image_generation_mud_points(kind, Some(model), image_size, has_reference_images) } impl EditorAgentTool for GenerateImageTool { @@ -370,6 +371,7 @@ impl EditorAgentTool for GenerateImageTool { None, args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -479,6 +481,7 @@ impl EditorAgentTool for GenerateCharacterTool { Some("character"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -591,6 +594,7 @@ impl EditorAgentTool for GenerateUiDesignTool { Some("ui-design"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -697,13 +701,10 @@ impl EditorAgentTool for EditImageTool { pricing: &EditorGenerationPricingConfig, args: &Value, ) -> Result { - let _: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; - Ok(editor_agent_image_mud_points( - pricing, - Some("quick-edit"), - GPT_IMAGE_2_5_BUSINESS_NAME, - Some("1K"), - )) + let args: EditImageToolArgs = parse_internal_image_args("edit image args", args)?; + // 中文注释:修改图片按编辑 concrete model 执行,计价必须走编辑档, + // 不能沿用生成档(admin 分开调价时生成档价格即错价)。 + Ok(pricing.image_edit_model_mud_points(Some(args.model.as_str()), Some("1K"))) } fn build_display_args( @@ -851,6 +852,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { Some("icon"), args.model.as_str(), Some(args.image_size.as_str()), + !args.reference_image_ids.is_empty(), )) } @@ -1298,7 +1300,8 @@ mod tests { use super::*; use crate::editor_generation_config::{ - EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, load_editor_generation_pricing_from_paths, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + load_editor_generation_pricing_from_paths, }; fn pricing() -> EditorGenerationPricingConfig { @@ -1626,6 +1629,61 @@ mod tests { } } + #[test] + fn dyn_pricing_routes_edit_tools_and_referenced_generation_to_the_edit_tier() { + let mut pricing = pricing(); + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit tier pricing should exist") + .prices + .insert("1K".to_string(), 41); + pricing + .models + .get_mut(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation tier pricing should exist") + .prices + .insert("1K".to_string(), 7); + + let context = context_with_image("image-1"); + let edit = + editor_agent_tool(EditImageTool::NAME, &context).expect("edit tool should resolve"); + let edit_args = edit + .validate_args(&json!({ "object_image_id": "image-1", "prompt": "修改" })) + .expect("edit args should validate"); + assert_eq!(edit.pricing(&pricing, &edit_args).expect("pricing"), 41); + + let generate = editor_agent_tool(GenerateImageTool::NAME, &context) + .expect("image tool should resolve"); + let without_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K" + })) + .expect("generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &without_reference) + .expect("pricing"), + 7 + ); + let with_reference = generate + .validate_args(&json!({ + "prompt": "生成图片", + "model": GPT_IMAGE_2_5_BUSINESS_NAME, + "image_size": "1K", + "reference_image_ids": ["image-1"] + })) + .expect("referenced generation args should validate"); + assert_eq!( + generate + .pricing(&pricing, &with_reference) + .expect("pricing"), + 41 + ); + } + #[test] fn dyn_pricing_uses_the_supplied_runtime_snapshot() { let mut pricing = pricing(); diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 90bc1259f..b846f949a 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -180,10 +180,18 @@ impl EditorGenerationPricingConfig { kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { return price_mud_points; } + // 中文注释:生成任务带参考图时执行端改走编辑 concrete model,计价必须同步切到编辑档, + // 否则会出现「生成档扣费 + 编辑档执行/审计」的分裂。 + if editor_image_generation_concrete_model(model, has_reference_images) + == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + { + return self.image_edit_model_mud_points(model, image_size); + } match kind.map(str::trim) { Some("spec") => self.spec_model_mud_points(model), Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => { @@ -490,8 +498,14 @@ pub(crate) fn editor_image_generation_mud_points( kind: Option<&str>, model: Option<&str>, image_size: Option<&str>, + has_reference_images: bool, ) -> u32 { - default_runtime_pricing().image_generation_mud_points(kind, model, image_size) + default_runtime_pricing().image_generation_mud_points( + kind, + model, + image_size, + has_reference_images, + ) } #[cfg(test)] @@ -558,6 +572,27 @@ fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str { } } +/// 生成类图片任务最终发送给 provider 的 concrete model。 +/// +/// 中文注释:GPT Image 2.5 生成任务只要带参考图,执行端就必须改走 edits multipart, +/// 因此执行、审计与计价统一使用编辑 concrete model `gpt-image-2.5-sunburst-c`; +/// nanobanana 的生成与编辑共用同一个 concrete model,不受参考图影响。 +/// 这里是生成类任务选择 concrete model 的唯一入口,dispatch 与计价都不得再各写一份判断。 +pub(crate) fn editor_image_generation_concrete_model( + model: Option<&str>, + has_reference_images: bool, +) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some( + EDITOR_IMAGE_MODEL_NANOBANANA2 + | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS + | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, + ) => EDITOR_IMAGE_MODEL_NANOBANANA2, + _ if has_reference_images => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + _ => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + } +} + fn normalize_editor_generation_image_price_size( model: &str, image_size: Option<&str>, @@ -808,7 +843,8 @@ mod tests { config.image_generation_mud_points( Some("character"), Some("gpt-image-2"), - Some("2K") + Some("2K"), + false ), queued_price ); @@ -955,7 +991,8 @@ mod tests { editor_image_generation_mud_points( Some("image"), Some("gemini-3.1-flash-image-preview"), - Some("0.5K") + Some("0.5K"), + false ), 8 ); @@ -963,7 +1000,8 @@ mod tests { editor_image_generation_mud_points( Some("character"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); @@ -971,24 +1009,124 @@ mod tests { editor_image_generation_mud_points( Some("scene"), Some("gemini-3.1-flash-image-preview"), - Some("1K") + Some("1K"), + false ), 12 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("1K"), + false + ), 3 ); assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); assert_eq!( - editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")), + editor_image_generation_mud_points( + Some("spec"), + Some("gpt-image-2"), + Some("2K"), + false + ), 5 ); } + #[test] + fn editor_image_generation_with_references_prices_at_the_edit_tier() { + let mut config = default_runtime_pricing(); + for (model, price_1k, price_2k) in [ + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, 7, 9), + (EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, 41, 43), + ] { + let prices = &mut config + .models + .get_mut(model) + .expect("gpt image pricing should exist") + .prices; + prices.insert("1K".to_string(), price_1k); + prices.insert("2K".to_string(), price_2k); + } + + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + false + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + ); + assert_eq!( + editor_image_generation_concrete_model( + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + true + ), + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + ); + assert_eq!( + editor_image_generation_concrete_model(Some(EDITOR_IMAGE_MODEL_NANOBANANA2), true), + EDITOR_IMAGE_MODEL_NANOBANANA2 + ); + + // GPT Image 2.5 生成带参考图按编辑 concrete model 执行,计价必须同步切到编辑档。 + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + true + ), + 41 + ); + assert_eq!( + config.image_generation_mud_points( + Some("character"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("1K"), + false + ), + 7 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + true + ), + 43 + ); + assert_eq!( + config.image_generation_mud_points( + Some("spec"), + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS), + Some("2K"), + false + ), + 9 + ); + // nanobanana 生成与编辑共用一个 concrete model,参考图不改变计价档位。 + assert_eq!( + config.image_generation_mud_points( + None, + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some("1K"), + true + ), + 12 + ); + } + #[test] fn editor_video_and_character_animation_share_model_resolution_rates() { assert_eq!( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6749dd47f..90ea6fcb9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -77,6 +77,7 @@ use crate::{ with_editor_generation_durable_billing_boundary, }, auth::{AuthenticatedAccessToken, optional_access_token_from_headers}, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, @@ -2920,6 +2921,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ), ); let source_entity_id = editor_generation_source_entity_id( @@ -3011,6 +3013,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + editor_generation_has_reference_images(payload.reference_image_srcs.as_deref()), ); Ok(()) } @@ -3222,6 +3225,7 @@ where normalized_kind, Some(generation_options.model), Some(generation_options.image_size), + !reference_images.is_empty(), ) }; let prompt_generation_inputs = payload.generation_inputs.take(); @@ -3291,13 +3295,10 @@ where &settings, EditorImageProviderRequest { model: generation_options.model, - provider_model: if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - generation_options.model - } else if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: submitted_prompt.as_str(), negative_prompt, size: provider_request_size.as_ref(), @@ -4098,8 +4099,8 @@ pub(crate) fn normalize_editor_generation_options( Some( GPT_IMAGE_2_MODEL | GPT_IMAGE_2_5_BUSINESS_NAME - | "gpt-image-2.5-flare-c" - | "gpt-image-2.5-sunburst-c", + | GPT_IMAGE_2_5_GENERATION_MODEL + | GPT_IMAGE_2_5_EDIT_MODEL, ) => GPT_IMAGE_2_5_BUSINESS_NAME, Some( EDITOR_IMAGE_MODEL_NANOBANANA2 @@ -12082,7 +12083,8 @@ async fn resolve_editor_ui_design_asset_extraction_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size); + // 中文注释:UI 素材提取始终以 UI 设计图作为来源图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true); Ok(expected_price_mud_points) } @@ -13278,6 +13280,14 @@ pub(crate) fn normalize_editor_reference_image_sources(sources: Option<&[String] .collect() } +/// 生成类任务是否带参考图。 +/// +/// 中文注释:dispatch、审计与计价必须共用同一个判据,否则又会出现 +/// 「生成档计价 + 编辑档执行」的分裂。 +pub(crate) fn editor_generation_has_reference_images(sources: Option<&[String]>) -> bool { + !normalize_editor_reference_image_sources(sources).is_empty() +} + pub(crate) fn ensure_editor_reference_image_source_limit( sources: Option<&[String]>, limit: usize, @@ -20058,6 +20068,7 @@ mod tests { Some("icon"), None, Some("1K"), + true, ), spritesheet_resource: None, spritesheet_asset: None, diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 8c18b7e7c..1e252094e 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -42,6 +42,7 @@ use crate::{ execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, }, auth::AuthenticatedAccessToken, + editor_generation_config::editor_image_generation_concrete_model, editor_generation_queue::{ EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, @@ -246,7 +247,8 @@ pub(crate) async fn resolve_editor_icon_spritesheet_price( "message": error.to_string(), })) })? - .image_generation_mud_points(Some("icon"), model, image_size)) + // 中文注释:图标素材图集始终以图标规范图为参考图,GPT Image 2.5 固定走编辑 concrete model。 + .image_generation_mud_points(Some("icon"), model, image_size, true)) } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -660,7 +662,7 @@ pub(crate) async fn enqueue_icon_spec_generation_for_owner( payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; validate_icon_spec_reference_for_owner(state, caller, &payload).await?; - let price_mud_points = resolve_icon_spec_price(state).await?; + let price_mud_points = resolve_icon_spec_price(state, payload.reference_id.is_some()).await?; enqueue_editor_generation_job_for_caller( state, request_context, @@ -740,7 +742,10 @@ async fn validate_icon_spec_reference_for_owner( Ok(()) } -async fn resolve_icon_spec_price(state: &AppState) -> Result { +async fn resolve_icon_spec_price( + state: &AppState, + has_reference_images: bool, +) -> Result { Ok(state .editor_generation_pricing() .await @@ -753,6 +758,7 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result { Some("spec"), Some(ICON_SPEC_MODEL), Some(ICON_SPEC_IMAGE_SIZE), + has_reference_images, )) } @@ -1774,15 +1780,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &settings, EditorImageProviderRequest { model: generation_options.model, - provider_model: if generation_options.model - == EDITOR_IMAGE_MODEL_NANOBANANA2 - { - generation_options.model - } else if reference_images.is_empty() { - GPT_IMAGE_2_5_GENERATION_MODEL - } else { - GPT_IMAGE_2_5_EDIT_MODEL - }, + provider_model: editor_image_generation_concrete_model( + Some(generation_options.model), + !reference_images.is_empty(), + ), prompt: prompt.as_str(), negative_prompt: None, size,