diff --git a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md index b38217e25..88d789cad 100644 --- a/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md +++ b/docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md @@ -16,7 +16,7 @@ provider 白名单同时拒绝非 provider model:业务模型名 `gpt-image-2. ## Consequences -- 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。 +- 定价配置的活动 key 是两个具体 provider model;持久化 override 与 SpacetimeDB record 只保存被显式设置过的模型,缺失条目一律继承编译内置默认(`server-rs/crates/api-server/config/editor-generation-pricing.default.json`),不再为每个新模型维护受控 backfill。旧单 key 配置因此把 GPT Image 2.5 档位落在默认值上,要改这两个具体 key 的价格必须显式写出。 - 定价档位跟随任务最终 concrete model:带参考图的生成任务与编辑任务同价,admin 单独调整 `gpt-image-2.5-sunburst-c` 时生成带参考图的任务必须同步生效。 - 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。 - 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ca70e52ca..ccb4164f5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-09-22 模型定价改为「编译内置默认 + 持久化覆盖」叠加,删除受控 backfill + +- 背景:`editor-generation-pricing.default.json`(`include_str!` 编译内置)是默认模型定价;磁盘 override 与 SpacetimeDB record 以前是「整份替换」,旧配置缺新模型 key 时 `validate()` 直接失败。为了让升级前只含单个 `gpt-image-2` key 的配置能启动,代码维护了 `backfill_legacy_gpt_image_2_5_pricing`、`backfill_legacy_sfx_pricing` 两个受控 backfill 加 record 路径上的内联 SFX 补齐,并且每新增一个模型都要再写一次同类兼容代码。 +- 决策:两条加载路径统一改为「以编译内置默认 JSON 为基线,叠加持久化配置里显式给出的模型条目」(`overlay_editor_generation_pricing`):缺失模型继承默认值,写坏的条目仍由 `validate()` 拦截。删除两个 backfill 函数与 record 路径的内联补齐;磁盘 override 与 record 只保存被显式设置过的模型,admin 保存时仍写回完整模型集合。 +- 行为差异:旧配置里自定义过、但没有写出 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 的 `gpt-image-2` 价格不再自动传播到 GPT Image 2.5 档位,新任务按内置默认档(1K 3 / 2K 5 泥点)计价。仓库内置默认里三者本来就相同,因此只影响「自定义过 `gpt-image-2` 价格且此后从未再保存定价」的部署。 +- 迁移建议:只读检查 SpacetimeDB `editor_generation_pricing_config` 是否缺少两个 `gpt-image-2.5-*-c` 模型行(或与默认档位不一致);确认后在后台保存一次定价即可把继承值落库,或显式写入期望档位。 +- 影响范围:`server-rs/crates/api-server/src/editor_generation_config.rs`、`server-rs/crates/api-server/src/state.rs` 及其单测;ADR `docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md` 同步更新。 +- 验证:`cargo test -p api-server editor_generation_pricing` 17 项通过(含新的 `editor_generation_pricing_legacy_override_inherits_missing_models_from_default`、`editor_generation_pricing_override_still_rejects_invalid_model_entries`,以及改名为 `..._inherits_legacy_*` 的两条 record 用例)。 + ## 2026-09-22 删除失效的图片模型兜底审计链路 - 背景:跨模型兜底 `gpt-image-2-c` 删除后,`platform-image` 已没有任何地方写入 `recovered_failure_audits`:executor 只创建空 Vec、成功路径空 append、失败路径原样返回,`PlatformImageError::FallbackFailed` 不可能被构造,`GeneratedImages.recovered_failure_audits` 和错误上的 `recovered_failure_audits()` 恒为空,api-server 成功运行摘要的 `recoveredFailureCount` 恒为 `0`。这条链路既是死代码,也会让排障误以为仍存在兜底审计。 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 b846f949a..9679a80d7 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -383,89 +383,34 @@ fn load_editor_generation_pricing_from_candidates( if let Some(path) = selected_override_path { let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?; let source = path.to_string_lossy(); - let mut override_config = + let override_config = serde_json::from_str::(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 { + config = overlay_editor_generation_pricing(&config, override_config); + config.validate().map_err(|error| match error { EditorGenerationPricingError::Invalid(message) => { EditorGenerationPricingError::Invalid(format!("{source}: {message}")) } other => other, })?; - config = override_config; } config.validate()?; Ok(config) } -/// 兼容升级前只含单个历史 `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, -) -> 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 - // 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) - .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, - source: &str, -) -> Result<(), EditorGenerationPricingError> { - if config - .models - .contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - { - return Ok(()); - } - let pricing = fallback - .models - .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - .cloned() - .ok_or_else(|| { - EditorGenerationPricingError::Invalid(format!( - "{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}" - )) - })?; - config - .models - .insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); - Ok(()) +/// 磁盘 override 与 SpacetimeDB record 只需要保存「被显式设置过的模型」: +/// 缺失条目一律继承编译内置默认值。这样新增模型不再需要为每个历史配置 +/// 手写一次受控 backfill,旧配置缺 key 也不会再阻止 api-server 启动; +/// 校验仍然保留,用于拦截持久化配置里写坏的模型条目。 +pub(crate) fn overlay_editor_generation_pricing( + default_config: &EditorGenerationPricingConfig, + override_config: EditorGenerationPricingConfig, +) -> EditorGenerationPricingConfig { + let mut models = default_config.models.clone(); + models.extend(override_config.models); + EditorGenerationPricingConfig { models } } pub(crate) fn parse_editor_generation_pricing_json( @@ -923,14 +868,21 @@ mod tests { } #[test] - fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() { - let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test"); + fn editor_generation_pricing_legacy_override_inherits_missing_models_from_default() { + let temp_dir = unique_temp_dir("genarrative-pricing-legacy-inherit-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); + let default_config = default_runtime_pricing(); + // 中文注释:模拟升级前的 override:没有 GPT Image 2.5 的两个具体 key, + // 也没有新加入的 ElevenLabs 音效模型;显式设置过的是历史 Vidu 音效定价。 let mut legacy_config = default_runtime_pricing(); - legacy_config - .models - .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + for model in [ + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, + EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS, + ] { + legacy_config.models.remove(model); + } legacy_config .models .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) @@ -943,11 +895,27 @@ mod tests { .expect("legacy override should write"); let loaded = load_editor_generation_pricing_from_paths(Some(&override_path)) - .expect("legacy override should backfill the new SFX model"); + .expect("legacy override should inherit missing models from the default"); assert_eq!( - loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)), - 5 + loaded + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), + default_config + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) + ); + assert_eq!( + loaded.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), + default_config + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) + ); + assert_eq!( + loaded.models.get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS), + default_config + .models + .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) ); assert_eq!( loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)), @@ -957,17 +925,20 @@ mod tests { } #[test] - fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() { - let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test"); + fn editor_generation_pricing_override_still_rejects_invalid_model_entries() { + let temp_dir = unique_temp_dir("genarrative-pricing-override-invalid-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); let mut legacy_config = default_runtime_pricing(); - legacy_config - .models - .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + // 中文注释:缺模型可以继承默认值,但显式写坏的模型条目仍必须被拒绝。 legacy_config .models .remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO); + legacy_config + .models + .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) + .expect("legacy sound effect pricing should exist") + .price = Some(0); std::fs::write( &override_path, serde_json::to_string(&legacy_config).expect("legacy config should serialize"), @@ -975,12 +946,11 @@ mod tests { .expect("legacy override should write"); let error = load_editor_generation_pricing_from_paths(Some(&override_path)) - .expect_err("only the new SFX model may be backfilled"); + .expect_err("invalid override entry should be rejected"); assert!( - error - .to_string() - .contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) + error.to_string().contains(EDITOR_SOUND_EFFECT_MODEL_VIDU), + "unexpected error: {error}" ); std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); } @@ -1166,6 +1136,9 @@ mod tests { let temp_dir = unique_temp_dir("genarrative-pricing-test"); std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); let override_path = temp_dir.join("editor-generation-pricing.override.json"); + // 中文注释:override 只需要保存要覆盖的模型;要改 GPT Image 定价必须显式写出 + // 两个具体 provider key,缺 key 的旧配置继承编译内置默认值(见 + // editor_generation_pricing_legacy_override_inherits_missing_models_from_default)。 std::fs::write( &override_path, r#"{ @@ -1178,6 +1151,14 @@ mod tests { "unit": "perGeneration", "prices": { "1K": 31, "2K": 62 } }, + "gpt-image-2.5-flare-c": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, + "gpt-image-2.5-sunburst-c": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 11, "720p": 22, "1080p": 44 } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ce50fd451..825439adb 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -43,10 +43,11 @@ use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; +#[cfg(test)] +use crate::editor_generation_config::EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS; use crate::editor_generation_config::{ - EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS, EditorGenerationModelPricing, - EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, - EditorGenerationPricingUnit, + EditorGenerationModelPricing, EditorGenerationPricingConfig, EditorGenerationPricingError, + EditorGenerationPricingStore, EditorGenerationPricingUnit, }; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; @@ -459,7 +460,7 @@ fn editor_generation_pricing_to_records( fn editor_generation_pricing_from_record( record: EditorGenerationPricingConfigRecord, - legacy_fallback: &EditorGenerationPricingConfig, + base_config: &EditorGenerationPricingConfig, ) -> Result { let mut models = BTreeMap::new(); for pricing in record.models { @@ -499,26 +500,13 @@ fn editor_generation_pricing_from_record( ))); } } - if !models.contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) { - let pricing = legacy_fallback - .models - .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) - .cloned() - .ok_or_else(|| { - EditorGenerationPricingError::Invalid(format!( - "本地模型定价配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}" - )) - })?; - models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); - } - 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 模型定价配置", - )?; + // 中文注释:升级前写入的 record 只保存当时存在的模型(例如单个历史 + // gpt-image-2 key)。缺失条目统一继承本地已校验配置,不再为每个新模型 + // 维护受控 backfill;写坏的条目仍由 validate() 拦截。 + let config = crate::editor_generation_config::overlay_editor_generation_pricing( + base_config, + EditorGenerationPricingConfig { models }, + ); config.validate()?; Ok(config) } @@ -3063,7 +3051,7 @@ mod tests { } #[test] - fn editor_generation_pricing_typed_record_backfills_legacy_sfx_model() { + fn editor_generation_pricing_typed_record_inherits_legacy_sfx_model() { let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json( crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON, "test default pricing", @@ -3090,7 +3078,7 @@ mod tests { } #[test] - fn editor_generation_pricing_typed_record_backfills_legacy_gpt_image_keys() { + fn editor_generation_pricing_typed_record_inherits_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, }; @@ -3107,11 +3095,6 @@ mod tests { 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, @@ -3120,28 +3103,25 @@ mod tests { updated_at_micros: 2, }; - let actual = editor_generation_pricing_from_record(record, &fallback) - .expect("legacy single-key record should be backfilled instead of rejected"); + let actual = editor_generation_pricing_from_record(record, &fallback).expect( + "legacy single-key record should inherit missing keys instead of being 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("gpt-image-2"), + fallback.models.get("gpt-image-2") + ); assert_eq!( actual .models .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION), - Some(&expected) + fallback + .models + .get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION) ); assert_eq!( actual.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT), - Some(&expected) + fallback.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT) ); }