接入 GPT Image 2.5 双 provider 启动路由

启动时分别构造 VectorEngine 与 Tiantoken 图片 client

按生成、编辑和 nanobanana 模型选择具体 provider

迁移 api-server、编辑器 Agent 与 raw edit 调用方

拆分 GPT Image 2.5 生成与编辑定价并隐藏 provider 具体值
This commit is contained in:
2026-09-18 16:28:45 +08:00
parent c34d24c2c8
commit b294cbc3db
16 changed files with 430 additions and 160 deletions
@@ -15,6 +15,20 @@
"2K": 5
}
},
"gpt-image-2.5-flare-c": {
"unit": "perGeneration",
"prices": {
"1K": 3,
"2K": 5
}
},
"gpt-image-2.5-sunburst-c": {
"unit": "perGeneration",
"prices": {
"1K": 3,
"2K": 5
}
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": {
@@ -36,16 +36,16 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings,
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_GENERATION_MODEL,
OpenAiImageSettings, build_openai_image_http_client,
create_openai_image_generation_with_model, require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
};
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL;
const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_5_BUSINESS_NAME;
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
@@ -777,13 +777,13 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
}
fn resolve_character_visual_model(value: &str) -> String {
// 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-proRPG 主图当前统一归一到 gpt-image-2
// 中文注释:旧前端和历史草稿可能仍传旧模型;只在新任务提交边界归一到当前业务模型
let trimmed = value.trim();
if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL {
tracing::warn!(
requested_model = trimmed,
effective_model = CHARACTER_VISUAL_MODEL,
"角色主形象图片模型已归一到 gpt-image-2"
"角色主形象图片模型已归一到当前业务模型"
);
}
CHARACTER_VISUAL_MODEL.to_string()
@@ -946,9 +946,10 @@ async fn create_character_visual_generation_once(
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let generated = create_openai_image_generation(
let generated = create_openai_image_generation_with_model(
http_client,
settings,
GPT_IMAGE_2_5_GENERATION_MODEL,
prompt,
Some(build_character_visual_negative_prompt().as_str()),
size,
@@ -1921,12 +1922,12 @@ mod tests {
}
#[test]
fn legacy_character_visual_model_normalizes_to_gpt_image_2() {
fn legacy_character_visual_model_normalizes_to_gpt_image_2_5() {
assert_eq!(
resolve_character_visual_model("wan2.7-image-pro"),
"gpt-image-2"
"gpt-image-2.5"
);
assert_eq!(resolve_character_visual_model(""), "gpt-image-2");
assert_eq!(resolve_character_visual_model(""), "gpt-image-2.5");
}
#[test]
+6 -15
View File
@@ -1428,18 +1428,15 @@ impl AppConfig {
}
}
/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider
///
/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。
/// Tiantoken 是 GPT Image 2.5 图片任务的独立 provider;凭证不得回退到 VectorEngine
pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"])
.unwrap_or_else(|| config.vector_engine_base_url.clone())
let _ = config;
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]).unwrap_or_default()
}
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
let _ = config;
read_first_non_empty_env(&["TIANTOKEN_API_KEY"])
.or_else(|| config.vector_engine_api_key.clone())
}
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
@@ -1864,14 +1861,8 @@ mod tests {
std::env::remove_var("TIANTOKEN_BASE_URL");
std::env::remove_var("TIANTOKEN_API_KEY");
}
assert_eq!(
tiantoken_base_url(&config),
"https://vector.example.invalid"
);
assert_eq!(
tiantoken_api_key(&config).as_deref(),
Some("legacy-vector-key")
);
assert_eq!(tiantoken_base_url(&config), "");
assert_eq!(tiantoken_api_key(&config), None);
}
#[test]
@@ -17,6 +17,9 @@ pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str =
include_str!("../config/editor-generation-pricing.default.json");
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS: &str = "gpt-image-2.5";
pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION: &str = "gpt-image-2.5-flare-c";
pub(crate) const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT: &str = "gpt-image-2.5-sunburst-c";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
@@ -94,6 +97,25 @@ pub(crate) enum EditorGenerationPricingError {
}
impl EditorGenerationPricingConfig {
/// Public main-site projection: provider-specific GPT Image keys remain an
/// admin/server concern and are represented by the business model name.
pub(crate) fn public_projection(&self) -> Self {
let mut models = self.models.clone();
if let Some(generation) = models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION)
.cloned()
{
models.insert(
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS.to_string(),
generation,
);
}
models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION);
models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT);
models.remove(EDITOR_IMAGE_MODEL_GPT_IMAGE_2);
Self { models }
}
pub(crate) fn image_model_mud_points(
&self,
model: Option<&str>,
@@ -114,15 +136,35 @@ impl EditorGenerationPricingConfig {
)
}
pub(crate) fn image_edit_model_mud_points(
&self,
model: Option<&str>,
image_size: Option<&str>,
) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_editor_image_edit_model(model);
let normalized_size =
normalize_editor_generation_image_price_size(normalized_model, image_size);
read_tier_price(
&self.models,
normalized_model,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT,
normalized_size,
DEFAULT_IMAGE_PRICE_SIZE,
)
}
pub(crate) fn spec_model_mud_points(&self, model: Option<&str>) -> u32 {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2);
let normalized_model = normalize_editor_image_model(model);
read_tier_price(
&self.models,
normalized_model,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
SPEC_IMAGE_PRICE_SIZE,
SPEC_IMAGE_PRICE_SIZE,
)
@@ -221,7 +263,13 @@ impl EditorGenerationPricingConfig {
)?;
validate_required_tier_prices(
&self.models,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
EditorGenerationPricingUnit::PerGeneration,
REQUIRED_GPT_IMAGE_SIZES,
)?;
validate_required_tier_prices(
&self.models,
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT,
EditorGenerationPricingUnit::PerGeneration,
REQUIRED_GPT_IMAGE_SIZES,
)?;
@@ -326,6 +374,7 @@ fn load_editor_generation_pricing_from_candidates(
serde_json::from_str::<EditorGenerationPricingConfig>(override_json.as_str())
.map_err(EditorGenerationPricingError::Json)?;
backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?;
backfill_legacy_gpt_image_2_5_pricing(&mut override_config, &config, source.as_ref())?;
override_config.validate().map_err(|error| match error {
EditorGenerationPricingError::Invalid(message) => {
EditorGenerationPricingError::Invalid(format!("{source}: {message}"))
@@ -338,6 +387,43 @@ fn load_editor_generation_pricing_from_candidates(
Ok(config)
}
fn backfill_legacy_gpt_image_2_5_pricing(
config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig,
source: &str,
) -> Result<(), EditorGenerationPricingError> {
if config
.models
.contains_key(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION)
&& config
.models
.contains_key(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT)
{
return Ok(());
}
// TODO: compatibility backfill for legacy single-key pricing; remove once
// all persisted overrides contain the two explicit GPT Image 2.5 keys.
let pricing = config
.models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2)
.or_else(|| fallback.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2))
.cloned()
.ok_or_else(|| {
EditorGenerationPricingError::Invalid(format!(
"{source}: 受控默认配置缺少模型 {EDITOR_IMAGE_MODEL_GPT_IMAGE_2} 的兼容泥点配置"
))
})?;
config
.models
.entry(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION.to_string())
.or_insert_with(|| pricing.clone());
config
.models
.entry(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT.to_string())
.or_insert(pricing);
Ok(())
}
fn backfill_legacy_sfx_pricing(
config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig,
@@ -437,7 +523,12 @@ fn default_runtime_pricing() -> EditorGenerationPricingConfig {
fn normalize_editor_image_model(model: Option<&str>) -> &'static str {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2,
Some(
EDITOR_IMAGE_MODEL_GPT_IMAGE_2
| EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS
| EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT,
Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2,
@@ -445,6 +536,18 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str {
}
}
fn normalize_editor_image_edit_model(model: Option<&str>) -> &'static str {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT,
Some(
EDITOR_IMAGE_MODEL_GPT_IMAGE_2
| EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_BUSINESS
| EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT,
_ => normalize_editor_image_model(model),
}
}
fn normalize_editor_generation_image_price_size(
model: &str,
image_size: Option<&str>,
@@ -112,8 +112,9 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings,
OpenAiReferenceImage, build_openai_image_http_client,
DownloadedOpenAiImage, GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL,
GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages,
OpenAiImageSettings, OpenAiReferenceImage, build_openai_image_http_client,
create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
require_openai_image_settings,
@@ -374,6 +375,9 @@ pub(crate) struct EditorImagePromptBuildOutput {
pub(crate) struct EditorImageProviderRequest<'a> {
pub(crate) model: &'a str,
/// Concrete provider/pricing route selected by api-server. The business
/// model remains `model`; this value must never be exposed to normal UI.
pub(crate) provider_model: &'a str,
pub(crate) prompt: &'a str,
pub(crate) negative_prompt: Option<&'a str>,
pub(crate) size: &'a str,
@@ -392,7 +396,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_nanobanana_generate_content(
http_client,
settings,
request.model,
request.provider_model,
request.prompt,
request.negative_prompt,
request.aspect_ratio,
@@ -405,7 +409,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_image_generation_with_model(
http_client,
settings,
request.model,
request.provider_model,
request.prompt,
request.negative_prompt,
request.size,
@@ -418,7 +422,7 @@ pub(crate) async fn request_editor_generated_images(
create_openai_image_edit_with_references_and_model(
http_client,
settings,
request.model,
request.provider_model,
request.prompt,
request.negative_prompt,
request.size,
@@ -1926,7 +1930,10 @@ pub async fn get_editor_generation_pricing(
"message": error.to_string(),
}))
})?;
Ok(json_success_body(Some(&request_context), pricing))
Ok(json_success_body(
Some(&request_context),
pricing.public_projection(),
))
}
pub async fn list_editor_projects(
@@ -2764,7 +2771,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else {
payload.model.as_deref()
},
@@ -2854,7 +2861,7 @@ pub(crate) async fn validate_editor_image_generation_parameters_for_owner(
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else {
payload.model.as_deref()
},
@@ -2987,7 +2994,7 @@ where
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
Some(GPT_IMAGE_2_5_BUSINESS_NAME)
} else {
payload.model.as_deref()
},
@@ -3181,6 +3188,11 @@ where
&settings,
EditorImageProviderRequest {
model: generation_options.model,
provider_model: if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
generation_options.model
} else {
GPT_IMAGE_2_5_GENERATION_MODEL
},
prompt: submitted_prompt.as_str(),
negative_prompt,
size: provider_request_size.as_ref(),
@@ -3832,7 +3844,7 @@ async fn resolve_editor_image_edit_price(
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size));
.image_edit_model_mud_points(Some(model), Some(price_size));
Ok(expected_price_mud_points)
}
@@ -3975,7 +3987,15 @@ pub(crate) fn normalize_editor_generation_options(
image_size: Option<&str>,
) -> EditorGenerationOptions {
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL,
// TODO: compatibility for persisted/client legacy `gpt-image-2`; do not
// rewrite the stored record, only use the current business value when a
// new task is submitted.
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_BUSINESS_NAME,
Some(
EDITOR_IMAGE_MODEL_NANOBANANA2
| EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS
@@ -3983,7 +4003,7 @@ pub(crate) fn normalize_editor_generation_options(
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
None => GPT_IMAGE_2_MODEL,
None => GPT_IMAGE_2_5_BUSINESS_NAME,
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
};
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio);
@@ -6048,7 +6068,7 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot(
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
GPT_IMAGE_2_5_EDIT_MODEL,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、变形主体"),
provider_size.as_str(),
@@ -8493,7 +8513,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
GPT_IMAGE_2_5_GENERATION_MODEL,
prompt.as_str(),
None,
generation_options.provider_size.as_str(),
@@ -17817,7 +17837,7 @@ mod tests {
#[test]
fn editor_generation_dimensions_follow_model_options() {
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
assert_eq!(default_generation.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(default_generation.size, "1024x1024");
let nanobanana = normalize_editor_generation_options(
@@ -17847,7 +17867,7 @@ mod tests {
assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9");
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K"));
assert_eq!(gpt.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(gpt.size, "683x1024");
assert_eq!(gpt.provider_size, "688x1024");
assert_eq!(gpt.aspect_ratio, "2:3");
@@ -17862,21 +17882,21 @@ mod tests {
let gpt_cover =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_cover.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(gpt_cover.size, "1024x768");
assert_eq!(gpt_cover.provider_size, "1024x768");
assert_eq!(gpt_cover.aspect_ratio, "4:3");
let gpt_landscape_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K"));
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(gpt_landscape_2k.size, "2048x1152");
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
assert_eq!(gpt_landscape_2k.image_size, "2K");
let gpt_portrait_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K"));
assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(gpt_portrait_2k.size, "1152x2048");
assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16");
assert_eq!(gpt_portrait_2k.image_size, "2K");
@@ -19989,7 +20009,7 @@ mod tests {
)
.expect("UI extraction dimensions should pass");
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
assert_eq!(generation_options.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(generation_options.size, "2048x2048");
assert_eq!(
resolve_editor_ui_design_asset_extraction_price(
@@ -64,7 +64,7 @@ use crate::{
state::AppState,
};
const ICON_SPEC_MODEL: &str = "gpt-image-2";
const ICON_SPEC_MODEL: &str = "gpt-image-2.5";
const ICON_SPEC_ASPECT_RATIO: &str = "16:9";
const ICON_SPEC_IMAGE_SIZE: &str = "2K";
const ICON_SPEC_SIZE: &str = "2048x1152";
@@ -1,17 +1,14 @@
use axum::http::StatusCode;
use platform_image::{
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
create_vector_engine_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
create_vector_engine_nanobanana_generate_content,
DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings,
NANOBANANA_2_MODEL, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
VECTOR_ENGINE_PROVIDER, build_image_http_client, create_image_edit,
create_image_edit_with_references, create_image_edit_with_references_and_model,
create_image_generation, create_image_generation_with_model,
create_nanobanana_generate_content,
};
#[cfg(test)]
use platform_image::{
build_vector_engine_image_request_body, vector_engine_images_edit_url,
vector_engine_images_generation_url,
};
use platform_image::{build_image_request_body, images_edit_url, images_generation_url};
use serde_json::{Value, json};
use std::time::Instant;
use time::OffsetDateTime;
@@ -27,9 +24,10 @@ use crate::{
tracking::record_external_generation_run_after_success,
};
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
#[cfg(test)]
use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL;
pub(crate) use platform_image::{
GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL,
GPT_IMAGE_2_MODEL,
};
pub(crate) type OpenAiGeneratedImages = GeneratedImages;
pub(crate) type DownloadedOpenAiImage = DownloadedImage;
@@ -45,6 +43,8 @@ pub(crate) struct OpenAiImageSettings {
pub external_api_audit_user_id: Option<String>,
pub external_api_audit_profile_id: Option<String>,
pub external_api_audit_request_id: Option<String>,
pub tiantoken_client: Option<ImageProviderClient>,
pub vector_engine_client: Option<ImageProviderClient>,
}
impl std::fmt::Debug for OpenAiImageSettings {
@@ -71,6 +71,11 @@ impl std::fmt::Debug for OpenAiImageSettings {
"external_api_audit_request_id",
&self.external_api_audit_request_id,
)
.field("tiantoken_client_enabled", &self.tiantoken_client.is_some())
.field(
"vector_engine_client_enabled",
&self.vector_engine_client.is_some(),
)
.finish()
}
}
@@ -110,18 +115,22 @@ pub(crate) fn require_openai_image_settings(
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: Some(state.tiantoken_image_client().clone()),
vector_engine_client: Some(state.vector_engine_image_client().clone()),
})
}
pub(crate) fn build_openai_image_http_client(
settings: &OpenAiImageSettings,
) -> Result<reqwest::Client, AppError> {
build_vector_engine_image_http_client(&settings.provider_settings())
.map_err(map_platform_image_error)
if let Some(client) = settings.tiantoken_client.as_ref() {
return Ok(client.http_client().clone());
}
build_image_http_client(&settings.provider_settings()).map_err(map_platform_image_error)
}
pub(crate) async fn create_openai_image_generation(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
@@ -138,9 +147,11 @@ pub(crate) async fn create_openai_image_generation(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_generation(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(GPT_IMAGE_2_5_GENERATION_MODEL);
let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_GENERATION_MODEL);
let result = create_image_generation(
provider_client.http_client(),
&provider_settings,
prompt,
negative_prompt,
size,
@@ -162,7 +173,7 @@ pub(crate) async fn create_openai_image_generation(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_image_generation_with_model(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
@@ -181,9 +192,11 @@ pub(crate) async fn create_openai_image_generation_with_model(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_generation_with_model(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(model);
let provider_settings = settings.provider_settings_for_model(model);
let result = create_image_generation_with_model(
provider_client.http_client(),
&provider_settings,
model,
prompt,
negative_prompt,
@@ -206,7 +219,7 @@ pub(crate) async fn create_openai_image_generation_with_model(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_nanobanana_generate_content(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
@@ -225,9 +238,11 @@ pub(crate) async fn create_openai_nanobanana_generate_content(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_nanobanana_generate_content(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(model);
let provider_settings = settings.provider_settings_for_model(model);
let result = create_nanobanana_generate_content(
provider_client.http_client(),
&provider_settings,
model,
prompt,
negative_prompt,
@@ -249,7 +264,7 @@ pub(crate) async fn create_openai_nanobanana_generate_content(
}
pub(crate) async fn create_openai_image_edit(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
@@ -264,9 +279,11 @@ pub(crate) async fn create_openai_image_edit(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": 1,
});
let result = create_vector_engine_image_edit(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
let result = create_image_edit(
provider_client.http_client(),
&provider_settings,
prompt,
negative_prompt,
size,
@@ -286,7 +303,7 @@ pub(crate) async fn create_openai_image_edit(
}
pub(crate) async fn create_openai_image_edit_with_references(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
@@ -303,9 +320,11 @@ pub(crate) async fn create_openai_image_edit_with_references(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
let provider_settings = settings.provider_settings_for_model(GPT_IMAGE_2_5_EDIT_MODEL);
let result = create_image_edit_with_references(
provider_client.http_client(),
&provider_settings,
prompt,
negative_prompt,
size,
@@ -327,7 +346,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_image_edit_with_references_and_model(
http_client: &reqwest::Client,
_http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
@@ -345,9 +364,11 @@ pub(crate) async fn create_openai_image_edit_with_references_and_model(
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references_and_model(
http_client,
&settings.provider_settings(),
let provider_client = settings.client_for_model(model);
let provider_settings = settings.provider_settings_for_model(model);
let result = create_image_edit_with_references_and_model(
provider_client.http_client(),
&provider_settings,
model,
prompt,
negative_prompt,
@@ -376,7 +397,7 @@ pub(crate) fn build_openai_image_request_body(
candidate_count: u32,
reference_images: &[String],
) -> Value {
build_vector_engine_image_request_body(
build_image_request_body(
prompt,
negative_prompt,
size,
@@ -386,6 +407,24 @@ pub(crate) fn build_openai_image_request_body(
}
impl OpenAiImageSettings {
fn client_for_model(&self, model: &str) -> &ImageProviderClient {
if model == NANOBANANA_2_MODEL {
self.vector_engine_client
.as_ref()
.expect("vector engine image client is initialized at startup")
} else {
self.tiantoken_client
.as_ref()
.expect("tiantoken image client is initialized at startup")
}
}
fn provider_settings_for_model(&self, model: &str) -> ImageProviderSettings {
let mut settings = self.client_for_model(model).settings().clone();
settings.request_deadline = self.request_deadline;
settings
}
pub(crate) fn with_external_api_audit_actor(
mut self,
user_id: Option<String>,
@@ -409,8 +448,9 @@ impl OpenAiImageSettings {
self
}
pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings {
VectorEngineImageSettings {
pub(crate) fn provider_settings(&self) -> ImageProviderSettings {
ImageProviderSettings {
provider: ImageProvider::Tiantoken,
base_url: self.base_url.clone(),
api_key: self.api_key.clone(),
request_timeout_ms: self.request_timeout_ms.max(1),
@@ -578,13 +618,13 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
}
#[cfg(test)]
fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String {
vector_engine_images_generation_url(&settings.provider_settings())
fn images_generation_url_for_test(settings: &OpenAiImageSettings) -> String {
images_generation_url(&settings.provider_settings())
}
#[cfg(test)]
fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String {
vector_engine_images_edit_url(&settings.provider_settings())
fn images_edit_url_for_test(settings: &OpenAiImageSettings) -> String {
images_edit_url(&settings.provider_settings())
}
#[cfg(test)]
@@ -611,6 +651,8 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
}
.with_external_api_audit_context(&request_context, None, None);
@@ -631,7 +673,7 @@ mod tests {
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL);
assert_eq!(body["size"], "1536x1024");
assert_eq!(body["n"], 2);
assert!(body.get("official_fallback").is_none());
@@ -650,6 +692,8 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
@@ -660,14 +704,16 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
};
assert_eq!(
vector_engine_images_generation_url_for_test(&root_settings),
images_generation_url_for_test(&root_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_generation_url_for_test(&v1_settings),
images_generation_url_for_test(&v1_settings),
"https://vector.example/v1/images/generations"
);
}
@@ -683,6 +729,8 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
@@ -693,14 +741,16 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
};
assert_eq!(
vector_engine_images_edit_url_for_test(&root_settings),
images_edit_url_for_test(&root_settings),
"https://vector.example/v1/images/edits"
);
assert_eq!(
vector_engine_images_edit_url_for_test(&v1_settings),
images_edit_url_for_test(&v1_settings),
"https://vector.example/v1/images/edits"
);
}
@@ -716,6 +766,8 @@ mod tests {
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
tiantoken_client: None,
vector_engine_client: None,
};
let http_client = reqwest::Client::new();
@@ -764,7 +816,7 @@ mod tests {
latency_ms: Some(321),
prompt_chars: Some(42),
reference_image_count: Some(1),
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
image_model: Some(GPT_IMAGE_2_MODEL),
};
let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(
&build_external_api_failure_draft_from_platform_image_audit(&audit),
@@ -782,10 +834,7 @@ mod tests {
assert_eq!(tracking.metadata["retryable"], true);
assert_eq!(tracking.metadata["promptChars"], 42);
assert_eq!(tracking.metadata["referenceImageCount"], 1);
assert_eq!(
tracking.metadata["imageModel"],
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
);
assert_eq!(tracking.metadata["imageModel"], GPT_IMAGE_2_MODEL);
}
}
+4 -4
View File
@@ -6,8 +6,8 @@ use axum::{
use bytes::Bytes;
use image::{ImageDecoder, ImageFormat, ImageReader};
use platform_image::{
GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS,
RawImageEditImage, RawImageEditOptions, create_vector_engine_raw_image_edit,
GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_EDIT_MODEL, RAW_IMAGE_MAX_EDGE,
RAW_IMAGE_MAX_PIXELS, RawImageEditImage, RawImageEditOptions, create_raw_image_edit,
validate_raw_image_edit_dimensions,
};
use serde::Serialize;
@@ -148,7 +148,7 @@ pub(crate) async fn edit_raw_image(
});
let started_at_micros = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64;
let operation = async move {
let generated = match create_vector_engine_raw_image_edit(
let generated = match create_raw_image_edit(
&http_client,
&provider_settings,
prepared.prompt.as_str(),
@@ -572,7 +572,7 @@ async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Resu
.editor_generation_pricing()
.await
.map(|pricing| {
pricing.image_generation_mud_points(Some("quick-edit"), Some("gpt-image-2"), Some(tier))
pricing.image_edit_model_mud_points(Some(GPT_IMAGE_2_5_EDIT_MODEL), Some(tier))
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+71
View File
@@ -23,6 +23,9 @@ use platform_auth::{
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_image::{
ImageProvider, ImageProviderClient, ImageProviderSettings, build_image_provider_client,
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField};
use platform_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -306,6 +309,8 @@ pub struct AppStateInner {
/// 非 Suno 的文本、图片和旧版音频生成 provider 配置。
tiantoken_base_url: String,
tiantoken_api_key: Option<String>,
vector_engine_image_client: ImageProviderClient,
tiantoken_image_client: ImageProviderClient,
matting_client: Option<MattingClient>,
bgfilter_provider_http_client: reqwest::Client,
bgfilter_worker_http_client: reqwest::Client,
@@ -605,6 +610,18 @@ impl AppState {
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
let tiantoken_api_key = crate::config::tiantoken_api_key(&config);
let vector_engine_image_client = build_required_image_provider_client(
ImageProvider::VectorEngine,
config.vector_engine_base_url.clone(),
config.vector_engine_api_key.clone(),
config.vector_engine_image_request_timeout_ms,
)?;
let tiantoken_image_client = build_required_image_provider_client(
ImageProvider::Tiantoken,
tiantoken_base_url.clone(),
tiantoken_api_key.clone(),
config.vector_engine_image_request_timeout_ms,
)?;
let llm_client = build_llm_client(&config)?;
let vector_engine_llm_client = build_vector_engine_llm_client(
&config,
@@ -688,6 +705,8 @@ impl AppState {
vector_engine_llm_client,
tiantoken_base_url,
tiantoken_api_key,
vector_engine_image_client,
tiantoken_image_client,
matting_client,
bgfilter_provider_http_client,
bgfilter_worker_http_client,
@@ -1598,6 +1617,14 @@ impl AppState {
self.tiantoken_api_key.as_deref()
}
pub(crate) fn vector_engine_image_client(&self) -> &ImageProviderClient {
&self.vector_engine_image_client
}
pub(crate) fn tiantoken_image_client(&self) -> &ImageProviderClient {
&self.tiantoken_image_client
}
pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref()
}
@@ -2309,6 +2336,50 @@ impl AdminRuntime {
}
}
fn build_required_image_provider_client(
provider: ImageProvider,
base_url: String,
api_key: Option<String>,
request_timeout_ms: u64,
) -> Result<ImageProviderClient, AppStateInitError> {
#[cfg(test)]
let (base_url, api_key) = (
if base_url.trim().is_empty() {
"http://127.0.0.1".to_string()
} else {
base_url
},
api_key.or_else(|| Some("test-key".to_string())),
);
#[cfg(not(test))]
let (base_url, api_key) = (base_url, api_key);
let base_url = base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(AppStateInitError::DependencyUnavailable(format!(
"{} 图片 provider 缺少 BASE_URL 配置",
provider.as_str()
)));
}
let api_key = api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppStateInitError::DependencyUnavailable(format!(
"{} 图片 provider 缺少 API_KEY 配置",
provider.as_str()
))
})?;
build_image_provider_client(ImageProviderSettings {
provider,
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: request_timeout_ms.max(1),
request_deadline: None,
})
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let oss_fields = [
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
@@ -2,7 +2,7 @@ use crate::agent::asset::ImageId;
use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description};
use crate::agent::tools::context::EditorToolContext;
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind};
use platform_image::GPT_IMAGE_2_MODEL;
use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::error::Error;
@@ -33,7 +33,7 @@ impl Display for EditImageError {
EditImageError::InvalidModel(model) => {
write!(
f,
"{model} is not a valid model name, only {GPT_IMAGE_2_MODEL} is supported for now."
"{model} is not a valid model name, only {GPT_IMAGE_2_5_BUSINESS_NAME} is supported for now."
)
}
}
@@ -52,7 +52,7 @@ pub struct EditImageToolArgs {
pub model: String,
}
fn default_model_name() -> String {
GPT_IMAGE_2_MODEL.to_string()
GPT_IMAGE_2_5_BUSINESS_NAME.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,9 +90,9 @@ impl Tool for EditImageTool {
// TODO need to introduce size param, but that needs more metadata such as original image size, skip in this version
"model": {
"type": "string",
"enum": [GPT_IMAGE_2_MODEL],
"default": GPT_IMAGE_2_MODEL,
"description": format!("图片编辑固定使用{GPT_IMAGE_2_MODEL}")
"enum": [GPT_IMAGE_2_5_BUSINESS_NAME],
"default": GPT_IMAGE_2_5_BUSINESS_NAME,
"description": format!("图片编辑固定使用{GPT_IMAGE_2_5_BUSINESS_NAME}")
}
},
"required": ["object_image_id", "prompt"],
@@ -155,7 +155,7 @@ pub struct EditorImageEditResult {
impl EditImageTool {
/// Validate the semantic correctness of the arguments.
pub fn validate_args(&self, args: &EditImageToolArgs) -> Option<EditImageError> {
if args.model != GPT_IMAGE_2_MODEL {
if args.model != GPT_IMAGE_2_5_BUSINESS_NAME {
return Some(EditImageError::InvalidModel(args.model.clone()));
}
if args.prompt.trim().is_empty() {
@@ -210,7 +210,7 @@ mod tests {
"sourceType": "generated",
"prompt": "修改图片",
"actualPrompt": "修改后的图片",
"model": "gpt-image-2",
"model": "gpt-image-2.5",
"taskId": "task-2",
"resource": null,
"asset": null,
@@ -10,7 +10,7 @@ use crate::agent::tools::image_generation_options::{
validate_image_generation_options,
};
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind};
use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL};
use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::error::Error;
@@ -39,7 +39,7 @@ impl Display for GenerateIconSpritesheetError {
match self {
Self::InvalidModel(model) => write!(
f,
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}"
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}"
),
Self::InvalidAspectRatio(aspect_ratio) => {
write!(f, "invalid aspect ratio: {aspect_ratio}")
@@ -8,7 +8,7 @@ use crate::agent::tools::image_generation_options::{
validate_image_generation_options,
};
use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind};
use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL};
use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::error::Error;
@@ -33,11 +33,11 @@ impl Display for GenerateImageError {
match self {
Self::InvalidModel(model) => write!(
f,
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}"
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}"
),
Self::UnsupportedUiDesignModel(model) => write!(
f,
"{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_MODEL}"
"{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_5_BUSINESS_NAME}"
),
Self::InvalidAspectRatio(aspect_ratio) => {
write!(f, "invalid aspect ratio: {aspect_ratio}")
@@ -222,7 +222,7 @@ mod tests {
"sourceType": "generated",
"prompt": "生成一张图片",
"actualPrompt": "生成一张清晰图片",
"model": "gpt-image-2",
"model": "gpt-image-2.5",
"taskId": "task-1",
"resource": null,
"asset": null,
@@ -12,7 +12,7 @@ use crate::agent::tools::image_generation_options::{
};
use crate::framework::tool::ToolFailureKind;
use crate::framework::tool::{Tool, ToolFailure};
use platform_image::GPT_IMAGE_2_MODEL;
use platform_image::GPT_IMAGE_2_5_BUSINESS_NAME;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -34,7 +34,7 @@ pub struct GenerateUiDesignToolArgs {
}
fn default_ui_design_model() -> String {
GPT_IMAGE_2_MODEL.to_string()
GPT_IMAGE_2_5_BUSINESS_NAME.to_string()
}
impl Tool for GenerateUiDesignTool {
@@ -54,9 +54,9 @@ impl Tool for GenerateUiDesignTool {
"prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" },
"model": {
"type": "string",
"enum": [GPT_IMAGE_2_MODEL],
"default": GPT_IMAGE_2_MODEL,
"description": "UI 设计图固定使用 gpt-image-2。"
"enum": [GPT_IMAGE_2_5_BUSINESS_NAME],
"default": GPT_IMAGE_2_5_BUSINESS_NAME,
"description": "UI 设计图固定使用 gpt-image-2.5"
},
"reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc UI 风格或布局" },
"aspect_ratio": image_aspect_ratio_parameter_schema(),
@@ -95,7 +95,7 @@ impl Tool for GenerateUiDesignTool {
impl GenerateUiDesignTool {
pub fn validate_args(&self, args: &GenerateUiDesignToolArgs) -> Result<(), GenerateImageError> {
if args.model != GPT_IMAGE_2_MODEL {
if args.model != GPT_IMAGE_2_5_BUSINESS_NAME {
return Err(GenerateImageError::UnsupportedUiDesignModel(
args.model.clone(),
));
@@ -165,11 +165,11 @@ mod tests {
assert_eq!(
parameters["properties"]["model"]["enum"],
json!([GPT_IMAGE_2_MODEL])
json!([GPT_IMAGE_2_5_BUSINESS_NAME])
);
assert_eq!(
parameters["properties"]["model"]["default"],
GPT_IMAGE_2_MODEL
GPT_IMAGE_2_5_BUSINESS_NAME
);
assert_eq!(
parameters["properties"]["image_size"]["enum"],
@@ -184,7 +184,10 @@ mod tests {
.as_array()
.is_some_and(|required| required.contains(&json!("model")))
);
assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok());
assert!(
tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME))
.is_ok()
);
assert!(matches!(
tool.validate_args(&args(NANOBANANA_2_MODEL)),
Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL
@@ -198,7 +201,7 @@ mod tests {
}))
.expect("旧版 UI 设计参数应能反序列化");
assert_eq!(args.model, GPT_IMAGE_2_MODEL);
assert_eq!(args.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert!(tool().validate_args(&args).is_ok());
}
}
@@ -1,4 +1,4 @@
use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL};
use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL};
use serde_json::{Value, json};
use std::error::Error;
use std::fmt::Display;
@@ -21,7 +21,7 @@ impl Display for ImageGenerationOptionsError {
match self {
Self::InvalidModel(model) => write!(
f,
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}"
"{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_5_BUSINESS_NAME}"
),
Self::InvalidAspectRatio(aspect_ratio) => write!(
f,
@@ -75,9 +75,9 @@ pub fn validate_image_generation_options(
pub fn image_model_parameter_schema() -> Value {
json!({
"type": "string",
"enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL],
"enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME],
"default": NANOBANANA_2_MODEL,
"description": "生图模型。默认 gemini-3.1-flash-image-previewuser may call it nanobanana2);也可选择 gpt-image-2。"
"description": "生图模型。默认 gemini-3.1-flash-image-previewuser may call it nanobanana2);也可选择 gpt-image-2.5"
})
}
@@ -95,7 +95,7 @@ pub fn image_size_parameter_schema() -> Value {
"type": "string",
"enum": NANOBANANA_2_IMAGE_SIZES,
"default": DEFAULT_IMAGE_SIZE,
"description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2Kgpt-image-2 仅支持 1K、2K;默认 1K。"
"description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2Kgpt-image-2.5 仅支持 1K、2K;默认 1K。"
})
}
@@ -104,14 +104,14 @@ pub fn gpt_image_2_size_parameter_schema() -> Value {
"type": "string",
"enum": GPT_IMAGE_2_IMAGE_SIZES,
"default": DEFAULT_IMAGE_SIZE,
"description": "图片尺寸档位。gpt-image-2 仅支持 1K、2K;默认 1K。"
"description": "图片尺寸档位。gpt-image-2.5 仅支持 1K、2K;默认 1K。"
})
}
pub fn image_model_size_constraint_schema() -> Value {
json!({
"if": {
"properties": { "model": { "const": GPT_IMAGE_2_MODEL } },
"properties": { "model": { "const": GPT_IMAGE_2_5_BUSINESS_NAME } },
// model 省略时运行时默认 nanobanana2,仍允许 0.5K。
"required": ["model"]
},
@@ -126,7 +126,7 @@ pub fn image_model_size_constraint_schema() -> Value {
fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> {
match model {
NANOBANANA_2_MODEL => Some(NANOBANANA_2_IMAGE_SIZES),
GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_IMAGE_SIZES),
GPT_IMAGE_2_5_BUSINESS_NAME => Some(GPT_IMAGE_2_IMAGE_SIZES),
_ => None,
}
}
@@ -150,14 +150,18 @@ mod tests {
}
for image_size in GPT_IMAGE_2_IMAGE_SIZES {
assert!(
validate_image_generation_options(GPT_IMAGE_2_MODEL, aspect_ratio, image_size,)
.is_ok()
validate_image_generation_options(
GPT_IMAGE_2_5_BUSINESS_NAME,
aspect_ratio,
image_size,
)
.is_ok()
);
}
}
assert!(matches!(
validate_image_generation_options(GPT_IMAGE_2_MODEL, "1:1", "0.5K"),
validate_image_generation_options(GPT_IMAGE_2_5_BUSINESS_NAME, "1:1", "0.5K"),
Err(ImageGenerationOptionsError::InvalidImageSize { .. })
));
assert!(matches!(
@@ -192,7 +196,7 @@ mod tests {
let model_size_constraint = image_model_size_constraint_schema();
assert_eq!(
model_size_constraint["if"]["properties"]["model"]["const"],
GPT_IMAGE_2_MODEL
GPT_IMAGE_2_5_BUSINESS_NAME
);
assert_eq!(model_size_constraint["if"]["required"], json!(["model"]));
assert_eq!(
@@ -26,7 +26,7 @@ mod tests {
use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs};
use crate::framework::tool::Tool;
use platform_audio::{ELEVENLABS_SOUND_EFFECT_MODEL, SUNO_DEFAULT_MODEL};
use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL};
use platform_image::{GPT_IMAGE_2_5_BUSINESS_NAME, NANOBANANA_2_MODEL};
use serde_json::json;
#[test]
@@ -69,11 +69,11 @@ mod tests {
assert_eq!(image.model, NANOBANANA_2_MODEL);
assert_eq!(image.aspect_ratio, "1:1");
assert_eq!(image.image_size, "1K");
assert_eq!(edit.model, GPT_IMAGE_2_MODEL);
assert_eq!(edit.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(character.model, NANOBANANA_2_MODEL);
assert_eq!(character.aspect_ratio, "1:1");
assert_eq!(character.image_size, "1K");
assert_eq!(ui_design.model, GPT_IMAGE_2_MODEL);
assert_eq!(ui_design.model, GPT_IMAGE_2_5_BUSINESS_NAME);
assert_eq!(ui_design.aspect_ratio, "1:1");
assert_eq!(ui_design.image_size, "1K");
assert_eq!(icon.model, NANOBANANA_2_MODEL);
@@ -133,14 +133,17 @@ mod tests {
};
assert!(tool.validate_args(&args(NANOBANANA_2_MODEL)).is_ok());
assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok());
assert!(
tool.validate_args(&args(GPT_IMAGE_2_5_BUSINESS_NAME))
.is_ok()
);
assert!(matches!(
tool.validate_args(&args("unknown-image-model")),
Err(GenerateImageError::InvalidModel(_))
));
assert_eq!(
tool.parameters()["properties"]["model"]["enum"],
json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL])
json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_5_BUSINESS_NAME])
);
}
@@ -166,7 +169,7 @@ mod tests {
));
let ui_args = GenerateUiDesignToolArgs {
prompt: "生成游戏主界面".to_string(),
model: GPT_IMAGE_2_MODEL.to_string(),
model: GPT_IMAGE_2_5_BUSINESS_NAME.to_string(),
reference_image_ids: vec![missing_image.clone()],
aspect_ratio: "16:9".to_string(),
image_size: "1K".to_string(),
@@ -208,7 +211,7 @@ mod tests {
assert_eq!(
edit["properties"]["model"]["enum"],
json!([GPT_IMAGE_2_MODEL])
json!([GPT_IMAGE_2_5_BUSINESS_NAME])
);
assert_eq!(
video["properties"]["duration_seconds"]["enum"],
@@ -229,7 +232,7 @@ mod tests {
for schema in [&image, &character, &icon] {
assert_eq!(
schema["allOf"][0]["if"]["properties"]["model"]["const"],
json!(GPT_IMAGE_2_MODEL)
json!(GPT_IMAGE_2_5_BUSINESS_NAME)
);
assert_eq!(
schema["allOf"][0]["then"]["properties"]["image_size"]["enum"],
@@ -44,6 +44,7 @@ import {
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_GPT_IMAGE_2_5 = 'gpt-image-2.5';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
@@ -51,7 +52,7 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
'nanobanana2',
'nano-banana',
]);
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2_5;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const SPEC_GENERATION_SIZE = '2048x1152';
@@ -93,6 +94,12 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
'1K': 12,
'2K': 24,
},
[IMAGE_MODEL_GPT_IMAGE_2_5]: {
'1K': 3,
'2K': 5,
},
// Legacy persisted model value; keep readable for old assets without exposing
// it as a new selectable model.
[IMAGE_MODEL_GPT_IMAGE_2]: {
'1K': 3,
'2K': 5,
@@ -127,7 +134,7 @@ export const QUICK_EDIT_SIZE_PRESETS = [
] as const;
export const EDITOR_IMAGE_MODEL_OPTIONS = [
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ label: 'GPT Image 2.5', value: IMAGE_MODEL_GPT_IMAGE_2_5 },
] as const;
export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS;
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
@@ -135,6 +142,10 @@ export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
imageSizes: ['0.5K', '1K', '2K'],
},
[IMAGE_MODEL_GPT_IMAGE_2_5]: {
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
imageSizes: ['1K', '2K'],
},
[IMAGE_MODEL_GPT_IMAGE_2]: {
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
imageSizes: ['1K', '2K'],
@@ -381,9 +392,9 @@ export const EDITOR_MODEL_MUD_POINT_CONFIG = {
unit: 'perGeneration',
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2],
},
[IMAGE_MODEL_GPT_IMAGE_2]: {
[IMAGE_MODEL_GPT_IMAGE_2_5]: {
unit: 'perGeneration',
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2_5],
},
[VIDEO_MODEL_SEEDANCE_2_FAST]: {
unit: 'perSecond',
@@ -1452,7 +1463,7 @@ export function decodeCanvasGenerationInputs(
} else if (action === 'ui-design.generate') {
normalizeStringField('prompt', '用户输入');
normalizeImageParameters({
defaultModel: IMAGE_MODEL_GPT_IMAGE_2,
defaultModel: IMAGE_MODEL_GPT_IMAGE_2_5,
defaultAspectRatio: '16:9',
});
} else if (action === 'image.edit') {
@@ -1993,7 +2004,7 @@ export function buildUiDesignGenerationInputs(
...createGenerationInputField('用户输入', prompt, 'prompt'),
...createGenerationInputField(
'模型',
options.model ?? IMAGE_MODEL_GPT_IMAGE_2,
options.model ?? IMAGE_MODEL_GPT_IMAGE_2_5,
'model',
),
...createGenerationInputField(