feat(pricing): add configurable model prices and manual catalog sync (#159)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-18 20:22:15 +08:00
committed by GitHub
co-authored by Zyy Codex
parent ede456877b
commit fba8941068
88 changed files with 3700 additions and 179 deletions
+1
View File
@@ -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)
+1
View File
@@ -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(),
@@ -184,6 +184,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"pricing",
"proxy_probe",
"serve",
"system_update",
+5 -1
View File
@@ -296,6 +296,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
@@ -317,6 +318,7 @@ pub async fn initialize(
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
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,
@@ -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;
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
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<ImageBillingBreakdown>,
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 验证成功后升级为完整分解。
@@ -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<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StoredPricing {
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[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<String>,
}
#[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<String>,
pub change: PricingChange,
}
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
@@ -2,6 +2,7 @@
pub mod backup;
pub mod client_distribution;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod store;
@@ -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<PricingSyncPreview, AdminError>;
}
@@ -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
@@ -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<crate::model::pricing::StoredPricing>;
async fn sync_pricing(
&self,
prices: gateway_core::metering::PricingOverrides,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn update_pricing(
&self,
command: crate::model::pricing::UpdatePricing,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
@@ -619,7 +619,7 @@ async fn recover_standard_costs(
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
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<DecimalAmount> {
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()?
@@ -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<crate::model::pricing::PricingSyncPreview, AdminError>;
async fn sync_pricing(
&self,
context: &MutationContext,
preview: crate::model::pricing::PricingSyncPreview,
) -> Result<(), AdminError>;
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
@@ -52,6 +66,8 @@ pub trait SettingsService: Send + Sync {
}
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
@@ -63,8 +79,12 @@ impl DefaultSettingsService {
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> 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<crate::model::pricing::PricingSyncPreview, AdminError> {
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<crate::model::pricing::PricingCatalog, AdminError> {
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<gateway_core::account::OpaqueProviderData, AdminError> {
@@ -971,6 +971,23 @@ struct StaticSettingsStore;
#[async_trait]
impl SettingsStore for StaticSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
@@ -62,6 +62,23 @@ struct FreezeSettingsStore {
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
@@ -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<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
@@ -716,6 +734,19 @@ struct UnavailableProvider {
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
struct UnavailablePricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
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<dyn ProviderAdmin> {
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"),
@@ -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<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
@@ -15,6 +15,23 @@ struct UnusedSettingsStore;
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
@@ -214,6 +214,7 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
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<Bil
multiplier_display: "—".to_owned(),
}),
domain::UsageBilling::Calculated(value) => 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<Bil
cache_read_price_display: format_token_price(&value.cache_read_price_per_million),
cache_write_price_display: format_token_price(&value.cache_write_price_per_million),
service_tier_display: format_service_tier(value.service_tier.as_deref()),
multiplier_display: format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0),
multiplier_display: if value.custom_multiplier_bps == 10_000 {
format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0)
} else {
format!(
"档位 {:.2}x · 自定义 {:.4}x",
f64::from(value.multiplier_percent) / 100.0,
f64::from(value.custom_multiplier_bps) / 10_000.0
)
},
}),
}
}
@@ -56,6 +56,8 @@ pub struct CostCoverageView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingView {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageBillingView>,
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")]
@@ -353,6 +353,16 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/pricing", get(pricing::<S>))
.route(
"/api/admin/settings/pricing/update",
post(update_pricing::<S>),
)
.route(
"/api/admin/settings/pricing/sync/preview",
post(preview_pricing_sync::<S>),
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.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<String>,
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<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(preview): AdminJson<gateway_admin::model::pricing::PricingSyncPreview>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<PricingUpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -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<gateway_admin::model::pricing::StoredPricing>,
settings: Mutex<RuntimeSettings>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
}
struct StaticPricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for StaticPricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
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<Mutex<Option<AdminApiKey>>>) -> 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<gateway_admin::model::pricing::StoredPricing> {
Ok(self.pricing.lock().expect("pricing").clone())
}
async fn sync_pricing(
&self,
prices: gateway_core::metering::PricingOverrides,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
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<gateway_admin::model::Revision> {
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<RuntimeSettings> {
Ok(self.settings.lock().expect("settings").clone())
}
@@ -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"),
@@ -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::<UpdateRuntimeSettingsRequest>(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
);
}
}
@@ -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)
@@ -314,6 +314,7 @@ impl ContinuationAttempt {
/// Provider 每次执行可见的 request-local context。
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
pricing: Arc<crate::metering::PricingOverrides>,
request_profile: Option<crate::account::OpaqueProviderData>,
disable_fast: bool,
request_location: Option<crate::account::RequestLocation>,
@@ -325,6 +326,12 @@ pub struct RequestAttemptContext {
}
impl RequestAttemptContext {
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> 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> {
@@ -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();
+127 -6
View File
@@ -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<Money>,
breakdown: Option<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// 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<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// Provider 受控价格规则计算出的运行时费用明细。
///
/// 该值只用于生成事件和管理端展示,不是持久化模型;数据库仍只保存最终总额、货币和来源。
/// 随本地费用事件保存,以免后续改价改变历史明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCostBreakdown {
image: Option<ImageCostBreakdown>,
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<String>,
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<Self> {
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<Self, MeteringError> {
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,
}
}
@@ -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<String> for TokenPrice {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
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<TokenPrice> 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<String, TokenPriceOverride>,
}
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<String, BTreeMap<String, ModelPriceOverride>>;
/// 按档位叠加配置;倍率仅由人工覆盖控制,同步价目不携带业务倍率。
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
}
@@ -536,6 +536,7 @@ impl ProviderCandidate {
/// 一次请求冻结的 Provider 尝试顺序。
#[derive(Debug, Clone)]
pub struct RoutingPlan {
pricing: Arc<crate::metering::PricingOverrides>,
disable_fast: bool,
request_location: Option<crate::account::RequestLocation>,
config_revision: ConfigRevision,
@@ -547,6 +548,11 @@ pub struct RoutingPlan {
}
impl RoutingPlan {
#[must_use]
pub fn pricing(&self) -> Arc<crate::metering::PricingOverrides> {
Arc::clone(&self.pricing)
}
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.disable_fast
@@ -28,6 +28,7 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
/// Store 在一个一致性读取中提供的调度设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSettingsFacts {
pricing: Arc<crate::metering::PricingOverrides>,
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
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<crate::metering::PricingOverrides>,
disable_fast: bool,
responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
request_location: Option<crate::account::RequestLocation>,
@@ -589,6 +599,12 @@ pub struct RuntimeSnapshot {
}
impl RuntimeSnapshot {
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> 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,
@@ -1,5 +1,7 @@
use std::str::FromStr;
mod pricing;
use gateway_core::metering::{
CalculatedCost, CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates,
CostEstimate, CostEstimateStatus, CostSource, CurrencyCode, Decimal, Money,
@@ -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::<TokenPrice>(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());
}
@@ -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<PricingOverrides> {
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(
+1
View File
@@ -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;
@@ -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<PricingSyncPreview, AdminError> {
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<PricingSyncPreview, AdminError> {
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<ModelPriceOverride> {
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<TokenPriceOverride> {
let price = |key: &str| -> Option<TokenPrice> {
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
},
})
}
@@ -1,6 +1,7 @@
mod client_distribution;
mod config;
mod logging;
mod pricing;
mod proxy_probe;
mod serve;
mod system_update;
@@ -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());
}
@@ -15,6 +15,53 @@ pub(crate) struct AdminSettingsStoreAdapter {
#[async_trait::async_trait]
impl SettingsStore for AdminSettingsStoreAdapter {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
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<gateway_admin::model::Revision> {
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<gateway_admin::model::Revision> {
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<AdminRuntimeSettings> {
let snapshot = postgres::ControlPlaneRepository::load_control_plane(&self.control_plane)
.await
@@ -268,6 +268,7 @@ impl ModelRequestTimings {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRequestFinalization {
pub billing_snapshot_json: Option<Value>,
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),
@@ -21,6 +21,7 @@ mod execution;
mod execution_buffer;
mod observability;
mod ops_events;
mod pricing;
mod provider_accounts;
mod proxies;
mod retention;
@@ -368,6 +368,10 @@ pub(crate) fn admin_calculated_usage_billing_fact(
fact: CalculatedUsageBillingFact,
) -> AdminStoreResult<admin_observability::UsageCalculatedBillingFact> {
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<admin_observability::UsageBilling>,
snapshot: Option<&serde_json::Value>,
) -> Option<admin_observability::UsageBilling> {
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<admin_observability::UsageListRecord> {
@@ -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<UsageListRecord> {
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<UsageRecord> {
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<CalculatedUsageBillingFact> {
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")?,
@@ -335,6 +335,7 @@ pub struct RequestMetricPoint {
/// 已完整交付且由 Provider 计算费用的请求事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedUsageBillingFact {
pub billing_snapshot_json: Option<serde_json::Value>,
pub bucket_start: DateTime<Utc>,
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<serde_json::Value>,
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<serde_json::Value>,
pub id: String,
pub client_api_key_ref: String,
pub config_revision: u64,
@@ -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);
@@ -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,
@@ -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<gateway_admin::model::observability::CalculatedBillingBreakdown> {
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::<usize>()
> 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<gateway_admin::model::pricing::StoredPricing> {
let (Json(overrides), Json(synced), synced_at) = sqlx::query_as::<_, (Json<PricingOverrides>, Json<PricingOverrides>, Option<chrono::DateTime<chrono::Utc>>)>(
"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<Revision> {
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<Revision> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin model pricing update"))?;
// 锁定当前配置再更新选中项,避免批量操作覆盖其他管理员已提交的模型。
let Json(mut pricing) = sqlx::query_scalar::<_, Json<PricingOverrides>>(
"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)
}
}
@@ -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<gateway_core::routing::ProviderKind, gateway_core::account::OpaqueProviderData>,
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<ConfigRevision, SnapshotStoreErro
#[derive(sqlx::FromRow)]
struct SnapshotSettingsRow {
pricing_synced_json: sqlx::types::Json<gateway_core::metering::PricingOverrides>,
pricing_overrides_json: sqlx::types::Json<gateway_core::metering::PricingOverrides>,
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(
@@ -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 {
@@ -21,6 +21,7 @@ mod execution_buffer;
mod health;
mod observability;
mod ops_events;
mod pricing;
mod provider_accounts;
mod proxies;
mod query_budget;
@@ -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;
}
@@ -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<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
@@ -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())?,
@@ -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<CalculatedCost>)> {
// 只保留 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())
@@ -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<gateway_core::metering::ModelPriceOverride>,
decoder: SseEventDecoder,
upstream_model: String,
response_id: Option<String>,
@@ -133,6 +133,15 @@ impl CodexCanonicalFailure {
}
impl CodexCanonicalDecoder {
#[must_use]
pub fn with_pricing(
mut self,
pricing: Option<gateway_core::metering::ModelPriceOverride>,
) -> Self {
self.pricing = pricing;
self
}
/// 使用路由后最终发往上游的请求模型计价,并在响应缺少模型时用于 canonical 兜底。
pub fn new(upstream_model: impl Into<String>) -> 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(),
)
})
{
@@ -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,
},
@@ -103,6 +103,8 @@ impl WebSearchPricing {
#[derive(Clone, Copy)]
struct TokenRates {
explicit_cache: bool,
cache_write_ticks: Option<u128>,
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<CalculatedCostBreakdown> {
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<CalculatedCostBreakdown> {
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<ModelPricing>,
custom: Option<&gateway_core::metering::ModelPriceOverride>,
tier: PricingTier,
long_context: bool,
) -> Option<TokenRates> {
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<String, gateway_core::metering::ModelPriceOverride> {
use gateway_core::metering::{ModelPriceOverride, TokenPrice, TokenPriceOverride};
let price = |ticks: u128| -> Option<TokenPrice> {
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<CalculatedCost> {
pub(crate) fn image_calculated_cost(
request_body: &[u8],
usage: &Value,
prices: &gateway_core::metering::PricingOverrides,
) -> Option<CalculatedCost> {
#[derive(serde::Deserialize)]
struct ImageModel {
model: String,
}
// 只读取模型,跳过编辑请求中可能很大的 base64 图片。
let request = serde_json::from_slice::<ImageModel>(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<ModelPricing> {
@@ -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<u128> {
if let Some(ticks) = rates.cache_write_ticks {
return Some(ticks);
}
if percent == 0 {
return Some(0);
}
@@ -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();
@@ -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::<Vec<_>>();
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<gateway_core::event::ProviderEvent> {
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<gateway_core::event::ProviderEvent> {
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());
@@ -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()
);
}
@@ -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())?,
@@ -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(
@@ -112,44 +112,138 @@ pub fn grok_billing_breakdown_with_tier(
output_tokens: u64,
cached_tokens: u64,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
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<CalculatedCostBreakdown> {
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<Money> {
@@ -167,6 +261,7 @@ fn usd_price_per_million(per_token_ticks: u128) -> Option<Money> {
///
/// 每个上游 event 同时保留 OpenAI wire,并在可识别时附加 canonical facts。
pub struct GrokCanonicalDecoder {
pricing: Option<gateway_core::metering::ModelPriceOverride>,
decoder: SseEventDecoder,
response_transform: GrokResponseTransform,
upstream_model: String,
@@ -187,6 +282,7 @@ impl GrokCanonicalDecoder {
pub fn new(upstream_model: impl Into<String>) -> 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<gateway_core::metering::ModelPriceOverride>,
) -> 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<CalculatedCost> {
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<ModelPricing> {
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 {
@@ -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<GatewayEvent> {
pricing_events_with_override(response, request, None)
}
fn pricing_events_with_override(
mut response: Value,
request: Option<&GrokResponsesRequest>,
pricing: Option<gateway_core::metering::ModelPriceOverride>,
) -> Vec<GatewayEvent> {
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 [
+1
View File
@@ -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
@@ -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');
+44 -2
View File
@@ -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. 版本、更新与重启
+16 -1
View File
@@ -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
管理写入遵循统一流程:
+3
View File
@@ -16,6 +16,9 @@ export * from './modules/backups'
// Dashboard 聚合
export * from './modules/dashboard'
// 模型定价
export * from './modules/pricing'
export * from './modules/proxies'
// 设置管理
+78
View File
@@ -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<Record<PriceBand, TokenPrices>>
}
export type PricingMap = Record<string, Record<string, ModelPricing>>
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<PricingCatalog>({
url: '/api/admin/settings/pricing',
method: 'GET',
...options,
})
}
export function updatePricing(data: UpdatePricingParam) {
return request<PricingMutationResponse>({
url: '/api/admin/settings/pricing/update',
method: 'POST',
data,
})
}
export function previewPricingSync() {
return request<PricingSyncPreview>({
url: '/api/admin/settings/pricing/sync/preview',
method: 'POST',
timeout: 40_000,
})
}
export function syncPricing(data: PricingSyncPreview) {
return request<PricingMutationResponse>({
url: '/api/admin/settings/pricing/sync',
method: 'POST',
data,
timeout: 45_000,
})
}
+6
View File
@@ -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
@@ -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"
>
<header
@@ -225,7 +225,7 @@ onBeforeUnmount(() => {
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 }}
</h2>
<p
v-if="description"
v-if="description || $slots.description"
:id="descriptionId"
class="mt-1 mb-0 text-cp leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
<slot name="description">
{{ description }}
</slot>
</p>
</div>
<BaseIconButton
@@ -49,6 +49,7 @@ export interface BaseTableProps<Row extends TableRow> {
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
showHeaderWhenEmpty?: boolean
scrollbarAlwaysVisible?: boolean
sort?: BaseTableSort
}
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
density: 'default',
loading: false,
emptyText: '暂无数据',
showHeaderWhenEmpty: false,
scrollbarAlwaysVisible: false,
sort: undefined,
})
@@ -185,9 +186,9 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<template>
<div class="@container/table isolate flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden">
<div v-loading="loading && (hasRows || !showHeaderWhenEmpty)" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden">
<BaseScrollbar
v-if="hasRows"
v-if="hasRows || showHeaderWhenEmpty"
ref="scrollbar"
class="min-h-0 flex-1"
:class="
@@ -306,8 +307,15 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
</tbody>
</table>
</BaseScrollbar>
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
<BaseEmpty v-if="!loading" :title="emptyText" surface="none" class="w-full max-w-80" />
<div
v-if="!hasRows"
v-loading="loading && showHeaderWhenEmpty"
class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4"
:class="showHeaderWhenEmpty ? ['absolute inset-x-0 bottom-0', density === 'compact' ? 'top-8' : 'top-10'] : undefined"
>
<slot v-if="!loading" name="empty">
<BaseEmpty :title="emptyText" surface="none" class="w-full max-w-80" />
</slot>
</div>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import { computed, useId } from 'vue'
type TagType = 'neutral' | 'primary' | 'success' | 'info' | 'warning' | 'danger'
type TagSize = 'sm' | 'md' | 'lg'
const props = withDefaults(defineProps<{
type?: TagType
size?: TagSize
round?: boolean
closable?: boolean
disabled?: boolean
closeLabel?: string
}>(), {
type: 'neutral',
size: 'md',
round: false,
closable: false,
disabled: false,
closeLabel: undefined,
})
// 关闭只通知调用方,由调用方决定是否移除标签。
defineEmits<{ close: [event: MouseEvent] }>()
defineSlots<{ default: () => unknown }>()
const contentId = useId()
const closeId = useId()
const typeClasses: Record<TagType, string> = {
neutral: 'bg-cp-fill-tertiary text-cp-text-secondary',
primary: 'bg-cp-primary-container text-cp-primary-on-container',
success: 'bg-cp-success-container text-cp-success-on-container',
info: 'bg-cp-info-container text-cp-info-on-container',
warning: 'bg-cp-warning-container text-cp-warning-on-container',
danger: 'bg-cp-error-container text-cp-error-on-container',
}
const sizeClasses: Record<TagSize, string> = {
sm: 'min-h-5 gap-1 px-1.5 py-0.5 text-cp-xs',
md: 'min-h-6 gap-1.5 px-2 py-0.5 text-cp-xs',
lg: 'min-h-7 gap-2 px-2.5 py-1 text-cp-sm',
}
const classes = computed(() => [
sizeClasses[props.size],
props.round ? 'rounded-full' : 'rounded-cp',
props.disabled ? 'bg-cp-bg-container-disabled text-cp-text-disabled' : typeClasses[props.type],
])
</script>
<template>
<span class="inline-flex max-w-full min-w-0 items-center align-middle font-emphasis leading-snug" :class="classes">
<span :id="closable ? contentId : undefined" class="min-w-0 [overflow-wrap:anywhere]">
<slot />
</span>
<button
v-if="closable"
type="button"
:disabled="disabled"
:aria-label="closeLabel"
:aria-labelledby="closeLabel ? undefined : `${closeId} ${contentId}`"
class="inline-grid size-5 shrink-0 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-inherit outline-none transition-colors duration-150 hover:bg-current/10 focus-visible:ring-2 focus-visible:ring-cp-control-outline active:bg-current/20 disabled:cursor-not-allowed disabled:hover:bg-transparent motion-reduce:transition-none"
@click.stop="$emit('close', $event)"
>
<span :id="closeId" class="sr-only">移除</span>
<X class="size-3" aria-hidden="true" />
</button>
</span>
</template>
+5
View File
@@ -60,6 +60,11 @@ export const routes: RouteRecordRaw[] = [
name: 'settings-backup',
component: () => import('@/views/settings/index.vue'),
},
{
path: 'settings/pricing',
name: 'settings-pricing',
component: () => import('@/views/settings/index.vue'),
},
],
},
{
+22 -11
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Save, Undo2 } from '@lucide/vue'
import { computed, onMounted } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -26,13 +26,15 @@ import TokenRefreshCard from './components/TokenRefreshCard.vue'
import { useAdminApiKey } from './composables/useAdminApiKey'
import { useSettingsForm } from './composables/useSettingsForm'
import { rotationOptions } from './constants'
import PricingSection from './pricing/index.vue'
const route = useRoute()
const router = useRouter()
const section = computed(() => route.name === 'settings-backup' ? 'backup' : 'runtime')
const pricingVisited = ref(false)
const section = computed(() => route.name === 'settings-pricing' ? 'pricing' : route.name === 'settings-backup' ? 'backup' : 'runtime')
function switchSection(value: string): void {
void router.push(value === 'backup' ? '/settings/backup' : '/settings')
void router.push(value === 'runtime' ? '/settings' : `/settings/${value}`)
}
const {
@@ -77,23 +79,31 @@ const {
loadStatus: loadAdminApiKeyStatus,
} = useAdminApiKey()
onMounted(() => {
void loadSettings()
void loadAdminApiKeyStatus()
})
const runtimeVisited = ref(false)
watch(section, (value) => {
if (value === 'pricing') {
pricingVisited.value = true
}
else if (!runtimeVisited.value) {
runtimeVisited.value = true
void loadSettings()
void loadAdminApiKeyStatus()
}
}, { immediate: true })
</script>
<template>
<div class="w-full">
<BasePageHeader title="系统设置" description="管理运行参数、管理员凭据与备份配置" />
<div class="w-full" :class="section === 'pricing' ? 'flex h-[calc(100dvh-2rem)] flex-none! flex-col min-[961px]:h-[calc(100dvh-3rem)]' : undefined">
<BasePageHeader title="系统设置" description="管理运行参数、模型定价、管理员凭据与备份配置" />
<div class="mt-4 flex min-h-cp-control flex-wrap items-center justify-between gap-3">
<div class="mt-4 flex min-h-cp-control shrink-0 flex-wrap items-center justify-between gap-3">
<BaseSegmented
:model-value="section"
label="设置分区"
class="bg-(--cp-input-bg)!"
:options="[
{ label: '运行设置', value: 'runtime' },
{ label: '模型定价', value: 'pricing' },
{ label: '备份', value: 'backup' },
]"
@update:model-value="switchSection"
@@ -114,7 +124,7 @@ onMounted(() => {
</div>
<!-- 切换页签只隐藏内容,保留运行设置和备份配置的未保存草稿。 -->
<div v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
<div v-if="runtimeVisited" v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
<div v-if="error" role="alert" class="flex flex-wrap items-center justify-between gap-3 rounded-cp-card bg-cp-error-container p-5 text-cp-error-on-container">
<p class="m-0 text-cp">
设置加载失败:{{ error }}
@@ -187,6 +197,7 @@ onMounted(() => {
</div>
<SettingsBackupSection v-show="section === 'backup'" class="mt-5" :active="section === 'backup'" />
<PricingSection v-if="pricingVisited" v-show="section === 'pricing'" class="mt-5 min-h-0 flex-1" />
<BaseConfirmModal
v-model="showDeleteAdminKeyModal"
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { PricingRow } from './model'
import type { PricingChange } from '@/api'
import { computed, ref, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { multiplierText, parseMultiplier } from './model'
defineProps<{ rows: PricingRow[], reset: boolean, saving: boolean }>()
defineEmits<{ confirm: [change: PricingChange] }>()
const open = defineModel<boolean>({ required: true })
const multiplier = ref('1')
const bps = computed(() => parseMultiplier(multiplier.value))
watch(open, (value) => {
if (value)
multiplier.value = '1'
})
</script>
<template>
<BaseModal v-model="open" :title="reset ? '清除人工覆盖' : '批量设置倍率'" :description="`仅修改下列 ${rows.length} 个模型;不重算历史账单`" :dismissible="!saving" tone="warning">
<div class="grid gap-4">
<p v-if="reset" class="m-0 text-cp leading-relaxed text-cp-text-secondary">
将移除选中模型的人工单价和倍率,回到同步价目或内置价目。仅有人工价格的模型将恢复为未配置。
</p>
<BaseFormItem v-else label="目标倍率" description="设置为此倍率,不与已有倍率相乘;0~100,最多四位小数">
<BaseInput v-model="multiplier" aria-label="目标倍率" inputmode="decimal" :disabled="saving">
<template #suffix>
×
</template>
</BaseInput>
</BaseFormItem>
<ul class="m-0 max-h-64 list-none overflow-auto rounded-cp bg-cp-fill-quaternary p-3 text-cp-sm">
<li v-for="row in rows" :key="row.model" class="flex items-start justify-between gap-3 py-2">
<span class="min-w-0 break-all font-mono">{{ row.model }}</span>
<span class="shrink-0 font-mono text-cp-text-secondary">{{ multiplierText(row.effective.multiplierBps) }} → {{ reset ? '1×' : bps === undefined ? '—' : multiplierText(bps) }}</span>
</li>
</ul>
<p v-if="!reset && bps === undefined" role="alert" class="m-0 text-cp-sm text-cp-error">
请输入合法倍率。
</p>
</div>
<template #footer>
<BaseButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" :loading="saving" :disabled="!rows.length || (!reset && bps === undefined)" @click="$emit('confirm', reset ? { action: 'reset' } : { action: 'multiplier', multiplierBps: bps! })">
{{ reset ? '确认清除覆盖' : '确认设置倍率' }}
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import type { PricingRow } from './model'
import type { ModelPricing, PriceBand, TokenPrices } from '@/api'
import { Info } from '@lucide/vue'
import { computed, ref, shallowRef, useId, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { bands, parseMultiplier, validPrice } from './model'
import PricingRateFields from './PricingRateFields.vue'
const props = defineProps<{ row?: PricingRow, provider: string, saving: boolean }>()
const emit = defineEmits<{ save: [model: string, pricing: ModelPricing] }>()
const open = defineModel<boolean>({ required: true })
const model = ref('')
const multiplier = ref('1')
const draft = ref<Partial<Record<PriceBand, TokenPrices>>>({})
const selectedBand = ref<PriceBand>('standard')
const validation = ref('')
const helpOpen = shallowRef(false)
const helpId = useId()
const base = computed(() => props.row?.base.bands[selectedBand.value])
const current = computed(() => draft.value[selectedBand.value])
const bps = computed(() => parseMultiplier(multiplier.value))
const sourceOptions = computed(() => [
{ label: '来源价', value: 'inherit', disabled: selectedBand.value === 'standard' && !props.row?.base.bands.standard },
{ label: '自定义', value: 'custom' },
])
const priceMode = computed({
get: () => current.value ? 'custom' : 'inherit',
set: value => toggleBand(value === 'custom'),
})
const bandOptions = computed(() => bands
.filter(band => props.row?.base.bands.image
? ['standard', 'image'].includes(band.value)
: props.provider !== 'xai' || !['flex', 'long_flex', 'image'].includes(band.value))
.map(band => ({ ...band, label: band.value === 'standard' && props.row?.base.bands.image ? '文本 Token' : band.label })))
watch(open, (value) => {
if (!value)
return
model.value = props.row?.model ?? ''
multiplier.value = String((props.row?.custom?.multiplierBps ?? 10_000) / 10_000)
draft.value = JSON.parse(JSON.stringify(props.row?.custom?.bands ?? {}))
selectedBand.value = props.row?.effective.bands.image ? 'image' : 'standard'
if (!props.row)
toggleBand(true)
validation.value = ''
helpOpen.value = false
})
watch([model, multiplier, draft], () => {
validation.value = ''
}, { deep: true })
function toggleBand(enabled: boolean) {
if (enabled)
draft.value[selectedBand.value] = { ...(base.value ?? { input: '', output: '', cacheRead: '', cacheWrite: '' }) }
else delete draft.value[selectedBand.value]
}
function updatePrice(field: keyof TokenPrices, value: string) {
if (current.value && !props.saving)
current.value[field] = value
}
function submit() {
if (!model.value || new TextEncoder().encode(model.value).length > 128 || /\s/.test(model.value) || [...model.value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
validation.value = '模型 ID 必须为 1~128 字节,不含空白或控制字符。'
return
}
if (bps.value === undefined) {
validation.value = '倍率范围为 0~100,最多四位小数。'
return
}
for (const [band, rates] of Object.entries(draft.value)) {
if (Object.values(rates).some(price => !validPrice(price))) {
validation.value = '请完整填写该档位的四项单价:0~1000000,最多四位小数。'
selectedBand.value = band as PriceBand
return
}
}
if (!props.row?.base.bands.standard && !draft.value.standard) {
validation.value = '新模型需要配置标准档单价。'
selectedBand.value = 'standard'
return
}
validation.value = ''
emit('save', model.value, { multiplierBps: bps.value, bands: draft.value })
}
</script>
<template>
<BaseModal v-model="open" :title="row ? '编辑模型价格' : '添加模型价格'" size="md-wide" :dismissible="!saving">
<template #description>
<span class="inline-flex items-center gap-2 align-middle text-cp-sm font-normal">
<ProviderIconGroup :provider="provider" size="sm" />
<span>USD / 1M Tokens</span>
</span>
</template>
<div class="grid gap-5">
<div class="grid grid-cols-[minmax(0,1fr)_6.5rem] gap-3 sm:grid-cols-[minmax(0,1fr)_8rem] sm:gap-4">
<BaseFormItem label="上游模型 ID">
<BaseInput v-model="model" :disabled="saving || !!row" aria-label="上游模型 ID" placeholder="例如 gpt-5.4" class="font-mono" />
</BaseFormItem>
<BaseFormItem label="倍率">
<BaseInput v-model="multiplier" aria-label="自定义倍率" class="font-mono" inputmode="decimal" :disabled="saving">
<template #suffix>
×
</template>
</BaseInput>
</BaseFormItem>
</div>
<section aria-label="档位价格">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<BaseSelect :model-value="selectedBand" :options="bandOptions" aria-label="价格档位" class="min-w-0 flex-1 sm:max-w-56" :disabled="saving" @update:model-value="selectedBand = $event as PriceBand" />
<BaseSegmented v-model="priceMode" label="单价来源" :options="sourceOptions" :disabled="saving" />
</div>
<PricingRateFields v-if="current || base" class="rounded-cp-lg bg-cp-fill-quaternary p-4" :base="base" :custom="current" :multiplier-bps="bps" :disabled="saving" @change="updatePrice" />
<div v-else class="grid justify-items-center gap-3 py-6 text-cp-sm text-cp-text-tertiary">
<p class="m-0">
此档位暂无来源价格
</p>
<BaseButton size="sm" :disabled="saving" @click="toggleBand(true)">
填写单价
</BaseButton>
</div>
</section>
<p v-if="validation" role="alert" class="m-0 rounded-cp bg-cp-error-container p-3 text-cp-sm text-cp-error-on-container">
{{ validation }}
</p>
</div>
<template #footer>
<BasePopover v-model="helpOpen" class="mr-auto self-center" placement="top-start" trigger="hover-click">
<template #trigger>
<button type="button" class="inline-flex h-cp-control cursor-pointer items-center gap-1.5 rounded-cp-sm border-0 bg-transparent p-0 text-cp-sm text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none" :aria-expanded="helpOpen" :aria-controls="helpOpen ? helpId : undefined">
<Info class="size-3.5" aria-hidden="true" />
计价说明
</button>
</template>
<div :id="helpId" class="grid w-72 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0">
模型 ID 使用实际发送给上游的名称,不是客户端别名。
</p>
<p class="m-0">
自定义档位需填写四项单价,0 表示免费;单价上限 1000000,倍率范围 0~100,最多四位小数。
</p>
<p class="m-0">
仅调整本地估算与金额限额,不代表订阅实际扣费。保存后仅新请求生效,历史账单不变。
</p>
</div>
</BasePopover>
<BaseButton :disabled="saving" @click="open = false">
取消
</BaseButton><BaseButton variant="primary" :loading="saving" @click="submit">
保存
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { TokenPrices } from '@/api'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import { effectivePrice, priceFields, validPrice } from './model'
const props = defineProps<{
base?: TokenPrices
custom?: TokenPrices
multiplierBps?: number
disabled: boolean
}>()
defineEmits<{ change: [field: keyof TokenPrices, value: string] }>()
function preview(field: keyof TokenPrices) {
const price = (props.custom ?? props.base)?.[field]
return props.multiplierBps === undefined || price === undefined || !validPrice(price)
? '—'
: effectivePrice(price, props.multiplierBps)
}
</script>
<template>
<div class="grid grid-cols-2 gap-x-4 gap-y-5 sm:gap-x-6">
<div v-for="field in priceFields" :key="field.key" class="min-w-0">
<BaseFormItem v-if="custom" :label="field.label">
<BaseInput
:model-value="custom[field.key]"
:aria-label="`${field.label}单价`"
class="font-mono"
inputmode="decimal"
placeholder="0.00"
:disabled="disabled"
@update:model-value="$emit('change', field.key, $event)"
/>
</BaseFormItem>
<div v-else>
<div class="mb-2 text-cp leading-none font-medium text-cp-text-secondary">
{{ field.label }}
</div>
<div class="flex h-cp-control items-center font-mono text-lg tabular-nums text-cp-text">
{{ effectivePrice(base?.[field.key]) }}
</div>
</div>
<div v-if="(custom && base) || multiplierBps !== 10000" class="mt-2 grid gap-1 text-cp-xs">
<span v-if="custom && base" class="text-cp-text-tertiary">
来源 <span class="break-all font-mono tabular-nums">{{ effectivePrice(base[field.key]) }}</span>
</span>
<span v-if="multiplierBps !== 10000" class="text-cp-text-secondary">
生效 <span class="break-all font-mono tabular-nums text-cp-primary-text">{{ preview(field.key) }}</span>
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { PricingCatalog, PricingSyncPreview } from '@/api'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseTag from '@/components/base/BaseTag.vue'
import { bands, effectivePrice, priceFields } from './model'
const props = defineProps<{ preview?: PricingSyncPreview, catalog: PricingCatalog, saving: boolean }>()
defineEmits<{ close: [], confirm: [] }>()
const changes = computed(() => {
const items: { id: string, custom: boolean, details: { label: string, before: string, after: string }[] }[] = []
const providers = new Set([...Object.keys(props.catalog.synced), ...Object.keys(props.preview?.prices ?? {})])
for (const provider of providers) {
const previous = props.catalog.synced[provider] ?? {}
const next = props.preview?.prices[provider] ?? {}
for (const model of new Set([...Object.keys(previous), ...Object.keys(next)])) {
if (JSON.stringify(previous[model]) === JSON.stringify(next[model]))
continue
const details = []
for (const band of bands) {
const before = previous[model]?.bands[band.value] ?? props.catalog.defaults[provider]?.[model]?.bands[band.value]
const after = next[model]?.bands[band.value] ?? props.catalog.defaults[provider]?.[model]?.bands[band.value]
for (const field of priceFields) {
if (before?.[field.key] !== after?.[field.key]) {
details.push({
label: `${band.label} · ${field.label}`,
before: effectivePrice(before?.[field.key]),
after: effectivePrice(after?.[field.key]),
})
}
}
}
items.push({ id: `${provider}/${model}`, details, custom: !!props.catalog.overrides[provider]?.[model] })
}
}
return items
})
</script>
<template>
<BaseModal :model-value="!!preview" title="确认同步来源价目" description="models.dev · USD / 1M Tokens" size="lg" :dismissible="!saving" @update:model-value="!$event && $emit('close')">
<div class="grid gap-4">
<p class="m-0 text-cp text-cp-text-secondary">
{{ changes.length }} 个模型的来源价格将变化。人工单价和倍率保留;未覆盖的档位会采用新的来源价格。models.dev 是社区价目,不代表订阅实际扣费。
</p>
<div class="max-h-80 overflow-auto rounded-cp bg-cp-fill-quaternary p-4">
<div v-for="item in changes" :key="item.id" class="grid gap-2 py-3 text-cp-sm">
<div class="min-w-0">
<div class="break-all font-mono">
{{ item.id }}
</div><span v-if="item.custom" class="text-cp-xs text-cp-primary-text">保留人工覆盖</span>
</div>
<div v-for="detail in item.details" :key="detail.label" class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-cp-xs">
<span class="text-cp-text-secondary">{{ detail.label }}</span>
<span class="flex items-center gap-2 font-mono tabular-nums">
<span class="text-cp-text-tertiary">{{ detail.before }}</span><span aria-hidden="true">→</span><span class="text-cp-primary-text">{{ detail.after }}</span>
</span>
</div>
</div>
<p v-if="!changes.length" class="m-0 text-cp-sm text-cp-text-secondary">
来源价格没有变化。
</p>
</div>
<details v-if="preview?.skipped.length" class="text-cp-sm text-cp-text-secondary">
<summary class="cursor-pointer">
跳过 {{ preview.skipped.length }} 个缺少完整价格或计价方式不匹配的模型
</summary>
<BaseScrollbar max-height="10rem" class="mt-3">
<ul class="m-0 flex list-none flex-wrap gap-2 p-0 pr-2" aria-label="跳过同步的模型">
<li v-for="model in preview.skipped" :key="model" class="flex max-w-full min-w-0">
<BaseTag class="font-mono">
{{ model }}
</BaseTag>
</li>
</ul>
</BaseScrollbar>
</details>
</div>
<template #footer>
<BaseButton :disabled="saving" @click="$emit('close')">
取消
</BaseButton><BaseButton variant="primary" :loading="saving" @click="$emit('confirm')">
确认同步
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { PricingRow } from './model'
import type { BaseTableColumn } from '@/components/base/BaseTable/columns'
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { effectivePrice, multiplierText, sourceLabels } from './model'
import PricingUnit from './PricingUnit.vue'
defineProps<{ rows: PricingRow[], selected: string[], loading: boolean, disabled: boolean }>()
defineEmits<{ toggle: [model: string, checked: boolean], togglePage: [checked: boolean], edit: [row: PricingRow] }>()
const columns: BaseTableColumn<PricingRow>[] = [
{ key: 'select', kind: 'selection' },
{ key: 'model', label: '模型', kind: 'identity' },
{ key: 'input', label: '输入', kind: 'numeric' },
{ key: 'output', label: '输出', kind: 'numeric' },
{ key: 'cacheRead', label: '缓存读取', kind: 'numeric' },
{ key: 'multiplier', label: '倍率', kind: 'numeric', align: 'center' },
{ key: 'actions', label: '操作', kind: 'actions', align: 'center' },
]
function summaryBand(row: PricingRow) {
return row.effective.bands.image ? 'image' : 'standard'
}
</script>
<template>
<BaseTable :columns="columns" :rows="rows" row-key="model" :loading="loading" :selected-row-keys="selected" empty-text="没有匹配的模型" show-header-when-empty>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
<template #header-select>
<BaseCheckbox label="选择本页模型" :disabled="disabled || !rows.length" :model-value="!!rows.length && rows.every(row => selected.includes(row.model))" :indeterminate="rows.some(row => selected.includes(row.model)) && !rows.every(row => selected.includes(row.model))" @update:model-value="$emit('togglePage', $event)" />
</template>
<template #header-model>
<div class="flex items-center gap-3">
<span>模型</span>
<PricingUnit />
</div>
</template>
<template #select="{ row }">
<BaseCheckbox :label="`选择 ${row.model}`" :disabled="disabled" :model-value="selected.includes(row.model)" @update:model-value="$emit('toggle', row.model, $event)" />
</template>
<template #model="{ row }">
<div class="min-w-0 py-1.5">
<div class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="truncate font-mono font-semibold" :title="row.model">{{ row.model }}</span>
<span class="shrink-0 text-cp-xs" :class="row.source === 'custom' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'">
{{ sourceLabels[row.source] }}<span v-if="row.effective.bands.image"> · 图像</span>
</span>
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-cp-xs text-cp-text-secondary sm:hidden">
<span>输入 <span class="font-mono">{{ effectivePrice(row.effective.bands[summaryBand(row)]?.input, row.effective.multiplierBps) }}</span></span>
<span>输出 <span class="font-mono">{{ effectivePrice(row.effective.bands[summaryBand(row)]?.output, row.effective.multiplierBps) }}</span></span>
</div>
</div>
</template>
<template v-for="field in (['input', 'output', 'cacheRead'] as const)" :key="field" #[field]="{ row }">
<div class="py-1 font-mono tabular-nums">
<div v-if="effectivePrice(row.base.bands[summaryBand(row)]?.[field]) !== effectivePrice(row.effective.bands[summaryBand(row)]?.[field], row.effective.multiplierBps)" class="text-cp-xs text-cp-text-tertiary line-through">
{{ effectivePrice(row.base.bands[summaryBand(row)]?.[field]) }}
</div>
<div :class="row.source === 'custom' ? 'text-cp-primary-text' : 'text-cp-text'">
{{ effectivePrice(row.effective.bands[summaryBand(row)]?.[field], row.effective.multiplierBps) }}
</div>
</div>
</template>
<template #multiplier="{ row }">
<span class="font-mono tabular-nums">{{ multiplierText(row.effective.multiplierBps) }}</span>
</template>
<template #actions="{ row }">
<button
type="button"
class="inline-flex h-cp-control shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp leading-none font-bold text-cp-text-secondary outline-none transition-colors enabled:hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:text-cp-text-disabled motion-reduce:transition-none"
:disabled="disabled"
@click="$emit('edit', row)"
>
编辑
</button>
</template>
</BaseTable>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { Openai, Xai } from '@boxicons/vue'
import { CircleAlert, Download, Percent, Plus, RotateCcw, Search, X } from '@lucide/vue'
import { computed, shallowRef, useId } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
import { sourceLabels } from './model'
const props = defineProps<{
selectedCount: number
disabled: boolean
saving: boolean
syncing: boolean
syncedAt?: string | null
}>()
defineEmits<{ add: [], sync: [], setMultiplier: [], reset: [], clear: [] }>()
const search = defineModel<string>('search', { required: true })
const provider = defineModel<string>('provider', { required: true })
const source = defineModel<string>('source', { required: true })
const syncInfoOpen = shallowRef(false)
const syncInfoId = useId()
const syncTime = computed(() => props.syncedAt ? new Date(props.syncedAt).toLocaleString() : '尚未同步')
const providerOptions = [
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
]
</script>
<template>
<div class="mb-3 shrink-0">
<div class="flex flex-wrap items-center gap-2">
<BaseInput v-model="search" class="w-full sm:w-72" placeholder="搜索模型…" aria-label="搜索模型 ID">
<template #prefix>
<Search class="size-4" />
</template>
</BaseInput>
<BaseSegmented v-model="provider" class="w-21 shrink-0 bg-(--cp-input-bg)!" label="模型提供商" :options="providerOptions" :disabled="saving" display="icon" />
<BaseSelect v-model="source" class="w-32" aria-label="价格来源" :options="[{ label: '全部来源', value: 'all' }, { label: sourceLabels.custom, value: 'custom' }, { label: sourceLabels.synced, value: 'synced' }, { label: sourceLabels.builtin, value: 'builtin' }]" />
<div v-if="selectedCount" class="ml-auto flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto" role="group" aria-label="批量操作">
<span class="inline-flex h-cp-control w-full items-center gap-1 whitespace-nowrap text-cp text-cp-text-secondary sm:mr-1 sm:w-auto" aria-live="polite">
已选 <span class="font-mono tabular-nums">{{ selectedCount }}</span> 项
</span>
<BaseButton :disabled="disabled || selectedCount > 500" @click="$emit('setMultiplier')">
<template #icon>
<Percent class="size-4" />
</template>
设置倍率
</BaseButton>
<BaseButton :disabled="disabled || selectedCount > 500" @click="$emit('reset')">
<template #icon>
<RotateCcw class="size-4" />
</template>
清除覆盖
</BaseButton>
<BaseIconButton label="取消选择" variant="filled" :disabled="disabled" @click="$emit('clear')">
<X class="size-4" />
</BaseIconButton>
</div>
<div v-else class="ml-auto flex items-center gap-2">
<div class="flex items-center gap-1">
<BasePopover v-model="syncInfoOpen" placement="bottom-end" trigger="hover-click">
<template #trigger>
<BaseIconButton label="同步说明" size="sm" :aria-expanded="syncInfoOpen" :aria-controls="syncInfoOpen ? syncInfoId : undefined">
<CircleAlert class="size-3.5" />
</BaseIconButton>
</template>
<div :id="syncInfoId" class="grid w-64 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0">
从 models.dev 获取社区价目,保留人工单价与倍率。
</p>
<p class="m-0 text-cp-text-tertiary">
最近同步:{{ syncTime }}
</p>
</div>
</BasePopover>
<BaseButton :loading="syncing" :disabled="disabled" aria-label="同步价目" @click="$emit('sync')">
<template #icon>
<Download class="size-4" />
</template>
同步
</BaseButton>
</div>
<BaseButton variant="primary" :disabled="disabled" @click="$emit('add')">
<template #icon>
<Plus class="size-4" />
</template>
添加模型
</BaseButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Info } from '@lucide/vue'
import { shallowRef, useId } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
const infoOpen = shallowRef(false)
const infoId = useId()
</script>
<template>
<BasePopover v-model="infoOpen" placement="bottom-start" trigger="hover-click">
<template #trigger>
<button
type="button"
class="inline-flex h-6 cursor-pointer touch-manipulation items-center gap-1.5 rounded-cp-sm border-0 bg-transparent p-0 text-cp-xs font-normal text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
aria-label="价格单位:美元 / 百万 Token,查看定价说明"
:aria-expanded="infoOpen"
:aria-controls="infoOpen ? infoId : undefined"
>
<span aria-hidden="true">USD / 1M Tokens</span>
<Info class="size-3.5" aria-hidden="true" />
</button>
</template>
<div :id="infoId" class="grid w-72 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0 font-emphasis text-cp-text">
单价单位:USD / 1M Tokens
</p>
<p class="m-0">
人工覆盖优先于同步价目,再继承内置价格。仅影响本地估算与金额限额。
</p>
<p class="m-0">
表中为标准档生效价;图像模型显示图像 Token 价格。划线为来源价,其他档位在编辑中查看。
</p>
</div>
</BasePopover>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { PricingRow } from './model'
import type { ModelPricing, PricingChange } from '@/api'
import { CircleAlert } from '@lucide/vue'
import { computed, ref, shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
import PricingBatchModal from './PricingBatchModal.vue'
import PricingEditor from './PricingEditor.vue'
import PricingSyncModal from './PricingSyncModal.vue'
import PricingTable from './PricingTable.vue'
import PricingToolbar from './PricingToolbar.vue'
import { usePricing } from './usePricing'
const { catalog, provider, search, source, page, pageSize, selected, error, rows, visible, pagination, loading, saving, syncing, preview, load, save, startSync, confirmSync, toggle, togglePage } = usePricing()
const editorOpen = ref(false)
const editing = shallowRef<PricingRow>()
const batchOpen = ref(false)
const batchReset = ref(false)
const targets = shallowRef<PricingRow[]>([])
const disabled = computed(() => loading.value || saving.value || !!error.value)
function edit(row?: PricingRow) {
editing.value = row
editorOpen.value = true
}
function batch(reset: boolean) {
targets.value = rows.value.filter(row => selected.value.includes(row.model))
batchReset.value = reset
batchOpen.value = true
}
async function saveModel(model: string, pricing: ModelPricing) {
if (await save([model], { action: 'replace', pricing }))
editorOpen.value = false
}
async function saveBatch(change: PricingChange) {
if (await save(targets.value.map(row => row.model), change))
batchOpen.value = false
}
</script>
<template>
<section class="flex min-h-0 min-w-0 flex-col rounded-cp-card bg-cp-bg-container p-3 shadow-cp-card sm:p-4" aria-label="模型定价">
<PricingToolbar
v-model:search="search"
v-model:provider="provider"
v-model:source="source"
:selected-count="selected.length"
:disabled="disabled"
:saving="saving"
:syncing="syncing"
:synced-at="catalog.syncedAt"
@add="edit()"
@sync="startSync"
@set-multiplier="batch(false)"
@reset="batch(true)"
@clear="selected = []"
/>
<PricingTable class="min-h-0 flex-1" :rows="error ? [] : visible" :selected="selected" :loading="loading" :disabled="disabled" @toggle="toggle" @toggle-page="togglePage" @edit="edit">
<template v-if="error" #empty>
<BaseEmpty title="价目加载失败" :description="error" :icon="CircleAlert" surface="none" class="w-full max-w-80" role="alert">
<template #action>
<BaseButton :loading="loading" @click="load">
重试
</BaseButton>
</template>
</BaseEmpty>
</template>
</PricingTable>
<BaseTablePagination :pagination="pagination" :loading="loading" @page-change="page = $event" @page-size-change="pageSize = $event" />
<PricingEditor v-model="editorOpen" :row="editing" :provider="provider" :saving="saving" @save="saveModel" />
<PricingBatchModal v-model="batchOpen" :rows="targets" :reset="batchReset" :saving="saving" @confirm="saveBatch" />
<PricingSyncModal :preview="preview" :catalog="catalog" :saving="saving" @close="preview = undefined" @confirm="confirmSync" />
</section>
</template>
@@ -0,0 +1,64 @@
import type { ModelPricing, PriceBand, PricingCatalog, TokenPrices } from '@/api'
export const bands: { value: PriceBand, label: string }[] = [
{ value: 'standard', label: '标准' },
{ value: 'fast', label: 'Priority / Fast' },
{ value: 'flex', label: 'Flex' },
{ value: 'long_standard', label: '长上下文 · 标准' },
{ value: 'long_fast', label: '长上下文 · Priority' },
{ value: 'long_flex', label: '长上下文 · Flex' },
{ value: 'image', label: '图像 Token' },
]
export const priceFields: { key: keyof TokenPrices, label: string }[] = [
{ key: 'input', label: '输入' },
{ key: 'output', label: '输出' },
{ key: 'cacheRead', label: '缓存读取' },
{ key: 'cacheWrite', label: '缓存写入' },
]
export interface PricingRow {
model: string
provider: string
source: 'builtin' | 'synced' | 'custom'
base: ModelPricing
custom?: ModelPricing
effective: ModelPricing
}
export function pricingRows(catalog: PricingCatalog, provider: string): PricingRow[] {
const defaults = catalog.defaults[provider] ?? {}
const synced = catalog.synced[provider] ?? {}
const custom = catalog.overrides[provider] ?? {}
return [...new Set([...Object.keys(defaults), ...Object.keys(synced), ...Object.keys(custom)])].sort().map((model) => {
const base: ModelPricing = { multiplierBps: 10_000, bands: { ...defaults[model]?.bands, ...synced[model]?.bands } }
const override = custom[model]
return {
model,
provider,
base,
custom: override,
source: override ? 'custom' : synced[model] ? 'synced' : 'builtin',
effective: { multiplierBps: override?.multiplierBps ?? 10_000, bands: { ...base.bands, ...override?.bands } },
}
})
}
export const sourceLabels = { builtin: '内置价目', synced: '同步价目', custom: '人工覆盖' }
export function parseMultiplier(value: string): number | undefined {
if (!/^\d{1,3}(?:\.\d{1,4})?$/.test(value))
return undefined
const [whole = '0', fraction = ''] = value.split('.')
const bps = Number(whole) * 10_000 + Number(fraction.padEnd(4, '0'))
return bps <= 1_000_000 ? bps : undefined
}
export function validPrice(value: string) {
return /^\d{1,7}(?:\.\d{1,4})?$/.test(value) && Number(value) <= 1_000_000
}
export function multiplierText(bps: number) {
return `${bps / 10_000}×`
}
export function effectivePrice(value: string | undefined, bps = 10_000): string {
if (value === undefined || !validPrice(value))
return '未配置'
const [whole = '0', fraction = ''] = value.split('.')
const scaled = (BigInt(whole) * 10_000n + BigInt(fraction.padEnd(4, '0'))) * BigInt(bps)
const decimal = (scaled % 100_000_000n).toString().padStart(8, '0').replace(/0+$/, '')
return `${scaled / 100_000_000n}${decimal ? `.${decimal}` : ''}`
}
@@ -0,0 +1,76 @@
import type { PricingCatalog, PricingChange, PricingSyncPreview } from '@/api'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { getPricing, previewPricingSync, syncPricing, updatePricing } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { pricingRows } from './model'
export function usePricing() {
const catalog = shallowRef<PricingCatalog>({ defaults: {}, overrides: {}, synced: {}, syncedAt: null })
const provider = ref('openai')
const search = ref('')
const source = ref('all')
const page = ref(1)
const pageSize = ref(20)
const selected = ref<string[]>([])
const error = ref('')
const loadAction = useAsyncAction()
const writeAction = useAsyncAction()
const previewAction = useAsyncAction()
const preview = shallowRef<PricingSyncPreview>()
const rows = computed(() => pricingRows(catalog.value, provider.value))
const filtered = computed(() => rows.value.filter(row => row.model.toLowerCase().includes(search.value.trim().toLowerCase())
&& (source.value === 'all' || row.source === source.value)))
const visible = computed(() => filtered.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const pagination = computed(() => ({ currentPage: page.value, pageSize: pageSize.value, total: filtered.value.length }))
watch([provider, search, source, pageSize], () => {
page.value = 1
})
// 筛选变更清空选择,避免批量修改屏幕之外的隐藏目标。
watch([provider, search, source], () => {
selected.value = []
})
async function load() {
await loadAction.run(async () => {
catalog.value = await getPricing()
error.value = ''
page.value = Math.min(page.value, Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
}, { onError: (cause) => { error.value = errorMessage(cause, '无法加载价目') } })
}
async function save(models: string[], change: PricingChange): Promise<boolean> {
return await writeAction.run(async () => {
await updatePricing({ provider: provider.value, models, change })
toast.success(change.action === 'reset' ? '已清除人工覆盖' : '模型定价已保存')
selected.value = []
await load()
return true
}) ?? false
}
async function startSync() {
await previewAction.run(async () => {
preview.value = await previewPricingSync()
})
}
async function confirmSync() {
if (!preview.value)
return
const approved = preview.value
await writeAction.run(async () => {
await syncPricing(approved)
preview.value = undefined
toast.success('来源价目已同步,人工覆盖保持不变')
await load()
})
}
function toggle(model: string, checked: boolean) {
selected.value = checked ? [...new Set([...selected.value, model])] : selected.value.filter(id => id !== model)
}
function togglePage(checked: boolean) {
const ids = visible.value.map(row => row.model)
selected.value = checked ? [...new Set([...selected.value, ...ids])] : selected.value.filter(id => !ids.includes(id))
}
onMounted(load)
return { catalog, provider, search, source, page, pageSize, selected, error, rows, filtered, visible, pagination, loading: loadAction.loading, saving: writeAction.loading, syncing: previewAction.loading, preview, load, save, startSync, confirmSync, toggle, togglePage }
}
@@ -38,6 +38,7 @@ import BaseSwitch from '@/components/base/BaseSwitch.vue'
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import BaseTag from '@/components/base/BaseTag.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
interface PreviewTableRow {
@@ -63,6 +64,7 @@ const selected = shallowRef(true)
const partialSelected = shallowRef(false)
const strategy = shallowRef('balanced')
const controlHeight = shallowRef(38)
const syncTagVisible = shallowRef(true)
const providerOptions = [
{ label: 'OpenAI', value: 'openai' },
@@ -138,7 +140,7 @@ function statusClass(status: PreviewTableRow['status']) {
<div class="flex shrink-0 items-center gap-6">
<span class="rounded-full bg-cp-primary-container px-3 py-1.5 font-mono text-[10px] font-heavy tracking-wide text-cp-primary-on-container uppercase">
20 Base Components
21 Base Components
</span>
<div class="flex items-center gap-5" aria-label="当前主题语义色">
<div
@@ -316,14 +318,25 @@ function statusClass(status: PreviewTableRow['status']) {
</template>
</BaseCard>
<BaseCard padding="compact" class="col-span-4 h-full min-w-0 bg-cp-fill-quaternary! shadow-none!" title="状态与进度" description="Alias Token · Progress Token">
<BaseCard padding="compact" class="col-span-4 h-full min-w-0 bg-cp-fill-quaternary! shadow-none!" title="状态与进度" description="BaseTag · Progress Token">
<template #body>
<div class="grid gap-4">
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-cp-success-container px-3 py-1.5 text-cp-xs font-heavy text-cp-success-on-container">运行正常</span>
<span class="rounded-full bg-cp-warning-container px-3 py-1.5 text-cp-xs font-heavy text-cp-warning-on-container">额度受限</span>
<span class="rounded-full bg-cp-error-container px-3 py-1.5 text-cp-xs font-heavy text-cp-error-on-container">需要处理</span>
<span class="rounded-full bg-cp-info-container px-3 py-1.5 text-cp-xs font-heavy text-cp-info-on-container">同步中</span>
<BaseTag type="success" round>
运行正常
</BaseTag>
<BaseTag type="warning" round>
额度受限
</BaseTag>
<BaseTag type="danger" round>
需要处理
</BaseTag>
<BaseTag v-if="syncTagVisible" type="info" round closable @close="syncTagVisible = false">
同步中
</BaseTag>
<BaseButton v-else size="sm" variant="ghost" @click="syncTagVisible = true">
恢复标签
</BaseButton>
</div>
<div v-for="item in progressItems" :key="item.label" class="grid gap-2">
<div class="flex justify-between text-cp-sm font-heavy text-cp-text-secondary">
@@ -29,11 +29,19 @@ const amountItems = computed(() => {
return []
return [
{ label: '输入费用', value: value.inputAmountDisplay, accent: false },
{ label: '输出费用', value: value.outputAmountDisplay, accent: false },
{ label: '输入单价', value: value.inputPriceDisplay, accent: true },
{ label: '输出单价', value: value.outputPriceDisplay, accent: true },
{ label: '缓存读取费用', value: value.cacheReadAmountDisplay, accent: false },
{ label: value.image ? '文本输入费用' : '输入费用', value: value.inputAmountDisplay, accent: false },
...(value.image
? [
{ label: '图像输入费用', value: value.image.inputAmountDisplay, accent: false },
{ label: '图像缓存费用', value: value.image.cacheReadAmountDisplay, accent: false },
{ label: '图像输入单价', value: value.image.inputPriceDisplay, accent: true },
{ label: '图像缓存单价', value: value.image.cacheReadPriceDisplay, accent: true },
]
: []),
{ label: value.image ? '图像输出费用' : '输出费用', value: value.outputAmountDisplay, accent: false },
{ label: value.image ? '文本输入单价' : '输入单价', value: value.inputPriceDisplay, accent: true },
{ label: value.image ? '图像输出单价' : '输出单价', value: value.outputPriceDisplay, accent: true },
{ label: value.image ? '文本缓存读取费用' : '缓存读取费用', value: value.cacheReadAmountDisplay, accent: false },
{ label: '缓存写入费用', value: value.cacheWriteAmountDisplay, accent: false },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, accent: true },
]
@@ -175,14 +175,22 @@ const billingItems = computed(() => {
return [
{ label: '总费用', value: value.totalAmountDisplay, mono: true },
{ label: '输入', value: value.inputAmountDisplay, mono: true },
{ label: '输出', value: value.outputAmountDisplay, mono: true },
{ label: '缓存读取', value: value.cacheReadAmountDisplay, mono: true },
...(value.image
? [
{ label: '图像输入费用', value: value.image.inputAmountDisplay, mono: true },
{ label: '图像缓存费用', value: value.image.cacheReadAmountDisplay, mono: true },
{ label: '图像输入单价', value: value.image.inputPriceDisplay, mono: true },
{ label: '图像缓存单价', value: value.image.cacheReadPriceDisplay, mono: true },
]
: []),
{ label: value.image ? '文本输入' : '输入', value: value.inputAmountDisplay, mono: true },
{ label: value.image ? '图像输出' : '输出', value: value.outputAmountDisplay, mono: true },
{ label: value.image ? '文本缓存读取' : '缓存读取', value: value.cacheReadAmountDisplay, mono: true },
{ label: '缓存写入', value: value.cacheWriteAmountDisplay, mono: true },
{ label: '标准费用', value: value.standardAmountDisplay, mono: true },
{ label: '输入单价', value: value.inputPriceDisplay, mono: true },
{ label: '输出单价', value: value.outputPriceDisplay, mono: true },
{ label: '缓存单价', value: value.cacheReadPriceDisplay, mono: true },
{ label: value.image ? '文本输入单价' : '输入单价', value: value.inputPriceDisplay, mono: true },
{ label: value.image ? '图像输出单价' : '输出单价', value: value.outputPriceDisplay, mono: true },
{ label: value.image ? '文本缓存单价' : '缓存单价', value: value.cacheReadPriceDisplay, mono: true },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, mono: true },
{ label: '服务档位', value: value.serviceTierDisplay },
{ label: '倍率', value: value.multiplierDisplay, mono: true },