模型定价改为默认配置叠加持久化覆盖
- 新增 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:
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user