mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
管理写入遵循统一流程:
|
||||
|
||||
@@ -16,6 +16,9 @@ export * from './modules/backups'
|
||||
// Dashboard 聚合
|
||||
export * from './modules/dashboard'
|
||||
|
||||
// 模型定价
|
||||
export * from './modules/pricing'
|
||||
|
||||
export * from './modules/proxies'
|
||||
|
||||
// 设置管理
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user