fix(usage): 统一使用记录与仪表盘的缓存命中率计算口径

- 新增归一化总输入上下文计算逻辑,按 provider 区分 OpenAI/Gemini 与 Claude 的 cache token 语义
- 将管理端使用聚合、用户使用记录、仪表盘缓存统计、缓存亲和性分析统一为 token 级缓存命中率
- 修正 total_input_context 字段,避免 cache_read 在部分 provider 上被重复计入分母
- 同步更新相关 Rust 单元测试与网关集成测试断言
- 调整前端 dashboard mock 中 cache_hit_rate 的单位为百分比
This commit is contained in:
AAEE86
2026-04-12 00:57:40 +08:00
parent 7c5bb7f383
commit 335e440cc5
9 changed files with 218 additions and 74 deletions

View File

@@ -4,8 +4,9 @@ use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value; use crate::handlers::admin::shared::query_param_value;
use crate::GatewayError; use crate::GatewayError;
use aether_admin::observability::usage::{ use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_data_unavailable_response, admin_usage_bad_request_response, admin_usage_cache_creation_tokens,
admin_usage_matches_optional_id, admin_usage_parse_recent_hours, admin_usage_data_unavailable_response, admin_usage_matches_optional_id,
admin_usage_parse_recent_hours, admin_usage_total_input_context,
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
}; };
use axum::{ use axum::{
@@ -50,10 +51,9 @@ pub(super) async fn build_admin_usage_cache_affinity_hit_analysis_response(
.iter() .iter()
.map(|item| item.cache_read_input_tokens) .map(|item| item.cache_read_input_tokens)
.sum(); .sum();
let total_cache_creation_tokens: u64 = filtered let total_cache_creation_tokens: u64 =
.iter() filtered.iter().map(admin_usage_cache_creation_tokens).sum();
.map(|item| item.cache_creation_input_tokens) let total_input_context: u64 = filtered.iter().map(admin_usage_total_input_context).sum();
.sum();
let total_cache_read_cost: f64 = filtered.iter().map(|item| item.cache_read_cost_usd).sum(); let total_cache_read_cost: f64 = filtered.iter().map(|item| item.cache_read_cost_usd).sum();
let total_cache_creation_cost: f64 = filtered let total_cache_creation_cost: f64 = filtered
.iter() .iter()
@@ -63,12 +63,11 @@ pub(super) async fn build_admin_usage_cache_affinity_hit_analysis_response(
.iter() .iter()
.filter(|item| item.cache_read_input_tokens > 0) .filter(|item| item.cache_read_input_tokens > 0)
.count(); .count();
let total_context_tokens = total_input_tokens.saturating_add(total_cache_read_tokens); let token_cache_hit_rate = if total_input_context == 0 {
let token_cache_hit_rate = if total_context_tokens == 0 {
0.0 0.0
} else { } else {
round_to( round_to(
total_cache_read_tokens as f64 / total_context_tokens as f64 * 100.0, total_cache_read_tokens as f64 / total_input_context as f64 * 100.0,
2, 2,
) )
}; };

View File

@@ -2,6 +2,7 @@ use super::{
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState, build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState,
GatewayError, GatewayPublicRequestContext, GatewayError, GatewayPublicRequestContext,
}; };
use aether_billing::normalize_total_input_context_for_cache_hit_rate;
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery}; use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
use axum::{ use axum::{
body::Body, body::Body,
@@ -28,6 +29,7 @@ struct DashboardUsageTotals {
total_tokens: u64, total_tokens: u64,
cache_creation_tokens: u64, cache_creation_tokens: u64,
cache_read_tokens: u64, cache_read_tokens: u64,
cache_hit_total_input_context: u64,
cache_creation_cost_usd: f64, cache_creation_cost_usd: f64,
cache_read_cost_usd: f64, cache_read_cost_usd: f64,
total_cost_usd: f64, total_cost_usd: f64,
@@ -69,12 +71,14 @@ pub(super) fn decision_route_kind(request_context: &GatewayPublicRequestContext)
impl DashboardUsageTotals { impl DashboardUsageTotals {
fn record(&mut self, item: &StoredRequestUsageAudit) { fn record(&mut self, item: &StoredRequestUsageAudit) {
let cache_creation_tokens = dashboard_cache_creation_tokens(item);
self.requests += 1; self.requests += 1;
self.input_tokens += item.input_tokens; self.input_tokens += item.input_tokens;
self.output_tokens += item.output_tokens; self.output_tokens += item.output_tokens;
self.total_tokens += item.total_tokens; self.total_tokens += item.total_tokens;
self.cache_creation_tokens += item.cache_creation_input_tokens; self.cache_creation_tokens += cache_creation_tokens;
self.cache_read_tokens += item.cache_read_input_tokens; self.cache_read_tokens += item.cache_read_input_tokens;
self.cache_hit_total_input_context += dashboard_total_input_context(item);
self.cache_creation_cost_usd += item.cache_creation_cost_usd; self.cache_creation_cost_usd += item.cache_creation_cost_usd;
self.cache_read_cost_usd += item.cache_read_cost_usd; self.cache_read_cost_usd += item.cache_read_cost_usd;
self.total_cost_usd += item.total_cost_usd; self.total_cost_usd += item.total_cost_usd;
@@ -102,15 +106,45 @@ impl DashboardUsageTotals {
} }
fn cache_hit_rate(&self) -> f64 { fn cache_hit_rate(&self) -> f64 {
let total_cache_tokens = self.cache_creation_tokens + self.cache_read_tokens; if self.cache_hit_total_input_context == 0 {
if total_cache_tokens == 0 {
0.0 0.0
} else { } else {
dashboard_round_f64(self.cache_read_tokens as f64 / total_cache_tokens as f64, 4) dashboard_round_f64(
self.cache_read_tokens as f64 / self.cache_hit_total_input_context as f64 * 100.0,
2,
)
} }
} }
} }
fn dashboard_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
let classified = item
.cache_creation_ephemeral_5m_input_tokens
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
if item.cache_creation_input_tokens == 0 && classified > 0 {
classified
} else {
item.cache_creation_input_tokens
}
}
fn dashboard_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
let api_format = item
.endpoint_api_format
.as_deref()
.or(item.api_format.as_deref());
let input_tokens = i64::try_from(item.input_tokens).unwrap_or(i64::MAX);
let cache_creation_tokens =
i64::try_from(dashboard_cache_creation_tokens(item)).unwrap_or(i64::MAX);
let cache_read_tokens = i64::try_from(item.cache_read_input_tokens).unwrap_or(i64::MAX);
normalize_total_input_context_for_cache_hit_rate(
api_format,
input_tokens,
cache_creation_tokens,
cache_read_tokens,
) as u64
}
fn dashboard_round_f64(value: f64, decimals: u32) -> f64 { fn dashboard_round_f64(value: f64, decimals: u32) -> f64 {
let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or_default()); let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or_default());
(value * factor).round() / factor (value * factor).round() / factor

View File

@@ -1,6 +1,8 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use aether_billing::normalize_input_tokens_for_billing; use aether_billing::{
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
};
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery}; use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
use axum::{ use axum::{
body::Body, body::Body,
@@ -101,9 +103,20 @@ fn users_me_usage_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
} }
fn users_me_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 { fn users_me_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
item.input_tokens let api_format = item
.saturating_add(users_me_usage_cache_creation_tokens(item)) .endpoint_api_format
.saturating_add(item.cache_read_input_tokens) .as_deref()
.or(item.api_format.as_deref());
let input_tokens = i64::try_from(item.input_tokens).unwrap_or(i64::MAX);
let cache_creation_tokens =
i64::try_from(users_me_usage_cache_creation_tokens(item)).unwrap_or(i64::MAX);
let cache_read_tokens = i64::try_from(item.cache_read_input_tokens).unwrap_or(i64::MAX);
normalize_total_input_context_for_cache_hit_rate(
api_format,
input_tokens,
cache_creation_tokens,
cache_read_tokens,
) as u64
} }
fn users_me_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 { fn users_me_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 {

View File

@@ -393,11 +393,11 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
assert_eq!(items[0]["request_count"], 2); assert_eq!(items[0]["request_count"], 2);
assert_eq!(items[0]["output_tokens"], 40); assert_eq!(items[0]["output_tokens"], 40);
assert_eq!(items[0]["effective_input_tokens"], 150); assert_eq!(items[0]["effective_input_tokens"], 150);
assert_eq!(items[0]["total_input_context"], 200); assert_eq!(items[0]["total_input_context"], 160);
assert_eq!(items[0]["cache_creation_tokens"], 30); assert_eq!(items[0]["cache_creation_tokens"], 30);
assert_eq!(items[0]["cache_creation_ephemeral_5m_tokens"], 12); assert_eq!(items[0]["cache_creation_ephemeral_5m_tokens"], 12);
assert_eq!(items[0]["cache_creation_ephemeral_1h_tokens"], 18); assert_eq!(items[0]["cache_creation_ephemeral_1h_tokens"], 18);
assert_eq!(items[0]["cache_hit_rate"], 5.0); assert_eq!(items[0]["cache_hit_rate"], 6.25);
assert_eq!(items[1]["model"], "claude-3-7"); assert_eq!(items[1]["model"], "claude-3-7");
assert_eq!(items[1]["output_tokens"], 20); assert_eq!(items[1]["output_tokens"], 20);
@@ -1498,7 +1498,7 @@ async fn gateway_handles_admin_usage_cache_affinity_hit_analysis_locally_with_tr
assert_eq!(payload["total_input_tokens"], 140); assert_eq!(payload["total_input_tokens"], 140);
assert_eq!(payload["total_cache_read_tokens"], 50); assert_eq!(payload["total_cache_read_tokens"], 50);
assert_eq!(payload["total_cache_creation_tokens"], 15); assert_eq!(payload["total_cache_creation_tokens"], 15);
assert_eq!(payload["token_cache_hit_rate"], 26.32); assert_eq!(payload["token_cache_hit_rate"], 35.71);
assert_eq!(payload["total_cache_read_cost_usd"], 0.02); assert_eq!(payload["total_cache_read_cost_usd"], 0.02);
assert_eq!(payload["total_cache_creation_cost_usd"], 0.015); assert_eq!(payload["total_cache_creation_cost_usd"], 0.015);
assert_eq!(payload["estimated_savings_usd"], 0.18); assert_eq!(payload["estimated_savings_usd"], 0.18);

View File

@@ -4763,7 +4763,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
payload["summary_by_model"][0]["effective_input_tokens"], payload["summary_by_model"][0]["effective_input_tokens"],
105 105
); );
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 145); assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
assert_eq!(payload["billing"]["id"], "wallet-auth-1"); assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);

