模型定价改为默认配置叠加持久化覆盖

- 新增 overlay_editor_generation_pricing,以编译内置默认为基线叠加持久化模型条目
- 删除 backfill_legacy_gpt_image_2_5_pricing 与 backfill_legacy_sfx_pricing,record 加载路径改为直接叠加
- 缺模型的旧 override / record 继承默认档位,写坏的条目仍由 validate() 拦截
- 更新 backfill 相关单测,补 legacy override 继承默认值与非法条目拒绝用例
- 同步 ADR 与决策记录里的受控 backfill 口径
This commit is contained in:
2026-09-22 14:42:34 +08:00
parent ff89951a9b
commit 84bb9ffd28
4 changed files with 103 additions and 133 deletions
@@ -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 配置缺失,启动失败而不是延迟到首次图片请求。
@@ -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`。这条链路既是死代码,也会让排障误以为仍存在兜底审计。
@@ -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::<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 {
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 }
+25 -45
View File
@@ -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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
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)
);
}