修复模型定价运行时身份边界

仅允许 HTTP 角色初始化定价,非 HTTP 角色改用队列只读鉴权
移除定价 procedure 快照中的 writer identity 并同步 bindings
后台保存复用受保护 bootstrap secret 支持缺失配置自愈
补充回归测试与架构运维文档
This commit is contained in:
2026-07-10 19:50:55 +08:00
parent 187d7735b9
commit f848cbf78b
9 changed files with 106 additions and 36 deletions
File diff suppressed because one or more lines are too long
@@ -543,7 +543,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorGenerationPricingConfig`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global``models: Vec<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 记录首次初始化的真实 `ctx.sender()`;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时 `api-server` 通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。
- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global``models: Vec<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer当前生产 API / worker / controller 因此继承同一 runtime token迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。
- 索引:主键 `config_id`
### `editor_generation_runtime_identity_rotation`
@@ -57,7 +57,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api``external-generation-worker``external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_background_removal` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
生产拆分角色时,`external-generation-worker``external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。
生产拆分角色时,`external-generation-worker``external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。
生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env``/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`
@@ -10,7 +10,7 @@
- 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表。
- 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。
默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。首次读取发现表为空时,`api-server`用当前本地缓存尝试写入 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。
默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。只有 HTTP 角色会在启动恢复阶段用当前本地缓存尝试初始化空的 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。`external-generation-worker` / `external-generation-controller` 不调用定价 initializer,而是在启动时通过只读的 queue-stats procedure 验证当前 SpacetimeDB identity 是否具备队列运行权限。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。
## 配置结构
@@ -57,13 +57,13 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 SpacetimeDB `editor_generation_pricing_config`;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。
后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为 `writer_identity`;表已存在时 initializer 只接受同一 writerbootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。
后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为表内 `writer_identity`procedure 对外返回的定价快照不包含该身份字段,公开主站和后台仍只经 BFF 读取价格。表已存在时 initializer 只接受同一 writerbootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer;若运行中的配置行意外缺失,保存请求会携带 `AppConfig` 已读取的受保护 bootstrap secret 完成原子首次写入,已有配置不会消费该 secret,也不会隐式轮换 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。
所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`
## 运行时身份首次授权
模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operatorbootstrap secret 不得再新增或接管 operator。
模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限;因此当前生产 API、worker 和 controller 必须继承同一份 runtime token。非 HTTP 角色只做 queue procedure 鉴权预检,不具备 seed 或轮换身份的职责。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operatorbootstrap secret 不得再新增或接管 operator。
运行时 token 轮换必须由已授权迁移操作员显式调用 `rotate_editor_generation_runtime_service_identity_and_return`,传入新 identity、操作员用户 ID 和原因。该 procedure 会拒绝把新 writer 设成当前 writer 或任一已登记 migration operator,只修改 writer,不覆盖模型价格,并向 `editor_generation_runtime_identity_rotation` 写入旧 writer、新 writer、迁移操作员 identity、操作人、原因和服务端时间。生产使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`:要求当前 SpacetimeDB CLI 登录 identity 与 `--operator-identity` 一致,并通过 `--next-writer-identity` / `--confirm-next-writer-identity` 双录确认;执行成功后必须核对审计行,再切换 API token。不得把 API 启动、普通定价 upsert 或 bootstrap secret 复用成隐式轮换流程。
@@ -89,7 +89,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝
## 验证
- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、路由保存与公开读取测试。
- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、仅 HTTP 角色 seed、非 HTTP 角色 queue-stats 鉴权预检、路由保存与公开读取不返回 `writer_identity`测试。
- 外部生成任务在配置变更前后仍按入队价格扣费并落资产的测试;旧 attempt 退款先于迟到 consume 时,持久化 settlement intent 必须阻止迟到扣费;最终 attempt lease 过期后必须直接失败结算且不再返回 provider executor。
- 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。
- 前端价格读取、运行时覆盖、图片尺寸档位计算测试。
+37 -10
View File
@@ -203,9 +203,12 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
))
})?;
state
.ensure_editor_generation_runtime_service_identity()
.spacetime_client()
.get_external_generation_queue_stats()
.await
.map_err(|error| io::Error::other(format!("初始化模型定价服务身份失败:{error}")))?;
.map_err(|error| {
io::Error::other(format!("验证外部生成队列运行时服务身份失败:{error}"))
})?;
spawn_common_app_state_background_workers(&state);
info!(
process_role = process_role.as_str(),
@@ -239,6 +242,10 @@ fn should_restore_auth_store_for_startup(process_role: ProcessRole) -> bool {
process_role.runs_http()
}
fn should_initialize_editor_generation_pricing_for_startup(process_role: ProcessRole) -> bool {
process_role.runs_http()
}
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
let bind_address = config.bind_socket_addr();
let listen_backlog = config.listen_backlog;
@@ -470,6 +477,7 @@ async fn restore_app_state_for_startup(
async fn try_restore_app_state_for_startup(
config: AppConfig,
) -> Result<AppState, state::AppStateInitError> {
let process_role = config.process_role;
let state = match timeout(
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
AppState::try_restore_auth_store_from_spacetime(config),
@@ -487,14 +495,16 @@ async fn try_restore_app_state_for_startup(
));
}
};
state
.ensure_editor_generation_runtime_service_identity()
.await
.map_err(|error| {
state::AppStateInitError::DependencyUnavailable(format!(
"初始化模型定价服务身份失败:{error}"
))
})?;
if should_initialize_editor_generation_pricing_for_startup(process_role) {
state
.ensure_editor_generation_runtime_service_identity()
.await
.map_err(|error| {
state::AppStateInitError::DependencyUnavailable(format!(
"初始化模型定价服务身份失败:{error}"
))
})?;
}
Ok(state)
}
@@ -574,6 +584,7 @@ fn is_valid_env_key(key: &str) -> bool {
mod tests {
use super::{
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
should_initialize_editor_generation_pricing_for_startup,
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
strip_env_value,
};
@@ -636,6 +647,22 @@ mod tests {
));
}
#[test]
fn editor_generation_pricing_initialization_is_limited_to_http_roles() {
assert!(should_initialize_editor_generation_pricing_for_startup(
ProcessRole::Api
));
assert!(should_initialize_editor_generation_pricing_for_startup(
ProcessRole::All
));
assert!(!should_initialize_editor_generation_pricing_for_startup(
ProcessRole::ExternalGenerationWorker
));
assert!(!should_initialize_editor_generation_pricing_for_startup(
ProcessRole::ExternalGenerationController
));
}
#[test]
fn profile_recharge_expiration_listener_is_limited_to_http_roles() {
assert!(should_start_profile_recharge_expiration_listener(ProcessRole::Api));
+50 -15
View File
@@ -406,6 +406,23 @@ fn editor_generation_pricing_from_record(
Ok(config)
}
fn editor_generation_pricing_upsert_input(
config: &AppConfig,
admin_user_id: String,
models: Vec<EditorGenerationModelPricingRecord>,
updated_at_micros: i64,
) -> EditorGenerationPricingConfigUpsertRecordInput {
EditorGenerationPricingConfigUpsertRecordInput {
admin_user_id,
models,
updated_at_micros,
bootstrap_secret: config
.spacetime_runtime_service_bootstrap_secret
.clone()
.unwrap_or_default(),
}
}
impl AppState {
#[cfg(test)]
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
@@ -605,12 +622,12 @@ impl AppState {
let record = self
.spacetime_client
.upsert_editor_generation_pricing_config(
EditorGenerationPricingConfigUpsertRecordInput {
editor_generation_pricing_upsert_input(
&self.config,
admin_user_id,
models,
updated_at_micros: crate::editor_project::current_utc_micros(),
bootstrap_secret: String::new(),
},
crate::editor_project::current_utc_micros(),
),
)
.await
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
@@ -637,16 +654,12 @@ impl AppState {
let record = self
.spacetime_client
.initialize_editor_generation_pricing_config_if_missing(
EditorGenerationPricingConfigUpsertRecordInput {
admin_user_id: "system:editor-generation-pricing".to_string(),
editor_generation_pricing_upsert_input(
&self.config,
"system:editor-generation-pricing".to_string(),
models,
updated_at_micros: crate::editor_project::current_utc_micros(),
bootstrap_secret: self
.config
.spacetime_runtime_service_bootstrap_secret
.clone()
.unwrap_or_default(),
},
crate::editor_project::current_utc_micros(),
),
)
.await
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
@@ -2002,7 +2015,6 @@ mod tests {
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at: "2026-07-10T00:00:00Z".to_string(),
updated_at_micros: 1,
writer_identity_hex: "11".repeat(32),
};
let actual = editor_generation_pricing_from_record(record)
@@ -2011,6 +2023,30 @@ mod tests {
assert_eq!(actual, expected);
}
#[test]
fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() {
let mut config = AppConfig::default();
let without_secret = editor_generation_pricing_upsert_input(
&config,
"admin:test".to_string(),
Vec::new(),
123,
);
assert_eq!(without_secret.bootstrap_secret, "");
config.spacetime_runtime_service_bootstrap_secret = Some("11".repeat(32));
let with_secret = editor_generation_pricing_upsert_input(
&config,
"admin:test".to_string(),
Vec::new(),
123,
);
assert_eq!(with_secret.bootstrap_secret, "11".repeat(32));
assert_eq!(with_secret.admin_user_id, "admin:test");
assert_eq!(with_secret.updated_at_micros, 123);
}
#[test]
fn editor_generation_pricing_typed_record_rejects_duplicate_model() {
let config = crate::editor_generation_config::parse_editor_generation_pricing_json(
@@ -2027,7 +2063,6 @@ mod tests {
updated_by_admin_user_id: None,
updated_at: "2026-07-10T00:00:00Z".to_string(),
updated_at_micros: 1,
writer_identity_hex: "11".repeat(32),
};
let error =
@@ -197,7 +197,6 @@ pub struct EditorGenerationPricingConfigRecord {
pub updated_by_admin_user_id: Option<String>,
pub updated_at: String,
pub updated_at_micros: i64,
pub writer_identity_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -1341,7 +1340,6 @@ fn map_editor_generation_pricing_config_snapshot(
updated_by_admin_user_id: snapshot.updated_by_admin_user_id,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
writer_identity_hex: snapshot.writer_identity.to_hex().to_string(),
}
}
@@ -13,7 +13,6 @@ pub struct EditorGenerationPricingConfigSnapshot {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub writer_identity: __sdk::Identity,
}
impl __sdk::InModule for EditorGenerationPricingConfigSnapshot {
@@ -536,7 +536,6 @@ pub struct EditorGenerationPricingConfigSnapshot {
pub models: Vec<EditorGenerationModelPricing>,
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub writer_identity: Identity,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -3057,7 +3056,6 @@ fn editor_generation_pricing_config_snapshot_from_row(
models: row.models,
updated_by_admin_user_id: row.updated_by_admin_user_id,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
writer_identity: row.writer_identity,
}
}
@@ -4151,6 +4149,18 @@ mod tests {
assert_eq!(normalized[0].model, EDITOR_GENERATION_SOUND_EFFECT_MODEL);
}
#[test]
fn editor_generation_pricing_procedure_snapshot_omits_writer_identity() {
let snapshot = EditorGenerationPricingConfigSnapshot {
config_id: EDITOR_GENERATION_PRICING_CONFIG_ID.to_string(),
models: valid_editor_generation_pricing_models(),
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at_micros: 1,
};
assert!(!format!("{snapshot:?}").contains("writer_identity"));
}
#[test]
fn normalize_editor_generation_pricing_models_rejects_missing_required_model() {
let mut models = valid_editor_generation_pricing_models();