mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
merge(main): 解决 usage 模型展示契约冲突
This commit is contained in:
@@ -221,6 +221,7 @@ pub(super) struct BillingModelContextCacheState {
|
||||
pub(super) inflight: std::sync::Mutex<HashMap<BillingModelContextCacheKey, u64>>,
|
||||
pub(super) inflight_notify: tokio::sync::Notify,
|
||||
pub(super) next_inflight_token: std::sync::atomic::AtomicU64,
|
||||
pub(super) epoch: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayDataState {
|
||||
|
||||
@@ -255,20 +255,28 @@ impl GatewayDataState {
|
||||
&self,
|
||||
record: &UpsertAdminProviderModelRecord,
|
||||
) -> Result<Option<StoredAdminProviderModel>, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => repository.create_admin_provider_model(record).await,
|
||||
None => Ok(None),
|
||||
};
|
||||
if result.as_ref().is_ok_and(Option::is_some) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn update_admin_provider_model(
|
||||
&self,
|
||||
record: &UpsertAdminProviderModelRecord,
|
||||
) -> Result<Option<StoredAdminProviderModel>, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => repository.update_admin_provider_model(record).await,
|
||||
None => Ok(None),
|
||||
};
|
||||
if result.as_ref().is_ok_and(Option::is_some) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_admin_provider_model(
|
||||
@@ -276,44 +284,60 @@ impl GatewayDataState {
|
||||
provider_id: &str,
|
||||
model_id: &str,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.delete_admin_provider_model(provider_id, model_id)
|
||||
.await
|
||||
}
|
||||
None => Ok(false),
|
||||
};
|
||||
if result.as_ref().is_ok_and(|changed| *changed) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn create_admin_global_model(
|
||||
&self,
|
||||
record: &CreateAdminGlobalModelRecord,
|
||||
) -> Result<Option<StoredAdminGlobalModel>, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => repository.create_admin_global_model(record).await,
|
||||
None => Ok(None),
|
||||
};
|
||||
if result.as_ref().is_ok_and(Option::is_some) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn update_admin_global_model(
|
||||
&self,
|
||||
record: &UpdateAdminGlobalModelRecord,
|
||||
) -> Result<Option<StoredAdminGlobalModel>, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => repository.update_admin_global_model(record).await,
|
||||
None => Ok(None),
|
||||
};
|
||||
if result.as_ref().is_ok_and(Option::is_some) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_admin_global_model(
|
||||
&self,
|
||||
global_model_id: &str,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
match &self.global_model_writer {
|
||||
let result = match &self.global_model_writer {
|
||||
Some(repository) => repository.delete_admin_global_model(global_model_id).await,
|
||||
None => Ok(false),
|
||||
};
|
||||
if result.as_ref().is_ok_and(|changed| *changed) {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn list_provider_model_stats(
|
||||
|
||||
@@ -1835,12 +1835,17 @@ impl GatewayDataState {
|
||||
let notified = self.billing_model_context_cache.inflight_notify.notified();
|
||||
match self.register_billing_model_context_inflight(&key) {
|
||||
BillingModelContextInflightRegistration::Bypass => {
|
||||
let load_epoch = self
|
||||
.billing_model_context_cache
|
||||
.epoch
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
return self
|
||||
.load_billing_model_context_by_name(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
global_model_name,
|
||||
load_epoch,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1861,12 +1866,17 @@ impl GatewayDataState {
|
||||
}
|
||||
BillingModelContextInflightRegistration::Leader(token) => {
|
||||
let mut guard = BillingModelContextInflightGuard::new(self, key.clone(), token);
|
||||
let load_epoch = self
|
||||
.billing_model_context_cache
|
||||
.epoch
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let result = self
|
||||
.load_billing_model_context_by_name(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
global_model_name,
|
||||
load_epoch,
|
||||
)
|
||||
.await;
|
||||
guard.finish();
|
||||
@@ -1894,12 +1904,17 @@ impl GatewayDataState {
|
||||
let notified = self.billing_model_context_cache.inflight_notify.notified();
|
||||
match self.register_billing_model_context_inflight(&key) {
|
||||
BillingModelContextInflightRegistration::Bypass => {
|
||||
let load_epoch = self
|
||||
.billing_model_context_cache
|
||||
.epoch
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
return self
|
||||
.load_billing_model_context_by_model_id(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
model_id,
|
||||
load_epoch,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1920,12 +1935,17 @@ impl GatewayDataState {
|
||||
}
|
||||
BillingModelContextInflightRegistration::Leader(token) => {
|
||||
let mut guard = BillingModelContextInflightGuard::new(self, key.clone(), token);
|
||||
let load_epoch = self
|
||||
.billing_model_context_cache
|
||||
.epoch
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let result = self
|
||||
.load_billing_model_context_by_model_id(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
model_id,
|
||||
load_epoch,
|
||||
)
|
||||
.await;
|
||||
guard.finish();
|
||||
@@ -1941,6 +1961,7 @@ impl GatewayDataState {
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
load_epoch: u64,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"billing_model_context",
|
||||
@@ -1951,11 +1972,11 @@ impl GatewayDataState {
|
||||
let value = repository
|
||||
.find_model_context(provider_id, provider_api_key_id, global_model_name)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
self.remember_billing_model_context(key, value.clone(), load_epoch);
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
self.remember_billing_model_context(key, None, load_epoch);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -1970,6 +1991,7 @@ impl GatewayDataState {
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
model_id: &str,
|
||||
load_epoch: u64,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"billing_model_context",
|
||||
@@ -1984,11 +2006,11 @@ impl GatewayDataState {
|
||||
model_id,
|
||||
)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
self.remember_billing_model_context(key, value.clone(), load_epoch);
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
self.remember_billing_model_context(key, None, load_epoch);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -2070,12 +2092,21 @@ impl GatewayDataState {
|
||||
&self,
|
||||
key: BillingModelContextCacheKey,
|
||||
value: Option<StoredBillingModelContext>,
|
||||
load_epoch: u64,
|
||||
) {
|
||||
let mut cache = self
|
||||
.billing_model_context_cache
|
||||
.entries
|
||||
.write()
|
||||
.expect("billing model context cache lock");
|
||||
if load_epoch
|
||||
!= self
|
||||
.billing_model_context_cache
|
||||
.epoch
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cache.retain(|_, (cached_at, _)| {
|
||||
cached_at.elapsed() <= Self::BILLING_MODEL_CONTEXT_CACHE_TTL
|
||||
});
|
||||
@@ -2091,7 +2122,10 @@ impl GatewayDataState {
|
||||
cache.insert(key, (Instant::now(), value));
|
||||
}
|
||||
|
||||
fn clear_billing_model_context_cache(&self) {
|
||||
pub(super) fn clear_billing_model_context_cache(&self) {
|
||||
self.billing_model_context_cache
|
||||
.epoch
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
self.billing_model_context_cache
|
||||
.entries
|
||||
.write()
|
||||
@@ -2527,15 +2561,20 @@ impl GatewayDataState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
||||
use aether_data::repository::users::{InMemoryUserReadRepository, StoredUserExportRow};
|
||||
use aether_data_contracts::repository::billing::{
|
||||
BillingReadRepository, StoredBillingModelContext,
|
||||
};
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
StoredAdminGlobalModel, StoredPublicGlobalModel, UpdateAdminGlobalModelRecord,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
use super::GatewayDataState;
|
||||
|
||||
@@ -2544,6 +2583,13 @@ mod tests {
|
||||
context: StoredBillingModelContext,
|
||||
}
|
||||
|
||||
struct BlockedBillingContextRepository {
|
||||
calls: AtomicUsize,
|
||||
context: Mutex<StoredBillingModelContext>,
|
||||
first_read: Barrier,
|
||||
release_first_read: Barrier,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for SlowBillingContextRepository {
|
||||
async fn find_model_context(
|
||||
@@ -2559,6 +2605,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for BlockedBillingContextRepository {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_provider_api_key_id: Option<&str>,
|
||||
_global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, aether_data_contracts::DataLayerError>
|
||||
{
|
||||
let call_index = self.calls.fetch_add(1, Ordering::AcqRel);
|
||||
let context = self
|
||||
.context
|
||||
.lock()
|
||||
.expect("mutable billing context lock")
|
||||
.clone();
|
||||
if call_index == 0 {
|
||||
self.first_read.wait().await;
|
||||
self.release_first_read.wait().await;
|
||||
}
|
||||
Ok(Some(context))
|
||||
}
|
||||
}
|
||||
|
||||
fn billing_context() -> StoredBillingModelContext {
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
@@ -2609,6 +2678,87 @@ mod tests {
|
||||
assert_eq!(repository.calls.load(Ordering::Acquire), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_model_price_update_invalidates_inflight_billing_context_cache() {
|
||||
let mut initial_context = billing_context();
|
||||
initial_context.default_price_per_request = None;
|
||||
initial_context.default_tiered_pricing = None;
|
||||
let repository = Arc::new(BlockedBillingContextRepository {
|
||||
calls: AtomicUsize::new(0),
|
||||
context: Mutex::new(initial_context),
|
||||
first_read: Barrier::new(2),
|
||||
release_first_read: Barrier::new(2),
|
||||
});
|
||||
let mut state = GatewayDataState::with_billing_reader_for_tests(repository.clone());
|
||||
let stored_global_model = StoredAdminGlobalModel::new(
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
"GPT-5".to_string(),
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
Some(1_711_000_000),
|
||||
Some(1_711_000_000),
|
||||
)
|
||||
.expect("stored global model should build");
|
||||
state.global_model_writer = Some(Arc::new(
|
||||
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
|
||||
.with_admin_global_models([stored_global_model]),
|
||||
));
|
||||
let state = Arc::new(state);
|
||||
let lookup_state = Arc::clone(&state);
|
||||
let stale_lookup = tokio::spawn(async move {
|
||||
lookup_state
|
||||
.find_billing_model_context("provider-1", Some("key-1"), "gpt-5")
|
||||
.await
|
||||
});
|
||||
repository.first_read.wait().await;
|
||||
|
||||
let updated_pricing =
|
||||
json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]});
|
||||
let update = UpdateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"GPT-5".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(updated_pricing.clone()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("global model update should build");
|
||||
repository
|
||||
.context
|
||||
.lock()
|
||||
.expect("mutable billing context lock")
|
||||
.default_tiered_pricing = Some(updated_pricing.clone());
|
||||
state
|
||||
.update_admin_global_model(&update)
|
||||
.await
|
||||
.expect("global model price update should succeed");
|
||||
repository.release_first_read.wait().await;
|
||||
|
||||
let before = stale_lookup
|
||||
.await
|
||||
.expect("initial billing lookup task should complete")
|
||||
.expect("initial billing lookup should succeed")
|
||||
.expect("initial billing context should exist");
|
||||
assert_eq!(before.default_tiered_pricing, None);
|
||||
assert_eq!(repository.calls.load(Ordering::Acquire), 1);
|
||||
|
||||
let after = state
|
||||
.find_billing_model_context("provider-1", Some("key-1"), "gpt-5")
|
||||
.await
|
||||
.expect("updated billing lookup should succeed")
|
||||
.expect("updated billing context should exist");
|
||||
assert_eq!(after.default_tiered_pricing, Some(updated_pricing));
|
||||
assert_eq!(repository.calls.load(Ordering::Acquire), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_non_admin_export_users_from_user_reader() {
|
||||
let repository = Arc::new(InMemoryUserReadRepository::seed_export_users(vec![
|
||||
|
||||
@@ -702,6 +702,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_fast_usage_without_overlay_inherits_global_model_pricing() {
|
||||
let lookup = TestLookup {
|
||||
name_context: Some(
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
Some("pay_as_you_go".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
None,
|
||||
None,
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5.6-sol".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5.6-sol".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("billing context should build"),
|
||||
),
|
||||
model_id_context: None,
|
||||
};
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-fast-global-fallback",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
target_model: Some("gpt-5.6-sol".to_string()),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
provider_api_key_id: Some("key-1".to_string()),
|
||||
request_type: Some("chat".to_string()),
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
endpoint_api_format: Some("openai:responses".to_string()),
|
||||
// OpenAI calls the Fast request tier `priority` on the wire.
|
||||
provider_request_body: Some(json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"service_tier": "priority"
|
||||
})),
|
||||
input_tokens: Some(1_000_000),
|
||||
status_code: Some(200),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
enrich_usage_event_with_billing(&lookup, &mut event)
|
||||
.await
|
||||
.expect("billing should succeed");
|
||||
|
||||
assert_eq!(event.data.total_cost_usd, Some(3.0));
|
||||
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
||||
assert_eq!(
|
||||
metadata.pointer("/billing_snapshot/status"),
|
||||
Some(&json!("complete"))
|
||||
);
|
||||
let pricing = metadata
|
||||
.pointer("/settlement_snapshot/pricing_snapshot")
|
||||
.expect("settlement pricing snapshot");
|
||||
assert_eq!(pricing["billing_processing_tier"], "priority");
|
||||
assert_eq!(pricing["pricing_source"], "global_default");
|
||||
assert_eq!(pricing["tiered_pricing_source"], "global_default");
|
||||
assert_eq!(
|
||||
pricing["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
||||
3.0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_uses_requested_processing_tier_catalog_and_ignores_response_tier() {
|
||||
let lookup = TestLookup {
|
||||
|
||||
@@ -192,6 +192,7 @@ impl BillingModelPricingSnapshot {
|
||||
)?;
|
||||
if !processing_tier_is_standard(&requested_billing_tier)
|
||||
&& requested_resolution.tiered_pricing.is_none()
|
||||
&& requested_resolution.price_per_request.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -295,7 +296,9 @@ impl BillingModelPricingSnapshot {
|
||||
processing_tier,
|
||||
reason,
|
||||
)),
|
||||
ProcessingTierOverlay::Missing => Ok(None),
|
||||
ProcessingTierOverlay::Missing => Ok(self
|
||||
.resolve_standard_tiered_pricing_checked()?
|
||||
.map(|(pricing, source)| (pricing.clone(), source, None))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -799,6 +802,54 @@ mod tests {
|
||||
assert_eq!(resolution.processing_tier_price_multiplier, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_processing_overlay_inherits_effective_standard_catalog() {
|
||||
let provider_pricing = json!({"tiers":[{"up_to":null,"input_price_per_1m":2.0}]});
|
||||
let default_pricing = json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0}]});
|
||||
|
||||
let provider = snapshot(
|
||||
Some(provider_pricing.clone()),
|
||||
Some(default_pricing.clone()),
|
||||
)
|
||||
.resolve_pricing(Some("fast"), None);
|
||||
assert_eq!(provider.billing_processing_tier.as_deref(), Some("fast"));
|
||||
assert_eq!(provider.tiered_pricing, Some(provider_pricing));
|
||||
assert_eq!(
|
||||
provider.tiered_pricing_source,
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
assert_eq!(provider.processing_tier_price_multiplier, None);
|
||||
|
||||
let global =
|
||||
snapshot(None, Some(default_pricing.clone())).resolve_pricing(Some("priority"), None);
|
||||
assert_eq!(global.billing_processing_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(global.tiered_pricing, Some(default_pricing));
|
||||
assert_eq!(
|
||||
global.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
assert_eq!(global.processing_tier_price_multiplier, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonstandard_tier_keeps_fixed_request_price_without_token_catalog() {
|
||||
let mut pricing = snapshot(None, None);
|
||||
pricing.default_price_per_request = Some(0.02);
|
||||
|
||||
let resolution = pricing.resolve_pricing(Some("fast"), None);
|
||||
assert_eq!(resolution.billing_processing_tier.as_deref(), Some("fast"));
|
||||
assert_eq!(resolution.tiered_pricing, None);
|
||||
assert_eq!(resolution.price_per_request, Some(0.02));
|
||||
assert_eq!(
|
||||
resolution.price_per_request_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
assert!(pricing
|
||||
.resolve_authorization_pricing_candidates(Some("fast"))
|
||||
.expect("fixed request pricing should be valid")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nonstandard_request_selects_requested_catalog_without_actual_tier() {
|
||||
let pricing = snapshot(
|
||||
|
||||
@@ -174,6 +174,7 @@ impl BillingService {
|
||||
}
|
||||
if !pricing_resolution.bills_standard_processing_tier()
|
||||
&& pricing_resolution.tiered_pricing.is_none()
|
||||
&& pricing_resolution.price_per_request.is_none()
|
||||
{
|
||||
return Ok(no_rule_computation(
|
||||
pricing,
|
||||
@@ -1123,6 +1124,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_fast_request_without_overlay_uses_global_standard_catalog() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
|
||||
let result = BillingService::new()
|
||||
.calculate(
|
||||
&pricing,
|
||||
&BillingUsageInput {
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
requested_processing_tier: Some("priority".to_string()),
|
||||
input_tokens: 1_000_000,
|
||||
..BillingUsageInput::new("chat")
|
||||
},
|
||||
)
|
||||
.expect("global Standard pricing should calculate OpenAI Fast usage");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(result.cost_result.cost, 3.0);
|
||||
assert_eq!(
|
||||
result.pricing_resolution.tiered_pricing_source,
|
||||
Some(crate::BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
|
||||
json!(3.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonstandard_request_with_only_fixed_price_is_still_billable() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: Some(0.02),
|
||||
default_tiered_pricing: None,
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
|
||||
let result = BillingService::new()
|
||||
.calculate(&pricing, &processing_usage(Some("fast"), None, 1_000))
|
||||
.expect("fixed request pricing should calculate Fast usage");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(result.cost_result.cost, 0.02);
|
||||
assert_eq!(result.pricing_resolution.tiered_pricing, None);
|
||||
assert_eq!(result.pricing_resolution.price_per_request, Some(0.02));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_actual_tier_does_not_override_requested_catalog() {
|
||||
let cases = ["default", "flex", "priority"];
|
||||
@@ -1592,8 +1651,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
service
|
||||
.estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate)
|
||||
.expect("unknown tier estimate should resolve"),
|
||||
None
|
||||
.expect("unknown tier should inherit the effective Standard catalog"),
|
||||
Some(0.000925)
|
||||
);
|
||||
|
||||
estimate.requested_processing_tier = Some("priority".to_string());
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full items-center gap-1"
|
||||
:class="modelRowClass"
|
||||
:class="[modelRowClass, actualModel ? 'flex-wrap' : '']"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
@@ -17,12 +17,10 @@
|
||||
data-usage-model-source
|
||||
>{{ record.model }}</span>
|
||||
<template v-if="actualModel">
|
||||
<span class="shrink-0 text-muted-foreground/70">-></span>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
:class="modelClass"
|
||||
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground"
|
||||
data-usage-model-target
|
||||
>{{ actualModel }}</span>
|
||||
><span class="mr-1">-></span>{{ actualModel }}</span>
|
||||
</template>
|
||||
<template v-if="!shouldStackBadges">
|
||||
<Badge
|
||||
@@ -71,7 +69,7 @@ import { Badge } from '@/components/ui'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber'
|
||||
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
|
||||
|
||||
interface ModelBadgePresentation {
|
||||
key: ModelBadgeKey
|
||||
@@ -90,6 +88,7 @@ interface UsageModelDisplayRecord {
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
reasoning_tokens?: number
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
@@ -100,12 +99,18 @@ const props = withDefaults(defineProps<{
|
||||
context?: 'usage' | 'detail'
|
||||
cyber?: boolean | null
|
||||
stackFullWidth?: boolean
|
||||
showServiceTierBadge?: boolean
|
||||
showCyberBadge?: boolean
|
||||
showReasoningBadge?: boolean
|
||||
}>(), {
|
||||
modelClass: '',
|
||||
modelRowClass: '',
|
||||
context: 'usage',
|
||||
cyber: null,
|
||||
stackFullWidth: false,
|
||||
showServiceTierBadge: true,
|
||||
showCyberBadge: true,
|
||||
showReasoningBadge: true,
|
||||
})
|
||||
|
||||
const actualModel = computed(() => {
|
||||
@@ -138,7 +143,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
|
||||
ariaLabel: '会话压缩',
|
||||
})
|
||||
}
|
||||
if (reasoningLabel.value) {
|
||||
if (props.showReasoningBadge && reasoningLabel.value) {
|
||||
badges.push({
|
||||
key: 'reasoning',
|
||||
label: reasoningLabel.value,
|
||||
@@ -149,36 +154,51 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (formatServiceTierFact(props.record.service_tier) === 'Fast') {
|
||||
if (props.showServiceTierBadge && formatServiceTierFact(props.record.service_tier) === 'Fast') {
|
||||
badges.push({
|
||||
key: 'fast',
|
||||
label: 'Fast',
|
||||
variant: 'outline-transparent',
|
||||
className: 'text-amber-700 dark:text-amber-300',
|
||||
className: 'text-blue-500 dark:text-blue-300',
|
||||
title: '上游请求档位:Fast\n计费档位:Fast',
|
||||
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.cyber ?? isCyberPolicyError(props.record.error_message)) {
|
||||
if (props.showCyberBadge && (props.cyber ?? isCyberPolicyError(props.record.error_message))) {
|
||||
badges.push({
|
||||
key: 'cyber',
|
||||
label: 'Cyber',
|
||||
variant: 'outline',
|
||||
className: 'border-primary/30 bg-primary/5 text-rose-600 dark:text-rose-300',
|
||||
className: 'border-primary/30 bg-primary/5 text-rose-500 dark:text-rose-300',
|
||||
title: '上游 Cyber Policy 拒绝',
|
||||
ariaLabel: '上游 Cyber Policy 拒绝',
|
||||
})
|
||||
}
|
||||
if (typeof props.record.reasoning_tokens === 'number' && props.record.reasoning_tokens > 0) {
|
||||
badges.push({
|
||||
key: 'reasoning_tokens',
|
||||
label: `推理 ${formatCompactTokens(props.record.reasoning_tokens)}`,
|
||||
variant: 'outline-transparent',
|
||||
className: 'text-muted-foreground',
|
||||
title: `推理 Token 数:${props.record.reasoning_tokens}`,
|
||||
ariaLabel: `推理 Token 数:${props.record.reasoning_tokens}`,
|
||||
})
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
const shouldStackBadges = computed(() => (
|
||||
actualModel.value !== null || modelBadges.value.length >= 3
|
||||
actualModel.value === null && modelBadges.value.length >= 3
|
||||
))
|
||||
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function formatCompactTokens(value: number): string {
|
||||
if (value < 1000) return `${value} Tokens`
|
||||
return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}K Tokens`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1191,7 +1191,10 @@ function sanitizeColumnIds(
|
||||
seen.add(id as UsageRecordColumnId)
|
||||
return true
|
||||
})
|
||||
return sanitized.length > 0 ? sanitized : [...fallback]
|
||||
if (sanitized.length === 0) return [...fallback]
|
||||
// Add newly introduced feature column to existing saved layouts, keeping it
|
||||
// immediately before Tokens as the default presentation order.
|
||||
return sanitized
|
||||
}
|
||||
|
||||
const visibleColumnIds = computed<UsageRecordColumnId[]>({
|
||||
@@ -1579,7 +1582,7 @@ function buildServiceTierBadgePresentation(
|
||||
const title = titleLines.join('\n')
|
||||
return {
|
||||
label: 'Fast',
|
||||
className: '!bg-transparent text-amber-700 dark:text-amber-300',
|
||||
className: '!bg-transparent text-blue-500 dark:text-blue-300',
|
||||
title,
|
||||
ariaLabel: titleLines.join(','),
|
||||
}
|
||||
|
||||
+16
-14
@@ -165,19 +165,21 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
const modelLayout = document.body.querySelector(
|
||||
'[data-request-detail-model-layout="stacked"]',
|
||||
'[data-request-detail-model-layout="inline"]',
|
||||
)
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
|
||||
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
|
||||
.toBeNull()
|
||||
const modelBadgesRow = modelLayout?.querySelector(
|
||||
'[data-request-detail-model-badges-row]',
|
||||
)
|
||||
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(modelBadgesRow?.textContent).toContain('Fast')
|
||||
expect(modelBadgesRow?.textContent).toContain('Cyber')
|
||||
const modelRow = modelLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
|
||||
.toBe(true)
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull()
|
||||
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
|
||||
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['上游请求层级', '计费层级'])
|
||||
@@ -359,7 +361,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1')
|
||||
.toBe('->gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
@@ -414,7 +416,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1-2026-07-17')
|
||||
.toBe('->gpt-5.1-2026-07-17')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ describe('UsageRecordsTable', () => {
|
||||
.toBe('会话压缩')
|
||||
})
|
||||
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model area', () => {
|
||||
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
@@ -364,25 +364,25 @@ describe('UsageRecordsTable', () => {
|
||||
expect(fastBadge?.classList.contains('border-amber-400/50')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('!bg-transparent')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('bg-amber-400/10')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('text-amber-700')).toBe(true)
|
||||
expect(fastBadge?.classList.contains('text-blue-500')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('text-rose-600')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('text-rose-500')).toBe(true)
|
||||
expect(cyberBadges.length).toBeGreaterThan(0)
|
||||
expect([...cyberBadges].every(badge => badge.textContent?.trim() === 'Cyber')).toBe(true)
|
||||
expect([...cyberBadges].every(badge => badge.title === '上游 Cyber Policy 拒绝')).toBe(true)
|
||||
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout).not.toBeNull()
|
||||
const modelRow = stackedLayout?.firstElementChild
|
||||
const inlineLayout = root.querySelector('[data-usage-model-layout="inline"]')
|
||||
expect(inlineLayout).not.toBeNull()
|
||||
const modelRow = inlineLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge]')).toBeNull()
|
||||
const badgesRow = stackedLayout?.querySelector('[data-usage-model-badges-row]')
|
||||
expect(badgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(badgesRow?.textContent).toContain('Fast')
|
||||
expect(badgesRow?.textContent).toContain('Cyber')
|
||||
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')).toBe(true)
|
||||
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('order-last')).toBe(true)
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber')
|
||||
})
|
||||
|
||||
it('stacks three model badges even without a model mapping', () => {
|
||||
@@ -398,13 +398,10 @@ describe('UsageRecordsTable', () => {
|
||||
})])
|
||||
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout?.firstElementChild?.textContent?.trim()).toBe('gpt-5')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('xhigh')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(stackedLayout?.textContent).toContain('gpt-5')
|
||||
expect(stackedLayout?.textContent).toContain('xhigh')
|
||||
expect(stackedLayout?.textContent).toContain('Fast')
|
||||
expect(stackedLayout?.textContent).toContain('Cyber')
|
||||
})
|
||||
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
|
||||
@@ -605,6 +605,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
input_tokens: mergeSparseRecordMetric(existing.input_tokens, record.input_tokens) ?? record.input_tokens,
|
||||
effective_input_tokens: mergeSparseRecordMetric(existing.effective_input_tokens, record.effective_input_tokens) ?? record.effective_input_tokens,
|
||||
output_tokens: mergeSparseRecordMetric(existing.output_tokens, record.output_tokens) ?? record.output_tokens,
|
||||
reasoning_tokens: mergeSparseRecordMetric(existing.reasoning_tokens, record.reasoning_tokens) ?? record.reasoning_tokens,
|
||||
total_tokens: mergeSparseRecordMetric(existing.total_tokens, record.total_tokens) ?? record.total_tokens,
|
||||
cache_creation_input_tokens: mergeSparseRecordMetric(existing.cache_creation_input_tokens, record.cache_creation_input_tokens) ?? record.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens:
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface UsageRecord {
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
reasoning_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_creation_ephemeral_5m_input_tokens?: number
|
||||
cache_creation_ephemeral_1h_input_tokens?: number
|
||||
|
||||
Reference in New Issue
Block a user