View File

@@ -1,5 +1,7 @@
use crate::observability::stats::{aggregate_usage_stats, parse_bounded_u32, round_to}; use crate::observability::stats::{aggregate_usage_stats, parse_bounded_u32, round_to};
use aether_billing::normalize_input_tokens_for_billing; use aether_billing::{
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
};
use aether_data::repository::users::StoredUserSummary; use aether_data::repository::users::StoredUserSummary;
use aether_data_contracts::repository::{ use aether_data_contracts::repository::{
provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider}, provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider},
@@ -318,9 +320,20 @@ pub fn admin_usage_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64
} }
pub fn admin_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 { pub fn admin_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
item.input_tokens let api_format = item
.saturating_add(admin_usage_cache_creation_tokens(item)) .endpoint_api_format
.saturating_add(item.cache_read_input_tokens) .as_deref()
.or(item.api_format.as_deref());
let input_tokens = i64::try_from(item.input_tokens).unwrap_or(i64::MAX);
let cache_creation_tokens =
i64::try_from(admin_usage_cache_creation_tokens(item)).unwrap_or(i64::MAX);
let cache_read_tokens = i64::try_from(item.cache_read_input_tokens).unwrap_or(i64::MAX);
normalize_total_input_context_for_cache_hit_rate(
api_format,
input_tokens,
cache_creation_tokens,
cache_read_tokens,
) as u64
} }
pub fn admin_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 { pub fn admin_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 {
@@ -358,13 +371,15 @@ pub fn admin_usage_aggregation_by_model_json(
limit: usize, limit: usize,
) -> Value { ) -> Value {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, u64, u64, u64, u64, u64, f64, f64)> = let mut grouped: BTreeMap<
BTreeMap::new(); String,
(u64, u64, u64, u64, u64, u64, u64, u64, u64, u64, f64, f64),
> = BTreeMap::new();
for item in usage { for item in usage {
let key = item.model.clone(); let key = item.model.clone();
let entry = grouped let entry = grouped
.entry(key) .entry(key)
.or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0)); .or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0));
entry.0 = entry.0.saturating_add(1); entry.0 = entry.0.saturating_add(1);
entry.1 = entry.1.saturating_add(item.total_tokens); entry.1 = entry.1.saturating_add(item.total_tokens);
entry.2 = entry.2.saturating_add(item.input_tokens); entry.2 = entry.2.saturating_add(item.input_tokens);
@@ -374,16 +389,19 @@ pub fn admin_usage_aggregation_by_model_json(
.saturating_add(admin_usage_effective_input_tokens(item)); .saturating_add(admin_usage_effective_input_tokens(item));
entry.5 = entry entry.5 = entry
.5 .5
.saturating_add(admin_usage_cache_creation_tokens(item)); .saturating_add(admin_usage_total_input_context(item));
entry.6 = entry entry.6 = entry
.6 .6
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens); .saturating_add(admin_usage_cache_creation_tokens(item));
entry.7 = entry entry.7 = entry
.7 .7
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
entry.8 = entry
.8
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens); .saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
entry.8 = entry.8.saturating_add(item.cache_read_input_tokens); entry.9 = entry.9.saturating_add(item.cache_read_input_tokens);
entry.9 += item.total_cost_usd; entry.10 += item.total_cost_usd;
entry.10 += item.actual_total_cost_usd; entry.11 += item.actual_total_cost_usd;
} }
let mut items: Vec<Value> = grouped let mut items: Vec<Value> = grouped
@@ -394,9 +412,10 @@ pub fn admin_usage_aggregation_by_model_json(
( (
request_count, request_count,
total_tokens, total_tokens,
input_tokens, _input_tokens,
output_tokens, output_tokens,
effective_input_tokens, effective_input_tokens,
total_input_context,
cache_creation_tokens, cache_creation_tokens,
cache_creation_ephemeral_5m_tokens, cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens, cache_creation_ephemeral_1h_tokens,
@@ -410,9 +429,7 @@ pub fn admin_usage_aggregation_by_model_json(
"request_count": request_count, "request_count": request_count,
"total_tokens": total_tokens, "total_tokens": total_tokens,
"effective_input_tokens": effective_input_tokens, "effective_input_tokens": effective_input_tokens,
"total_input_context": input_tokens "total_input_context": total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
"output_tokens": output_tokens, "output_tokens": output_tokens,
"total_cost": round_to(total_cost, 6), "total_cost": round_to(total_cost, 6),
"actual_cost": round_to(actual_cost, 6), "actual_cost": round_to(actual_cost, 6),
@@ -421,9 +438,7 @@ pub fn admin_usage_aggregation_by_model_json(
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens, "cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
"cache_read_tokens": cache_read_tokens, "cache_read_tokens": cache_read_tokens,
"cache_hit_rate": admin_usage_token_cache_hit_rate( "cache_hit_rate": admin_usage_token_cache_hit_rate(
input_tokens total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
cache_read_tokens, cache_read_tokens,
), ),
}) })
@@ -464,6 +479,7 @@ pub fn admin_usage_aggregation_by_provider_json(
u64, u64,
u64, u64,
u64, u64,
u64,
f64, f64,
f64, f64,
u64, u64,
@@ -488,6 +504,7 @@ pub fn admin_usage_aggregation_by_provider_json(
0, 0,
0, 0,
0, 0,
0,
0.0, 0.0,
0.0, 0.0,
0, 0,
@@ -505,21 +522,24 @@ pub fn admin_usage_aggregation_by_provider_json(
.saturating_add(admin_usage_effective_input_tokens(item)); .saturating_add(admin_usage_effective_input_tokens(item));
entry.6 = entry entry.6 = entry
.6 .6
.saturating_add(admin_usage_cache_creation_tokens(item)); .saturating_add(admin_usage_total_input_context(item));
entry.7 = entry entry.7 = entry
.7 .7
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens); .saturating_add(admin_usage_cache_creation_tokens(item));
entry.8 = entry entry.8 = entry
.8 .8
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
entry.9 = entry
.9
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens); .saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
entry.9 = entry.9.saturating_add(item.cache_read_input_tokens); entry.10 = entry.10.saturating_add(item.cache_read_input_tokens);
entry.10 += item.total_cost_usd; entry.11 += item.total_cost_usd;
entry.11 += item.actual_total_cost_usd; entry.12 += item.actual_total_cost_usd;
entry.12 = entry
.12
.saturating_add(item.response_time_ms.unwrap_or_default());
entry.13 = entry entry.13 = entry
.13 .13
.saturating_add(item.response_time_ms.unwrap_or_default());
entry.14 = entry
.14
.saturating_add(if admin_usage_is_success(item) { 1 } else { 0 }); .saturating_add(if admin_usage_is_success(item) { 1 } else { 0 });
} }
@@ -532,9 +552,10 @@ pub fn admin_usage_aggregation_by_provider_json(
provider_name, provider_name,
request_count, request_count,
total_tokens, total_tokens,
input_tokens, _input_tokens,
output_tokens, output_tokens,
effective_input_tokens, effective_input_tokens,
total_input_context,
cache_creation_tokens, cache_creation_tokens,
cache_creation_ephemeral_5m_tokens, cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens, cache_creation_ephemeral_1h_tokens,
@@ -562,9 +583,7 @@ pub fn admin_usage_aggregation_by_provider_json(
"request_count": request_count, "request_count": request_count,
"total_tokens": total_tokens, "total_tokens": total_tokens,
"effective_input_tokens": effective_input_tokens, "effective_input_tokens": effective_input_tokens,
"total_input_context": input_tokens "total_input_context": total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
"output_tokens": output_tokens, "output_tokens": output_tokens,
"total_cost": round_to(total_cost, 6), "total_cost": round_to(total_cost, 6),
"actual_cost": round_to(actual_cost, 6), "actual_cost": round_to(actual_cost, 6),
@@ -576,9 +595,7 @@ pub fn admin_usage_aggregation_by_provider_json(
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens, "cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
"cache_read_tokens": cache_read_tokens, "cache_read_tokens": cache_read_tokens,
"cache_hit_rate": admin_usage_token_cache_hit_rate( "cache_hit_rate": admin_usage_token_cache_hit_rate(
input_tokens total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
cache_read_tokens, cache_read_tokens,
), ),
}) })
@@ -608,7 +625,21 @@ pub fn admin_usage_aggregation_by_api_format_json(
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
let mut grouped: BTreeMap< let mut grouped: BTreeMap<
String, String,
(u64, u64, u64, u64, u64, u64, u64, u64, u64, f64, f64, u64), (
u64,
u64,
u64,
u64,
u64,
u64,
u64,
u64,
u64,
u64,
f64,
f64,
u64,
),
> = BTreeMap::new(); > = BTreeMap::new();
for item in usage { for item in usage {
let key = item let key = item
@@ -617,7 +648,7 @@ pub fn admin_usage_aggregation_by_api_format_json(
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let entry = grouped let entry = grouped
.entry(key) .entry(key)
.or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0, 0)); .or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0, 0));
entry.0 = entry.0.saturating_add(1); entry.0 = entry.0.saturating_add(1);
entry.1 = entry.1.saturating_add(item.total_tokens); entry.1 = entry.1.saturating_add(item.total_tokens);
entry.2 = entry.2.saturating_add(item.input_tokens); entry.2 = entry.2.saturating_add(item.input_tokens);
@@ -627,18 +658,21 @@ pub fn admin_usage_aggregation_by_api_format_json(
.saturating_add(admin_usage_effective_input_tokens(item)); .saturating_add(admin_usage_effective_input_tokens(item));
entry.5 = entry entry.5 = entry
.5 .5
.saturating_add(admin_usage_cache_creation_tokens(item)); .saturating_add(admin_usage_total_input_context(item));
entry.6 = entry entry.6 = entry
.6 .6
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens); .saturating_add(admin_usage_cache_creation_tokens(item));
entry.7 = entry entry.7 = entry
.7 .7
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
entry.8 = entry
.8
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens); .saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
entry.8 = entry.8.saturating_add(item.cache_read_input_tokens); entry.9 = entry.9.saturating_add(item.cache_read_input_tokens);
entry.9 += item.total_cost_usd; entry.10 += item.total_cost_usd;
entry.10 += item.actual_total_cost_usd; entry.11 += item.actual_total_cost_usd;
entry.11 = entry entry.12 = entry
.11 .12
.saturating_add(item.response_time_ms.unwrap_or_default()); .saturating_add(item.response_time_ms.unwrap_or_default());
} }
@@ -650,9 +684,10 @@ pub fn admin_usage_aggregation_by_api_format_json(
( (
request_count, request_count,
total_tokens, total_tokens,
input_tokens, _input_tokens,
output_tokens, output_tokens,
effective_input_tokens, effective_input_tokens,
total_input_context,
cache_creation_tokens, cache_creation_tokens,
cache_creation_ephemeral_5m_tokens, cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens, cache_creation_ephemeral_1h_tokens,
@@ -672,9 +707,7 @@ pub fn admin_usage_aggregation_by_api_format_json(
"request_count": request_count, "request_count": request_count,
"total_tokens": total_tokens, "total_tokens": total_tokens,
"effective_input_tokens": effective_input_tokens, "effective_input_tokens": effective_input_tokens,
"total_input_context": input_tokens "total_input_context": total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
"output_tokens": output_tokens, "output_tokens": output_tokens,
"total_cost": round_to(total_cost, 6), "total_cost": round_to(total_cost, 6),
"actual_cost": round_to(actual_cost, 6), "actual_cost": round_to(actual_cost, 6),
@@ -684,9 +717,7 @@ pub fn admin_usage_aggregation_by_api_format_json(
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens, "cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
"cache_read_tokens": cache_read_tokens, "cache_read_tokens": cache_read_tokens,
"cache_hit_rate": admin_usage_token_cache_hit_rate( "cache_hit_rate": admin_usage_token_cache_hit_rate(
input_tokens total_input_context,
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
cache_read_tokens, cache_read_tokens,
), ),
}) })

View File

@@ -27,4 +27,6 @@ pub use schema::{
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION, BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
}; };
pub use service::BillingService; pub use service::BillingService;
pub use token_normalization::normalize_input_tokens_for_billing; pub use token_normalization::{
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
};

