merge(main): 解决 usage 模型展示契约冲突

This commit is contained in:
MMEXA
2026-07-18 19:26:14 +08:00
12 changed files with 446 additions and 61 deletions
@@ -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 {
+30 -6
View File
@@ -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(
+156 -6
View File
@@ -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 {
+52 -1
View File
@@ -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(
+61 -2
View File
@@ -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">-&gt;</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">-&gt;</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(''),
}
@@ -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:
+1
View File
@@ -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