From fba894106800bb700df7d1afe4d418e24c697da5 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:22:15 +0800 Subject: [PATCH] feat(pricing): add configurable model prices and manual catalog sync (#159) Co-authored-by: Zyy Co-authored-by: Codex --- README.md | 1 + backend/apps/gateway/src/bootstrap.rs | 1 + backend/apps/gateway/tests/architecture.rs | 1 + backend/crates/gateway-admin/src/lib.rs | 6 +- backend/crates/gateway-admin/src/model/mod.rs | 1 + .../gateway-admin/src/model/observability.rs | 13 + .../crates/gateway-admin/src/model/pricing.rs | 42 +++ backend/crates/gateway-admin/src/ports/mod.rs | 1 + .../crates/gateway-admin/src/ports/pricing.rs | 9 + .../gateway-admin/src/ports/provider.rs | 13 + .../crates/gateway-admin/src/ports/store.rs | 12 + .../src/use_case/observability.rs | 33 +- .../gateway-admin/src/use_case/settings.rs | 126 ++++++++ .../gateway-admin/tests/use_case/accounts.rs | 17 + .../tests/use_case/freeze_recovery.rs | 17 + .../gateway-admin/tests/use_case/mod.rs | 33 ++ .../tests/use_case/observability.rs | 19 ++ .../gateway-admin/tests/use_case/settings.rs | 17 + .../src/admin/observability/presenter.rs | 22 +- .../src/admin/observability/wire.rs | 11 + .../crates/gateway-api/src/admin/settings.rs | 127 ++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 64 ++++ .../tests/admin/observability/response.rs | 3 + .../gateway-api/tests/admin/settings.rs | 180 +++++++++++ .../gateway-core/src/engine/coordinator.rs | 1 + backend/crates/gateway-core/src/engine/mod.rs | 13 + .../gateway-core/src/engine/observation.rs | 2 +- .../crates/gateway-core/src/metering/mod.rs | 133 +++++++- .../gateway-core/src/metering/pricing.rs | 104 ++++++ .../crates/gateway-core/src/routing/mod.rs | 6 + .../gateway-core/src/routing/snapshot.rs | 19 ++ .../crates/gateway-core/tests/metering/mod.rs | 2 + .../gateway-core/tests/metering/pricing.rs | 97 ++++++ .../gateway-core/tests/routing/snapshot.rs | 39 +++ backend/crates/gateway-host/src/lib.rs | 1 + .../crates/gateway-host/src/pricing/mod.rs | 145 +++++++++ backend/crates/gateway-host/tests/main.rs | 1 + .../crates/gateway-host/tests/pricing/mod.rs | 41 +++ .../crates/gateway-store/src/admin_adapter.rs | 47 +++ .../gateway-store/src/postgres/execution.rs | 8 +- .../crates/gateway-store/src/postgres/mod.rs | 1 + .../src/postgres/observability/mapping.rs | 25 ++ .../src/postgres/observability/model.rs | 3 + .../observability/queries/dashboard.rs | 2 +- .../postgres/observability/queries/usage.rs | 4 +- .../gateway-store/src/postgres/pricing.rs | 222 +++++++++++++ .../gateway-store/src/postgres/snapshot.rs | 14 +- .../gateway-store/tests/postgres/execution.rs | 70 ++++ .../gateway-store/tests/postgres/mod.rs | 1 + .../gateway-store/tests/postgres/pricing.rs | 125 +++++++ backend/crates/providers/openai/src/admin.rs | 6 + .../openai/src/provider/execution.rs | 6 +- .../openai/src/transport/canonical.rs | 17 +- .../providers/openai/src/transport/mod.rs | 5 +- .../providers/openai/src/transport/usage.rs | 262 ++++++++++++--- .../crates/providers/openai/tests/admin.rs | 20 ++ .../openai/tests/provider/contract.rs | 64 +++- .../providers/openai/tests/transport/usage.rs | 84 +++++ backend/crates/providers/xai/src/admin.rs | 5 + .../providers/xai/src/provider/stream.rs | 6 +- .../providers/xai/src/transport/canonical.rs | 304 ++++++++++++++---- .../xai/tests/transport/canonical.rs | 61 +++- backend/migrations/.frozen-sha256 | 1 + backend/migrations/0017_model_pricing.sql | 8 + docs/api.md | 46 ++- docs/architecture.md | 17 +- frontend/src/api/index.ts | 3 + frontend/src/api/modules/pricing.ts | 78 +++++ frontend/src/api/modules/usage.ts | 6 + .../src/components/base/BaseModal/index.vue | 10 +- .../src/components/base/BaseTable/columns.ts | 1 + .../src/components/base/BaseTable/index.vue | 16 +- frontend/src/components/base/BaseTag.vue | 68 ++++ frontend/src/router/routes.ts | 5 + frontend/src/views/settings/index.vue | 33 +- .../settings/pricing/PricingBatchModal.vue | 54 ++++ .../views/settings/pricing/PricingEditor.vue | 161 ++++++++++ .../settings/pricing/PricingRateFields.vue | 55 ++++ .../settings/pricing/PricingSyncModal.vue | 89 +++++ .../views/settings/pricing/PricingTable.vue | 81 +++++ .../views/settings/pricing/PricingToolbar.vue | 97 ++++++ .../views/settings/pricing/PricingUnit.vue | 36 +++ frontend/src/views/settings/pricing/index.vue | 76 +++++ frontend/src/views/settings/pricing/model.ts | 64 ++++ .../src/views/settings/pricing/usePricing.ts | 76 +++++ .../components/ThemeComponentPreview.vue | 25 +- .../usage/components/UsageBillingCell.vue | 18 +- .../components/UsageRecordDetailModal.vue | 20 +- 88 files changed, 3700 insertions(+), 179 deletions(-) create mode 100644 backend/crates/gateway-admin/src/model/pricing.rs create mode 100644 backend/crates/gateway-admin/src/ports/pricing.rs create mode 100644 backend/crates/gateway-core/src/metering/pricing.rs create mode 100644 backend/crates/gateway-core/tests/metering/pricing.rs create mode 100644 backend/crates/gateway-host/src/pricing/mod.rs create mode 100644 backend/crates/gateway-host/tests/pricing/mod.rs create mode 100644 backend/crates/gateway-store/src/postgres/pricing.rs create mode 100644 backend/crates/gateway-store/tests/postgres/pricing.rs create mode 100644 backend/migrations/0017_model_pricing.sql create mode 100644 frontend/src/api/modules/pricing.ts create mode 100644 frontend/src/components/base/BaseTag.vue create mode 100644 frontend/src/views/settings/pricing/PricingBatchModal.vue create mode 100644 frontend/src/views/settings/pricing/PricingEditor.vue create mode 100644 frontend/src/views/settings/pricing/PricingRateFields.vue create mode 100644 frontend/src/views/settings/pricing/PricingSyncModal.vue create mode 100644 frontend/src/views/settings/pricing/PricingTable.vue create mode 100644 frontend/src/views/settings/pricing/PricingToolbar.vue create mode 100644 frontend/src/views/settings/pricing/PricingUnit.vue create mode 100644 frontend/src/views/settings/pricing/index.vue create mode 100644 frontend/src/views/settings/pricing/model.ts create mode 100644 frontend/src/views/settings/pricing/usePricing.ts diff --git a/README.md b/README.md index 26178f98f..b9f3a476c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ curl http://127.0.0.1:8080/v1/models \ - [客户端接入与生图](deploy/README.md#客户端配置) - [部署、备份与恢复](deploy/README.md) - [API 参考](docs/api.md) +- [模型定价与手动同步](docs/api.md#模型定价) - [系统架构](docs/architecture.md) - [管理端主题](docs/theme.md) - [数据库迁移](backend/migrations/README.md) diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index fc7590e09..ebee62c91 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -84,6 +84,7 @@ pub async fn run() -> Result<(), BootstrapError> { client, store.admin_ports(), gateway_admin::AdminRuntimePorts { + pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing), providers: vec![openai.admin_provider(), xai.admin_provider()], snapshot: core.snapshot_control(), account_probe: core.account_probe(), diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index 1ccc77177..a2a82238b 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -184,6 +184,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[ &[ "client_distribution", "config", + "pricing", "proxy_probe", "serve", "system_update", diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index d6fd7b9b9..3134fb07b 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -296,6 +296,7 @@ impl AdminBundle { /// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。 pub struct AdminRuntimePorts { + pub pricing_source: Arc, pub providers: Vec>, pub snapshot: Arc, pub account_probe: Arc, @@ -317,6 +318,7 @@ pub async fn initialize( runtime: AdminRuntimePorts, ) -> Result { let AdminRuntimePorts { + pricing_source, providers, snapshot, account_probe: probe, @@ -414,12 +416,14 @@ pub async fn initialize( store.observability(), store.accounts(), store.settings(), - registry, + registry.clone(), )), settings: Arc::new(DefaultSettingsService::new( store.settings(), snapshot.clone(), profile_provider, + registry, + pricing_source, )), system: Arc::new(DefaultSystemService::new(system)), openai, diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index c83730a8d..ae3d474f4 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -11,6 +11,7 @@ pub mod client_keys; pub mod import_tasks; pub mod key_usage; pub mod observability; +pub mod pricing; pub mod provider_credentials; pub mod proxies; pub mod quota_forecast; diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 8d8d3e804..0adf53a02 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -297,6 +297,7 @@ pub struct ProviderBillingInput { /// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsageCalculatedBillingFact { + pub breakdown: Option, pub bucket_start: DateTime, pub provider_kind: String, pub upstream_model_id: String, @@ -311,6 +312,8 @@ pub struct UsageCalculatedBillingFact { /// Provider 已确认的逐项费用与单价。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CalculatedBillingBreakdown { + pub image: Option, + pub custom_multiplier_bps: u32, pub input_amount: CurrencyCost, pub output_amount: CurrencyCost, pub cache_read_amount: CurrencyCost, @@ -325,6 +328,16 @@ pub struct CalculatedBillingBreakdown { pub multiplier_percent: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageBillingBreakdown { + pub input_tokens: u64, + pub cached_tokens: u64, + pub input_amount: CurrencyCost, + pub cache_read_amount: CurrencyCost, + pub input_price_per_million: CurrencyCost, + pub cache_read_price_per_million: CurrencyCost, +} + /// 单次请求的费用语义。 /// /// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。 diff --git a/backend/crates/gateway-admin/src/model/pricing.rs b/backend/crates/gateway-admin/src/model/pricing.rs new file mode 100644 index 000000000..69515693c --- /dev/null +++ b/backend/crates/gateway-admin/src/model/pricing.rs @@ -0,0 +1,42 @@ +//! 全局模型定价的管理合同。 + +use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PricingCatalog { + pub defaults: PricingOverrides, + pub overrides: PricingOverrides, + pub synced: PricingOverrides, + pub synced_at: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StoredPricing { + pub overrides: PricingOverrides, + pub synced: PricingOverrides, + pub synced_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PricingSyncPreview { + pub prices: PricingOverrides, + pub skipped: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PricingChange { + Replace(ModelPriceOverride), + Multiplier(u32), + Reset, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdatePricing { + pub provider: String, + pub models: Vec, + pub change: PricingChange, +} + +pub type ProviderPricingCatalog = BTreeMap; diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index fca5cbf02..0b1539fd3 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -2,6 +2,7 @@ pub mod backup; pub mod client_distribution; +pub mod pricing; pub mod provider; pub mod proxy; pub mod store; diff --git a/backend/crates/gateway-admin/src/ports/pricing.rs b/backend/crates/gateway-admin/src/ports/pricing.rs new file mode 100644 index 000000000..1c14ca534 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/pricing.rs @@ -0,0 +1,9 @@ +//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。 + +use crate::model::{AdminError, pricing::PricingSyncPreview}; +use async_trait::async_trait; + +#[async_trait] +pub trait PricingSource: Send + Sync { + async fn fetch(&self) -> Result; +} diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index cf8beac31..a40eebac4 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -110,6 +110,11 @@ impl ProviderAdminError { /// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。 #[async_trait] pub trait ProviderAdmin: Send + Sync { + /// 只读内置价目;没有本地计价能力的 Provider 返回空目录。 + fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog { + Default::default() + } + fn provider_kind(&self) -> &ProviderKind; /// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。 @@ -273,6 +278,14 @@ pub struct ProviderAdminRegistry { } impl ProviderAdminRegistry { + #[must_use] + pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides { + self.providers + .iter() + .map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog())) + .collect() + } + /// 创建无重复 ProviderKind 的注册表。 /// /// # Errors diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 4c1147ec7..62ff56e93 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -401,6 +401,18 @@ pub trait ObservabilityStore: Send + Sync { /// Runtime settings 与管理员 API Key 写入。 #[async_trait] pub trait SettingsStore: Send + Sync { + async fn load_pricing(&self) -> AdminStoreResult; + async fn sync_pricing( + &self, + prices: gateway_core::metering::PricingOverrides, + context: &MutationContext, + ) -> AdminStoreResult; + async fn update_pricing( + &self, + command: crate::model::pricing::UpdatePricing, + context: &MutationContext, + ) -> AdminStoreResult; + async fn load_runtime_settings(&self) -> AdminStoreResult; async fn admin_api_key_exists(&self) -> AdminStoreResult; diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index feb45008e..e341ed59a 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -619,7 +619,7 @@ async fn recover_standard_costs( mut facts: UsageCalculatedBillingStream<'_>, ) -> Result { let mut scenarios = UsageCostScenarios::default(); - while let Some(fact) = facts + while let Some(mut fact) = facts .try_next() .await .map_err(|error| map_store_error(error, "usage billing facts"))? @@ -636,7 +636,14 @@ async fn recover_standard_costs( cache_write_tokens: fact.cache_write_tokens, total: fact.total.clone(), }; - let breakdown = match providers.calculated_billing(&provider_kind, &input) { + let breakdown = match fact + .breakdown + .take() + .filter(|b| b.total_amount == input.total) + .map_or_else( + || providers.calculated_billing(&provider_kind, &input), + |b| Ok(Some(b)), + ) { Ok(breakdown) => breakdown, Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue, Err(error) => return Err(map_provider_error(error, "usage billing")), @@ -665,8 +672,15 @@ fn no_cache_cost( fact: &UsageCalculatedBillingFact, breakdown: &crate::model::observability::CalculatedBillingBreakdown, ) -> Option { - let input_tokens = fact.input_tokens?; - let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens); + let image = breakdown.image.as_ref(); + let input_tokens = fact + .input_tokens? + .checked_sub(image.map_or(0, |i| i.input_tokens))?; + let cached_tokens = fact + .cached_tokens + .unwrap_or_default() + .checked_sub(image.map_or(0, |i| i.cached_tokens))? + .min(input_tokens); let cache_write_tokens = fact .cache_write_tokens .unwrap_or_default() @@ -676,11 +690,20 @@ fn no_cache_cost( .scaled() .checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))? .checked_div(1_000_000)?; - let total = decimal(&breakdown.total_amount.amount)? + let mut total = decimal(&breakdown.total_amount.amount)? .scaled() .checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())? .checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())? .checked_add(replaced_input_amount)?; + if let Some(image) = image { + let image_no_cache = decimal(&image.input_price_per_million.amount)? + .scaled() + .checked_mul(u128::from(image.cached_tokens))? + .checked_div(1_000_000)?; + total = total + .checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())? + .checked_add(image_no_cache)?; + } DecimalAmount::from_str( &gateway_core::metering::Decimal::from_scaled(total) .ok()? diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 9af9f53ef..166f18d57 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -23,6 +23,20 @@ use super::{map_store_error, publish_committed}; /// API 消费的 Runtime settings 管理服务。 #[async_trait] pub trait SettingsService: Send + Sync { + async fn preview_pricing_sync( + &self, + ) -> Result; + async fn sync_pricing( + &self, + context: &MutationContext, + preview: crate::model::pricing::PricingSyncPreview, + ) -> Result<(), AdminError>; + async fn pricing(&self) -> Result; + async fn update_pricing( + &self, + context: &MutationContext, + command: crate::model::pricing::UpdatePricing, + ) -> Result<(), AdminError>; async fn client_profile_options( &self, ) -> Result { @@ -52,6 +66,8 @@ pub trait SettingsService: Send + Sync { } pub(crate) struct DefaultSettingsService { + providers: crate::ports::provider::ProviderAdminRegistry, + pricing_source: Arc, profile_provider: Arc, store: Arc, snapshot: Arc, @@ -63,8 +79,12 @@ impl DefaultSettingsService { store: Arc, snapshot: Arc, profile_provider: Arc, + providers: crate::ports::provider::ProviderAdminRegistry, + pricing_source: Arc, ) -> Self { Self { + providers, + pricing_source, store, snapshot, profile_provider, @@ -74,6 +94,112 @@ impl DefaultSettingsService { #[async_trait] impl SettingsService for DefaultSettingsService { + async fn preview_pricing_sync( + &self, + ) -> Result { + self.pricing_source.fetch().await + } + + async fn sync_pricing( + &self, + context: &MutationContext, + preview: crate::model::pricing::PricingSyncPreview, + ) -> Result<(), AdminError> { + let current = self.pricing_source.fetch().await?; + if current != preview { + return Err(AdminError::invalid( + "models.dev 价目已变化,请重新预览后确认", + )); + } + let revision = self + .store + .sync_pricing(current.prices, context) + .await + .map_err(|error| map_store_error(error, "model pricing sync"))?; + publish_committed(self.snapshot.as_ref(), revision).await + } + + async fn pricing(&self) -> Result { + let stored = self + .store + .load_pricing() + .await + .map_err(|error| map_store_error(error, "model pricing"))?; + Ok(crate::model::pricing::PricingCatalog { + defaults: self.providers.pricing_catalog(), + overrides: stored.overrides, + synced: stored.synced, + synced_at: stored.synced_at, + }) + } + + async fn update_pricing( + &self, + context: &MutationContext, + command: crate::model::pricing::UpdatePricing, + ) -> Result<(), AdminError> { + use crate::model::pricing::PricingChange; + let catalog = self.pricing().await?; + if !catalog.defaults.contains_key(&command.provider) + || command.models.is_empty() + || command.models.len() > 500 + || command.models.iter().any(|model| { + model.is_empty() + || model.len() > 128 + || model.trim() != model + || model.chars().any(char::is_whitespace) + || model.chars().any(char::is_control) + }) + { + return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法")); + } + let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced); + let defaults = defaults.get(&command.provider); + match &command.change { + PricingChange::Replace(pricing) => { + pricing.validate().map_err(AdminError::invalid)?; + if command.provider == "xai" + && pricing + .bands + .keys() + .any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex")) + { + return Err(AdminError::invalid("xAI 不支持此价格档位")); + } + if !pricing.bands.contains_key("standard") + && command + .models + .iter() + .any(|model| !defaults.is_some_and(|p| p.contains_key(model))) + { + return Err(AdminError::invalid("未知模型必须提供标准档价格")); + } + } + PricingChange::Multiplier(bps) if *bps > 1_000_000 => { + return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间")); + } + PricingChange::Multiplier(_) + if command.models.iter().any(|model| { + !defaults.is_some_and(|p| p.contains_key(model)) + && !catalog + .overrides + .get(&command.provider) + .and_then(|p| p.get(model)) + .is_some_and(|p| p.bands.contains_key("standard")) + }) => + { + return Err(AdminError::invalid("请先为未知模型配置标准档价格")); + } + _ => {} + } + let revision = self + .store + .update_pricing(command, context) + .await + .map_err(|error| map_store_error(error, "model pricing"))?; + publish_committed(self.snapshot.as_ref(), revision).await + } + async fn client_profile_options( &self, ) -> Result { diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index f9e1f4901..f2508562d 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -971,6 +971,23 @@ struct StaticSettingsStore; #[async_trait] impl SettingsStore for StaticSettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(Default::default()) + } + async fn sync_pricing( + &self, + _: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + async fn update_pricing( + &self, + _: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing update") + } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 47627b623..ac1ea4375 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -62,6 +62,23 @@ struct FreezeSettingsStore { #[async_trait] impl SettingsStore for FreezeSettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(Default::default()) + } + async fn sync_pricing( + &self, + _: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + async fn update_pricing( + &self, + _: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing update") + } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(self.settings.clone()) } diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index f23e98188..063088448 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -243,6 +243,7 @@ impl AdminHarness { self.backup, ), gateway_admin::AdminRuntimePorts { + pricing_source: Arc::new(UnavailablePricingSource), providers: self.providers, snapshot: Arc::new(NoopSnapshot), account_probe: self.probe, @@ -678,6 +679,23 @@ impl ObservabilityStore for UnavailableStore { #[async_trait] impl SettingsStore for UnavailableStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(Default::default()) + } + async fn sync_pricing( + &self, + _: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + async fn update_pricing( + &self, + _: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing update") + } async fn load_runtime_settings(&self) -> AdminStoreResult { Err(unavailable("settings")) } @@ -716,6 +734,19 @@ struct UnavailableProvider { calculated_billing: Option, } +struct UnavailablePricingSource; +#[async_trait] +impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource { + async fn fetch( + &self, + ) -> Result + { + Err(gateway_admin::model::AdminError::internal( + "pricing source unavailable", + )) + } +} + impl UnavailableProvider { fn new(kind: &str) -> Self { Self { @@ -863,6 +894,8 @@ pub(super) fn calculated_billing_provider() -> Arc { dashboard_profile: None, calculated_billing: Some( gateway_admin::model::observability::CalculatedBillingBreakdown { + custom_multiplier_bps: 10_000, + image: None, input_amount: amount("0.8"), output_amount: amount("0.2"), cache_read_amount: amount("0"), diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 2a761d9aa..0d74e70e0 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -372,6 +372,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s }], }]); store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact { + breakdown: None, bucket_start: quarter_hour_start(now), provider_kind: "openai".to_owned(), upstream_model_id: "gpt-5.5".to_owned(), @@ -543,6 +544,7 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails() let range = observation_range(now); let store = Arc::new(FixtureObservabilityStore::new(range)); store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact { + breakdown: None, bucket_start: quarter_hour_start(now), provider_kind: "openai".to_owned(), upstream_model_id: "gpt-5.5".to_owned(), @@ -751,6 +753,23 @@ struct FixtureSettingsStore; #[async_trait] impl SettingsStore for FixtureSettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(Default::default()) + } + async fn sync_pricing( + &self, + _: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + async fn update_pricing( + &self, + _: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing update") + } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 56889be54..a5b599acf 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -15,6 +15,23 @@ struct UnusedSettingsStore; #[async_trait] impl SettingsStore for UnusedSettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(Default::default()) + } + async fn sync_pricing( + &self, + _: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + async fn update_pricing( + &self, + _: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing update") + } async fn load_runtime_settings(&self) -> AdminStoreResult { Err(unused()) } diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 1f6678e35..89063e667 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -214,6 +214,7 @@ fn capitalize_first(value: &str) -> String { pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option { match billing? { domain::UsageBilling::Total { source, total } => Some(BillingView { + image: None, input_amount_display: "—".to_owned(), output_amount_display: "—".to_owned(), cache_read_amount_display: "—".to_owned(), @@ -232,6 +233,17 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option Some(BillingView { + image: value + .image + .as_ref() + .map(|image| super::wire::ImageBillingView { + input_amount_display: format_money(&image.input_amount), + cache_read_amount_display: format_money(&image.cache_read_amount), + input_price_display: format_token_price(&image.input_price_per_million), + cache_read_price_display: format_token_price( + &image.cache_read_price_per_million, + ), + }), input_amount_display: format_money(&value.input_amount), output_amount_display: format_money(&value.output_amount), cache_read_amount_display: format_money(&value.cache_read_amount), @@ -243,7 +255,15 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option, pub input_amount_display: String, pub output_amount_display: String, pub cache_read_amount_display: String, @@ -70,6 +72,15 @@ pub struct BillingView { pub multiplier_display: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageBillingView { + pub input_amount_display: String, + pub cache_read_amount_display: String, + pub input_price_display: String, + pub cache_read_price_display: String, +} + /// 使用记录表格的窄展示。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 10516430f..1928de900 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -353,6 +353,16 @@ where S: SessionState + Clone + Send + Sync + 'static, { Router::new() + .route("/api/admin/settings/pricing", get(pricing::)) + .route( + "/api/admin/settings/pricing/update", + post(update_pricing::), + ) + .route( + "/api/admin/settings/pricing/sync/preview", + post(preview_pricing_sync::), + ) + .route("/api/admin/settings/pricing/sync", post(sync_pricing::)) .route("/api/admin/settings", get(settings::)) .route( "/api/admin/settings/client-profiles/openai", @@ -400,6 +410,123 @@ where ) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PricingUpdateRequest { + provider: String, + models: Vec, + change: PricingChangeRequest, +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +enum PricingChangeRequest { + Replace { + pricing: gateway_core::metering::ModelPriceOverride, + }, + Multiplier { + #[serde(rename = "multiplierBps")] + multiplier_bps: u32, + }, + Reset {}, +} + +async fn pricing( + _auth: AdminAuth, + State(state): State, +) -> Result +where + S: SessionState + Send + Sync, +{ + let pricing = state + .admin_services() + .settings() + .pricing() + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ + "defaults": pricing.defaults, "overrides": pricing.overrides, + "synced": pricing.synced, "syncedAt": pricing.synced_at, + })), + )) +} + +async fn preview_pricing_sync( + _auth: AdminAuth, + State(state): State, +) -> Result +where + S: SessionState + Send + Sync, +{ + let preview = state + .admin_services() + .settings() + .preview_pricing_sync() + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(preview), + )) +} + +async fn sync_pricing( + auth: AdminAuth, + State(state): State, + AdminJson(preview): AdminJson, +) -> Result +where + S: SessionState + Send + Sync, +{ + state + .admin_services() + .settings() + .sync_pricing(&auth.context().mutation_context(), preview) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"saved": true})), + )) +} + +async fn update_pricing( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: SessionState + Send + Sync, +{ + use gateway_admin::model::pricing::{PricingChange, UpdatePricing}; + let change = match request.change { + PricingChangeRequest::Replace { pricing } => PricingChange::Replace(pricing), + PricingChangeRequest::Multiplier { multiplier_bps } => { + PricingChange::Multiplier(multiplier_bps) + } + PricingChangeRequest::Reset {} => PricingChange::Reset, + }; + state + .admin_services() + .settings() + .update_pricing( + &auth.context().mutation_context(), + UpdatePricing { + provider: request.provider, + models: request.models, + change, + }, + ) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"saved": true})), + )) +} + async fn settings( _auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 192023e4e..db8f48bf9 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -176,6 +176,7 @@ impl AdminTestFixture { ClientConfig::default(), stores, gateway_admin::AdminRuntimePorts { + pricing_source: Arc::new(StaticPricingSource), providers, snapshot: Arc::new(NoopSnapshot), account_probe: Arc::new(NoopProbe), @@ -368,14 +369,31 @@ impl AuthStore for MemoryAuthStore { } pub(super) struct MemorySettingsStore { + pricing: Mutex, settings: Mutex, api_key: Arc>>, } +struct StaticPricingSource; +#[async_trait] +impl gateway_admin::ports::pricing::PricingSource for StaticPricingSource { + async fn fetch( + &self, + ) -> Result + { + Ok(gateway_admin::model::pricing::PricingSyncPreview { + prices: serde_json::from_value(serde_json::json!({"openai":{"gpt-5.4":{ + "multiplierBps":10000,"bands":{"standard":{"input":"2.5","output":"15","cacheRead":"0.25","cacheWrite":"0"}} + }}})).expect("prices"), skipped: vec![], + }) + } +} + impl MemorySettingsStore { fn new(api_key: Arc>>) -> Self { Self { settings: Mutex::new(test_runtime_settings()), + pricing: Mutex::default(), api_key, } } @@ -387,6 +405,52 @@ impl MemorySettingsStore { #[async_trait] impl SettingsStore for MemorySettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(self.pricing.lock().expect("pricing").clone()) + } + async fn sync_pricing( + &self, + prices: gateway_core::metering::PricingOverrides, + _: &MutationContext, + ) -> AdminStoreResult { + let mut pricing = self.pricing.lock().expect("pricing"); + pricing.synced = prices; + pricing.synced_at = Some(Utc::now()); + let mut settings = self.settings.lock().expect("settings"); + settings.config_revision = next_revision(settings.config_revision); + Ok(settings.config_revision) + } + async fn update_pricing( + &self, + command: gateway_admin::model::pricing::UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + use gateway_admin::model::pricing::PricingChange; + let mut pricing = self.pricing.lock().expect("pricing"); + let models = pricing.overrides.entry(command.provider).or_default(); + for model in command.models { + match &command.change { + PricingChange::Reset => { + models.remove(&model); + } + PricingChange::Replace(p) => { + models.insert(model, p.clone()); + } + PricingChange::Multiplier(bps) => { + models + .entry(model) + .or_insert_with(|| gateway_core::metering::ModelPriceOverride { + multiplier_bps: 10_000, + bands: Default::default(), + }) + .multiplier_bps = *bps; + } + } + } + let mut settings = self.settings.lock().expect("settings"); + settings.config_revision = next_revision(settings.config_revision); + Ok(settings.config_revision) + } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(self.settings.lock().expect("settings").clone()) } diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index ff62b436e..22ce905ea 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -169,6 +169,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() { #[test] fn billing_view_should_preserve_the_original_detail_contract() { let value = serde_json::to_value(BillingView { + image: None, input_amount_display: "$0.03".to_owned(), output_amount_display: "$0.00".to_owned(), cache_read_amount_display: "$0.14".to_owned(), @@ -979,6 +980,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { cost_currency: None, billing: Some(UsageBilling::Calculated(Box::new( CalculatedBillingBreakdown { + custom_multiplier_bps: 10_000, + image: None, input_amount: usd("0.03"), output_amount: usd("0.07"), cache_read_amount: usd("0.00"), diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 4341bb0db..ab31f2021 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -654,3 +654,183 @@ fn global_profile_can_be_omitted_but_cannot_be_cleared() { json!({"client":"cli", "platform":"linux", "versionMode":"latest"}); assert!(serde_json::from_value::(body).is_ok()); } + +fn custom_pricing() -> Value { + json!({"multiplierBps":12500,"bands":{"standard":{"input":"3","output":"12","cacheRead":"0","cacheWrite":"0"}}}) +} + +#[tokio::test] +async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = app(fixture.state()); + let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({ + "provider":"openai", "models":["gpt-5.4"], "change":{"action":"replace", "pricing":custom_pricing()} + })))).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let preview = response_json( + app.clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync/preview", + None, + )) + .await + .unwrap(), + ) + .await; + let approved = preview["data"].clone(); + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(approved), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let result = response_json( + app.clone() + .oneshot(request(Method::GET, "/api/admin/settings/pricing", None)) + .await + .unwrap(), + ) + .await; + assert_eq!( + result["data"]["overrides"]["openai"]["gpt-5.4"], + custom_pricing() + ); + assert_eq!( + result["data"]["synced"]["openai"]["gpt-5.4"]["bands"]["standard"]["input"], + "2.5" + ); + assert!(result["data"]["syncedAt"].is_string()); + for _ in 0..2 { + let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({ + "provider":"openai", "models":["gpt-5.4"], "change":{"action":"multiplier", "multiplierBps":20000} + })))).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + let result = response_json( + app.clone() + .oneshot(request(Method::GET, "/api/admin/settings/pricing", None)) + .await + .unwrap(), + ) + .await; + assert_eq!( + result["data"]["overrides"]["openai"]["gpt-5.4"]["multiplierBps"], + 20000 + ); + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/update", + Some(json!({ + "provider":"openai", "models":["gpt-5.4"], "change":{"action":"reset"} + })), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let result = response_json( + app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None)) + .await + .unwrap(), + ) + .await; + assert!(result["data"]["overrides"]["openai"]["gpt-5.4"].is_null()); + assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object()); +} + +#[tokio::test] +async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = app(fixture.state()); + for body in [ + json!({"provider":"unknown","models":["model"],"change":{"action":"reset"}}), + json!({"provider":"openai","models":[],"change":{"action":"reset"}}), + json!({"provider":"openai","models":["bad model"],"change":{"action":"reset"}}), + json!({"provider":"openai","models":["model"],"change":{"action":"multiplier","multiplierBps":1_000_001}}), + json!({"provider":"openai","models":["model"],"change":{"action":"replace","pricing":{"multiplierBps":10000,"bands":{}}}}), + ] { + let scenario = body.to_string(); + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{scenario}"); + } + // JSON 合同错误沿用 AdminJson 的 422,业务校验错误为 400。 + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/update", + Some(json!({ + "provider":"openai","models":["model"],"change":{"action":"reset","extra":true} + })), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(json!({"prices":{},"skipped":[]})), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let result = response_json( + app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None)) + .await + .unwrap(), + ) + .await; + assert_eq!(result["data"]["overrides"], json!({})); + assert_eq!(result["data"]["synced"], json!({})); +} + +#[tokio::test] +async fn pricing_endpoints_require_administrator_authentication() { + let fixture = AdminTestFixture::new().await; + let app = app(fixture.state()); + for (method, path, body) in [ + (Method::GET, "/api/admin/settings/pricing", None), + ( + Method::POST, + "/api/admin/settings/pricing/update", + Some(json!({"provider":"openai","models":["model"],"change":{"action":"reset"}})), + ), + ( + Method::POST, + "/api/admin/settings/pricing/sync/preview", + None, + ), + ( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(json!({"prices":{},"skipped":[]})), + ), + ] { + assert_eq!( + app.clone() + .oneshot(request(method, path, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } +} diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index 3fc53ac93..660783435 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -818,6 +818,7 @@ where RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone()) .with_request_profile(self.request_profiles.get(candidate.provider()).cloned()) .with_disable_fast(self.plan.disable_fast()) + .with_pricing(self.plan.pricing()) .with_request_location(self.plan.request_location().cloned()) .with_concurrency_wait_budget(self.concurrency_wait_budget.clone()) .with_timing_started_at(self.observation.timing_started_at) diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index 7ca499673..f5f6c6ab1 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -314,6 +314,7 @@ impl ContinuationAttempt { /// Provider 每次执行可见的 request-local context。 #[derive(Debug, Clone)] pub struct RequestAttemptContext { + pricing: Arc, request_profile: Option, disable_fast: bool, request_location: Option, @@ -325,6 +326,12 @@ pub struct RequestAttemptContext { } impl RequestAttemptContext { + #[must_use] + pub fn with_pricing(mut self, pricing: Arc) -> Self { + self.pricing = pricing; + self + } + #[must_use] pub fn with_request_profile( mut self, @@ -355,6 +362,7 @@ impl RequestAttemptContext { request_id, client_api_key_ref, request_profile: None, + pricing: Arc::default(), disable_fast: false, request_location: None, timing_started_at: Instant::now(), @@ -418,6 +426,11 @@ pub struct AttemptContext { } impl AttemptContext { + #[must_use] + pub fn pricing(&self) -> &crate::metering::PricingOverrides { + &self.request.pricing + } + /// 本次逻辑请求首次解析的 Provider 身份,换号及传输重试保持不变。 #[must_use] pub const fn request_profile(&self) -> Option<&crate::account::OpaqueProviderData> { diff --git a/backend/crates/gateway-core/src/engine/observation.rs b/backend/crates/gateway-core/src/engine/observation.rs index 8fb7c8cd5..1df1c1b44 100644 --- a/backend/crates/gateway-core/src/engine/observation.rs +++ b/backend/crates/gateway-core/src/engine/observation.rs @@ -44,7 +44,7 @@ impl ResponseObservation { if let GatewayEvent::CalculatedCost(observed) = event && self.cost.source() != CostSource::ProviderReported { - self.cost = observed.into_estimate(); + self.cost = observed.clone().into_estimate(); } if let GatewayEvent::ProviderCost(observed) = event { self.cost = observed.into_estimate(); diff --git a/backend/crates/gateway-core/src/metering/mod.rs b/backend/crates/gateway-core/src/metering/mod.rs index d3e0245ed..70df933b3 100644 --- a/backend/crates/gateway-core/src/metering/mod.rs +++ b/backend/crates/gateway-core/src/metering/mod.rs @@ -5,6 +5,11 @@ use std::str::FromStr; use crate::validation::MeteringError; +mod pricing; +pub use pricing::{ + ModelPriceOverride, PricingOverrides, TokenPrice, TokenPriceOverride, merge_pricing, +}; + const DECIMAL_SCALE: u128 = 10_000_000_000; const MAX_SCALED_DECIMAL: u128 = 99_999_999_999_999_999_999; @@ -272,12 +277,13 @@ impl CostEstimateStatus { } } -/// 单次模型请求的总费用,不保存价格版本或 breakdown。 +/// 单次模型请求的总费用及当次确定的本地计算明细。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CostEstimate { status: CostEstimateStatus, source: CostSource, total: Option, + breakdown: Option>, } /// Provider 在单次请求终态上报的实际已计费总额。 @@ -287,16 +293,18 @@ pub struct ProviderReportedCost { } /// Provider 域依据公开单价和实际用量算出的单次总额。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CalculatedCost { total: Money, + breakdown: Option>, } /// Provider 受控价格规则计算出的运行时费用明细。 /// -/// 该值只用于生成事件和管理端展示,不是持久化模型;数据库仍只保存最终总额、货币和来源。 +/// 随本地费用事件保存,以免后续改价改变历史明细。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CalculatedCostBreakdown { + image: Option, input_amount: Money, output_amount: Money, cache_read_amount: Money, @@ -309,6 +317,18 @@ pub struct CalculatedCostBreakdown { cache_write_price_per_million: Money, service_tier: Option, multiplier_percent: u32, + custom_multiplier_bps: u32, +} + +/// 图像输入是总输入的子集,单独保留其价格,不能伪装成文本平均单价。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageCostBreakdown { + pub input_tokens: u64, + pub cached_tokens: u64, + pub input_amount: Money, + pub cache_read_amount: Money, + pub input_price_per_million: Money, + pub cache_read_price_per_million: Money, } /// 一次请求的费用组成,全部使用同一币种。 @@ -374,6 +394,7 @@ impl CalculatedCostBreakdown { ) -> Self { Self { input_amount: amounts.input, + image: None, output_amount: amounts.output, cache_read_amount: amounts.cache_read, cache_write_amount: amounts.cache_write, @@ -385,9 +406,21 @@ impl CalculatedCostBreakdown { cache_write_price_per_million: rates.cache_write, service_tier, multiplier_percent, + custom_multiplier_bps: 10_000, } } + #[must_use] + pub fn with_image(mut self, image: ImageCostBreakdown) -> Self { + self.image = Some(image); + self + } + + #[must_use] + pub const fn image(&self) -> Option<&ImageCostBreakdown> { + self.image.as_ref() + } + #[must_use] pub const fn input_amount(&self) -> Money { self.input_amount @@ -449,11 +482,90 @@ impl CalculatedCostBreakdown { } #[must_use] - pub const fn calculated_cost(&self) -> CalculatedCost { + pub fn calculated_cost(&self) -> CalculatedCost { CalculatedCost { total: self.total_amount, + breakdown: Some(std::sync::Arc::new(self.clone())), } } + + #[must_use] + pub const fn custom_multiplier_bps(&self) -> u32 { + self.custom_multiplier_bps + } + + /// 统一调整金额和有效单价,保留服务档位倍率的独立含义。 + #[must_use] + pub fn with_custom_multiplier(mut self, bps: u32) -> Option { + if bps > 1_000_000 { + return None; + } + let scale = |money: Money| { + let ticks = money + .amount() + .scaled() + .checked_mul(u128::from(bps))? + .checked_add(5_000)? + .checked_div(10_000)?; + Some(Money::new( + Decimal::from_scaled(ticks).ok()?, + money.currency(), + )) + }; + let previous_total = self.total_amount; + let same_standard = self.standard_amount == previous_total; + let mut token_total = self + .input_amount + .amount() + .scaled() + .checked_add(self.output_amount.amount().scaled())? + .checked_add(self.cache_read_amount.amount().scaled())? + .checked_add(self.cache_write_amount.amount().scaled())?; + if let Some(image) = &mut self.image { + token_total = token_total + .checked_add(image.input_amount.amount().scaled())? + .checked_add(image.cache_read_amount.amount().scaled())?; + image.input_amount = scale(image.input_amount)?; + image.cache_read_amount = scale(image.cache_read_amount)?; + image.input_price_per_million = scale(image.input_price_per_million)?; + image.cache_read_price_per_million = scale(image.cache_read_price_per_million)?; + } + let other = Money::new( + Decimal::from_scaled(previous_total.amount().scaled().checked_sub(token_total)?) + .ok()?, + previous_total.currency(), + ); + self.input_amount = scale(self.input_amount)?; + self.output_amount = scale(self.output_amount)?; + self.cache_read_amount = scale(self.cache_read_amount)?; + self.cache_write_amount = scale(self.cache_write_amount)?; + self.standard_amount = scale(self.standard_amount)?; + // 各费用项独立四舍五入后求和,防止明细合计与账本金额相差一个 tick。 + let mut total = self + .input_amount + .amount() + .scaled() + .checked_add(self.output_amount.amount().scaled())? + .checked_add(self.cache_read_amount.amount().scaled())? + .checked_add(self.cache_write_amount.amount().scaled())? + .checked_add(scale(other)?.amount().scaled())?; + if let Some(image) = &self.image { + total = total + .checked_add(image.input_amount.amount().scaled())? + .checked_add(image.cache_read_amount.amount().scaled())?; + } + self.total_amount = + Money::new(Decimal::from_scaled(total).ok()?, previous_total.currency()); + if same_standard { + self.standard_amount = self.total_amount; + } + self.input_price_per_million = scale(self.input_price_per_million)?; + self.output_price_per_million = scale(self.output_price_per_million)?; + self.cache_read_price_per_million = scale(self.cache_read_price_per_million)?; + self.cache_write_price_per_million = scale(self.cache_write_price_per_million)?; + self.custom_multiplier_bps = bps; + Some(self) + } } impl ProviderReportedCost { @@ -479,6 +591,7 @@ impl ProviderReportedCost { status: CostEstimateStatus::Known, source: CostSource::ProviderReported, total: Some(self.total), + breakdown: None, } } } @@ -492,31 +605,39 @@ impl CalculatedCost { pub fn from_usd_ticks(ticks: u128) -> Result { Ok(Self { total: Money::new(Decimal::from_scaled(ticks)?, CurrencyCode(*b"USD")), + breakdown: None, }) } #[must_use] - pub const fn total(self) -> Money { + pub const fn total(&self) -> Money { self.total } #[must_use] - pub const fn into_estimate(self) -> CostEstimate { + pub fn into_estimate(self) -> CostEstimate { CostEstimate { status: CostEstimateStatus::Known, source: CostSource::Calculated, total: Some(self.total), + breakdown: self.breakdown, } } } impl CostEstimate { + #[must_use] + pub fn breakdown(&self) -> Option<&CalculatedCostBreakdown> { + self.breakdown.as_deref() + } + #[must_use] pub const fn unavailable() -> Self { Self { status: CostEstimateStatus::Unknown, source: CostSource::Unavailable, total: None, + breakdown: None, } } diff --git a/backend/crates/gateway-core/src/metering/pricing.rs b/backend/crates/gateway-core/src/metering/pricing.rs new file mode 100644 index 000000000..ea21a9917 --- /dev/null +++ b/backend/crates/gateway-core/src/metering/pricing.rs @@ -0,0 +1,104 @@ +//! 请求冻结的本地价格覆盖;默认价目及模型别名继续由 Provider 拥有。 + +use std::collections::BTreeMap; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use super::Decimal; + +/// 每百万 Token 的 USD 价格,保留四位小数以对齐单 Token 的金额精度。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct TokenPrice(Decimal); + +impl TokenPrice { + #[must_use] + pub const fn ticks_per_token(self) -> u128 { + self.0.scaled() / 1_000_000 + } +} + +impl TryFrom for TokenPrice { + type Error = &'static str; + + fn try_from(value: String) -> Result { + let amount = Decimal::from_str(&value).map_err(|_| "单价必须为非负十进制字符串")?; + if amount.scaled() % 1_000_000 != 0 || amount.scaled() > 1_000_000 * 10_000_000_000 { + return Err("单价最多四位小数且不超过 1000000 USD / 百万 Token"); + } + Ok(Self(amount)) + } +} + +impl From for String { + fn from(value: TokenPrice) -> Self { + value.0.canonical() + } +} + +/// 一档完整 Token 单价;零价格与未覆盖的档位有不同含义。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TokenPriceOverride { + pub input: TokenPrice, + pub output: TokenPrice, + pub cache_read: TokenPrice, + pub cache_write: TokenPrice, +} + +/// 一个精确模型的覆盖项;倍率使用基点,10000 表示 1 倍。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ModelPriceOverride { + pub multiplier_bps: u32, + pub bands: BTreeMap, +} + +impl ModelPriceOverride { + /// 校验存储与管理入口共用的价格约束。 + /// + /// # Errors + /// 倍率超过 100 倍或档位不受支持时返回错误。 + pub fn validate(&self) -> Result<(), &'static str> { + if self.multiplier_bps > 1_000_000 { + return Err("倍率必须在 0 至 100 倍之间"); + } + if self.bands.keys().any(|band| { + !matches!( + band.as_str(), + "standard" + | "fast" + | "flex" + | "long_standard" + | "long_fast" + | "long_flex" + | "image" + ) + }) { + return Err("价格档位不受支持"); + } + Ok(()) + } +} + +/// 按 Provider、上游模型索引的不可变覆盖事实。 +pub type PricingOverrides = BTreeMap>; + +/// 按档位叠加配置;倍率仅由人工覆盖控制,同步价目不携带业务倍率。 +pub fn merge_pricing(mut base: PricingOverrides, overrides: &PricingOverrides) -> PricingOverrides { + for (provider, models) in overrides { + let target = base.entry(provider.clone()).or_default(); + for (model, pricing) in models { + let entry = target + .entry(model.clone()) + .or_insert_with(|| ModelPriceOverride { + multiplier_bps: 10_000, + bands: BTreeMap::new(), + }); + entry.multiplier_bps = pricing.multiplier_bps; + entry.bands.extend(pricing.bands.clone()); + } + } + base +} diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 07e0fdc42..b32869cef 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -536,6 +536,7 @@ impl ProviderCandidate { /// 一次请求冻结的 Provider 尝试顺序。 #[derive(Debug, Clone)] pub struct RoutingPlan { + pricing: Arc, disable_fast: bool, request_location: Option, config_revision: ConfigRevision, @@ -547,6 +548,11 @@ pub struct RoutingPlan { } impl RoutingPlan { + #[must_use] + pub fn pricing(&self) -> Arc { + Arc::clone(&self.pricing) + } + #[must_use] pub const fn disable_fast(&self) -> bool { self.disable_fast diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 45ba11792..e208fcaf8 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -28,6 +28,7 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4; /// Store 在一个一致性读取中提供的调度设置事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotSettingsFacts { + pricing: Arc, request_profiles: BTreeMap, disable_fast: bool, request_location_enabled: bool, @@ -45,6 +46,12 @@ pub struct SnapshotSettingsFacts { } impl SnapshotSettingsFacts { + #[must_use] + pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self { + self.pricing = Arc::new(pricing); + self + } + #[must_use] pub fn with_request_profiles( mut self, @@ -102,6 +109,7 @@ impl SnapshotSettingsFacts { Self { disable_fast: false, request_profiles: BTreeMap::new(), + pricing: Arc::default(), request_location_enabled: false, request_location: crate::account::RequestLocation::default(), max_concurrent_per_account, @@ -556,6 +564,7 @@ async fn compile_runtime_snapshot( .map_err(|_| RuntimeSnapshotCompileError::InvalidData) .map(|snapshot| { snapshot + .with_pricing(facts.settings.pricing) .with_disable_fast(facts.settings.disable_fast) .with_request_location(request_location) .with_responses_max_decompressed_body_bytes(decompressed_body_limit) @@ -570,6 +579,7 @@ async fn compile_runtime_snapshot( /// 数据面使用的不可变配置快照。 #[derive(Debug, Clone)] pub struct RuntimeSnapshot { + pricing: Arc, disable_fast: bool, responses_max_decompressed_body_bytes: std::num::NonZeroUsize, request_location: Option, @@ -589,6 +599,12 @@ pub struct RuntimeSnapshot { } impl RuntimeSnapshot { + #[must_use] + pub fn with_pricing(mut self, pricing: Arc) -> Self { + self.pricing = pricing; + self + } + #[must_use] pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self { self.disable_fast = disable_fast; @@ -699,6 +715,7 @@ impl RuntimeSnapshot { Ok(Self { responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024) .expect("positive default limit"), + pricing: Arc::default(), disable_fast: false, request_location: None, revision, @@ -1011,6 +1028,7 @@ impl RuntimeSnapshot { Ok(RoutingPlan { config_revision: self.revision, + pricing: Arc::clone(&self.pricing), disable_fast: self.disable_fast || account_scope.disable_fast(), request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, @@ -1054,6 +1072,7 @@ impl RuntimeSnapshot { }; Ok(RoutingPlan { config_revision: self.revision, + pricing: Arc::clone(&self.pricing), disable_fast: self.disable_fast || account_scope.disable_fast(), request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, diff --git a/backend/crates/gateway-core/tests/metering/mod.rs b/backend/crates/gateway-core/tests/metering/mod.rs index 29f1e69e3..2f00bb087 100644 --- a/backend/crates/gateway-core/tests/metering/mod.rs +++ b/backend/crates/gateway-core/tests/metering/mod.rs @@ -1,5 +1,7 @@ use std::str::FromStr; +mod pricing; + use gateway_core::metering::{ CalculatedCost, CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CostEstimate, CostEstimateStatus, CostSource, CurrencyCode, Decimal, Money, diff --git a/backend/crates/gateway-core/tests/metering/pricing.rs b/backend/crates/gateway-core/tests/metering/pricing.rs new file mode 100644 index 000000000..2e4a6389d --- /dev/null +++ b/backend/crates/gateway-core/tests/metering/pricing.rs @@ -0,0 +1,97 @@ +use gateway_core::metering::{ModelPriceOverride, PricingOverrides, TokenPrice, merge_pricing}; +use serde_json::json; + +#[test] +fn token_price_preserves_zero_and_exact_precision_but_rejects_unrepresentable_prices() { + for (price, ticks) in [ + ("0", 0), + ("0.0001", 1), + ("2.5000", 25_000), + ("1000000", 10_000_000_000), + ] { + let price: TokenPrice = price.to_owned().try_into().unwrap(); + assert_eq!(price.ticks_per_token(), ticks); + } + for price in ["-1", "1e3", "NaN", "0.00001", "1000000.0001", "", "+2"] { + assert!(TokenPrice::try_from(price.to_owned()).is_err(), "{price}"); + } + assert!(serde_json::from_value::(json!(2.5)).is_err()); +} + +#[test] +fn override_validation_rejects_unsupported_bands_and_excessive_multiplier() { + let mut price: ModelPriceOverride = + serde_json::from_value(json!({"multiplierBps": 1_000_001, "bands": {}})).unwrap(); + assert!(price.validate().is_err()); + price.multiplier_bps = 0; + assert!(price.validate().is_ok()); + price.bands.insert( + "unknown".to_owned(), + serde_json::from_value( + json!({"input":"1", "output":"1", "cacheRead":"0", "cacheWrite":"0"}), + ) + .unwrap(), + ); + assert!(price.validate().is_err()); +} + +#[test] +fn sync_layer_cannot_erase_manual_bands_or_multiplier() { + let synced: PricingOverrides = serde_json::from_value(json!({"openai":{"model":{ + "multiplierBps":10000, "bands":{"standard":{"input":"2", "output":"10", "cacheRead":"0.2", "cacheWrite":"0"}} + }}})).unwrap(); + let manual: PricingOverrides = serde_json::from_value(json!({"openai":{"model":{ + "multiplierBps":12345, "bands":{"fast":{"input":"6", "output":"30", "cacheRead":"0.6", "cacheWrite":"0"}} + }}})).unwrap(); + let effective = merge_pricing(synced.clone(), &manual); + let model = &effective["openai"]["model"]; + assert_eq!(model.multiplier_bps, 12345); + assert_eq!( + model.bands["standard"], + synced["openai"]["model"].bands["standard"] + ); + assert_eq!(model.bands["fast"], manual["openai"]["model"].bands["fast"]); + assert_eq!(manual["openai"]["model"].bands.len(), 1); +} + +#[test] +fn custom_multiplier_rounds_components_once_and_snapshot_survives_conversion() { + use gateway_core::metering::{ + CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal, + Money, + }; + let money = |ticks| { + Money::new( + Decimal::from_scaled(ticks).unwrap(), + CurrencyCode::new("USD").unwrap(), + ) + }; + let original = CalculatedCostBreakdown::new( + CalculatedCostAmounts::new(money(1), money(1), money(1), money(0), money(3), money(3)), + CalculatedCostRates::new( + money(1_000_000), + money(1_000_000), + money(1_000_000), + money(0), + ), + None, + 100, + ); + let adjusted = original.clone().with_custom_multiplier(5000).unwrap(); + assert_eq!(adjusted.total_amount().amount().scaled(), 3); + assert_eq!(adjusted.standard_amount(), adjusted.total_amount()); + let estimate = adjusted.calculated_cost().into_estimate(); + assert_eq!(estimate.breakdown(), Some(&adjusted)); + assert_eq!(estimate.total(), Some(adjusted.total_amount())); + assert_eq!( + original + .clone() + .with_custom_multiplier(0) + .unwrap() + .total_amount() + .amount() + .scaled(), + 0 + ); + assert!(original.with_custom_multiplier(1_000_001).is_none()); +} diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index cbbbb5f44..e5fbe83a6 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -135,6 +135,45 @@ fn compiler_should_reject_revision_changed_during_consistent_read() { assert_eq!(error, RuntimeSnapshotCompileError::RevisionChanged); } +#[test] +fn routing_plans_share_frozen_pricing_after_a_new_snapshot_is_published() { + use gateway_core::{metering::PricingOverrides, runtime::RuntimeSnapshotHandle}; + let prices = |bps| -> Arc { + Arc::new( + serde_json::from_value(serde_json::json!({"openai":{"gpt-5.5":{ + "multiplierBps":bps,"bands":{} + }}})) + .unwrap(), + ) + }; + let original = prices(12500); + let handle = RuntimeSnapshotHandle::new(super::snapshot().with_pricing(original.clone())); + let frozen = handle.acquire().unwrap(); + let plan = |snapshot: &gateway_core::routing::RuntimeSnapshot| { + snapshot + .plan( + &PublicModelId::new("gpt-5.4").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext::default(), + ) + .unwrap() + }; + let old_plan = plan(&frozen); + handle.publish(super::snapshot().with_pricing(prices(20000))); + let new_plan = plan(&handle.acquire().unwrap()); + assert!(Arc::ptr_eq(&old_plan.pricing(), &original)); + assert!(Arc::ptr_eq(&plan(&frozen).pricing(), &original)); + assert_eq!( + old_plan.pricing()["openai"]["gpt-5.5"].multiplier_bps, + 12500 + ); + assert_eq!( + new_plan.pricing()["openai"]["gpt-5.5"].multiplier_bps, + 20000 + ); +} + #[test] fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() { let compiler = RuntimeSnapshotCompiler::new( diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 92d523855..20291b645 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -3,6 +3,7 @@ pub mod client_distribution; pub mod config; mod logging; +pub mod pricing; pub mod proxy_probe; pub mod serve; pub mod system_update; diff --git a/backend/crates/gateway-host/src/pricing/mod.rs b/backend/crates/gateway-host/src/pricing/mod.rs new file mode 100644 index 000000000..b58af3a43 --- /dev/null +++ b/backend/crates/gateway-host/src/pricing/mod.rs @@ -0,0 +1,145 @@ +//! models.dev 的只读价目适配;固定地址、禁止跳转,不进入推理请求链路。 + +use async_trait::async_trait; +use gateway_admin::{ + model::{AdminError, pricing::PricingSyncPreview}, + ports::pricing::PricingSource, +}; +use gateway_core::metering::{ModelPriceOverride, TokenPrice, TokenPriceOverride}; +use serde_json::Value; +use std::{collections::BTreeMap, time::Duration}; + +const SOURCE_URL: &str = "https://models.dev/api.json"; +const MAX_BYTES: usize = 32 * 1024 * 1024; + +pub struct ModelsDevPricing; + +#[async_trait] +impl PricingSource for ModelsDevPricing { + async fn fetch(&self) -> Result { + let client = reqwest::Client::builder() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| source_error())?; + let mut response = client + .get(SOURCE_URL) + .send() + .await + .map_err(|_| source_error())? + .error_for_status() + .map_err(|_| source_error())?; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| source_error())? { + if chunk.len() > MAX_BYTES.saturating_sub(body.len()) { + return Err(source_error()); + } + body.extend_from_slice(&chunk); + } + decode_catalog(&body) + } +} + +fn source_error() -> AdminError { + AdminError::bad_gateway("无法读取 models.dev 价目,请稍后重试;现有价格未变更") +} + +/// 只接受当前网关 Provider 的文本 Token 价格;不猜测缺失字段或模态换算。 +pub fn decode_catalog(body: &[u8]) -> Result { + let catalog: Value = serde_json::from_slice(body).map_err(|_| source_error())?; + let mut result = PricingSyncPreview { + prices: BTreeMap::new(), + skipped: Vec::new(), + }; + for provider in ["openai", "xai"] { + let models = catalog + .get(provider) + .and_then(|p| p.get("models")) + .and_then(Value::as_object) + .ok_or_else(source_error)?; + let mut prices = BTreeMap::new(); + for (model, value) in models { + if model.is_empty() + || model.len() > 128 + || model.chars().any(|c| c.is_whitespace() || c.is_control()) + { + continue; + } + match model_price(provider, value) { + Some(price) => { + prices.insert(model.clone(), price); + } + None => result.skipped.push(format!("{provider}/{model}")), + } + } + if !prices.is_empty() { + result.prices.insert(provider.to_owned(), prices); + } + } + if result.prices.is_empty() { + return Err(source_error()); + } + Ok(result) +} + +fn model_price(provider: &str, model: &Value) -> Option { + let output = model.pointer("/modalities/output")?.as_array()?; + if output.len() != 1 || output[0].as_str()? != "text" { + return None; + } + let cost = model.get("cost")?; + // 独立 reasoning/audio 价格无法用当前 Token 用量拆分,不能静默吞掉。 + if ["reasoning", "input_audio", "output_audio"] + .iter() + .any(|key| cost.get(key).is_some()) + { + return None; + } + let mut bands = BTreeMap::from([("standard".to_owned(), rates(cost)?)]); + if let Some(tiers) = cost.get("tiers") { + let tiers = tiers.as_array()?; + if tiers.len() > 1 { + return None; + } + if let Some(tier) = tiers.first() { + let expected = if provider == "xai" { 200_000 } else { 272_000 }; + if tier.pointer("/tier/size")?.as_u64()? != expected { + return None; + } + bands.insert("long_standard".to_owned(), rates(tier)?); + } + } else if let Some(long) = cost.get("context_over_200k") { + if provider != "xai" { + return None; + } + bands.insert("long_standard".to_owned(), rates(long)?); + } + Some(ModelPriceOverride { + multiplier_bps: 10_000, + bands, + }) +} + +fn rates(cost: &Value) -> Option { + let price = |key: &str| -> Option { + cost.get(key)?.as_number()?.to_string().try_into().ok() + }; + let input = price("input")?; + Some(TokenPriceOverride { + input, + output: price("output")?, + // 没有缓存折扣的模型按普通输入价;不能把缺项解释成免费。 + cache_read: if cost.get("cache_read").is_some() { + price("cache_read")? + } else { + input + }, + cache_write: if cost.get("cache_write").is_some() { + price("cache_write")? + } else { + input + }, + }) +} diff --git a/backend/crates/gateway-host/tests/main.rs b/backend/crates/gateway-host/tests/main.rs index 689ff5d3f..f99f3a2dd 100644 --- a/backend/crates/gateway-host/tests/main.rs +++ b/backend/crates/gateway-host/tests/main.rs @@ -1,6 +1,7 @@ mod client_distribution; mod config; mod logging; +mod pricing; mod proxy_probe; mod serve; mod system_update; diff --git a/backend/crates/gateway-host/tests/pricing/mod.rs b/backend/crates/gateway-host/tests/pricing/mod.rs new file mode 100644 index 000000000..8d0213ad3 --- /dev/null +++ b/backend/crates/gateway-host/tests/pricing/mod.rs @@ -0,0 +1,41 @@ +use gateway_host::pricing::decode_catalog; +use serde_json::json; + +#[test] +fn source_selects_supported_providers_and_preserves_free_prices() { + let catalog = json!({ + "openai":{"models":{ + "free":{"modalities":{"output":["text"]},"cost":{"input":0,"output":0}}, + "normal":{"modalities":{"output":["text"]},"cost":{"input":2.5,"output":10,"cache_read":0.25}}, + "image":{"modalities":{"output":["image"]},"cost":{"input":1,"output":2}}, + "invalid":{"modalities":{"output":["text"]},"cost":{"input":-1,"output":2}}, + "wrong-tier":{"modalities":{"output":["text"]},"cost":{"input":1,"output":2,"tiers":[{"tier":{"size":200000},"input":2,"output":4}]}} + }}, + "xai":{"models":{}}, + "unrelated":{"models":{"secret":{}}} + }); + let result = decode_catalog(&serde_json::to_vec(&catalog).unwrap()).unwrap(); + let models = &result.prices["openai"]; + assert_eq!(models.len(), 2); + assert_eq!(models["free"].bands["standard"].input.ticks_per_token(), 0); + assert_eq!( + models["normal"].bands["standard"] + .cache_read + .ticks_per_token(), + 2500 + ); + assert_eq!(result.skipped.len(), 3); + assert!(!result.prices.contains_key("unrelated")); +} + +#[test] +fn source_rejects_empty_partial_or_malformed_documents() { + for document in [ + json!({}), + json!({"openai":{"models":{}}}), + json!({"openai":{"models":{}},"xai":{"models":{}}}), + ] { + assert!(decode_catalog(&serde_json::to_vec(&document).unwrap()).is_err()); + } + assert!(decode_catalog(b"not json").is_err()); +} diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 9caa00059..f2043044f 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -15,6 +15,53 @@ pub(crate) struct AdminSettingsStoreAdapter { #[async_trait::async_trait] impl SettingsStore for AdminSettingsStoreAdapter { + async fn load_pricing(&self) -> AdminStoreResult { + self.control_plane + .load_pricing() + .await + .map_err(|error| admin_store_error("model pricing", error)) + } + + async fn sync_pricing( + &self, + prices: gateway_core::metering::PricingOverrides, + context: &MutationContext, + ) -> AdminStoreResult { + let audit = mutation_audit( + context, + "pricing.sync", + "model_pricing", + "models.dev", + vec!["synced".to_owned()], + ); + let revision = self + .control_plane + .sync_pricing(prices, audit) + .await + .map_err(|error| admin_store_error("model pricing sync", error))?; + admin_revision(revision) + } + + async fn update_pricing( + &self, + command: gateway_admin::model::pricing::UpdatePricing, + context: &MutationContext, + ) -> AdminStoreResult { + let audit = mutation_audit( + context, + "pricing.update", + "model_pricing", + &command.provider, + command.models.clone(), + ); + let revision = self + .control_plane + .update_pricing(command, audit) + .await + .map_err(|error| admin_store_error("model pricing", error))?; + admin_revision(revision) + } + async fn load_runtime_settings(&self) -> AdminStoreResult { let snapshot = postgres::ControlPlaneRepository::load_control_plane(&self.control_plane) .await diff --git a/backend/crates/gateway-store/src/postgres/execution.rs b/backend/crates/gateway-store/src/postgres/execution.rs index 8c0cf33be..cbba02fde 100644 --- a/backend/crates/gateway-store/src/postgres/execution.rs +++ b/backend/crates/gateway-store/src/postgres/execution.rs @@ -268,6 +268,7 @@ impl ModelRequestTimings { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelRequestFinalization { + pub billing_snapshot_json: Option, pub model_request_id: String, pub outcome: ModelRequestOutcome, pub upstream_send_state: UpstreamSendState, @@ -743,7 +744,7 @@ impl ModelRequestRepository for PgExecutionStore { upstream_connection_exit_reason = $45, upstream_connection_age_ms = $46, upstream_connection_idle_ms = $47, diagnostic_trace_json = $48, - upstream_response_model = $49 + upstream_response_model = $49, billing_snapshot_json = $50 where id = $1 and outcome = 'running' returning id, client_api_key_ref, continuation_affinity_hash, continuation_requested, provider_kind, upstream_transport, @@ -936,6 +937,7 @@ impl ModelRequestRepository for PgExecutionStore { )?) .bind(finalization.diagnostic_trace_json.map(sqlx::types::Json)) .bind(finalization.upstream_response_model) + .bind(finalization.billing_snapshot_json.map(sqlx::types::Json)) .fetch_one(&self.pool) .await .map_err(|_| postgres_unavailable("finalize model request"))?; @@ -1216,6 +1218,10 @@ impl ExecutionStore for PgExecutionStore { let completed = ModelRequestRepository::finalize_model_request( self, ModelRequestFinalization { + billing_snapshot_json: finalization + .cost + .breakdown() + .map(super::pricing::encode_billing_snapshot), model_request_id: finalization.request_id.as_str().to_owned(), outcome: outcome_from_core(finalization.outcome)?, upstream_send_state: send_state_from_core(finalization.send_state), diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index ca4307df0..714926d4c 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -21,6 +21,7 @@ mod execution; mod execution_buffer; mod observability; mod ops_events; +mod pricing; mod provider_accounts; mod proxies; mod retention; diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index b37e93ff3..eb85e2fbd 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -368,6 +368,10 @@ pub(crate) fn admin_calculated_usage_billing_fact( fact: CalculatedUsageBillingFact, ) -> AdminStoreResult { Ok(admin_observability::UsageCalculatedBillingFact { + breakdown: fact + .billing_snapshot_json + .as_ref() + .and_then(super::super::pricing::decode_billing_snapshot), bucket_start: fact.bucket_start, provider_kind: fact.provider_kind, upstream_model_id: fact.upstream_model_id, @@ -408,6 +412,22 @@ pub(crate) fn admin_usage_page( }) } +fn restore_billing_snapshot( + billing: Option, + snapshot: Option<&serde_json::Value>, +) -> Option { + if let Some(admin_observability::UsageBilling::Total { source, total }) = &billing + && source == "calculated" + && let Some(detail) = snapshot.and_then(super::super::pricing::decode_billing_snapshot) + && &detail.total_amount == total + { + return Some(admin_observability::UsageBilling::Calculated(Box::new( + detail, + ))); + } + billing +} + pub(crate) fn admin_usage_list_record( record: UsageListRecord, ) -> AdminStoreResult { @@ -427,6 +447,7 @@ pub(crate) fn admin_usage_list_record( })); } }; + let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref()); Ok(admin_observability::UsageListRecord { id: record.id, endpoint: record.endpoint, @@ -495,6 +516,7 @@ pub(crate) fn admin_usage_record( })); } }; + let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref()); Ok(admin_observability::UsageRecord { id: record.id, client_api_key_ref: record.client_api_key_ref, @@ -755,6 +777,7 @@ pub(crate) fn usage_list_record_from_row( row: &sqlx::postgres::PgRow, ) -> StoreResult { Ok(UsageListRecord { + billing_snapshot_json: get(row, "billing_snapshot_json")?, id: get(row, "id")?, endpoint: get(row, "endpoint")?, client_transport: get(row, "client_transport")?, @@ -804,6 +827,7 @@ pub(crate) fn usage_list_record_from_row( pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult { Ok(UsageRecord { + billing_snapshot_json: get(row, "billing_snapshot_json")?, id: get(row, "id")?, client_api_key_ref: get(row, "client_api_key_ref")?, config_revision: unsigned(row, "config_revision")?, @@ -954,6 +978,7 @@ pub(crate) fn calculated_usage_billing_fact_from_row( row: &sqlx::postgres::PgRow, ) -> StoreResult { Ok(CalculatedUsageBillingFact { + billing_snapshot_json: get(row, "billing_snapshot_json")?, bucket_start: get(row, "bucket_start")?, provider_kind: get(row, "provider_kind")?, upstream_model_id: get(row, "upstream_model_id")?, diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index 5cdb2e4f8..2cadb8443 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -335,6 +335,7 @@ pub struct RequestMetricPoint { /// 已完整交付且由 Provider 计算费用的请求事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CalculatedUsageBillingFact { + pub billing_snapshot_json: Option, pub bucket_start: DateTime, pub provider_kind: String, pub upstream_model_id: String, @@ -479,6 +480,7 @@ pub struct DashboardObservation { /// 使用记录列表所需的窄投影;完整执行、路由和客户端详情按 ID 单独读取。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsageListRecord { + pub billing_snapshot_json: Option, pub id: String, pub endpoint: String, pub client_transport: String, @@ -527,6 +529,7 @@ pub struct UsageListRecord { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsageRecord { + pub billing_snapshot_json: Option, pub id: String, pub client_api_key_ref: String, pub config_revision: u64, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs index e64820e54..46783476c 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs @@ -299,7 +299,7 @@ pub(crate) fn calculated_usage_billing_facts( "::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start, mr.provider_kind, mr.upstream_model_id, mr.service_tier, mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens, - mr.cost_currency, mr.cost_amount::text as amount + mr.billing_snapshot_json, mr.cost_currency, mr.cost_amount::text as amount from model_requests mr where mr.started_at >= ", ); query.push_bind(range.start); diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index 0101a137a..e9a2cfb03 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -133,7 +133,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str = mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier, mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens, mr.reasoning_tokens, mr.image_input_tokens, mr.image_output_tokens, - mr.total_tokens, mr.cost_source, mr.cost_amount::text, mr.cost_currency, + mr.total_tokens, mr.billing_snapshot_json, mr.cost_source, mr.cost_amount::text, mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms, mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms, mr.provider_processing_ms, mr.latency_ms, mr.admission_decision_ms, @@ -161,7 +161,7 @@ pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str = mr.error_kind, mr.provider_error_code, mr.error_message, mr.retry_after_ms, mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens, mr.reasoning_tokens, mr.image_input_tokens, mr.image_output_tokens, - mr.total_tokens, mr.cost_source, mr.cost_amount::text, + mr.total_tokens, mr.billing_snapshot_json, mr.cost_source, mr.cost_amount::text, mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms, mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms, mr.provider_processing_ms, mr.latency_ms, mr.admission_decision_ms, diff --git a/backend/crates/gateway-store/src/postgres/pricing.rs b/backend/crates/gateway-store/src/postgres/pricing.rs new file mode 100644 index 000000000..9241725cd --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/pricing.rs @@ -0,0 +1,222 @@ +//! 价格覆盖与请求费用明细的持久化。 + +use gateway_admin::model::pricing::{PricingChange, UpdatePricing}; +use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; +use sqlx::types::Json; + +use super::{ + AdminAuditEvent, PgControlPlaneRepository, append_admin_audit_event_in_transaction, + bump_config_revision_in_transaction, +}; +use crate::{Revision, StoreError, StoreResult, postgres_unavailable}; + +pub(crate) fn encode_billing_snapshot( + b: &gateway_core::metering::CalculatedCostBreakdown, +) -> serde_json::Value { + serde_json::json!({ + "version": 1, + "image": b.image().map(|image| serde_json::json!({ + "inputTokens": image.input_tokens, "cachedTokens": image.cached_tokens, + "input": image.input_amount.amount().canonical(), + "cacheRead": image.cache_read_amount.amount().canonical(), + "inputPrice": image.input_price_per_million.amount().canonical(), + "cacheReadPrice": image.cache_read_price_per_million.amount().canonical(), + })), + "input": b.input_amount().amount().canonical(), + "output": b.output_amount().amount().canonical(), + "cacheRead": b.cache_read_amount().amount().canonical(), + "cacheWrite": b.cache_write_amount().amount().canonical(), + "standard": b.standard_amount().amount().canonical(), + "total": b.total_amount().amount().canonical(), + "inputPrice": b.input_price_per_million().amount().canonical(), + "outputPrice": b.output_price_per_million().amount().canonical(), + "cacheReadPrice": b.cache_read_price_per_million().amount().canonical(), + "cacheWritePrice": b.cache_write_price_per_million().amount().canonical(), + "currency": b.total_amount().currency().as_str(), + "serviceTier": b.service_tier(), + "multiplierPercent": b.multiplier_percent(), + "customMultiplierBps": b.custom_multiplier_bps(), + }) +} + +pub(crate) fn decode_billing_snapshot( + value: &serde_json::Value, +) -> Option { + use gateway_admin::model::observability::{CalculatedBillingBreakdown, CurrencyCost}; + if value.get("version")?.as_u64()? != 1 { + return None; + } + let currency = value.get("currency")?.as_str()?; + gateway_core::metering::CurrencyCode::new(currency).ok()?; + let amount = |key| { + Some(CurrencyCost { + currency: currency.to_owned(), + amount: value.get(key)?.as_str()?.parse().ok()?, + }) + }; + Some(CalculatedBillingBreakdown { + image: match value.get("image").filter(|v| !v.is_null()) { + Some(image) => { + let amount = |key| { + Some(CurrencyCost { + currency: currency.to_owned(), + amount: image.get(key)?.as_str()?.parse().ok()?, + }) + }; + Some(gateway_admin::model::observability::ImageBillingBreakdown { + input_tokens: image.get("inputTokens")?.as_u64()?, + cached_tokens: image.get("cachedTokens")?.as_u64()?, + input_amount: amount("input")?, + cache_read_amount: amount("cacheRead")?, + input_price_per_million: amount("inputPrice")?, + cache_read_price_per_million: amount("cacheReadPrice")?, + }) + } + None => None, + }, + input_amount: amount("input")?, + output_amount: amount("output")?, + cache_read_amount: amount("cacheRead")?, + cache_write_amount: amount("cacheWrite")?, + standard_amount: amount("standard")?, + total_amount: amount("total")?, + input_price_per_million: amount("inputPrice")?, + output_price_per_million: amount("outputPrice")?, + cache_read_price_per_million: amount("cacheReadPrice")?, + cache_write_price_per_million: amount("cacheWritePrice")?, + service_tier: value.get("serviceTier")?.as_str().map(str::to_owned), + multiplier_percent: value.get("multiplierPercent")?.as_u64()?.try_into().ok()?, + custom_multiplier_bps: value + .get("customMultiplierBps")? + .as_u64()? + .try_into() + .ok()?, + }) +} + +pub(crate) fn validate_pricing(pricing: &PricingOverrides) -> StoreResult<()> { + if pricing + .values() + .map(std::collections::BTreeMap::len) + .sum::() + > 10_000 + { + return Err(invalid_pricing()); + } + for (provider, models) in pricing { + if !matches!(provider.as_str(), "openai" | "xai") { + return Err(invalid_pricing()); + } + for (model, pricing) in models { + if model.is_empty() + || model.len() > 128 + || model.chars().any(char::is_whitespace) + || model.chars().any(char::is_control) + || pricing.validate().is_err() + { + return Err(invalid_pricing()); + } + } + } + Ok(()) +} + +fn invalid_pricing() -> StoreError { + StoreError::InvalidData { + entity: "model pricing", + message: "invalid model pricing".to_owned(), + } +} + +impl PgControlPlaneRepository { + pub(crate) async fn load_pricing( + &self, + ) -> StoreResult { + let (Json(overrides), Json(synced), synced_at) = sqlx::query_as::<_, (Json, Json, Option>)>( + "select pricing_overrides_json, pricing_synced_json, pricing_synced_at from runtime_settings where id = 1") + .fetch_one(&self.pool).await.map_err(|_| postgres_unavailable("load model pricing"))?; + validate_pricing(&overrides)?; + validate_pricing(&synced)?; + Ok(gateway_admin::model::pricing::StoredPricing { + overrides, + synced, + synced_at, + }) + } + + pub(crate) async fn sync_pricing( + &self, + prices: PricingOverrides, + audit: AdminAuditEvent, + ) -> StoreResult { + validate_pricing(&prices)?; + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| postgres_unavailable("begin pricing sync"))?; + // 同步只替换来源层;不写人工覆盖。运行设置行锁统一串行化配置修订。 + sqlx::query("update runtime_settings set pricing_synced_json = $1, pricing_synced_at = now() where id = 1") + .bind(Json(prices)).execute(&mut *transaction).await.map_err(|_| postgres_unavailable("sync model pricing"))?; + let revision = bump_config_revision_in_transaction(&mut transaction).await?; + append_admin_audit_event_in_transaction(&mut transaction, audit, revision).await?; + transaction + .commit() + .await + .map_err(|_| postgres_unavailable("commit pricing sync"))?; + Ok(revision) + } + + pub(crate) async fn update_pricing( + &self, + command: UpdatePricing, + audit: AdminAuditEvent, + ) -> StoreResult { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| postgres_unavailable("begin model pricing update"))?; + // 锁定当前配置再更新选中项,避免批量操作覆盖其他管理员已提交的模型。 + let Json(mut pricing) = sqlx::query_scalar::<_, Json>( + "select pricing_overrides_json from runtime_settings where id = 1 for update", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| postgres_unavailable("lock model pricing"))?; + let models = pricing.entry(command.provider).or_default(); + for model in command.models { + match &command.change { + PricingChange::Reset => { + models.remove(&model); + } + PricingChange::Replace(value) => { + models.insert(model, value.clone()); + } + PricingChange::Multiplier(bps) => { + models + .entry(model) + .or_insert_with(|| ModelPriceOverride { + multiplier_bps: 10_000, + bands: Default::default(), + }) + .multiplier_bps = *bps; + } + } + } + pricing.retain(|_, models| !models.is_empty()); + validate_pricing(&pricing)?; + sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1") + .bind(Json(pricing)) + .execute(&mut *transaction) + .await + .map_err(|_| postgres_unavailable("update model pricing"))?; + let revision = bump_config_revision_in_transaction(&mut transaction).await?; + append_admin_audit_event_in_transaction(&mut transaction, audit, revision).await?; + transaction + .commit() + .await + .map_err(|_| postgres_unavailable("commit model pricing"))?; + Ok(revision) + } +} diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 1aab3f7cb..2f0c04aa8 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -20,6 +20,7 @@ use super::ClientApiKeySnapshot; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotRuntimeSettings { + pub pricing: gateway_core::metering::PricingOverrides, pub request_profiles: BTreeMap, pub disable_fast: bool, @@ -164,6 +165,7 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { ) .with_disable_fast(data.settings.disable_fast) .with_request_profiles(data.settings.request_profiles) + .with_pricing(data.settings.pricing) .with_request_location( data.settings.request_location, data.settings.request_location_enabled, @@ -247,6 +249,8 @@ fn core_revision(revision: Revision) -> Result, + pricing_overrides_json: sqlx::types::Json, config_revision: i64, refresh_margin_seconds: i64, refresh_concurrency: i64, @@ -271,7 +275,7 @@ async fn load_settings( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult<(Revision, SnapshotRuntimeSettings)> { let row = sqlx::query_as::<_, SnapshotSettingsRow>( - "select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, disable_fast, provider_request_profiles_json from runtime_settings where id = 1", + "select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, disable_fast, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) .await @@ -283,6 +287,14 @@ async fn load_settings( Ok(( revision_from_i64(row.config_revision)?, SnapshotRuntimeSettings { + pricing: { + super::pricing::validate_pricing(&row.pricing_synced_json.0)?; + super::pricing::validate_pricing(&row.pricing_overrides_json.0)?; + gateway_core::metering::merge_pricing( + row.pricing_synced_json.0, + &row.pricing_overrides_json.0, + ) + }, request_profiles: decode_request_profiles(row.provider_request_profiles_json.0)?, disable_fast: row.disable_fast, responses_max_decompressed_body_bytes: to_u64( diff --git a/backend/crates/gateway-store/tests/postgres/execution.rs b/backend/crates/gateway-store/tests/postgres/execution.rs index 37ce5c421..34699bbd3 100644 --- a/backend/crates/gateway-store/tests/postgres/execution.rs +++ b/backend/crates/gateway-store/tests/postgres/execution.rs @@ -429,6 +429,76 @@ async fn core_adapter_should_persist_calculated_cost_exactly() { database.close().await; } +#[tokio::test] +async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads() { + use gateway_core::metering::{ + CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal, + Money, + }; + let Some(database) = TestDatabase::create("billing_snapshot").await else { + return; + }; + seed_running_request(&database.pool, "req_billing_snapshot") + .await + .unwrap(); + let money = |ticks| { + Money::new( + Decimal::from_scaled(ticks).unwrap(), + CurrencyCode::new("USD").unwrap(), + ) + }; + let billing = CalculatedCostBreakdown::new( + CalculatedCostAmounts::new( + money(1_000_000), + money(2_000_000), + money(3_000_000), + money(0), + money(6_000_000), + money(6_000_000), + ), + CalculatedCostRates::new( + money(10_000_000_000), + money(20_000_000_000), + money(5_000_000_000), + money(0), + ), + Some("default".to_owned()), + 100, + ) + .with_custom_multiplier(12500) + .unwrap(); + let mut finalization = successful_core_finalization("req_billing_snapshot"); + finalization.cost = billing.calculated_cost().into_estimate(); + ExecutionStore::finalize_model_request( + &PgExecutionStore::new(database.pool.clone()), + finalization, + ) + .await + .unwrap(); + let observation = admin_observability_store(&database.pool); + let original = observation + .usage_record_detail("req_billing_snapshot") + .await + .unwrap(); + let Some(admin_observability::UsageBilling::Calculated(saved)) = &original.request.billing + else { + panic!("persisted billing breakdown must be restored without provider recalculation"); + }; + assert_eq!(saved.custom_multiplier_bps, 12500); + assert_eq!(saved.total_amount.amount, "0.00075".parse().unwrap()); + sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1") + .bind(json!({"openai":{"gpt-5.4":{"multiplierBps":90000,"bands":{}}}})) + .execute(&database.pool) + .await + .unwrap(); + let after = observation + .usage_record_detail("req_billing_snapshot") + .await + .unwrap(); + assert_eq!(original.request.billing, after.request.billing); + database.close().await; +} + #[tokio::test] async fn core_adapter_should_persist_image_result_and_new_websocket_pool() { let Some(database) = TestDatabase::create("execution_image_usage").await else { diff --git a/backend/crates/gateway-store/tests/postgres/mod.rs b/backend/crates/gateway-store/tests/postgres/mod.rs index 10c8abacf..f9fca9e88 100644 --- a/backend/crates/gateway-store/tests/postgres/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/mod.rs @@ -21,6 +21,7 @@ mod execution_buffer; mod health; mod observability; mod ops_events; +mod pricing; mod provider_accounts; mod proxies; mod query_budget; diff --git a/backend/crates/gateway-store/tests/postgres/pricing.rs b/backend/crates/gateway-store/tests/postgres/pricing.rs new file mode 100644 index 000000000..d51c99130 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/pricing.rs @@ -0,0 +1,125 @@ +use gateway_admin::model::{ + MutationActor, MutationContext, + pricing::{PricingChange, UpdatePricing}, +}; +use gateway_core::metering::ModelPriceOverride; +use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository}; +use serde_json::json; + +use super::TestDatabase; + +#[tokio::test] +async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_prices() { + let Some(redis_url) = crate::support::test_env("CPR_TEST_REDIS_URL") else { + return; + }; + let Some(database) = TestDatabase::create("pricing").await else { + return; + }; + let mut database_url = url::Url::parse( + &crate::support::test_env("CPR_TEST_DATABASE_URL").expect("test database URL"), + ) + .unwrap(); + database_url + .query_pairs_mut() + .append_pair("options", &format!("-csearch_path={}", database.schema)); + let connection = |mut url: url::Url| { + let password = url.password().expect("test password").to_owned(); + url.set_password(None).unwrap(); + json!({"url":url.as_str(), "password":password}) + }; + let mut config: gateway_store::StoreConfig = serde_json::from_value(json!({ + "database":connection(database_url), + "redis":connection(url::Url::parse(&redis_url).unwrap()), + })) + .unwrap(); + let runtime = tempfile::tempdir().unwrap(); + config.resolve_and_validate(runtime.path()).unwrap(); + let bundle = gateway_store::initialize(config).await.unwrap(); + let settings = bundle.admin_ports().settings(); + let context = MutationContext { + actor: MutationActor::System, + request_id: "pricing-test".to_owned(), + }; + let pricing: ModelPriceOverride = serde_json::from_value(json!({ + "multiplierBps":12500, + "bands":{"standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"1"}} + })) + .unwrap(); + let update = |model: &str, change| UpdatePricing { + provider: "openai".to_owned(), + models: vec![model.to_owned()], + change, + }; + let (first, second) = tokio::join!( + settings.update_pricing( + update("model-a", PricingChange::Replace(pricing.clone())), + &context + ), + settings.update_pricing( + update("model-b", PricingChange::Replace(pricing.clone())), + &context + ), + ); + assert_ne!(first.unwrap(), second.unwrap()); + let stored = settings.load_pricing().await.unwrap(); + assert_eq!(stored.overrides["openai"].len(), 2); + + let mut source = stored.overrides.clone(); + source + .get_mut("openai") + .unwrap() + .get_mut("model-a") + .unwrap() + .bands + .get_mut("standard") + .unwrap() + .input = "9".to_owned().try_into().unwrap(); + settings + .sync_pricing(source.clone(), &context) + .await + .unwrap(); + let stored = settings.load_pricing().await.unwrap(); + assert_eq!(stored.overrides["openai"]["model-a"], pricing); + assert!(stored.synced_at.is_some()); + for _ in 0..2 { + settings + .update_pricing( + update("model-a", PricingChange::Multiplier(20000)), + &context, + ) + .await + .unwrap(); + } + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()); + let frozen = snapshot.load_runtime_snapshot().await.unwrap(); + assert_eq!( + frozen.settings.pricing["openai"]["model-a"].multiplier_bps, + 20000 + ); + assert_eq!( + frozen.settings.pricing["openai"]["model-a"].bands, + pricing.bands + ); + + settings + .update_pricing(update("model-a", PricingChange::Reset), &context) + .await + .unwrap(); + let restored = snapshot.load_runtime_snapshot().await.unwrap(); + assert_eq!( + restored.settings.pricing["openai"]["model-a"], + source["openai"]["model-a"] + ); + assert_eq!(restored.settings.pricing["openai"]["model-b"], pricing); + assert_eq!( + frozen.settings.pricing["openai"]["model-a"].multiplier_bps, + 20000 + ); + let audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events where action in ('pricing.update', 'pricing.sync')") + .fetch_one(&database.pool).await.unwrap(); + assert_eq!(audits, 6); + drop(settings); + drop(bundle); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 4667adc68..b3764e5c4 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -163,6 +163,10 @@ impl OpenAiAdminProvider { #[async_trait] impl ProviderAdmin for OpenAiAdminProvider { + fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { + crate::transport::usage::pricing_catalog() + } + fn client_profile_options( &self, ) -> Result { @@ -367,6 +371,8 @@ impl ProviderAdmin for OpenAiAdminProvider { return Ok(None); } Ok(Some(CalculatedBillingBreakdown { + image: None, + custom_multiplier_bps: breakdown.custom_multiplier_bps(), input_amount: currency_cost(breakdown.input_amount())?, output_amount: currency_cost(breakdown.output_amount())?, cache_read_amount: currency_cost(breakdown.cache_read_amount())?, diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 016080bc6..f3d024205 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -504,7 +504,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea ResponseMeta::for_provider_endpoint(request.context.request_id().as_str()); yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone())); if matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH) - && let Some((usage, cost)) = image_response_metering(&request.body, &response.body) + && let Some((usage, cost)) = image_response_metering(&request.body, &response.body, request.context.pricing()) { yield ProviderEvent::canonical(GatewayEvent::Usage(usage)); if let Some(cost) = cost { @@ -524,6 +524,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea fn image_response_metering( request_body: &[u8], body: &[u8], + prices: &gateway_core::metering::PricingOverrides, ) -> Option<(Usage, Option)> { // 只保留 usage,跳过通常很大的 base64 图片;原始响应仍按字节透传。 #[derive(Deserialize)] @@ -548,7 +549,7 @@ fn image_response_metering( .and_then(Value::as_u64); // 总量是上游独立报告的事实;图片明细是总输入/输出的子集,不能再次相加。 usage.total_tokens = raw.get("total_tokens").and_then(Value::as_u64); - let cost = crate::transport::usage::image_calculated_cost(request_body, &raw); + let cost = crate::transport::usage::image_calculated_cost(request_body, &raw, prices); (usage != Usage::default()).then_some((usage, cost)) } @@ -740,6 +741,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream { // WS 帧由 reducer 以 encode_sse_event(&event, raw) 逐字节内嵌上游原始 JSON // (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。 let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str()) + .with_pricing(context.pricing().get("openai").and_then(|models| models.get(upstream_model.as_str())).cloned()) .with_reported_model(response.response_metadata.effective_model.as_deref()) .with_requested_service_tier(request.service_tier()) .with_request_tool_pricing(upstream_model.as_str(), request.tools()) diff --git a/backend/crates/providers/openai/src/transport/canonical.rs b/backend/crates/providers/openai/src/transport/canonical.rs index d86b67bf9..bc15a83f3 100644 --- a/backend/crates/providers/openai/src/transport/canonical.rs +++ b/backend/crates/providers/openai/src/transport/canonical.rs @@ -25,8 +25,7 @@ use super::protocol::responses::{ ResponseEventSignals, ResponsesSseFailure, response_event_signals, }; use super::usage::{ - OpenAiBillingUsage, WebSearchPricing, normalize_service_tier, openai_billing_breakdown, - web_search_pricing, + OpenAiBillingUsage, WebSearchPricing, normalize_service_tier, web_search_pricing, }; const CONTENTS_PER_OUTPUT: u32 = 1_024; @@ -36,6 +35,7 @@ const CONTENTS_PER_OUTPUT: u32 = 1_024; /// 上游 wire 是客户端可见的事实来源;canonical facts 只用于观测、亲和和计费。 /// 因而未知或形状变化的 JSON event 只能放弃 canonical 投影,不能截断 wire 流。 pub struct CodexCanonicalDecoder { + pricing: Option, decoder: SseEventDecoder, upstream_model: String, response_id: Option, @@ -133,6 +133,15 @@ impl CodexCanonicalFailure { } impl CodexCanonicalDecoder { + #[must_use] + pub fn with_pricing( + mut self, + pricing: Option, + ) -> Self { + self.pricing = pricing; + self + } + /// 使用路由后最终发往上游的请求模型计价,并在响应缺少模型时用于 canonical 兜底。 pub fn new(upstream_model: impl Into) -> Self { Self { @@ -152,6 +161,7 @@ impl CodexCanonicalDecoder { response_model: ResponseModelObservation::default(), reported_model: None, web_search_pricing: None, + pricing: None, timing_signals: ResponseEventSignals::default(), raw_sse_passthrough: false, } @@ -902,12 +912,13 @@ impl CodexCanonicalDecoder { .filter(|usage| billable_usage_is_complete(response, *usage)) .and_then(|usage| { let (web_search_calls, file_search_calls) = tool_calls?; - openai_billing_breakdown( + super::usage::openai_billing_breakdown_with_override( &self.upstream_model, OpenAiBillingUsage::from(usage) .with_web_search_calls(web_search_calls, self.web_search_pricing) .with_file_search_calls(file_search_calls), service_tier, + self.pricing.as_ref(), ) }) { diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 6b2cf0785..bdd8d3d69 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -61,7 +61,10 @@ pub use self::{ CodexRateLimitResetCreditsConsumeResult, MAX_CODEX_RESET_CREDITS_BODY_BYTES, }, response_meta::CodexResponseMetadata, - usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown}, + usage::{ + MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown, + openai_billing_breakdown_with_override, + }, websocket::{ CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision, }, diff --git a/backend/crates/providers/openai/src/transport/usage.rs b/backend/crates/providers/openai/src/transport/usage.rs index d129321a7..f5adcaa59 100644 --- a/backend/crates/providers/openai/src/transport/usage.rs +++ b/backend/crates/providers/openai/src/transport/usage.rs @@ -103,6 +103,8 @@ impl WebSearchPricing { #[derive(Clone, Copy)] struct TokenRates { + explicit_cache: bool, + cache_write_ticks: Option, input_ticks: u128, output_ticks: u128, cache_read_ticks: u128, @@ -117,11 +119,16 @@ impl TokenRates { input_ticks, output_ticks, cache_read_ticks, + explicit_cache: false, + cache_write_ticks: None, } } const fn is_configured(self) -> bool { - self.input_ticks > 0 || self.output_ticks > 0 || self.cache_read_ticks > 0 + self.explicit_cache + || self.input_ticks > 0 + || self.output_ticks > 0 + || self.cache_read_ticks > 0 } } @@ -467,6 +474,24 @@ pub fn openai_billing_breakdown( usage, service_tier, usage.input_tokens > LONG_CONTEXT_THRESHOLD, + None, + ) +} + +/// 使用请求开始时冻结的价格覆盖,不读取管理存储。 +#[must_use] +pub fn openai_billing_breakdown_with_override( + model: &str, + usage: OpenAiBillingUsage, + service_tier: Option<&str>, + pricing: Option<&gateway_core::metering::ModelPriceOverride>, +) -> Option { + openai_billing_breakdown_with_context( + model, + usage, + service_tier, + usage.input_tokens > LONG_CONTEXT_THRESHOLD, + pricing, ) } @@ -475,6 +500,7 @@ fn openai_billing_breakdown_with_context( usage: OpenAiBillingUsage, service_tier: Option<&str>, long_context: bool, + custom: Option<&gateway_core::metering::ModelPriceOverride>, ) -> Option { if usage.cached_tokens.checked_add(usage.cache_write_tokens)? > usage.input_tokens || usage.image_input_tokens > 0 @@ -484,14 +510,15 @@ fn openai_billing_breakdown_with_context( } let tool_ticks = web_search_amount_ticks(usage)? .checked_add(u128::from(usage.file_search_calls).checked_mul(FILE_SEARCH_CALL_TICKS)?)?; - let pricing = model_pricing(model)?; + let pricing = model_pricing(model); let normalized_tier = normalize_service_tier(service_tier); let tier = pricing_tier(normalized_tier.as_deref())?; - let standard_rates = pricing.rates(PricingTier::Standard, long_context)?; - let selected_rates = pricing.rates(tier, long_context)?; + let standard_rates = effective_rates(pricing, custom, PricingTier::Standard, long_context)?; + let selected_rates = effective_rates(pricing, custom, tier, long_context)?; + let cache_write_percent = pricing.map_or(0, |pricing| pricing.cache_write_percent); let mut standard = token_amounts( standard_rates, - pricing.cache_write_percent, + cache_write_percent, usage.input_tokens, usage.output_tokens, usage.cached_tokens, @@ -499,7 +526,7 @@ fn openai_billing_breakdown_with_context( )?; let mut selected = token_amounts( selected_rates, - pricing.cache_write_percent, + cache_write_percent, usage.input_tokens, usage.output_tokens, usage.cached_tokens, @@ -509,9 +536,9 @@ fn openai_billing_breakdown_with_context( selected.total_ticks = selected.total_ticks.checked_add(tool_ticks)?; let multiplier_percent = effective_multiplier_percent(selected.total_ticks, standard.total_ticks)?; - let cache_write_rate = cache_write_rate(selected_rates, pricing.cache_write_percent)?; + let cache_write_rate = cache_write_rate(selected_rates, cache_write_percent)?; - Some(CalculatedCostBreakdown::new( + CalculatedCostBreakdown::new( CalculatedCostAmounts::new( usd_money(selected.input_ticks)?, usd_money(selected.output_ticks)?, @@ -528,28 +555,166 @@ fn openai_billing_breakdown_with_context( ), Some(normalized_tier.unwrap_or_else(|| "default".to_owned())), multiplier_percent, - )) + ) + .with_custom_multiplier(custom.map_or(10_000, |pricing| pricing.multiplier_bps)) } +fn effective_rates( + default: Option, + custom: Option<&gateway_core::metering::ModelPriceOverride>, + tier: PricingTier, + long_context: bool, +) -> Option { + let long_band = match tier { + PricingTier::Standard => "long_standard", + PricingTier::Fast => "long_fast", + PricingTier::Flex => "long_flex", + }; + let uses_long = long_context + && (default.is_some_and(|p| p.long_standard.is_configured() || p.unpriced_long_context) + || custom.is_some_and(|p| p.bands.contains_key(long_band))); + let band = match (tier, uses_long) { + (PricingTier::Standard, false) => "standard", + (PricingTier::Fast, false) => "fast", + (PricingTier::Flex, false) => "flex", + (PricingTier::Standard, true) => "long_standard", + (PricingTier::Fast, true) => "long_fast", + (PricingTier::Flex, true) => "long_flex", + }; + if let Some(rates) = custom.and_then(|p| p.bands.get(band)) { + return Some(TokenRates { + input_ticks: rates.input.ticks_per_token(), + output_ticks: rates.output.ticks_per_token(), + cache_read_ticks: rates.cache_read.ticks_per_token(), + cache_write_ticks: Some(rates.cache_write.ticks_per_token()), + explicit_cache: true, + }); + } + default?.rates(tier, long_context) +} + +pub(crate) fn pricing_catalog() +-> std::collections::BTreeMap { + use gateway_core::metering::{ModelPriceOverride, TokenPrice, TokenPriceOverride}; + let price = |ticks: u128| -> Option { + Decimal::from_scaled(ticks.checked_mul(1_000_000)?) + .ok()? + .canonical() + .try_into() + .ok() + }; + let mut catalog: std::collections::BTreeMap<_, _> = PRICING_RULES + .iter() + .map(|rule| { + let pricing = rule.pricing; + let bands = [ + ("standard", pricing.standard), + ("fast", pricing.fast), + ("flex", pricing.flex), + ("long_standard", pricing.long_standard), + ("long_fast", pricing.long_fast), + ("long_flex", pricing.long_flex), + ] + .into_iter() + .filter(|(_, rates)| rates.is_configured()) + .filter_map(|(name, rates)| { + Some(( + name.to_owned(), + TokenPriceOverride { + input: price(rates.input_ticks)?, + output: price(rates.output_ticks)?, + cache_read: price(if rates.cache_read_ticks == 0 { + rates.input_ticks + } else { + rates.cache_read_ticks + })?, + // 内置表没有独立缓存写入费时,这部分 Token 原本按输入计费。 + // 管理页复制来源档位不能把它改成显式免费。 + cache_write: price(if pricing.cache_write_percent == 0 { + rates.input_ticks + } else { + cache_write_rate(rates, pricing.cache_write_percent)? + })?, + }, + )) + }) + .collect(); + ( + rule.model.to_owned(), + ModelPriceOverride { + multiplier_bps: 10_000, + bands, + }, + ) + }) + .collect(); + for model in IMAGE_MODELS { + let bands = [("standard", IMAGE_TEXT_RATES), ("image", IMAGE_TOKEN_RATES)] + .into_iter() + .map(|(band, rates)| { + ( + band.to_owned(), + TokenPriceOverride { + input: price(rates.input_ticks).expect("内置图像价格合法"), + output: price(rates.output_ticks).expect("内置图像价格合法"), + cache_read: price(rates.cache_read_ticks).expect("内置图像价格合法"), + cache_write: price(0).expect("零价格合法"), + }, + ) + }) + .collect(); + catalog.insert( + (*model).to_owned(), + ModelPriceOverride { + multiplier_bps: 10_000, + bands, + }, + ); + } + catalog +} + +const IMAGE_MODELS: &[&str] = &[ + "gpt-image-2", + "gpt-image-2-2026-04-21", + "gpt-image-2.5-sunburst", + "gpt-image-2.5-sunburst-2026-09-08", + "gpt-image-2.5-flare", + "gpt-image-2.5-flare-2026-09-08", +]; +const IMAGE_TEXT_RATES: TokenRates = TokenRates::new(50_000, 0, 12_500); +const IMAGE_TOKEN_RATES: TokenRates = TokenRates::new(80_000, 300_000, 20_000); + /// 独立 Images 端点按公开标准 API 单价估算;不是 ChatGPT 账号实际扣费。 -pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Option { +pub(crate) fn image_calculated_cost( + request_body: &[u8], + usage: &Value, + prices: &gateway_core::metering::PricingOverrides, +) -> Option { #[derive(serde::Deserialize)] struct ImageModel { model: String, } // 只读取模型,跳过编辑请求中可能很大的 base64 图片。 let request = serde_json::from_slice::(request_body).ok()?; - if !matches!( - request.model.as_str(), - "gpt-image-2" - | "gpt-image-2-2026-04-21" - | "gpt-image-2.5-sunburst" - | "gpt-image-2.5-sunburst-2026-09-08" - | "gpt-image-2.5-flare" - | "gpt-image-2.5-flare-2026-09-08" - ) { - return None; - } + let custom = prices + .get("openai") + .and_then(|models| models.get(&request.model)); + let known = IMAGE_MODELS.contains(&request.model.as_str()); + let rates = |band, builtin| { + custom + .and_then(|p| p.bands.get(band)) + .map(|p| TokenRates { + input_ticks: p.input.ticks_per_token(), + output_ticks: p.output.ticks_per_token(), + cache_read_ticks: p.cache_read.ticks_per_token(), + cache_write_ticks: Some(p.cache_write.ticks_per_token()), + explicit_cache: true, + }) + .or_else(|| known.then_some(builtin)) + }; + let text_rates = rates("standard", IMAGE_TEXT_RATES)?; + let image_rates = rates("image", IMAGE_TOKEN_RATES)?; let input = usage.get("input_tokens")?.as_u64()?; let output = usage.get("output_tokens")?.as_u64()?; let details = usage.get("input_tokens_details")?; @@ -588,23 +753,37 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio // 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。 // GPT Image 2 和两个 2.5 型号按相同的 token 单价计费,不使用按张估价。 // 每百万 token 的美元单价:文本 5 / 缓存 1.25;图片 8 / 缓存 2 / 输出 30。 - let text = token_amounts( - TokenRates::new(50_000, 0, 12_500), - 0, - text_input, - 0, - cached_text, - 0, - )?; - let image = token_amounts( - TokenRates::new(80_000, 300_000, 20_000), - 0, - image_input, - output, - cached_image, - 0, - )?; - CalculatedCost::from_usd_ticks(text.total_ticks.checked_add(image.total_ticks)?).ok() + let text = token_amounts(text_rates, 0, text_input, 0, cached_text, 0)?; + let image = token_amounts(image_rates, 0, image_input, output, cached_image, 0)?; + let total = usd_money(text.total_ticks.checked_add(image.total_ticks)?)?; + let breakdown = CalculatedCostBreakdown::new( + CalculatedCostAmounts::new( + usd_money(text.input_ticks)?, + usd_money(image.output_ticks)?, + usd_money(text.cache_read_ticks)?, + usd_money(0)?, + total, + total, + ), + CalculatedCostRates::new( + usd_price_per_million(text_rates.input_ticks)?, + usd_price_per_million(image_rates.output_ticks)?, + usd_price_per_million(text_rates.cache_read_ticks)?, + usd_money(0)?, + ), + Some("default".to_owned()), + 100, + ) + .with_image(gateway_core::metering::ImageCostBreakdown { + input_tokens: image_input, + cached_tokens: cached_image, + input_amount: usd_money(image.input_ticks)?, + cache_read_amount: usd_money(image.cache_read_ticks)?, + input_price_per_million: usd_price_per_million(image_rates.input_ticks)?, + cache_read_price_per_million: usd_price_per_million(image_rates.cache_read_ticks)?, + }) + .with_custom_multiplier(custom.map_or(10_000, |p| p.multiplier_bps))?; + Some(breakdown.calculated_cost()) } fn model_pricing(model: &str) -> Option { @@ -686,13 +865,13 @@ fn token_amounts( if cached_tokens.checked_add(cache_write_tokens)? > input_tokens { return None; } - let billed_cache_read = if rates.cache_read_ticks > 0 { + let billed_cache_read = if rates.explicit_cache || rates.cache_read_ticks > 0 { cached_tokens } else { 0 }; let cache_write_rate = cache_write_rate(rates, cache_write_percent)?; - let billed_cache_write = if cache_write_rate > 0 { + let billed_cache_write = if rates.explicit_cache || cache_write_rate > 0 { cache_write_tokens } else { 0 @@ -718,6 +897,9 @@ fn token_amounts( } fn cache_write_rate(rates: TokenRates, percent: u32) -> Option { + if let Some(ticks) = rates.cache_write_ticks { + return Some(ticks); + } if percent == 0 { return Some(0); } diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 4182ca4e4..f17a6d1f7 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -289,6 +289,26 @@ async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinc assert_ne!(conversation_ids[0], conversation_ids[1]); } +#[tokio::test] +async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() { + use provider_openai::transport::{ + OpenAiBillingUsage, openai_billing_breakdown, openai_billing_breakdown_with_override, + }; + let config = valid_config(); + let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) + .await + .unwrap(); + let prices = bundle.admin_provider().pricing_catalog(); + for model in ["gpt-4", "gpt-4o", "gpt-6-astra"] { + let usage = OpenAiBillingUsage::new(100, 10, 20, 15); + let inherited = openai_billing_breakdown(model, usage, None).unwrap(); + let copied = + openai_billing_breakdown_with_override(model, usage, None, Some(&prices[model])) + .unwrap(); + assert_eq!(inherited.total_amount(), copied.total_amount(), "{model}"); + } +} + #[tokio::test] async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() { let config = valid_config(); diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 0a616b2aa..5f8b368ff 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -727,6 +727,15 @@ fn context_with_fast_policy( request_id: &str, cancellation: CancellationToken, disable_fast: bool, +) -> AttemptContext { + context_with_pricing(request_id, cancellation, disable_fast, Default::default()) +} + +fn context_with_pricing( + request_id: &str, + cancellation: CancellationToken, + disable_fast: bool, + pricing: gateway_core::metering::PricingOverrides, ) -> AttemptContext { AttemptContext::new( RequestAttemptContext::new( @@ -734,6 +743,7 @@ fn context_with_fast_policy( ClientApiKeyId::new("key_openai_contract").expect("client key id"), ) .with_disable_fast(disable_fast) + .with_pricing(Arc::new(pricing)) .with_request_location(Some(global_request_location())), NonZeroU32::new(1).expect("attempt"), SystemTime::now() + Duration::from_secs(30), @@ -1558,13 +1568,13 @@ async fn image_prices_should_use_modality_rates_and_precede_delivery() { .canonical_facts() .iter() .filter_map(move |fact| match fact { - GatewayEvent::CalculatedCost(cost) => Some((index, *cost)), + GatewayEvent::CalculatedCost(cost) => Some((index, cost.clone())), _ => None, }) }) .collect::>(); assert_eq!(costs.len(), 1, "model={model}"); - let (cost_index, cost) = costs[0]; + let (cost_index, cost) = costs[0].clone(); assert_eq!( cost.total().amount().scaled(), expected_ticks, @@ -1661,6 +1671,15 @@ async fn image_metering_events( kind: ImageRequestKind, response: &[u8], model: &str, +) -> Vec { + image_metering_events_with_pricing(kind, response, model, Default::default()).await +} + +async fn image_metering_events_with_pricing( + kind: ImageRequestKind, + response: &[u8], + model: &str, + pricing: gateway_core::metering::PricingOverrides, ) -> Vec { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_provider_contract").await; @@ -1690,7 +1709,7 @@ async fn image_metering_events( let mut stream = provider .execute( planned_provider_endpoint_request("openai", operation), - context("req_image_usage", CancellationToken::new()), + context_with_pricing("req_image_usage", CancellationToken::new(), false, pricing), ) .await .expect("prepare image stream"); @@ -1702,6 +1721,45 @@ async fn image_metering_events( events } +#[tokio::test] +async fn image_custom_prices_apply_text_and_image_rates_from_the_frozen_request() { + let pricing = serde_json::from_value(json!({"openai":{"gpt-image-2":{ + "multiplierBps":20000,"bands":{ + "standard":{"input":"2","output":"0","cacheRead":"0","cacheWrite":"0"}, + "image":{"input":"8","output":"30","cacheRead":"2","cacheWrite":"0"} + } + }}})) + .unwrap(); + let response = serde_json::to_vec(&json!({"data":[{"b64_json":"AAEC"}],"usage":{ + "input_tokens":100,"input_tokens_details":{"text_tokens":20,"image_tokens":80}, + "output_tokens":10,"output_tokens_details":{"text_tokens":0,"image_tokens":10},"total_tokens":110 + }})).unwrap(); + let events = image_metering_events_with_pricing( + ImageRequestKind::Generation, + &response, + "gpt-image-2", + pricing, + ) + .await; + let cost = events + .iter() + .flat_map(|event| event.canonical_facts()) + .find_map(|event| match event { + GatewayEvent::CalculatedCost(cost) => Some(cost.clone()), + _ => None, + }) + .unwrap() + .into_estimate(); + assert_eq!(cost.total().unwrap().amount().canonical(), "0.00196"); + let breakdown = cost.breakdown().unwrap(); + assert_eq!(breakdown.input_amount().amount().canonical(), "0.00008"); + assert_eq!( + breakdown.image().unwrap().input_amount.amount().canonical(), + "0.00128" + ); + assert_eq!(breakdown.custom_multiplier_bps(), 20000); +} + #[tokio::test] async fn image_endpoint_returns_the_exact_upstream_error_response() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index 553cc7a34..e05b4d80b 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -735,3 +735,87 @@ fn context() -> CodexRequestContext<'static> { account_selection: Default::default(), } } + +use gateway_core::metering::ModelPriceOverride; +use provider_openai::transport::openai_billing_breakdown_with_override; +use serde_json::json; + +#[test] +fn custom_price_has_exact_cache_and_multiplier_math() { + let custom: ModelPriceOverride = serde_json::from_value(json!({"multiplierBps":12500,"bands":{ + "standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"3"} + }})) + .unwrap(); + let breakdown = openai_billing_breakdown_with_override( + "custom-model", + billing_usage(100, 10, 20, 10), + None, + Some(&custom), + ) + .unwrap(); + assert_eq!(breakdown.input_amount().amount().canonical(), "0.000175"); + assert_eq!(breakdown.output_amount().amount().canonical(), "0.000125"); + assert_eq!(breakdown.cache_read_amount().amount().canonical(), "0"); + assert_eq!( + breakdown.cache_write_amount().amount().canonical(), + "0.0000375" + ); + assert_eq!(breakdown.total_amount().amount().canonical(), "0.0003375"); + assert_eq!(breakdown.custom_multiplier_bps(), 12500); + assert!( + openai_billing_breakdown_with_override( + "custom-model", + billing_usage(100, 10, 0, 0), + Some("flex"), + Some(&custom) + ) + .is_none() + ); +} + +#[test] +fn a_custom_long_priority_band_does_not_require_a_long_standard_band() { + let pricing = serde_json::from_value(json!({"multiplierBps":10000,"bands":{ + "standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"0"}, + "long_fast":{"input":"8","output":"40","cacheRead":"0","cacheWrite":"0"} + }})) + .unwrap(); + let result = openai_billing_breakdown_with_override( + "custom-model", + billing_usage(300_000, 0, 0, 0), + Some("priority"), + Some(&pricing), + ) + .unwrap(); + assert_eq!(result.total_amount().amount().canonical(), "2.4"); + assert_eq!(result.standard_amount().amount().canonical(), "0.6"); +} + +#[test] +fn multiplier_only_preserves_builtin_tiers_and_unknown_models_remain_unknown() { + let custom: ModelPriceOverride = + serde_json::from_value(json!({"multiplierBps":20000,"bands":{}})).unwrap(); + for tier in [None, Some("priority"), Some("flex")] { + for input in [100, 272_001] { + let usage = billing_usage(input, 10, 20, 5); + let original = openai_billing_breakdown("gpt-6-astra", usage, tier).unwrap(); + let adjusted = + openai_billing_breakdown_with_override("gpt-6-astra", usage, tier, Some(&custom)) + .unwrap(); + assert_eq!( + adjusted.total_amount().amount().scaled(), + original.total_amount().amount().scaled() * 2 + ); + assert_eq!(adjusted.multiplier_percent(), original.multiplier_percent()); + } + } + assert!( + openai_billing_breakdown_with_override( + "unknown-model", + billing_usage(100, 10, 0, 0), + None, + Some(&custom) + ) + .is_none() + ); +} diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index d1a22418f..8e76ddf86 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -321,6 +321,9 @@ impl XaiAdminProvider { #[async_trait] impl ProviderAdmin for XaiAdminProvider { + fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { + crate::transport::canonical::pricing_catalog() + } fn provider_kind(&self) -> &ProviderKind { &self.provider_kind } @@ -404,6 +407,8 @@ impl ProviderAdmin for XaiAdminProvider { return Ok(None); } Ok(Some(CalculatedBillingBreakdown { + image: None, + custom_multiplier_bps: breakdown.custom_multiplier_bps(), input_amount: currency_cost(breakdown.input_amount())?, output_amount: currency_cost(breakdown.output_amount())?, cache_read_amount: currency_cost(breakdown.cache_read_amount())?, diff --git a/backend/crates/providers/xai/src/provider/stream.rs b/backend/crates/providers/xai/src/provider/stream.rs index 5d600a3ec..5c4ea544f 100644 --- a/backend/crates/providers/xai/src/provider/stream.rs +++ b/backend/crates/providers/xai/src/provider/stream.rs @@ -189,7 +189,8 @@ pub(super) fn cold_compaction_http_sse_stream( yield ProviderEvent::observation(observation.clone()); let mut body = accepted.response.into_body(); - let mut canonical = GrokCanonicalDecoder::new(upstream_model.as_str()); + let mut canonical = GrokCanonicalDecoder::new(upstream_model.as_str()) + .with_pricing(context.pricing().get("xai").and_then(|p| p.get(upstream_model.as_str())).cloned()); let mut summary = GrokCompactionSummaryDecoder::new(); let mut facts = CompactionFacts::default(); @@ -475,7 +476,8 @@ pub(super) fn cold_http_sse_stream( yield ProviderEvent::observation(observation.clone()); let mut body = response.into_body(); - let mut decoder = GrokCanonicalDecoder::for_request(upstream_model.as_str(), &request); + let mut decoder = GrokCanonicalDecoder::for_request(upstream_model.as_str(), &request) + .with_pricing(context.pricing().get("xai").and_then(|p| p.get(upstream_model.as_str())).cloned()); loop { let Some(stream_deadline) = remaining(context.deadline()) else { Err(provider_error( diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index fc07971c4..125655576 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -112,44 +112,138 @@ pub fn grok_billing_breakdown_with_tier( output_tokens: u64, cached_tokens: u64, service_tier: Option<&str>, +) -> Option { + grok_billing_breakdown_with_override( + model, + input_tokens, + output_tokens, + cached_tokens, + 0, + service_tier, + None, + ) +} + +#[must_use] +pub fn grok_billing_breakdown_with_override( + model: &str, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + cache_write_tokens: u64, + service_tier: Option<&str>, + custom: Option<&gateway_core::metering::ModelPriceOverride>, ) -> Option { let (tier, multiplier) = match service_tier.map(str::trim) { None | Some("default" | "standard") => ("default", 1_u128), Some("priority") => ("priority", 2), Some(_) => return None, }; - let pricing = model_pricing(model)?; - let rates = if input_tokens >= LONG_CONTEXT_THRESHOLD { - pricing.long - } else { - pricing.short + let long = input_tokens >= LONG_CONTEXT_THRESHOLD; + let standard_band = if long { "long_standard" } else { "standard" }; + let fast_band = if long { "long_fast" } else { "fast" }; + let convert = |rates: &gateway_core::metering::TokenPriceOverride| TokenRates { + input_ticks: rates.input.ticks_per_token(), + cached_input_ticks: rates.cache_read.ticks_per_token(), + output_ticks: rates.output.ticks_per_token(), }; - let uncached_tokens = input_tokens.checked_sub(cached_tokens)?; + let standard = custom + .and_then(|p| p.bands.get(standard_band)) + .map(convert) + .or_else(|| model_pricing(model).map(|p| if long { p.long } else { p.short }))?; + let rates = if multiplier == 2 { + custom + .and_then(|p| p.bands.get(fast_band)) + .map(convert) + .or_else(|| { + // 未覆盖档位继承内置价,不把人工标准价再次解释成 Priority 价。 + let builtin = model_pricing(model).map(|p| if long { p.long } else { p.short })?; + Some(TokenRates { + input_ticks: builtin.input_ticks.checked_mul(2)?, + cached_input_ticks: builtin.cached_input_ticks.checked_mul(2)?, + output_ticks: builtin.output_ticks.checked_mul(2)?, + }) + })? + } else { + standard + }; + if cached_tokens.checked_add(cache_write_tokens)? > input_tokens { + return None; + } + let standard_write = custom + .and_then(|p| p.bands.get(standard_band)) + .map(|p| p.cache_write.ticks_per_token()); + let selected_band = if multiplier == 2 { + fast_band + } else { + standard_band + }; + let selected_write = custom + .and_then(|p| p.bands.get(selected_band)) + .map(|p| p.cache_write.ticks_per_token()); + let uncached_tokens = + input_tokens + .checked_sub(cached_tokens)? + .checked_sub(if selected_write.is_some() { + cache_write_tokens + } else { + 0 + })?; + let standard_input_tokens = + input_tokens + .checked_sub(cached_tokens)? + .checked_sub(if standard_write.is_some() { + cache_write_tokens + } else { + 0 + })?; + let cache_write_amount_ticks = + u128::from(cache_write_tokens).checked_mul(selected_write.unwrap_or_default())?; let input_amount_ticks = u128::from(uncached_tokens).checked_mul(rates.input_ticks)?; let cache_read_amount_ticks = u128::from(cached_tokens).checked_mul(rates.cached_input_ticks)?; let output_amount_ticks = u128::from(output_tokens).checked_mul(rates.output_ticks)?; - let standard_amount_ticks = input_amount_ticks + let selected_amount_ticks = input_amount_ticks .checked_add(cache_read_amount_ticks)? + .checked_add(cache_write_amount_ticks)? .checked_add(output_amount_ticks)?; - Some(CalculatedCostBreakdown::new( + let standard_amount_ticks = u128::from(standard_input_tokens) + .checked_mul(standard.input_ticks)? + .checked_add(u128::from(cached_tokens).checked_mul(standard.cached_input_ticks)?)? + .checked_add( + u128::from(cache_write_tokens).checked_mul(standard_write.unwrap_or_default())?, + )? + .checked_add(u128::from(output_tokens).checked_mul(standard.output_ticks)?)?; + let multiplier_percent = if standard_amount_ticks == 0 { + 100 + } else { + u32::try_from( + selected_amount_ticks + .checked_mul(100)? + .checked_add(standard_amount_ticks / 2)? + .checked_div(standard_amount_ticks)?, + ) + .ok()? + }; + CalculatedCostBreakdown::new( CalculatedCostAmounts::new( - usd_money(input_amount_ticks.checked_mul(multiplier)?)?, - usd_money(output_amount_ticks.checked_mul(multiplier)?)?, - usd_money(cache_read_amount_ticks.checked_mul(multiplier)?)?, - usd_money(0)?, + usd_money(input_amount_ticks)?, + usd_money(output_amount_ticks)?, + usd_money(cache_read_amount_ticks)?, + usd_money(cache_write_amount_ticks)?, usd_money(standard_amount_ticks)?, - usd_money(standard_amount_ticks.checked_mul(multiplier)?)?, + usd_money(selected_amount_ticks)?, ), CalculatedCostRates::new( - usd_price_per_million(rates.input_ticks.checked_mul(multiplier)?)?, - usd_price_per_million(rates.output_ticks.checked_mul(multiplier)?)?, - usd_price_per_million(rates.cached_input_ticks.checked_mul(multiplier)?)?, - usd_money(0)?, + usd_price_per_million(rates.input_ticks)?, + usd_price_per_million(rates.output_ticks)?, + usd_price_per_million(rates.cached_input_ticks)?, + usd_price_per_million(selected_write.unwrap_or_default())?, ), Some(tier.to_owned()), - if multiplier == 2 { 200 } else { 100 }, - )) + multiplier_percent, + ) + .with_custom_multiplier(custom.map_or(10_000, |p| p.multiplier_bps)) } fn usd_money(ticks: u128) -> Option { @@ -167,6 +261,7 @@ fn usd_price_per_million(per_token_ticks: u128) -> Option { /// /// 每个上游 event 同时保留 OpenAI wire,并在可识别时附加 canonical facts。 pub struct GrokCanonicalDecoder { + pricing: Option, decoder: SseEventDecoder, response_transform: GrokResponseTransform, upstream_model: String, @@ -187,6 +282,7 @@ impl GrokCanonicalDecoder { pub fn new(upstream_model: impl Into) -> Self { Self { decoder: SseEventDecoder::default(), + pricing: None, response_transform: GrokResponseTransform::default(), upstream_model: upstream_model.into(), response_id: None, @@ -202,6 +298,15 @@ impl GrokCanonicalDecoder { } } + #[must_use] + pub fn with_pricing( + mut self, + pricing: Option, + ) -> Self { + self.pricing = pricing; + self + } + /// 创建在 canonical 与 wire 投影处理每个上游 event 前先还原请求级 tool 别名的 /// 解码器。 #[must_use] @@ -673,6 +778,7 @@ impl GrokCanonicalDecoder { &self.upstream_model, usage, self.response_service_tier(), + self.pricing.as_ref(), ) }) { @@ -858,6 +964,7 @@ fn calculated_cost( model: &str, usage: TokenUsage, service_tier: Option<&str>, + pricing: Option<&gateway_core::metering::ModelPriceOverride>, ) -> Option { if !billable_usage_is_complete(response, usage) || usage.image_input_tokens > 0 @@ -865,62 +972,129 @@ fn calculated_cost( { return None; } - let breakdown = grok_billing_breakdown_with_tier( + let breakdown = grok_billing_breakdown_with_override( model, usage.input_tokens, usage.output_tokens, usage.cached_tokens, + usage.cache_write_tokens, service_tier, + pricing, )?; Some(breakdown.calculated_cost()) } +const PRICING_RULES: &[(&[&str], ModelPricing)] = &[ + (&["grok-4.6", "grok-4.6-latest"], GROK_46_PRICING), + ( + &[ + "grok-4.5", + "grok-4.5-latest", + "grok-4.5-build-free", + "grok-build-latest", + ], + GROK_45_PRICING, + ), + ( + &[ + "grok-build-0.1", + "grok-code-fast-1", + "grok-code-fast", + "grok-code-fast-1-0825", + ], + GROK_BUILD_PRICING, + ), + ( + &[ + "grok-4.3", + "grok-4.3-latest", + "grok-latest", + "grok-4.20-multi-agent-0309", + "grok-4.20-multi-agent", + "grok-4.20-multi-agent-latest", + "grok-4.20-multi-agent-beta-latest", + "grok-4.20-multi-agent-experimental-beta-0304", + "grok-4.20-multi-agent-experimental-beta-latest", + "grok-4.20-multi-agent-beta-0309", + "grok-4.20-0309-reasoning", + "grok-4.20-reasoning-latest", + "grok-4.20", + "grok-4.20-reasoning", + "grok-4.20-0309", + "grok-4.20-beta-0309-reasoning", + "grok-4.20-beta", + "grok-4.20-beta-0309", + "grok-4.20-beta-latest", + "grok-4.20-beta-latest-reasoning", + "grok-4.20-beta-reasoning", + "grok-4.20-experimental-beta-0304-reasoning", + "grok-4.20-experimental-beta-0304", + "grok-4.20-experimental-beta-reasoning-latest", + "grok-4.20-experimental-beta-latest", + "grok-4.20-reasoning-gv2", + "grok-4.20-0309-non-reasoning", + "grok-4.20-non-reasoning", + "grok-4.20-non-reasoning-latest", + "grok-4.20-beta-non-reasoning", + "grok-4.20-beta-latest-non-reasoning", + "grok-4.20-experimental-beta-0304-non-reasoning", + "grok-4.20-experimental-beta-non-reasoning-latest", + "grok-4.20-beta-0309-non-reasoning", + "grok-4.20-non-reasoning-gv2", + ], + GROK_43_PRICING, + ), +]; + fn model_pricing(model: &str) -> Option { - match model { - "grok-4.6" | "grok-4.6-latest" => Some(GROK_46_PRICING), - "grok-4.5" | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest" => { - Some(GROK_45_PRICING) - } - "grok-build-0.1" | "grok-code-fast-1" | "grok-code-fast" | "grok-code-fast-1-0825" => { - Some(GROK_BUILD_PRICING) - } - "grok-4.3" - | "grok-4.3-latest" - | "grok-latest" - | "grok-4.20-multi-agent-0309" - | "grok-4.20-multi-agent" - | "grok-4.20-multi-agent-latest" - | "grok-4.20-multi-agent-beta-latest" - | "grok-4.20-multi-agent-experimental-beta-0304" - | "grok-4.20-multi-agent-experimental-beta-latest" - | "grok-4.20-multi-agent-beta-0309" - | "grok-4.20-0309-reasoning" - | "grok-4.20-reasoning-latest" - | "grok-4.20" - | "grok-4.20-reasoning" - | "grok-4.20-0309" - | "grok-4.20-beta-0309-reasoning" - | "grok-4.20-beta" - | "grok-4.20-beta-0309" - | "grok-4.20-beta-latest" - | "grok-4.20-beta-latest-reasoning" - | "grok-4.20-beta-reasoning" - | "grok-4.20-experimental-beta-0304-reasoning" - | "grok-4.20-experimental-beta-0304" - | "grok-4.20-experimental-beta-reasoning-latest" - | "grok-4.20-experimental-beta-latest" - | "grok-4.20-reasoning-gv2" - | "grok-4.20-0309-non-reasoning" - | "grok-4.20-non-reasoning" - | "grok-4.20-non-reasoning-latest" - | "grok-4.20-beta-non-reasoning" - | "grok-4.20-beta-latest-non-reasoning" - | "grok-4.20-experimental-beta-0304-non-reasoning" - | "grok-4.20-experimental-beta-non-reasoning-latest" - | "grok-4.20-beta-0309-non-reasoning" - | "grok-4.20-non-reasoning-gv2" => Some(GROK_43_PRICING), - _ => None, - } + PRICING_RULES + .iter() + .find(|(models, _)| models.contains(&model)) + .map(|(_, price)| *price) +} + +pub(crate) fn pricing_catalog() -> gateway_admin::model::pricing::ProviderPricingCatalog { + use gateway_core::metering::{ModelPriceOverride, TokenPrice, TokenPriceOverride}; + let price = |ticks: u128| -> TokenPrice { + Decimal::from_scaled(ticks * 1_000_000) + .expect("内置价格在范围内") + .canonical() + .try_into() + .expect("内置价格精度合法") + }; + PRICING_RULES + .iter() + .flat_map(|(models, pricing)| { + models.iter().map(|model| { + let bands = [ + ("standard", pricing.short, 1), + ("fast", pricing.short, 2), + ("long_standard", pricing.long, 1), + ("long_fast", pricing.long, 2), + ] + .into_iter() + .map(|(band, rates, multiplier)| { + ( + band.to_owned(), + TokenPriceOverride { + input: price(rates.input_ticks * multiplier), + output: price(rates.output_ticks * multiplier), + cache_read: price(rates.cached_input_ticks * multiplier), + cache_write: price(rates.input_ticks * multiplier), + }, + ) + }) + .collect(); + ( + (*model).to_owned(), + ModelPriceOverride { + multiplier_bps: 10_000, + bands, + }, + ) + }) + }) + .collect() } fn incomplete_finish_reason(response: &Value) -> FinishReason { diff --git a/backend/crates/providers/xai/tests/transport/canonical.rs b/backend/crates/providers/xai/tests/transport/canonical.rs index dae13d82a..62a65e5c1 100644 --- a/backend/crates/providers/xai/tests/transport/canonical.rs +++ b/backend/crates/providers/xai/tests/transport/canonical.rs @@ -336,9 +336,14 @@ fn billing_should_price_grok_46_cache_and_priority_at_the_context_boundary() { assert_eq!(priority.multiplier_percent(), 200); } -fn pricing_events( +fn pricing_events(response: Value, request: Option<&GrokResponsesRequest>) -> Vec { + pricing_events_with_override(response, request, None) +} + +fn pricing_events_with_override( mut response: Value, request: Option<&GrokResponsesRequest>, + pricing: Option, ) -> Vec { response["id"] = serde_json::json!("resp_pricing"); response["model"] = serde_json::json!("grok-4.6"); @@ -348,10 +353,12 @@ fn pricing_events( let body = format!( "event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n" ); - let mut decoder = request.map_or_else( - || GrokCanonicalDecoder::new("grok-4.6"), - |request| GrokCanonicalDecoder::for_request("grok-4.6", request), - ); + let mut decoder = request + .map_or_else( + || GrokCanonicalDecoder::new("grok-4.6"), + |request| GrokCanonicalDecoder::for_request("grok-4.6", request), + ) + .with_pricing(pricing); let events = decoder .push(body.as_bytes()) .expect("计费未知时仍应交付响应"); @@ -367,6 +374,50 @@ fn pricing_events( facts } +#[test] +fn custom_pricing_scales_calculations_but_never_changes_reported_cost() { + let pricing: gateway_core::metering::ModelPriceOverride = + serde_json::from_value(serde_json::json!({ + "multiplierBps":15000,"bands":{"standard":{ + "input":"3", "output":"12", "cacheRead":"0", "cacheWrite":"0" + }} + })) + .unwrap(); + let mut response = serde_json::json!({"service_tier":"default","output":[],"usage":{ + "input_tokens":100,"output_tokens":10,"input_tokens_details":{"cached_tokens":20} + }}); + assert_eq!( + calculated_cost_ticks(&pricing_events_with_override( + response.clone(), + None, + Some(pricing.clone()) + )), + Some(5_400_000) + ); + response["usage"]["input_tokens_details"]["cache_write_tokens"] = serde_json::json!(10); + assert_eq!( + calculated_cost_ticks(&pricing_events_with_override( + response.clone(), + None, + Some(pricing.clone()) + )), + Some(4_950_000) + ); + response["service_tier"] = serde_json::json!("priority"); + assert_eq!( + calculated_cost_ticks(&pricing_events_with_override( + response.clone(), + None, + Some(pricing.clone()) + )), + Some(6_900_000) + ); + response["usage"]["cost_in_usd_ticks"] = serde_json::json!(123); + let events = pricing_events_with_override(response, None, Some(pricing)); + assert_eq!(provider_cost_ticks(&events), Some(123)); + assert_eq!(calculated_cost_ticks(&events), None); +} + #[test] fn billing_should_use_the_final_response_tier_and_preserve_provider_cost() { for (tier, ticks, calculated, provider) in [ diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 8cef79bf5..a8d771aa5 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -14,3 +14,4 @@ b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_ 12a540be31d65c4925cbe63dab42ea2d4ec2864fecf71a6dd4ee942e71726857 0014_disable_fast.sql 9bd37bd314b6864816c43cb4fd1869fb415920d4f10f766976e344334a1f60fc 0015_proxy_dual_stack_exit.sql 3eb917aab407a497355b21db0faf7974aac757801473b0a13b47b136598b371f 0016_client_request_profiles.sql +3569e370689caac3f03e76539bc7df4b22304988f428cb7f8911aba170c59167 0017_model_pricing.sql diff --git a/backend/migrations/0017_model_pricing.sql b/backend/migrations/0017_model_pricing.sql new file mode 100644 index 000000000..77602ef45 --- /dev/null +++ b/backend/migrations/0017_model_pricing.sql @@ -0,0 +1,8 @@ +-- 覆盖配置与请求历史明细分离,改价不重算已发生的费用。 +alter table runtime_settings add column pricing_overrides_json jsonb not null default '{}' + check (jsonb_typeof(pricing_overrides_json) = 'object'); +alter table runtime_settings add column pricing_synced_json jsonb not null default '{}' + check (jsonb_typeof(pricing_synced_json) = 'object'); +alter table runtime_settings add column pricing_synced_at timestamptz; +alter table model_requests add column billing_snapshot_json jsonb + check (billing_snapshot_json is null or jsonb_typeof(billing_snapshot_json) = 'object'); diff --git a/docs/api.md b/docs/api.md index e8423e810..d390e1d62 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1010,6 +1010,45 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 `rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。 两个 `minCodex*Version` 字段为 `string | null`,只设置最低版本,不存在最大版本字段。 +### 模型定价 + +定价接口独立于运行设置的整体替换,仅允许管理员访问。改价影响本地估算费用与 Client Key 金额限额, +不改变上游报告金额、订阅账号实际扣额或历史费用。请求开始时冻结价格,内部重试沿用同一份配置。 + +| 方法 | 路由 | 请求与返回 | +| --- | --- | --- | +| `GET` | `/api/admin/settings/pricing` | 返回 `{ defaults, synced, overrides, syncedAt }` | +| `POST` | `/api/admin/settings/pricing/update` | `{ provider, models, change }`,成功返回 `{ saved: true }` | +| `POST` | `/api/admin/settings/pricing/sync/preview` | 无 body;返回 `{ prices, skipped }`,不写入配置 | +| `POST` | `/api/admin/settings/pricing/sync` | 原样提交确认的 `{ prices, skipped }`;成功返回 `{ saved: true }` | + +价目使用 `Provider → 精确上游模型 ID → { multiplierBps, bands }` 的映射。优先级为人工覆盖、已同步价目、 +内置价目;按档位合并,不从客户端模型别名或响应模型猜测价格。`syncedAt` 为 ISO 时间或 `null`。 +每个档位包含四个非负十进制字符串:`input`、`output`、`cacheRead`、`cacheWrite`,单位 USD / 百万 +Token,范围 0~1000000、最多四位小数。`"0"` 表示免费,缺少整个档位表示继承;不能只缺少部分单价。 + +`bands` 可用键为 `standard`、`fast`、`flex`、`long_standard`、`long_fast`、`long_flex`、`image`。 +OpenAI 长上下文为输入超过 272000 Token,xAI 为输入达到 200000 Token;仍按 Provider 支持的 +模型与服务档位判断是否能估算。xAI 不接受 `flex`、`long_flex`、`image`。图像端点的 `standard` +用于文本输入,`image` 用于图像 Token;不能用文本输出单价替代图像输出单价。用量无法完整拆分时不估算。 + +`multiplierBps` 为 0~1000000 的整数,10000 表示 1 倍、0 表示本地估算为零,最大 100 倍。 +它作用于本地费用及对应明细,独立于服务档位;缺少计价依据的请求即使倍率为零仍然是费用未知。 + +`provider` 为 `openai` 或 `xai`;`models` 为 1~500 个模型 ID,每个 ID 为 1~128 字节且不含空白、 +控制字符。`change` 为以下形式之一: + +- `{ "action": "replace", "pricing": { "multiplierBps": 12500, "bands": { ... } } }`:替换选中模型的人工 + 配置,未提供档位重新继承来源;内置和同步均未登记的模型必须包含 `standard`。 +- `{ "action": "multiplier", "multiplierBps": 20000 }`:设置目标倍率,保留已有人工单价;重复提交不连续相乘。 +- `{ "action": "reset" }`:移除人工单价与倍率,恢复同步价或内置价;仅有人工价格的模型恢复为未配置。 + +一批更新原子提交并写审计,不覆盖未选中的模型。未知字段、错误类型和非法价格字符串等 JSON 合同错误 +返回 422;Provider、模型 ID、批量数量、倍率上限和不支持的档位等业务校验错误返回 400。 +models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 模型;不完整价格、其他输出模态及 +不匹配的上下文梯度在 `skipped` 中返回 `provider/model`。确认会重新抓取价目;若与预览不同返回 400, +需重新预览。来源不可用返回 502,已有价目保持不变。同步只更新来源层,始终保留人工单价与倍率。 + ### OpenAI 上游客户端身份 `openaiClientProfile` 保存通用选择,首次默认 `MacOS · Desktop · 自动最新`。 @@ -1247,8 +1286,11 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 原始 `response.service_tier` 不变。用量中的 Fast 仅表示发送档位,不能证明上游实际加速,本地费用 估算也不能代替官方账单。档位与费用在请求记录生成时确定;查询不会回填历史档位或重算已存储费用。 -本地计价规则覆盖尚在服务的型号。已关闭型号或缺少计价规则时,不生成新的本地估价;已存储的历史费用 -保留原值,但缺少规则时无法补充费用拆分。 +本地计价使用[模型定价](#模型定价)的生效规则。缺少内置、同步及人工价格时不生成估价。新请求的本地 +费用明细、有效单价、服务档位与自定义倍率随终态记录持久化,后续改价和清除覆盖不重算历史明细。 +旧记录没有费用快照时仍按内置规则核对总额后补充拆分,核对失败只显示原总额。图像明细通过可选的 +`billing.image` 返回 `inputAmountDisplay`、`cacheReadAmountDisplay`、`inputPriceDisplay` 和 +`cacheReadPriceDisplay`;存在该字段时,普通输入与缓存字段仅表示文本输入,输出字段表示图像输出。 ## 11. 版本、更新与重启 diff --git a/docs/architecture.md b/docs/architecture.md index 20eab7baf..336858ddc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,7 @@ flowchart LR | `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义和备份策略 | | `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider | | `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 | -| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 | +| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督、系统更新及外部价格源适配 | | `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images/Search transport | | `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 | | `frontend` | Vue 管理端与 Key 用量页,仅通过各自身份允许的控制面 API 访问状态 | @@ -406,6 +406,21 @@ PostgreSQL 不可用时拒绝所有新的计费请求,Redis 继续管理并发 账本独立于可丢弃的请求观测日志,日志清理不重置金额;费用事件保留至删除 Key,已有日志不会回填为账本费用。 字段与错误合同见 [Client Key API](api.md#7-client-key)。 +### 模型价格与费用快照 + +Provider 是内置价目、服务档位、模态和工具费规则的唯一 owner;Core metering 定义中立价格覆盖与 +精确金额运算。Admin 管理人工覆盖与手动同步,Host `pricing` 适配固定的 models.dev HTTPS 来源, +由组合根注入 Admin 的 `PricingSource` 端口;Store 负责来源层、人工层与审计的事务持久化。 +同步不能覆盖人工项,更新仅修改选中的模型。HTTP 合同与价格边界见 [模型定价 API](api.md#模型定价)。 + +编译 RuntimeSnapshot 时合并同步与人工配置,Provider 缺省项仍由其内置表解释。不可变价格集合经 +Arc 随 RoutingPlan 冻结并进入所有 attempt,不在推理请求中读取数据库、Redis 或外部价目,也不复制 +整份价目。Provider 按实际发送模型选择配置;自定义倍率只作用于本地计算费用,上游报告金额不变。 + +本地计算费用携带当次拆分、有效单价和倍率,终态观测将其写入版本化费用快照。查询优先还原快照, +不使用新配置解释历史;无快照的旧记录保留总额核对后补充拆分的路径。Client Key 账本使用同一费用 +结果沿既有幂等结算流程累计,不依赖请求明细写入成功。 + ## 7. 控制面与 revision 管理写入遵循统一流程: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4e54ead52..e8f1d3a45 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,6 +16,9 @@ export * from './modules/backups' // Dashboard 聚合 export * from './modules/dashboard' +// 模型定价 +export * from './modules/pricing' + export * from './modules/proxies' // 设置管理 diff --git a/frontend/src/api/modules/pricing.ts b/frontend/src/api/modules/pricing.ts new file mode 100644 index 000000000..b4874caae --- /dev/null +++ b/frontend/src/api/modules/pricing.ts @@ -0,0 +1,78 @@ +import type { RequestOptions } from '../request' +import request from '../request' + +export type PriceBand = 'standard' | 'fast' | 'flex' | 'long_standard' | 'long_fast' | 'long_flex' | 'image' + +export interface TokenPrices { + input: string + output: string + cacheRead: string + cacheWrite: string +} + +export interface ModelPricing { + multiplierBps: number + bands: Partial> +} + +export type PricingMap = Record> + +export interface PricingCatalog { + defaults: PricingMap + synced: PricingMap + overrides: PricingMap + syncedAt: string | null +} + +export type PricingChange + = | { action: 'replace', pricing: ModelPricing } + | { action: 'multiplier', multiplierBps: number } + | { action: 'reset' } + +export interface PricingSyncPreview { + prices: PricingMap + skipped: string[] +} + +interface UpdatePricingParam { + provider: string + models: string[] + change: PricingChange +} + +interface PricingMutationResponse { + saved: boolean +} + +export function getPricing(options: RequestOptions = {}) { + return request({ + url: '/api/admin/settings/pricing', + method: 'GET', + ...options, + }) +} + +export function updatePricing(data: UpdatePricingParam) { + return request({ + url: '/api/admin/settings/pricing/update', + method: 'POST', + data, + }) +} + +export function previewPricingSync() { + return request({ + url: '/api/admin/settings/pricing/sync/preview', + method: 'POST', + timeout: 40_000, + }) +} + +export function syncPricing(data: PricingSyncPreview) { + return request({ + url: '/api/admin/settings/pricing/sync', + method: 'POST', + data, + timeout: 45_000, + }) +} diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 82effb080..a95f3227e 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -33,6 +33,12 @@ export interface UsageCostCoverage { } export interface UsageBilling { + image?: { + inputAmountDisplay: string + cacheReadAmountDisplay: string + inputPriceDisplay: string + cacheReadPriceDisplay: string + } inputAmountDisplay: string outputAmountDisplay: string cacheReadAmountDisplay: string diff --git a/frontend/src/components/base/BaseModal/index.vue b/frontend/src/components/base/BaseModal/index.vue index e9981496c..7c7f26dda 100644 --- a/frontend/src/components/base/BaseModal/index.vue +++ b/frontend/src/components/base/BaseModal/index.vue @@ -216,7 +216,7 @@ onBeforeUnmount(() => { :role="role" aria-modal="true" :aria-labelledby="titleId" - :aria-describedby="description ? descriptionId : undefined" + :aria-describedby="description || $slots.description ? descriptionId : undefined" tabindex="-1" >
{ tone === 'neutral' ? 'grid-cols-[minmax(0,1fr)_28px]' : 'grid-cols-[auto_minmax(0,1fr)_28px]', - description ? 'items-start' : 'items-center', + description || $slots.description ? 'items-start' : 'items-center', draggable ? 'cp-modal-header--draggable' : undefined, ]" @pointerdown="handlePointerDown" @@ -244,11 +244,13 @@ onBeforeUnmount(() => { {{ title }}

- {{ description }} + + {{ description }} +

{ density?: 'compact' | 'default' loading?: boolean emptyText?: string + showHeaderWhenEmpty?: boolean scrollbarAlwaysVisible?: boolean sort?: BaseTableSort } diff --git a/frontend/src/components/base/BaseTable/index.vue b/frontend/src/components/base/BaseTable/index.vue index 460b5b3cc..e17d02824 100644 --- a/frontend/src/components/base/BaseTable/index.vue +++ b/frontend/src/components/base/BaseTable/index.vue @@ -26,6 +26,7 @@ const props = withDefaults(defineProps>(), { density: 'default', loading: false, emptyText: '暂无数据', + showHeaderWhenEmpty: false, scrollbarAlwaysVisible: false, sort: undefined, }) @@ -185,9 +186,9 @@ function sortButtonLabel(column: ResolvedTableColumn) {