View File

@@ -43,9 +43,42 @@ pub fn normalize_input_tokens_for_billing(
} }
} }
pub fn normalize_total_input_context_for_cache_hit_rate(
api_format: Option<&str>,
input_tokens: i64,
cache_creation_tokens: i64,
cache_read_tokens: i64,
) -> i64 {
let normalized_input_tokens = input_tokens.max(0);
let normalized_cache_creation_tokens = cache_creation_tokens.max(0);
let normalized_cache_read_tokens = cache_read_tokens.max(0);
let fresh_input_tokens = match parse_api_family(api_format) {
ApiFamily::Claude => {
normalized_input_tokens.saturating_add(normalized_cache_creation_tokens)
}
ApiFamily::OpenAi | ApiFamily::Gemini => normalize_input_tokens_for_billing(
api_format,
normalized_input_tokens,
normalized_cache_read_tokens,
),
ApiFamily::Unknown => {
if normalized_cache_creation_tokens > 0 {
normalized_input_tokens.saturating_add(normalized_cache_creation_tokens)
} else {
normalized_input_tokens
}
}
};
fresh_input_tokens.saturating_add(normalized_cache_read_tokens)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::normalize_input_tokens_for_billing; use super::{
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
};
#[test] #[test]
fn subtracts_cache_tokens_for_openai_and_gemini() { fn subtracts_cache_tokens_for_openai_and_gemini() {
@@ -66,4 +99,36 @@ mod tests {
100 100
); );
} }
#[test]
fn normalizes_cache_hit_context_for_openai_and_gemini() {
assert_eq!(
normalize_total_input_context_for_cache_hit_rate(Some("openai:chat"), 120, 10, 15),
120
);
assert_eq!(
normalize_total_input_context_for_cache_hit_rate(Some("gemini:chat"), 120, 10, 15),
120
);
}
#[test]
fn includes_cache_creation_for_claude_cache_hit_context() {
assert_eq!(
normalize_total_input_context_for_cache_hit_rate(Some("claude:chat"), 60, 15, 5),
80
);
}
#[test]
fn falls_back_to_creation_aware_context_for_unknown_formats() {
assert_eq!(
normalize_total_input_context_for_cache_hit_rate(None, 20, 10, 5),
35
);
assert_eq!(
normalize_total_input_context_for_cache_hit_rate(None, 20, 0, 5),
25
);
}
} }

View File

@@ -197,7 +197,7 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
cache_read_tokens: 200000, cache_read_tokens: 200000,
cache_creation_cost: 0.25, cache_creation_cost: 0.25,
cache_read_cost: 0.10, cache_read_cost: 0.10,
cache_hit_rate: 0.35, cache_hit_rate: 35.0,
total_cache_tokens: 250000 total_cache_tokens: 250000
}, },
users: { users: {