生成带参考图统一按编辑 concrete model 执行与计价

- 新增 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 工具档位路由单测
This commit is contained in:
2026-09-21 22:00:08 +08:00
parent f3c362b1ec
commit c38e4b4175
5 changed files with 255 additions and 49 deletions
@@ -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<GeneratedCharacterVisuals, AppError> {
// 中文注释:参考图存在时后端会走编辑端点,因此必须提交编辑模型而不是生成模型。
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,
@@ -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<u32, EditorAgentToolError> {
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();
@@ -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!(
@@ -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,
@@ -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<u32, AppError> {
async fn resolve_icon_spec_price(
state: &AppState,
has_reference_images: bool,
) -> Result<u32, AppError> {
Ok(state
.editor_generation_pricing()
.await
@@ -753,6 +758,7 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result<u32, AppError> {
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,