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 7754fa746..e3383d23b 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -393,7 +393,11 @@ fn load_editor_generation_pricing_from_candidates( Ok(config) } -fn backfill_legacy_gpt_image_2_5_pricing( +/// 兼容升级前只含单个历史 `gpt-image-2` key 的持久化定价配置。 +/// +/// 磁盘 override 与 SpacetimeDB record 两条加载路径都要调用,补齐缺失的 +/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 后才允许 `validate()`。 +pub(crate) fn backfill_legacy_gpt_image_2_5_pricing( config: &mut EditorGenerationPricingConfig, fallback: &EditorGenerationPricingConfig, source: &str, @@ -408,7 +412,8 @@ fn backfill_legacy_gpt_image_2_5_pricing( return Ok(()); } // TODO: compatibility backfill for legacy single-key pricing; remove once - // all persisted overrides contain the two explicit GPT Image 2.5 keys. + // every persisted override and SpacetimeDB record contains the two explicit + // GPT Image 2.5 keys. let pricing = config .models .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 49d721a48..82eebe5e1 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -495,7 +495,14 @@ fn editor_generation_pricing_from_record( })?; models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); } - let config = EditorGenerationPricingConfig { models }; + let mut config = EditorGenerationPricingConfig { models }; + // 中文注释:升级前写入的 record 可能只有单个历史 gpt-image-2 key, + // 与磁盘 override 一样先做受控 backfill,再进入必填校验。 + crate::editor_generation_config::backfill_legacy_gpt_image_2_5_pricing( + &mut config, + legacy_fallback, + "SpacetimeDB 模型定价配置", + )?; config.validate()?; Ok(config) } @@ -2875,6 +2882,138 @@ mod tests { ); } + #[test] + fn editor_generation_pricing_typed_record_backfills_legacy_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + // 中文注释:模拟升级前持久化的旧 record:只有单个历史 gpt-image-2 key。 + models.retain(|pricing| { + pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION + && pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT + }); + let legacy_pricing = models + .iter() + .find(|pricing| pricing.model == "gpt-image-2") + .cloned() + .expect("legacy record kept the single gpt-image-2 key"); + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 2, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback) + .expect("legacy single-key record should be backfilled instead of rejected"); + + assert_eq!(legacy_pricing.unit.as_str(), "perGeneration"); + let expected = EditorGenerationModelPricing { + unit: EditorGenerationPricingUnit::PerGeneration, + price: legacy_pricing.price, + prices: legacy_pricing + .prices + .iter() + .map(|tier| (tier.key.clone(), tier.price)) + .collect(), + }; + assert_eq!( + actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), + Some(&expected) + ); + assert_eq!( + actual.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), + Some(&expected) + ); + } + + #[test] + fn editor_generation_pricing_typed_record_keeps_explicit_gpt_image_keys() { + use crate::editor_generation_config::{ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + }; + + let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( + crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "test default pricing", + ) + .expect("default pricing should parse"); + let mut models = + editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); + for pricing in models.iter_mut() { + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 71, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 72, + }, + ]; + } + if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 81, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 82, + }, + ]; + } + if pricing.model == "gpt-image-2" { + pricing.prices = vec![ + EditorGenerationPricingTierRecord { + key: "1K".to_string(), + price: 1, + }, + EditorGenerationPricingTierRecord { + key: "2K".to_string(), + price: 2, + }, + ]; + } + } + let record = EditorGenerationPricingConfigRecord { + config_id: "global".to_string(), + models, + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at: "2026-09-18T00:00:00Z".to_string(), + updated_at_micros: 3, + }; + + let actual = editor_generation_pricing_from_record(record, &fallback) + .expect("explicit GPT Image 2.5 keys should be preserved"); + + let generation = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + .expect("generation key should exist"); + assert_eq!(generation.prices.get("1K"), Some(&71)); + assert_eq!(generation.prices.get("2K"), Some(&72)); + let edit = actual + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + .expect("edit key should exist"); + assert_eq!(edit.prices.get("1K"), Some(&81)); + assert_eq!(edit.prices.get("2K"), Some(&82)); + } + #[test] fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() { let mut config = AppConfig::default();