mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
持久化 provider key 使用统计并移除 usage 汇总覆盖 (#313)
* 持久化 provider key 使用统计并移除 usage 汇总覆盖 - 在 usage upsert 时按请求前后差值同步 provider_api_keys 统计 - 增加基于保留 usage 记录的 provider key 统计重建能力 - 号池管理列表直接读取 provider_api_keys 统计字段 * fix: harden provider key usage stats sync --------- Co-authored-by: fawney19 <[email protected]>
This commit is contained in:
@@ -722,6 +722,14 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
|
||||||
|
match &self.usage_writer {
|
||||||
|
Some(repository) => repository.rebuild_provider_api_key_usage_stats().await,
|
||||||
|
None => Ok(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn find_request_usage_by_request_id(
|
pub(crate) async fn find_request_usage_by_request_id(
|
||||||
&self,
|
&self,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use super::{
|
|||||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -138,38 +137,16 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
|||||||
}
|
}
|
||||||
_ => AdminProviderPoolRuntimeState::default(),
|
_ => AdminProviderPoolRuntimeState::default(),
|
||||||
};
|
};
|
||||||
let usage_summary_by_key_id = state
|
|
||||||
.app()
|
|
||||||
.summarize_usage_by_provider_api_key_ids(&key_ids)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let items = keys
|
let items = keys
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|key| {
|
.map(|key| {
|
||||||
let mut payload = pool_payloads::build_admin_pool_key_payload(
|
pool_payloads::build_admin_pool_key_payload(
|
||||||
state,
|
state,
|
||||||
&provider.provider_type,
|
&provider.provider_type,
|
||||||
&key,
|
&key,
|
||||||
&runtime,
|
&runtime,
|
||||||
pool_config.clone(),
|
pool_config.clone(),
|
||||||
);
|
)
|
||||||
if let Some(summary) = usage_summary_by_key_id.get(&key.id) {
|
|
||||||
if let Some(object) = payload.as_object_mut() {
|
|
||||||
object.insert("request_count".to_string(), json!(summary.request_count));
|
|
||||||
object.insert("total_tokens".to_string(), json!(summary.total_tokens));
|
|
||||||
object.insert(
|
|
||||||
"total_cost_usd".to_string(),
|
|
||||||
json!(format!("{:.8}", summary.total_cost_usd)),
|
|
||||||
);
|
|
||||||
object.insert(
|
|
||||||
"last_used_at".to_string(),
|
|
||||||
json!(summary
|
|
||||||
.last_used_at_unix_secs
|
|
||||||
.and_then(unix_secs_to_rfc3339)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ use aether_data_contracts::repository::{candidates, usage};
|
|||||||
use usage::{StoredUsageDailySummary, UsageDailyHeatmapQuery};
|
use usage::{StoredUsageDailySummary, UsageDailyHeatmapQuery};
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, GatewayError> {
|
||||||
|
self.data
|
||||||
|
.rebuild_provider_api_key_usage_stats()
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_request_candidates_by_request_id(
|
pub(crate) async fn read_request_candidates_by_request_id(
|
||||||
&self,
|
&self,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||||
use aether_data::repository::{
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
provider_catalog::InMemoryProviderCatalogReadRepository, usage::InMemoryUsageReadRepository,
|
|
||||||
};
|
|
||||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
|
||||||
use axum::body::{to_bytes, Body, Bytes};
|
use axum::body::{to_bytes, Body, Bytes};
|
||||||
use axum::routing::{any, get, post};
|
use axum::routing::{any, get, post};
|
||||||
use axum::{extract::Request, Router};
|
use axum::{extract::Request, Router};
|
||||||
@@ -471,7 +468,7 @@ async fn gateway_pool_list_includes_usage_totals_and_nullable_lru_score() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_pool_list_uses_usage_aggregates_for_stats_and_last_used() {
|
async fn gateway_pool_list_ignores_usage_rows_and_uses_persisted_key_stats() {
|
||||||
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
@@ -488,110 +485,27 @@ async fn gateway_pool_list_uses_usage_aggregates_for_stats_and_last_used() {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
let mut key = sample_key(
|
let mut key = sample_key(
|
||||||
"key-openai-usage-live",
|
"key-openai-persisted",
|
||||||
"provider-openai",
|
"provider-openai",
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
"sk-usage-live",
|
"sk-persisted",
|
||||||
);
|
);
|
||||||
key.name = "live usage key".to_string();
|
key.name = "persisted usage key".to_string();
|
||||||
key.request_count = Some(0);
|
key.request_count = Some(7);
|
||||||
key.total_tokens = 0;
|
key.total_tokens = 1_024;
|
||||||
key.total_cost_usd = 0.0;
|
key.total_cost_usd = 3.5;
|
||||||
key.last_used_at_unix_secs = None;
|
key.last_used_at_unix_secs = Some(1_711_000_999);
|
||||||
|
|
||||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![provider],
|
vec![provider],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![key],
|
vec![key],
|
||||||
));
|
));
|
||||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
|
||||||
StoredRequestUsageAudit::new(
|
|
||||||
"usage-1".to_string(),
|
|
||||||
"request-1".to_string(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"OpenAI".to_string(),
|
|
||||||
"gpt-5.4".to_string(),
|
|
||||||
None,
|
|
||||||
Some("provider-openai".to_string()),
|
|
||||||
None,
|
|
||||||
Some("key-openai-usage-live".to_string()),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
100,
|
|
||||||
50,
|
|
||||||
150,
|
|
||||||
0.5,
|
|
||||||
0.5,
|
|
||||||
Some(200),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"completed".to_string(),
|
|
||||||
"settled".to_string(),
|
|
||||||
1_711_000_000,
|
|
||||||
1_711_000_001,
|
|
||||||
Some(1_711_000_002),
|
|
||||||
)
|
|
||||||
.expect("usage row should build"),
|
|
||||||
StoredRequestUsageAudit::new(
|
|
||||||
"usage-2".to_string(),
|
|
||||||
"request-2".to_string(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"OpenAI".to_string(),
|
|
||||||
"gpt-5.4".to_string(),
|
|
||||||
None,
|
|
||||||
Some("provider-openai".to_string()),
|
|
||||||
None,
|
|
||||||
Some("key-openai-usage-live".to_string()),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
200,
|
|
||||||
100,
|
|
||||||
300,
|
|
||||||
0.75,
|
|
||||||
0.75,
|
|
||||||
Some(200),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"completed".to_string(),
|
|
||||||
"settled".to_string(),
|
|
||||||
1_711_000_250,
|
|
||||||
1_711_000_251,
|
|
||||||
Some(1_711_000_252),
|
|
||||||
)
|
|
||||||
.expect("usage row should build"),
|
|
||||||
]));
|
|
||||||
let state = AppState::new()
|
let state = AppState::new()
|
||||||
.expect("gateway should build")
|
.expect("gateway should build")
|
||||||
.with_data_state_for_tests(
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
GatewayDataState::with_provider_catalog_and_usage_reader_for_tests(
|
provider_catalog_repository,
|
||||||
provider_catalog_repository,
|
));
|
||||||
usage_repository,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = local_admin_pool_response(
|
let response = local_admin_pool_response(
|
||||||
&state,
|
&state,
|
||||||
@@ -610,12 +524,12 @@ async fn gateway_pool_list_uses_usage_aggregates_for_stats_and_last_used() {
|
|||||||
.expect("json body should parse");
|
.expect("json body should parse");
|
||||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
assert_eq!(keys.len(), 1);
|
assert_eq!(keys.len(), 1);
|
||||||
assert_eq!(keys[0]["request_count"], json!(2));
|
assert_eq!(keys[0]["request_count"], json!(7));
|
||||||
assert_eq!(keys[0]["total_tokens"], json!(450u64));
|
assert_eq!(keys[0]["total_tokens"], json!(1_024u64));
|
||||||
assert_eq!(keys[0]["total_cost_usd"], json!("1.25000000"));
|
assert_eq!(keys[0]["total_cost_usd"], json!("3.50000000"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keys[0]["last_used_at"],
|
keys[0]["last_used_at"],
|
||||||
json!(crate::handlers::shared::unix_secs_to_rfc3339(1_711_000_250))
|
json!(crate::handlers::shared::unix_secs_to_rfc3339(1_711_000_999))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1539,6 +1539,8 @@ pub trait UsageWriteRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
usage: UpsertUsageRecord,
|
usage: UpsertUsageRecord,
|
||||||
) -> Result<StoredRequestUsageAudit, crate::DataLayerError>;
|
) -> Result<StoredRequestUsageAudit, crate::DataLayerError>;
|
||||||
|
|
||||||
|
async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, crate::DataLayerError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UsageRepository: UsageReadRepository + UsageWriteRepository + Send + Sync {}
|
pub trait UsageRepository: UsageReadRepository + UsageWriteRepository + Send + Sync {}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use super::{
|
|||||||
ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||||
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
|
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
|
use crate::repository::usage::{ProviderApiKeyUsageContribution, ProviderApiKeyUsageDelta};
|
||||||
use crate::DataLayerError;
|
use crate::DataLayerError;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -42,6 +43,117 @@ impl InMemoryProviderCatalogReadRepository {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_usage_stats_delta(
|
||||||
|
&self,
|
||||||
|
key_id: &str,
|
||||||
|
delta: &ProviderApiKeyUsageDelta,
|
||||||
|
recomputed_last_used_at_unix_secs: Option<u64>,
|
||||||
|
) {
|
||||||
|
let mut index = self
|
||||||
|
.index
|
||||||
|
.write()
|
||||||
|
.expect("provider catalog repository lock");
|
||||||
|
let Some(key) = index.keys.get_mut(key_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
key.request_count = Some(apply_i64_delta_to_u32(
|
||||||
|
key.request_count.unwrap_or_default(),
|
||||||
|
delta.request_count,
|
||||||
|
));
|
||||||
|
key.success_count = Some(apply_i64_delta_to_u32(
|
||||||
|
key.success_count.unwrap_or_default(),
|
||||||
|
delta.success_count,
|
||||||
|
));
|
||||||
|
key.error_count = Some(apply_i64_delta_to_u32(
|
||||||
|
key.error_count.unwrap_or_default(),
|
||||||
|
delta.error_count,
|
||||||
|
));
|
||||||
|
key.total_tokens = apply_i64_delta_to_u64(key.total_tokens, delta.total_tokens);
|
||||||
|
key.total_cost_usd = apply_f64_delta(key.total_cost_usd, delta.total_cost_usd);
|
||||||
|
key.total_response_time_ms = Some(apply_i64_delta_to_u32(
|
||||||
|
key.total_response_time_ms.unwrap_or_default(),
|
||||||
|
delta.total_response_time_ms,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(candidate) = delta.candidate_last_used_at_unix_secs {
|
||||||
|
key.last_used_at_unix_secs = Some(
|
||||||
|
key.last_used_at_unix_secs
|
||||||
|
.map(|existing| existing.max(candidate))
|
||||||
|
.unwrap_or(candidate),
|
||||||
|
);
|
||||||
|
} else if delta.removed_last_used_at_unix_secs.is_some()
|
||||||
|
&& key.last_used_at_unix_secs == delta.removed_last_used_at_unix_secs
|
||||||
|
{
|
||||||
|
key.last_used_at_unix_secs = recomputed_last_used_at_unix_secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rebuild_usage_stats(
|
||||||
|
&self,
|
||||||
|
contributions: &BTreeMap<String, ProviderApiKeyUsageContribution>,
|
||||||
|
) {
|
||||||
|
let mut index = self
|
||||||
|
.index
|
||||||
|
.write()
|
||||||
|
.expect("provider catalog repository lock");
|
||||||
|
for key in index.keys.values_mut() {
|
||||||
|
key.request_count = Some(0);
|
||||||
|
key.success_count = Some(0);
|
||||||
|
key.error_count = Some(0);
|
||||||
|
key.total_tokens = 0;
|
||||||
|
key.total_cost_usd = 0.0;
|
||||||
|
key.total_response_time_ms = Some(0);
|
||||||
|
key.last_used_at_unix_secs = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key_id, contribution) in contributions {
|
||||||
|
let Some(key) = index.keys.get_mut(key_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
key.request_count = Some(clamp_i64_to_u32(contribution.request_count));
|
||||||
|
key.success_count = Some(clamp_i64_to_u32(contribution.success_count));
|
||||||
|
key.error_count = Some(clamp_i64_to_u32(contribution.error_count));
|
||||||
|
key.total_tokens = clamp_i64_to_u64(contribution.total_tokens);
|
||||||
|
key.total_cost_usd = contribution.total_cost_usd.max(0.0);
|
||||||
|
key.total_response_time_ms =
|
||||||
|
Some(clamp_i64_to_u32(contribution.total_response_time_ms));
|
||||||
|
key.last_used_at_unix_secs = contribution.last_used_at_unix_secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_i64_delta_to_u32(current: u32, delta: i64) -> u32 {
|
||||||
|
clamp_i64_to_u32(i64::from(current).saturating_add(delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_i64_delta_to_u64(current: u64, delta: i64) -> u64 {
|
||||||
|
if delta >= 0 {
|
||||||
|
current.saturating_add(delta as u64)
|
||||||
|
} else {
|
||||||
|
current.saturating_sub(delta.unsigned_abs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_i64_to_u32(value: i64) -> u32 {
|
||||||
|
value.clamp(0, i64::from(u32::MAX)) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_i64_to_u64(value: i64) -> u64 {
|
||||||
|
value.max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_f64_delta(current: f64, delta: f64) -> f64 {
|
||||||
|
if !current.is_finite() && !delta.is_finite() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let next = current.max(0.0) + delta;
|
||||||
|
if next.is_finite() {
|
||||||
|
next.max(0.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
@@ -23,11 +24,13 @@ use chrono::Utc;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
strip_deprecated_usage_display_fields, usage_can_recover_terminal_failure,
|
provider_api_key_usage_contribution, strip_deprecated_usage_display_fields,
|
||||||
|
usage_can_recover_terminal_failure, ProviderApiKeyUsageContribution, ProviderApiKeyUsageDelta,
|
||||||
StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||||
StoredRequestUsageAudit, StoredUsageDailySummary, UpsertUsageRecord, UsageAuditListQuery,
|
StoredRequestUsageAudit, StoredUsageDailySummary, UpsertUsageRecord, UsageAuditListQuery,
|
||||||
UsageDailyHeatmapQuery, UsageReadRepository, UsageWriteRepository,
|
UsageDailyHeatmapQuery, UsageReadRepository, UsageWriteRepository,
|
||||||
};
|
};
|
||||||
|
use crate::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use crate::DataLayerError;
|
use crate::DataLayerError;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -35,6 +38,7 @@ pub struct InMemoryUsageReadRepository {
|
|||||||
by_request_id: RwLock<BTreeMap<String, StoredRequestUsageAudit>>,
|
by_request_id: RwLock<BTreeMap<String, StoredRequestUsageAudit>>,
|
||||||
detached_bodies: RwLock<BTreeMap<String, Value>>,
|
detached_bodies: RwLock<BTreeMap<String, Value>>,
|
||||||
provider_usage_windows: RwLock<Vec<StoredProviderUsageWindow>>,
|
provider_usage_windows: RwLock<Vec<StoredProviderUsageWindow>>,
|
||||||
|
provider_catalog: Option<Arc<InMemoryProviderCatalogReadRepository>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryUsageReadRepository {
|
impl InMemoryUsageReadRepository {
|
||||||
@@ -51,6 +55,7 @@ impl InMemoryUsageReadRepository {
|
|||||||
by_request_id: RwLock::new(by_request_id),
|
by_request_id: RwLock::new(by_request_id),
|
||||||
detached_bodies: RwLock::new(BTreeMap::new()),
|
detached_bodies: RwLock::new(BTreeMap::new()),
|
||||||
provider_usage_windows: RwLock::new(Vec::new()),
|
provider_usage_windows: RwLock::new(Vec::new()),
|
||||||
|
provider_catalog: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +106,7 @@ impl InMemoryUsageReadRepository {
|
|||||||
by_request_id: RwLock::new(by_request_id),
|
by_request_id: RwLock::new(by_request_id),
|
||||||
detached_bodies: RwLock::new(detached_bodies),
|
detached_bodies: RwLock::new(detached_bodies),
|
||||||
provider_usage_windows: RwLock::new(Vec::new()),
|
provider_usage_windows: RwLock::new(Vec::new()),
|
||||||
|
provider_catalog: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +118,17 @@ impl InMemoryUsageReadRepository {
|
|||||||
by_request_id: self.by_request_id,
|
by_request_id: self.by_request_id,
|
||||||
detached_bodies: self.detached_bodies,
|
detached_bodies: self.detached_bodies,
|
||||||
provider_usage_windows: RwLock::new(items.into_iter().collect()),
|
provider_usage_windows: RwLock::new(items.into_iter().collect()),
|
||||||
|
provider_catalog: self.provider_catalog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_provider_catalog_repository(
|
||||||
|
mut self,
|
||||||
|
repository: Arc<InMemoryProviderCatalogReadRepository>,
|
||||||
|
) -> Self {
|
||||||
|
self.provider_catalog = Some(repository);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage_status_is_finalized(status: &str) -> bool {
|
fn usage_status_is_finalized(status: &str) -> bool {
|
||||||
@@ -124,6 +139,38 @@ fn usage_status_is_lifecycle(status: &str) -> bool {
|
|||||||
matches!(status, "pending" | "streaming")
|
matches!(status, "pending" | "streaming")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn accumulate_provider_api_key_usage_contribution(
|
||||||
|
aggregates: &mut BTreeMap<String, ProviderApiKeyUsageContribution>,
|
||||||
|
contribution: ProviderApiKeyUsageContribution,
|
||||||
|
) {
|
||||||
|
let entry = aggregates
|
||||||
|
.entry(contribution.key_id.clone())
|
||||||
|
.or_insert_with(|| ProviderApiKeyUsageContribution {
|
||||||
|
key_id: contribution.key_id.clone(),
|
||||||
|
..ProviderApiKeyUsageContribution::default()
|
||||||
|
});
|
||||||
|
entry.request_count = entry
|
||||||
|
.request_count
|
||||||
|
.saturating_add(contribution.request_count);
|
||||||
|
entry.success_count = entry
|
||||||
|
.success_count
|
||||||
|
.saturating_add(contribution.success_count);
|
||||||
|
entry.error_count = entry.error_count.saturating_add(contribution.error_count);
|
||||||
|
entry.total_tokens = entry.total_tokens.saturating_add(contribution.total_tokens);
|
||||||
|
entry.total_cost_usd += contribution.total_cost_usd;
|
||||||
|
entry.total_response_time_ms = entry
|
||||||
|
.total_response_time_ms
|
||||||
|
.saturating_add(contribution.total_response_time_ms);
|
||||||
|
entry.last_used_at_unix_secs = match (
|
||||||
|
entry.last_used_at_unix_secs,
|
||||||
|
contribution.last_used_at_unix_secs,
|
||||||
|
) {
|
||||||
|
(Some(existing), Some(candidate)) => Some(existing.max(candidate)),
|
||||||
|
(None, Some(candidate)) => Some(candidate),
|
||||||
|
(existing, None) => existing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditListQuery) -> bool {
|
fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditListQuery) -> bool {
|
||||||
// The field is historically named `created_at_unix_ms`, but usage audit rows
|
// The field is historically named `created_at_unix_ms`, but usage audit rows
|
||||||
// across gateway handlers, SQL repositories and tests are stored as epoch seconds.
|
// across gateway handlers, SQL repositories and tests are stored as epoch seconds.
|
||||||
@@ -2039,6 +2086,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
usage.validate()?;
|
usage.validate()?;
|
||||||
let usage = strip_deprecated_usage_display_fields(usage);
|
let usage = strip_deprecated_usage_display_fields(usage);
|
||||||
let mut by_request_id = self.by_request_id.write().expect("usage repository lock");
|
let mut by_request_id = self.by_request_id.write().expect("usage repository lock");
|
||||||
|
let existing = by_request_id.get(&usage.request_id).cloned();
|
||||||
|
|
||||||
let created_at_unix_ms = by_request_id
|
let created_at_unix_ms = by_request_id
|
||||||
.get(&usage.request_id)
|
.get(&usage.request_id)
|
||||||
@@ -2055,8 +2103,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let existing = by_request_id.get(&usage.request_id);
|
if existing.as_ref().is_some_and(|existing| {
|
||||||
if existing.is_some_and(|existing| {
|
|
||||||
usage_status_is_finalized(existing.status.as_str())
|
usage_status_is_finalized(existing.status.as_str())
|
||||||
&& usage_status_is_lifecycle(usage.status.as_str())
|
&& usage_status_is_lifecycle(usage.status.as_str())
|
||||||
&& !usage_can_recover_terminal_failure(
|
&& !usage_can_recover_terminal_failure(
|
||||||
@@ -2068,7 +2115,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
}) {
|
}) {
|
||||||
return Ok(existing.expect("existing usage should be present").clone());
|
return Ok(existing.expect("existing usage should be present").clone());
|
||||||
}
|
}
|
||||||
if existing.is_some_and(|existing| {
|
if existing.as_ref().is_some_and(|existing| {
|
||||||
existing.billing_status == "pending"
|
existing.billing_status == "pending"
|
||||||
&& existing.status == "streaming"
|
&& existing.status == "streaming"
|
||||||
&& usage.status == "pending"
|
&& usage.status == "pending"
|
||||||
@@ -2076,47 +2123,53 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
return Ok(existing.expect("existing usage should be present").clone());
|
return Ok(existing.expect("existing usage should be present").clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_metadata = usage
|
let request_metadata = usage.request_metadata.clone().or_else(|| {
|
||||||
.request_metadata
|
existing
|
||||||
.clone()
|
.as_ref()
|
||||||
.or_else(|| existing.and_then(|existing| existing.request_metadata.clone()));
|
.and_then(|existing| existing.request_metadata.clone())
|
||||||
|
});
|
||||||
let request_body_ref = persisted_usage_body_ref(
|
let request_body_ref = persisted_usage_body_ref(
|
||||||
usage.request_body_ref.as_deref(),
|
usage.request_body_ref.as_deref(),
|
||||||
usage.request_body.as_ref(),
|
usage.request_body.as_ref(),
|
||||||
request_metadata.as_ref(),
|
request_metadata.as_ref(),
|
||||||
existing,
|
existing.as_ref(),
|
||||||
UsageBodyField::RequestBody,
|
UsageBodyField::RequestBody,
|
||||||
);
|
);
|
||||||
let provider_request_body_ref = persisted_usage_body_ref(
|
let provider_request_body_ref = persisted_usage_body_ref(
|
||||||
usage.provider_request_body_ref.as_deref(),
|
usage.provider_request_body_ref.as_deref(),
|
||||||
usage.provider_request_body.as_ref(),
|
usage.provider_request_body.as_ref(),
|
||||||
request_metadata.as_ref(),
|
request_metadata.as_ref(),
|
||||||
existing,
|
existing.as_ref(),
|
||||||
UsageBodyField::ProviderRequestBody,
|
UsageBodyField::ProviderRequestBody,
|
||||||
);
|
);
|
||||||
let response_body_ref = persisted_usage_body_ref(
|
let response_body_ref = persisted_usage_body_ref(
|
||||||
usage.response_body_ref.as_deref(),
|
usage.response_body_ref.as_deref(),
|
||||||
usage.response_body.as_ref(),
|
usage.response_body.as_ref(),
|
||||||
request_metadata.as_ref(),
|
request_metadata.as_ref(),
|
||||||
existing,
|
existing.as_ref(),
|
||||||
UsageBodyField::ResponseBody,
|
UsageBodyField::ResponseBody,
|
||||||
);
|
);
|
||||||
let client_response_body_ref = persisted_usage_body_ref(
|
let client_response_body_ref = persisted_usage_body_ref(
|
||||||
usage.client_response_body_ref.as_deref(),
|
usage.client_response_body_ref.as_deref(),
|
||||||
usage.client_response_body.as_ref(),
|
usage.client_response_body.as_ref(),
|
||||||
request_metadata.as_ref(),
|
request_metadata.as_ref(),
|
||||||
existing,
|
existing.as_ref(),
|
||||||
UsageBodyField::ClientResponseBody,
|
UsageBodyField::ClientResponseBody,
|
||||||
);
|
);
|
||||||
let stored = StoredRequestUsageAudit {
|
let stored = StoredRequestUsageAudit {
|
||||||
id: existing
|
id: existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.id.clone())
|
.map(|existing| existing.id.clone())
|
||||||
.unwrap_or_else(|| format!("usage-{}", usage.request_id)),
|
.unwrap_or_else(|| format!("usage-{}", usage.request_id)),
|
||||||
request_id: usage.request_id.clone(),
|
request_id: usage.request_id.clone(),
|
||||||
user_id: usage.user_id,
|
user_id: usage.user_id,
|
||||||
api_key_id: usage.api_key_id,
|
api_key_id: usage.api_key_id,
|
||||||
username: existing.and_then(|existing| existing.username.clone()),
|
username: existing
|
||||||
api_key_name: existing.and_then(|existing| existing.api_key_name.clone()),
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.username.clone()),
|
||||||
|
api_key_name: existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.api_key_name.clone()),
|
||||||
provider_name: usage.provider_name,
|
provider_name: usage.provider_name,
|
||||||
model: usage.model,
|
model: usage.model,
|
||||||
target_model: usage.target_model,
|
target_model: usage.target_model,
|
||||||
@@ -2137,6 +2190,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
total_tokens,
|
total_tokens,
|
||||||
cache_creation_input_tokens: usage.cache_creation_input_tokens.unwrap_or_else(|| {
|
cache_creation_input_tokens: usage.cache_creation_input_tokens.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_creation_input_tokens)
|
.map(|existing| existing.cache_creation_input_tokens)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
@@ -2144,6 +2198,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
.cache_creation_ephemeral_5m_input_tokens
|
.cache_creation_ephemeral_5m_input_tokens
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_creation_ephemeral_5m_input_tokens)
|
.map(|existing| existing.cache_creation_ephemeral_5m_input_tokens)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
@@ -2151,32 +2206,40 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
.cache_creation_ephemeral_1h_input_tokens
|
.cache_creation_ephemeral_1h_input_tokens
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_creation_ephemeral_1h_input_tokens)
|
.map(|existing| existing.cache_creation_ephemeral_1h_input_tokens)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or_else(|| {
|
cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_read_input_tokens)
|
.map(|existing| existing.cache_read_input_tokens)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
cache_creation_cost_usd: usage.cache_creation_cost_usd.unwrap_or_else(|| {
|
cache_creation_cost_usd: usage.cache_creation_cost_usd.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_creation_cost_usd)
|
.map(|existing| existing.cache_creation_cost_usd)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
cache_read_cost_usd: usage.cache_read_cost_usd.unwrap_or_else(|| {
|
cache_read_cost_usd: usage.cache_read_cost_usd.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.cache_read_cost_usd)
|
.map(|existing| existing.cache_read_cost_usd)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
output_price_per_1m: existing.and_then(|existing| existing.output_price_per_1m),
|
output_price_per_1m: existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.output_price_per_1m),
|
||||||
total_cost_usd: usage.total_cost_usd.unwrap_or_else(|| {
|
total_cost_usd: usage.total_cost_usd.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.total_cost_usd)
|
.map(|existing| existing.total_cost_usd)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
actual_total_cost_usd: usage.actual_total_cost_usd.unwrap_or_else(|| {
|
actual_total_cost_usd: usage.actual_total_cost_usd.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.map(|existing| existing.actual_total_cost_usd)
|
.map(|existing| existing.actual_total_cost_usd)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
@@ -2187,71 +2250,108 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
first_byte_time_ms: usage.first_byte_time_ms,
|
first_byte_time_ms: usage.first_byte_time_ms,
|
||||||
status: usage.status,
|
status: usage.status,
|
||||||
billing_status: usage.billing_status,
|
billing_status: usage.billing_status,
|
||||||
request_headers: usage
|
request_headers: usage.request_headers.or_else(|| {
|
||||||
.request_headers
|
existing
|
||||||
.or_else(|| existing.and_then(|existing| existing.request_headers.clone())),
|
.as_ref()
|
||||||
request_body: usage
|
.and_then(|existing| existing.request_headers.clone())
|
||||||
.request_body
|
}),
|
||||||
.or_else(|| existing.and_then(|existing| existing.request_body.clone())),
|
request_body: usage.request_body.or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.request_body.clone())
|
||||||
|
}),
|
||||||
request_body_ref,
|
request_body_ref,
|
||||||
request_body_state: usage
|
request_body_state: usage.request_body_state.or_else(|| {
|
||||||
.request_body_state
|
existing
|
||||||
.or_else(|| existing.and_then(|existing| existing.request_body_state)),
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.request_body_state)
|
||||||
|
}),
|
||||||
provider_request_headers: usage.provider_request_headers.or_else(|| {
|
provider_request_headers: usage.provider_request_headers.or_else(|| {
|
||||||
existing.and_then(|existing| existing.provider_request_headers.clone())
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.provider_request_headers.clone())
|
||||||
|
}),
|
||||||
|
provider_request_body: usage.provider_request_body.or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.provider_request_body.clone())
|
||||||
}),
|
}),
|
||||||
provider_request_body: usage
|
|
||||||
.provider_request_body
|
|
||||||
.or_else(|| existing.and_then(|existing| existing.provider_request_body.clone())),
|
|
||||||
provider_request_body_ref,
|
provider_request_body_ref,
|
||||||
provider_request_body_state: usage
|
provider_request_body_state: usage.provider_request_body_state.or_else(|| {
|
||||||
.provider_request_body_state
|
existing
|
||||||
.or_else(|| existing.and_then(|existing| existing.provider_request_body_state)),
|
.as_ref()
|
||||||
response_headers: usage
|
.and_then(|existing| existing.provider_request_body_state)
|
||||||
.response_headers
|
}),
|
||||||
.or_else(|| existing.and_then(|existing| existing.response_headers.clone())),
|
response_headers: usage.response_headers.or_else(|| {
|
||||||
response_body: usage
|
existing
|
||||||
.response_body
|
.as_ref()
|
||||||
.or_else(|| existing.and_then(|existing| existing.response_body.clone())),
|
.and_then(|existing| existing.response_headers.clone())
|
||||||
|
}),
|
||||||
|
response_body: usage.response_body.or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.response_body.clone())
|
||||||
|
}),
|
||||||
response_body_ref,
|
response_body_ref,
|
||||||
response_body_state: usage
|
response_body_state: usage.response_body_state.or_else(|| {
|
||||||
.response_body_state
|
existing
|
||||||
.or_else(|| existing.and_then(|existing| existing.response_body_state)),
|
.as_ref()
|
||||||
client_response_headers: usage
|
.and_then(|existing| existing.response_body_state)
|
||||||
.client_response_headers
|
}),
|
||||||
.or_else(|| existing.and_then(|existing| existing.client_response_headers.clone())),
|
client_response_headers: usage.client_response_headers.or_else(|| {
|
||||||
client_response_body: usage
|
existing
|
||||||
.client_response_body
|
.as_ref()
|
||||||
.or_else(|| existing.and_then(|existing| existing.client_response_body.clone())),
|
.and_then(|existing| existing.client_response_headers.clone())
|
||||||
|
}),
|
||||||
|
client_response_body: usage.client_response_body.or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.client_response_body.clone())
|
||||||
|
}),
|
||||||
client_response_body_ref,
|
client_response_body_ref,
|
||||||
client_response_body_state: usage
|
client_response_body_state: usage.client_response_body_state.or_else(|| {
|
||||||
.client_response_body_state
|
existing
|
||||||
.or_else(|| existing.and_then(|existing| existing.client_response_body_state)),
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.client_response_body_state)
|
||||||
|
}),
|
||||||
candidate_id: usage.candidate_id.or_else(|| {
|
candidate_id: usage.candidate_id.or_else(|| {
|
||||||
existing.and_then(|existing| existing.routing_candidate_id().map(ToOwned::to_owned))
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_candidate_id().map(ToOwned::to_owned))
|
||||||
|
}),
|
||||||
|
candidate_index: usage.candidate_index.or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_candidate_index())
|
||||||
}),
|
}),
|
||||||
candidate_index: usage
|
|
||||||
.candidate_index
|
|
||||||
.or_else(|| existing.and_then(|existing| existing.routing_candidate_index())),
|
|
||||||
key_name: usage.key_name.or_else(|| {
|
key_name: usage.key_name.or_else(|| {
|
||||||
existing.and_then(|existing| existing.routing_key_name().map(ToOwned::to_owned))
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_key_name().map(ToOwned::to_owned))
|
||||||
}),
|
}),
|
||||||
planner_kind: usage.planner_kind.or_else(|| {
|
planner_kind: usage.planner_kind.or_else(|| {
|
||||||
existing.and_then(|existing| existing.routing_planner_kind().map(ToOwned::to_owned))
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_planner_kind().map(ToOwned::to_owned))
|
||||||
}),
|
}),
|
||||||
route_family: usage.route_family.or_else(|| {
|
route_family: usage.route_family.or_else(|| {
|
||||||
existing.and_then(|existing| existing.routing_route_family().map(ToOwned::to_owned))
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_route_family().map(ToOwned::to_owned))
|
||||||
}),
|
}),
|
||||||
route_kind: usage.route_kind.or_else(|| {
|
route_kind: usage.route_kind.or_else(|| {
|
||||||
existing.and_then(|existing| existing.routing_route_kind().map(ToOwned::to_owned))
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|existing| existing.routing_route_kind().map(ToOwned::to_owned))
|
||||||
}),
|
}),
|
||||||
execution_path: usage.execution_path.or_else(|| {
|
execution_path: usage.execution_path.or_else(|| {
|
||||||
existing
|
existing
|
||||||
|
.as_ref()
|
||||||
.and_then(|existing| existing.routing_execution_path().map(ToOwned::to_owned))
|
.and_then(|existing| existing.routing_execution_path().map(ToOwned::to_owned))
|
||||||
}),
|
}),
|
||||||
local_execution_runtime_miss_reason: usage.local_execution_runtime_miss_reason.or_else(
|
local_execution_runtime_miss_reason: usage.local_execution_runtime_miss_reason.or_else(
|
||||||
|| {
|
|| {
|
||||||
existing.and_then(|existing| {
|
existing.as_ref().and_then(|existing| {
|
||||||
existing
|
existing
|
||||||
.routing_local_execution_runtime_miss_reason()
|
.routing_local_execution_runtime_miss_reason()
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
@@ -2265,13 +2365,76 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
by_request_id.insert(stored.request_id.clone(), stored.clone());
|
by_request_id.insert(stored.request_id.clone(), stored.clone());
|
||||||
|
if let Some(provider_catalog) = self.provider_catalog.as_ref() {
|
||||||
|
let before_contribution = existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(provider_api_key_usage_contribution);
|
||||||
|
let after_contribution = provider_api_key_usage_contribution(&stored);
|
||||||
|
|
||||||
|
match (before_contribution.as_ref(), after_contribution.as_ref()) {
|
||||||
|
(Some(before), Some(after)) if before.key_id == after.key_id => {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::between(before, after);
|
||||||
|
provider_catalog.apply_usage_stats_delta(before.key_id.as_str(), &delta, None);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(before) = before_contribution.as_ref() {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::removal(before);
|
||||||
|
let recomputed_last_used_at_unix_secs = by_request_id
|
||||||
|
.values()
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.provider_api_key_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|key_id| *key_id == before.key_id.as_str())
|
||||||
|
.map(|_| item.created_at_unix_ms)
|
||||||
|
})
|
||||||
|
.max();
|
||||||
|
provider_catalog.apply_usage_stats_delta(
|
||||||
|
before.key_id.as_str(),
|
||||||
|
&delta,
|
||||||
|
recomputed_last_used_at_unix_secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(after) = after_contribution.as_ref() {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::addition(after);
|
||||||
|
provider_catalog.apply_usage_stats_delta(
|
||||||
|
after.key_id.as_str(),
|
||||||
|
&delta,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(stored)
|
Ok(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
|
||||||
|
let Some(provider_catalog) = self.provider_catalog.as_ref() else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let by_request_id = self.by_request_id.read().expect("usage repository lock");
|
||||||
|
let mut aggregates = BTreeMap::new();
|
||||||
|
for usage in by_request_id.values() {
|
||||||
|
let Some(contribution) = provider_api_key_usage_contribution(usage) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
accumulate_provider_api_key_usage_contribution(&mut aggregates, contribution);
|
||||||
|
}
|
||||||
|
provider_catalog.rebuild_usage_stats(&aggregates);
|
||||||
|
Ok(aggregates.len() as u64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::InMemoryUsageReadRepository;
|
use super::InMemoryUsageReadRepository;
|
||||||
|
use crate::repository::provider_catalog::{
|
||||||
|
InMemoryProviderCatalogReadRepository, ProviderCatalogReadRepository,
|
||||||
|
ProviderCatalogWriteRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
|
};
|
||||||
use crate::repository::usage::{
|
use crate::repository::usage::{
|
||||||
StoredProviderUsageWindow, StoredRequestUsageAudit, UpsertUsageRecord, UsageReadRepository,
|
StoredProviderUsageWindow, StoredRequestUsageAudit, UpsertUsageRecord, UsageReadRepository,
|
||||||
UsageWriteRepository,
|
UsageWriteRepository,
|
||||||
@@ -2321,6 +2484,78 @@ mod tests {
|
|||||||
.expect("usage should build")
|
.expect("usage should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_upsert_usage_record(request_id: &str) -> UpsertUsageRecord {
|
||||||
|
UpsertUsageRecord {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
user_id: None,
|
||||||
|
api_key_id: None,
|
||||||
|
username: None,
|
||||||
|
api_key_name: None,
|
||||||
|
provider_name: "OpenAI".to_string(),
|
||||||
|
model: "gpt-5".to_string(),
|
||||||
|
target_model: None,
|
||||||
|
provider_id: Some("provider-1".to_string()),
|
||||||
|
provider_endpoint_id: None,
|
||||||
|
provider_api_key_id: None,
|
||||||
|
request_type: None,
|
||||||
|
api_format: None,
|
||||||
|
api_family: None,
|
||||||
|
endpoint_kind: None,
|
||||||
|
endpoint_api_format: None,
|
||||||
|
provider_api_family: None,
|
||||||
|
provider_endpoint_kind: None,
|
||||||
|
has_format_conversion: Some(false),
|
||||||
|
is_stream: Some(false),
|
||||||
|
input_tokens: None,
|
||||||
|
output_tokens: None,
|
||||||
|
total_tokens: None,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_creation_ephemeral_5m_input_tokens: None,
|
||||||
|
cache_creation_ephemeral_1h_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
|
cache_creation_cost_usd: None,
|
||||||
|
cache_read_cost_usd: None,
|
||||||
|
output_price_per_1m: None,
|
||||||
|
total_cost_usd: None,
|
||||||
|
actual_total_cost_usd: None,
|
||||||
|
status_code: None,
|
||||||
|
error_message: None,
|
||||||
|
error_category: None,
|
||||||
|
response_time_ms: None,
|
||||||
|
first_byte_time_ms: None,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
billing_status: "pending".to_string(),
|
||||||
|
request_headers: None,
|
||||||
|
request_body: None,
|
||||||
|
request_body_ref: None,
|
||||||
|
request_body_state: None,
|
||||||
|
provider_request_headers: None,
|
||||||
|
provider_request_body: None,
|
||||||
|
provider_request_body_ref: None,
|
||||||
|
provider_request_body_state: None,
|
||||||
|
response_headers: None,
|
||||||
|
response_body: None,
|
||||||
|
response_body_ref: None,
|
||||||
|
response_body_state: None,
|
||||||
|
client_response_headers: None,
|
||||||
|
client_response_body: None,
|
||||||
|
client_response_body_ref: None,
|
||||||
|
client_response_body_state: None,
|
||||||
|
candidate_id: None,
|
||||||
|
candidate_index: None,
|
||||||
|
key_name: None,
|
||||||
|
planner_kind: None,
|
||||||
|
route_family: None,
|
||||||
|
route_kind: None,
|
||||||
|
execution_path: None,
|
||||||
|
local_execution_runtime_miss_reason: None,
|
||||||
|
request_metadata: None,
|
||||||
|
finalized_at_unix_secs: None,
|
||||||
|
created_at_unix_ms: Some(1_700_000_000),
|
||||||
|
updated_at_unix_secs: 1_700_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn finds_usage_by_request_id() {
|
async fn finds_usage_by_request_id() {
|
||||||
let repository = InMemoryUsageReadRepository::seed(vec![
|
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||||
@@ -3719,4 +3954,203 @@ mod tests {
|
|||||||
assert_eq!(usage.request_count, 2);
|
assert_eq!(usage.request_count, 2);
|
||||||
assert_eq!(usage.last_used_at_unix_secs, Some(2_500));
|
assert_eq!(usage.last_used_at_unix_secs, Some(2_500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_provider_catalog_key(key_id: &str) -> StoredProviderCatalogKey {
|
||||||
|
StoredProviderCatalogKey::new(
|
||||||
|
key_id.to_string(),
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"provider key".to_string(),
|
||||||
|
"api_key".to_string(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("provider key should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_provider_catalog_repository(
|
||||||
|
key_ids: &[&str],
|
||||||
|
) -> Arc<InMemoryProviderCatalogReadRepository> {
|
||||||
|
Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![StoredProviderCatalogProvider::new(
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"OpenAI".to_string(),
|
||||||
|
None,
|
||||||
|
"openai".to_string(),
|
||||||
|
)
|
||||||
|
.expect("provider should build")],
|
||||||
|
Vec::new(),
|
||||||
|
key_ids
|
||||||
|
.iter()
|
||||||
|
.map(|key_id| sample_provider_catalog_key(key_id))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upsert_syncs_linked_provider_key_stats_without_double_counting_request_count() {
|
||||||
|
let provider_catalog = sample_provider_catalog_repository(&["provider-key-1"]);
|
||||||
|
let repository = InMemoryUsageReadRepository::default()
|
||||||
|
.with_provider_catalog_repository(Arc::clone(&provider_catalog));
|
||||||
|
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
provider_api_key_id: Some("provider-key-1".to_string()),
|
||||||
|
total_tokens: Some(100),
|
||||||
|
total_cost_usd: Some(0.5),
|
||||||
|
created_at_unix_ms: Some(1_711_100_000),
|
||||||
|
updated_at_unix_secs: 1_711_100_000,
|
||||||
|
..sample_upsert_usage_record("req-linked-1")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("pending upsert should succeed");
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
provider_api_key_id: Some("provider-key-1".to_string()),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
billing_status: "settled".to_string(),
|
||||||
|
status_code: Some(200),
|
||||||
|
response_time_ms: Some(240),
|
||||||
|
total_tokens: Some(180),
|
||||||
|
total_cost_usd: Some(0.75),
|
||||||
|
created_at_unix_ms: Some(1_711_100_000),
|
||||||
|
updated_at_unix_secs: 1_711_100_010,
|
||||||
|
finalized_at_unix_secs: Some(1_711_100_011),
|
||||||
|
..sample_upsert_usage_record("req-linked-1")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("completed upsert should succeed");
|
||||||
|
|
||||||
|
let key = provider_catalog
|
||||||
|
.list_keys_by_ids(&["provider-key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key list should succeed")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("provider key should exist");
|
||||||
|
assert_eq!(key.request_count, Some(1));
|
||||||
|
assert_eq!(key.success_count, Some(1));
|
||||||
|
assert_eq!(key.error_count, Some(0));
|
||||||
|
assert_eq!(key.total_tokens, 180);
|
||||||
|
assert_eq!(key.total_cost_usd, 0.75);
|
||||||
|
assert_eq!(key.total_response_time_ms, Some(240));
|
||||||
|
assert_eq!(key.last_used_at_unix_secs, Some(1_711_100_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upsert_moves_linked_provider_key_stats_when_key_assignment_changes() {
|
||||||
|
let provider_catalog =
|
||||||
|
sample_provider_catalog_repository(&["provider-key-a", "provider-key-b"]);
|
||||||
|
let repository = InMemoryUsageReadRepository::default()
|
||||||
|
.with_provider_catalog_repository(Arc::clone(&provider_catalog));
|
||||||
|
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
provider_api_key_id: Some("provider-key-a".to_string()),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
billing_status: "settled".to_string(),
|
||||||
|
status_code: Some(200),
|
||||||
|
response_time_ms: Some(100),
|
||||||
|
total_tokens: Some(120),
|
||||||
|
total_cost_usd: Some(0.4),
|
||||||
|
created_at_unix_ms: Some(1_711_200_000),
|
||||||
|
updated_at_unix_secs: 1_711_200_000,
|
||||||
|
finalized_at_unix_secs: Some(1_711_200_001),
|
||||||
|
..sample_upsert_usage_record("req-move-1")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first upsert should succeed");
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
provider_api_key_id: Some("provider-key-b".to_string()),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
billing_status: "settled".to_string(),
|
||||||
|
status_code: Some(200),
|
||||||
|
response_time_ms: Some(150),
|
||||||
|
total_tokens: Some(140),
|
||||||
|
total_cost_usd: Some(0.6),
|
||||||
|
created_at_unix_ms: Some(1_711_200_000),
|
||||||
|
updated_at_unix_secs: 1_711_200_010,
|
||||||
|
finalized_at_unix_secs: Some(1_711_200_011),
|
||||||
|
..sample_upsert_usage_record("req-move-1")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("moved upsert should succeed");
|
||||||
|
|
||||||
|
let keys = provider_catalog
|
||||||
|
.list_keys_by_ids(&["provider-key-a".to_string(), "provider-key-b".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key list should succeed");
|
||||||
|
let key_a = keys
|
||||||
|
.iter()
|
||||||
|
.find(|key| key.id == "provider-key-a")
|
||||||
|
.expect("key a should exist");
|
||||||
|
let key_b = keys
|
||||||
|
.iter()
|
||||||
|
.find(|key| key.id == "provider-key-b")
|
||||||
|
.expect("key b should exist");
|
||||||
|
|
||||||
|
assert_eq!(key_a.request_count, Some(0));
|
||||||
|
assert_eq!(key_a.success_count, Some(0));
|
||||||
|
assert_eq!(key_a.total_tokens, 0);
|
||||||
|
assert_eq!(key_a.total_cost_usd, 0.0);
|
||||||
|
assert_eq!(key_a.total_response_time_ms, Some(0));
|
||||||
|
assert_eq!(key_a.last_used_at_unix_secs, None);
|
||||||
|
|
||||||
|
assert_eq!(key_b.request_count, Some(1));
|
||||||
|
assert_eq!(key_b.success_count, Some(1));
|
||||||
|
assert_eq!(key_b.total_tokens, 140);
|
||||||
|
assert_eq!(key_b.total_cost_usd, 0.6);
|
||||||
|
assert_eq!(key_b.total_response_time_ms, Some(150));
|
||||||
|
assert_eq!(key_b.last_used_at_unix_secs, Some(1_711_200_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_provider_key_usage_stats_resets_linked_catalog_to_current_usage() {
|
||||||
|
let provider_catalog = sample_provider_catalog_repository(&["provider-key-1"]);
|
||||||
|
let mut stale_key = provider_catalog
|
||||||
|
.list_keys_by_ids(&["provider-key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key list should succeed")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("provider key should exist");
|
||||||
|
stale_key.request_count = Some(99);
|
||||||
|
stale_key.success_count = Some(88);
|
||||||
|
stale_key.error_count = Some(11);
|
||||||
|
stale_key.total_tokens = 9_999;
|
||||||
|
stale_key.total_cost_usd = 42.0;
|
||||||
|
stale_key.total_response_time_ms = Some(9_999);
|
||||||
|
stale_key.last_used_at_unix_secs = Some(9_999);
|
||||||
|
provider_catalog
|
||||||
|
.update_key(&stale_key)
|
||||||
|
.await
|
||||||
|
.expect("stale key should update");
|
||||||
|
|
||||||
|
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||||
|
sample_usage("req-1", 1_711_300_000),
|
||||||
|
sample_usage("req-2", 1_711_300_250),
|
||||||
|
])
|
||||||
|
.with_provider_catalog_repository(Arc::clone(&provider_catalog));
|
||||||
|
|
||||||
|
let rebuilt = repository
|
||||||
|
.rebuild_provider_api_key_usage_stats()
|
||||||
|
.await
|
||||||
|
.expect("rebuild should succeed");
|
||||||
|
assert_eq!(rebuilt, 1);
|
||||||
|
|
||||||
|
let key = provider_catalog
|
||||||
|
.list_keys_by_ids(&["provider-key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key list should succeed")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("provider key should exist");
|
||||||
|
assert_eq!(key.request_count, Some(2));
|
||||||
|
assert_eq!(key.success_count, Some(2));
|
||||||
|
assert_eq!(key.error_count, Some(0));
|
||||||
|
assert_eq!(key.total_tokens, 300);
|
||||||
|
assert_eq!(key.total_cost_usd, 0.24);
|
||||||
|
assert_eq!(key.total_response_time_ms, Some(840));
|
||||||
|
assert_eq!(key.last_used_at_unix_secs, Some(1_711_300_250));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,85 @@ pub(crate) use aether_data_contracts::repository::usage::{
|
|||||||
pub use memory::InMemoryUsageReadRepository;
|
pub use memory::InMemoryUsageReadRepository;
|
||||||
pub use sql::SqlxUsageReadRepository;
|
pub use sql::SqlxUsageReadRepository;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub(crate) struct ProviderApiKeyUsageContribution {
|
||||||
|
pub key_id: String,
|
||||||
|
pub request_count: i64,
|
||||||
|
pub success_count: i64,
|
||||||
|
pub error_count: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub total_response_time_ms: i64,
|
||||||
|
pub last_used_at_unix_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub(crate) struct ProviderApiKeyUsageDelta {
|
||||||
|
pub request_count: i64,
|
||||||
|
pub success_count: i64,
|
||||||
|
pub error_count: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub total_response_time_ms: i64,
|
||||||
|
pub candidate_last_used_at_unix_secs: Option<u64>,
|
||||||
|
pub removed_last_used_at_unix_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderApiKeyUsageDelta {
|
||||||
|
pub(crate) fn between(
|
||||||
|
before: &ProviderApiKeyUsageContribution,
|
||||||
|
after: &ProviderApiKeyUsageContribution,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
request_count: after.request_count - before.request_count,
|
||||||
|
success_count: after.success_count - before.success_count,
|
||||||
|
error_count: after.error_count - before.error_count,
|
||||||
|
total_tokens: after.total_tokens - before.total_tokens,
|
||||||
|
total_cost_usd: after.total_cost_usd - before.total_cost_usd,
|
||||||
|
total_response_time_ms: after.total_response_time_ms - before.total_response_time_ms,
|
||||||
|
candidate_last_used_at_unix_secs: after.last_used_at_unix_secs,
|
||||||
|
removed_last_used_at_unix_secs: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addition(after: &ProviderApiKeyUsageContribution) -> Self {
|
||||||
|
Self {
|
||||||
|
request_count: after.request_count,
|
||||||
|
success_count: after.success_count,
|
||||||
|
error_count: after.error_count,
|
||||||
|
total_tokens: after.total_tokens,
|
||||||
|
total_cost_usd: after.total_cost_usd,
|
||||||
|
total_response_time_ms: after.total_response_time_ms,
|
||||||
|
candidate_last_used_at_unix_secs: after.last_used_at_unix_secs,
|
||||||
|
removed_last_used_at_unix_secs: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn removal(before: &ProviderApiKeyUsageContribution) -> Self {
|
||||||
|
Self {
|
||||||
|
request_count: -before.request_count,
|
||||||
|
success_count: -before.success_count,
|
||||||
|
error_count: -before.error_count,
|
||||||
|
total_tokens: -before.total_tokens,
|
||||||
|
total_cost_usd: -before.total_cost_usd,
|
||||||
|
total_response_time_ms: -before.total_response_time_ms,
|
||||||
|
candidate_last_used_at_unix_secs: None,
|
||||||
|
removed_last_used_at_unix_secs: before.last_used_at_unix_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_noop(&self) -> bool {
|
||||||
|
self.request_count == 0
|
||||||
|
&& self.success_count == 0
|
||||||
|
&& self.error_count == 0
|
||||||
|
&& self.total_tokens == 0
|
||||||
|
&& self.total_cost_usd == 0.0
|
||||||
|
&& self.total_response_time_ms == 0
|
||||||
|
&& self.candidate_last_used_at_unix_secs.is_none()
|
||||||
|
&& self.removed_last_used_at_unix_secs.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn incoming_usage_can_recover_terminal_failure(
|
pub(crate) fn incoming_usage_can_recover_terminal_failure(
|
||||||
incoming_status: &str,
|
incoming_status: &str,
|
||||||
incoming_billing_status: &str,
|
incoming_billing_status: &str,
|
||||||
@@ -57,11 +136,77 @@ pub(crate) fn strip_deprecated_usage_display_fields(
|
|||||||
usage
|
usage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_api_key_usage_is_success(
|
||||||
|
status: &str,
|
||||||
|
status_code: Option<u16>,
|
||||||
|
error_message: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
"completed" | "success" | "ok" | "billed" | "settled"
|
||||||
|
) && status_code.is_none_or(|code| code < 400)
|
||||||
|
&& error_message.is_none_or(|value| value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_api_key_usage_is_error(
|
||||||
|
status: &str,
|
||||||
|
status_code: Option<u16>,
|
||||||
|
error_message: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
!matches!(status, "pending" | "streaming")
|
||||||
|
&& !provider_api_key_usage_is_success(status, status_code, error_message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_api_key_usage_contribution(
|
||||||
|
usage: &StoredRequestUsageAudit,
|
||||||
|
) -> Option<ProviderApiKeyUsageContribution> {
|
||||||
|
let key_id = usage
|
||||||
|
.provider_api_key_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())?
|
||||||
|
.to_string();
|
||||||
|
let is_success = provider_api_key_usage_is_success(
|
||||||
|
usage.status.as_str(),
|
||||||
|
usage.status_code,
|
||||||
|
usage.error_message.as_deref(),
|
||||||
|
);
|
||||||
|
let is_error = provider_api_key_usage_is_error(
|
||||||
|
usage.status.as_str(),
|
||||||
|
usage.status_code,
|
||||||
|
usage.error_message.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(ProviderApiKeyUsageContribution {
|
||||||
|
key_id,
|
||||||
|
request_count: 1,
|
||||||
|
success_count: i64::from(is_success),
|
||||||
|
error_count: i64::from(is_error),
|
||||||
|
total_tokens: i64::try_from(usage.total_tokens).unwrap_or(i64::MAX),
|
||||||
|
total_cost_usd: if usage.total_cost_usd.is_finite() {
|
||||||
|
usage.total_cost_usd.max(0.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
total_response_time_ms: if is_success {
|
||||||
|
usage
|
||||||
|
.response_time_ms
|
||||||
|
.and_then(|value| i64::try_from(value).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
last_used_at_unix_secs: Some(usage.created_at_unix_ms),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
incoming_usage_can_recover_terminal_failure, strip_deprecated_usage_display_fields,
|
incoming_usage_can_recover_terminal_failure, provider_api_key_usage_contribution,
|
||||||
usage_can_recover_terminal_failure, UpsertUsageRecord,
|
provider_api_key_usage_is_error, provider_api_key_usage_is_success,
|
||||||
|
strip_deprecated_usage_display_fields, usage_can_recover_terminal_failure,
|
||||||
|
StoredRequestUsageAudit, UpsertUsageRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -187,4 +332,98 @@ mod tests {
|
|||||||
"failed", "void", "failed", "void"
|
"failed", "void", "failed", "void"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_usage_success_requires_clean_terminal_success() {
|
||||||
|
assert!(provider_api_key_usage_is_success(
|
||||||
|
"completed",
|
||||||
|
Some(200),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
assert!(!provider_api_key_usage_is_success(
|
||||||
|
"completed",
|
||||||
|
Some(500),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
assert!(!provider_api_key_usage_is_success(
|
||||||
|
"completed",
|
||||||
|
Some(200),
|
||||||
|
Some("boom")
|
||||||
|
));
|
||||||
|
assert!(!provider_api_key_usage_is_success(
|
||||||
|
"streaming",
|
||||||
|
Some(200),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_usage_error_ignores_pending_states() {
|
||||||
|
assert!(provider_api_key_usage_is_error(
|
||||||
|
"failed",
|
||||||
|
Some(500),
|
||||||
|
Some("boom")
|
||||||
|
));
|
||||||
|
assert!(provider_api_key_usage_is_error(
|
||||||
|
"completed",
|
||||||
|
Some(200),
|
||||||
|
Some("boom")
|
||||||
|
));
|
||||||
|
assert!(!provider_api_key_usage_is_error("pending", None, None));
|
||||||
|
assert!(!provider_api_key_usage_is_error("streaming", None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_usage_contribution_tracks_success_response_time() {
|
||||||
|
let usage = StoredRequestUsageAudit::new(
|
||||||
|
"usage-1".to_string(),
|
||||||
|
"request-1".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"OpenAI".to_string(),
|
||||||
|
"gpt-5".to_string(),
|
||||||
|
None,
|
||||||
|
Some("provider-1".to_string()),
|
||||||
|
None,
|
||||||
|
Some("provider-key-1".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
20,
|
||||||
|
0.25,
|
||||||
|
0.25,
|
||||||
|
Some(200),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(120),
|
||||||
|
None,
|
||||||
|
"completed".to_string(),
|
||||||
|
"settled".to_string(),
|
||||||
|
123,
|
||||||
|
124,
|
||||||
|
Some(125),
|
||||||
|
)
|
||||||
|
.expect("usage should build");
|
||||||
|
|
||||||
|
let contribution =
|
||||||
|
provider_api_key_usage_contribution(&usage).expect("contribution should exist");
|
||||||
|
assert_eq!(contribution.key_id, "provider-key-1");
|
||||||
|
assert_eq!(contribution.request_count, 1);
|
||||||
|
assert_eq!(contribution.success_count, 1);
|
||||||
|
assert_eq!(contribution.error_count, 0);
|
||||||
|
assert_eq!(contribution.total_tokens, 20);
|
||||||
|
assert_eq!(contribution.total_cost_usd, 0.25);
|
||||||
|
assert_eq!(contribution.total_response_time_ms, 120);
|
||||||
|
assert_eq!(contribution.last_used_at_unix_secs, Some(123));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use std::io::{Read, Write};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
incoming_usage_can_recover_terminal_failure, strip_deprecated_usage_display_fields,
|
incoming_usage_can_recover_terminal_failure, provider_api_key_usage_contribution,
|
||||||
|
strip_deprecated_usage_display_fields, ProviderApiKeyUsageDelta,
|
||||||
StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary, StoredRequestUsageAudit,
|
StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary, StoredRequestUsageAudit,
|
||||||
StoredUsageDailySummary, UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery,
|
StoredUsageDailySummary, UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery,
|
||||||
UsageReadRepository, UsageWriteRepository,
|
UsageReadRepository, UsageWriteRepository,
|
||||||
@@ -78,6 +79,9 @@ SET
|
|||||||
WHERE request_id = $1
|
WHERE request_id = $1
|
||||||
AND billing_status = 'void'
|
AND billing_status = 'void'
|
||||||
"#;
|
"#;
|
||||||
|
const LOCK_USAGE_REQUEST_ID_SQL: &str = r#"
|
||||||
|
SELECT pg_advisory_xact_lock(hashtext($1)::BIGINT)
|
||||||
|
"#;
|
||||||
const UPSERT_USAGE_HTTP_AUDIT_SQL: &str = r#"
|
const UPSERT_USAGE_HTTP_AUDIT_SQL: &str = r#"
|
||||||
INSERT INTO usage_http_audits (
|
INSERT INTO usage_http_audits (
|
||||||
request_id,
|
request_id,
|
||||||
@@ -563,6 +567,112 @@ GROUP BY provider_api_key_id
|
|||||||
ORDER BY provider_api_key_id ASC
|
ORDER BY provider_api_key_id ASC
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const APPLY_PROVIDER_API_KEY_USAGE_DELTA_SQL: &str = r#"
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
request_count = GREATEST(COALESCE(request_count, 0) + $2, 0),
|
||||||
|
success_count = GREATEST(COALESCE(success_count, 0) + $3, 0),
|
||||||
|
error_count = GREATEST(COALESCE(error_count, 0) + $4, 0),
|
||||||
|
total_tokens = GREATEST(total_tokens + $5, 0),
|
||||||
|
total_cost_usd = CAST(
|
||||||
|
GREATEST(CAST(total_cost_usd AS DOUBLE PRECISION) + $6, 0) AS NUMERIC(20,8)
|
||||||
|
),
|
||||||
|
total_response_time_ms = GREATEST(COALESCE(total_response_time_ms, 0) + $7, 0),
|
||||||
|
last_used_at = CASE
|
||||||
|
WHEN $8::double precision IS NOT NULL THEN CASE
|
||||||
|
WHEN last_used_at IS NULL THEN TO_TIMESTAMP($8::double precision)
|
||||||
|
ELSE GREATEST(last_used_at, TO_TIMESTAMP($8::double precision))
|
||||||
|
END
|
||||||
|
WHEN $9::double precision IS NOT NULL
|
||||||
|
AND last_used_at IS NOT NULL
|
||||||
|
AND EXTRACT(EPOCH FROM last_used_at)::BIGINT = $9::BIGINT
|
||||||
|
THEN (
|
||||||
|
SELECT MAX(created_at)
|
||||||
|
FROM "usage"
|
||||||
|
WHERE provider_api_key_id = $1
|
||||||
|
)
|
||||||
|
ELSE last_used_at
|
||||||
|
END
|
||||||
|
WHERE id = $1
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const RESET_PROVIDER_API_KEY_USAGE_STATS_SQL: &str = r#"
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
request_count = 0,
|
||||||
|
success_count = 0,
|
||||||
|
error_count = 0,
|
||||||
|
total_tokens = 0,
|
||||||
|
total_cost_usd = 0,
|
||||||
|
total_response_time_ms = 0,
|
||||||
|
last_used_at = NULL
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL: &str = r#"
|
||||||
|
WITH aggregated AS (
|
||||||
|
SELECT
|
||||||
|
provider_api_key_id,
|
||||||
|
COUNT(*)::INTEGER AS request_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN status IN ('completed', 'success', 'ok', 'billed', 'settled')
|
||||||
|
AND (status_code IS NULL OR status_code < 400)
|
||||||
|
AND NULLIF(BTRIM(error_message), '') IS NULL
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::INTEGER AS success_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN status NOT IN ('pending', 'streaming')
|
||||||
|
AND NOT (
|
||||||
|
status IN ('completed', 'success', 'ok', 'billed', 'settled')
|
||||||
|
AND (status_code IS NULL OR status_code < 400)
|
||||||
|
AND NULLIF(BTRIM(error_message), '') IS NULL
|
||||||
|
)
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::INTEGER AS error_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
GREATEST(
|
||||||
|
COALESCE(
|
||||||
|
total_tokens,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)::BIGINT
|
||||||
|
), 0)::BIGINT AS total_tokens,
|
||||||
|
COALESCE(SUM(COALESCE(total_cost_usd, 0)), 0)::NUMERIC(20,8) AS total_cost_usd,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN status IN ('completed', 'success', 'ok', 'billed', 'settled')
|
||||||
|
AND (status_code IS NULL OR status_code < 400)
|
||||||
|
AND NULLIF(BTRIM(error_message), '') IS NULL
|
||||||
|
AND response_time_ms IS NOT NULL
|
||||||
|
THEN GREATEST(response_time_ms, 0)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::INTEGER AS total_response_time_ms,
|
||||||
|
MAX(created_at) AS last_used_at
|
||||||
|
FROM "usage"
|
||||||
|
WHERE provider_api_key_id IS NOT NULL
|
||||||
|
AND BTRIM(provider_api_key_id) <> ''
|
||||||
|
GROUP BY provider_api_key_id
|
||||||
|
)
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
request_count = aggregated.request_count,
|
||||||
|
success_count = aggregated.success_count,
|
||||||
|
error_count = aggregated.error_count,
|
||||||
|
total_tokens = aggregated.total_tokens,
|
||||||
|
total_cost_usd = aggregated.total_cost_usd,
|
||||||
|
total_response_time_ms = aggregated.total_response_time_ms,
|
||||||
|
last_used_at = aggregated.last_used_at
|
||||||
|
FROM aggregated
|
||||||
|
WHERE provider_api_keys.id = aggregated.provider_api_key_id
|
||||||
|
"#;
|
||||||
|
|
||||||
const LIST_USAGE_AUDITS_PREFIX: &str = r#"
|
const LIST_USAGE_AUDITS_PREFIX: &str = r#"
|
||||||
SELECT
|
SELECT
|
||||||
"usage".id,
|
"usage".id,
|
||||||
@@ -3971,6 +4081,8 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision)"#,
|
|||||||
self.tx_runner
|
self.tx_runner
|
||||||
.run_read_write(|tx| {
|
.run_read_write(|tx| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
lock_usage_request_id_in_tx(tx, &usage.request_id).await?;
|
||||||
|
|
||||||
if incoming_usage_can_recover_terminal_failure(
|
if incoming_usage_can_recover_terminal_failure(
|
||||||
usage.status.as_str(),
|
usage.status.as_str(),
|
||||||
usage.billing_status.as_str(),
|
usage.billing_status.as_str(),
|
||||||
@@ -3987,6 +4099,9 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision)"#,
|
|||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previous_usage =
|
||||||
|
find_usage_by_request_id_in_tx(tx, &usage.request_id).await?;
|
||||||
|
|
||||||
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
||||||
let request_body_storage =
|
let request_body_storage =
|
||||||
prepare_usage_body_storage(usage.request_body.as_ref())?;
|
prepare_usage_body_storage(usage.request_body.as_ref())?;
|
||||||
@@ -4292,11 +4407,66 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision)"#,
|
|||||||
routing_snapshot.local_execution_runtime_miss_reason.clone();
|
routing_snapshot.local_execution_runtime_miss_reason.clone();
|
||||||
stored.output_price_per_1m = settlement_pricing_snapshot.output_price_per_1m;
|
stored.output_price_per_1m = settlement_pricing_snapshot.output_price_per_1m;
|
||||||
stored.request_metadata = request_metadata_value;
|
stored.request_metadata = request_metadata_value;
|
||||||
|
|
||||||
|
let before_contribution = previous_usage
|
||||||
|
.as_ref()
|
||||||
|
.and_then(provider_api_key_usage_contribution);
|
||||||
|
let after_contribution = provider_api_key_usage_contribution(&stored);
|
||||||
|
match (before_contribution.as_ref(), after_contribution.as_ref()) {
|
||||||
|
(Some(before), Some(after)) if before.key_id == after.key_id => {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::between(before, after);
|
||||||
|
apply_provider_api_key_usage_delta_in_tx(
|
||||||
|
tx,
|
||||||
|
before.key_id.as_str(),
|
||||||
|
&delta,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(before) = before_contribution.as_ref() {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::removal(before);
|
||||||
|
apply_provider_api_key_usage_delta_in_tx(
|
||||||
|
tx,
|
||||||
|
before.key_id.as_str(),
|
||||||
|
&delta,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(after) = after_contribution.as_ref() {
|
||||||
|
let delta = ProviderApiKeyUsageDelta::addition(after);
|
||||||
|
apply_provider_api_key_usage_delta_in_tx(
|
||||||
|
tx,
|
||||||
|
after.key_id.as_str(),
|
||||||
|
&delta,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(stored)
|
Ok(stored)
|
||||||
}) as BoxFuture<'_, Result<StoredRequestUsageAudit, DataLayerError>>
|
}) as BoxFuture<'_, Result<StoredRequestUsageAudit, DataLayerError>>
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
|
||||||
|
self.tx_runner
|
||||||
|
.run_read_write(|tx| {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query(RESET_PROVIDER_API_KEY_USAGE_STATS_SQL)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
let rows_affected = sqlx::query(REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(rows_affected)
|
||||||
|
}) as BoxFuture<'_, Result<u64, DataLayerError>>
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -4517,6 +4687,97 @@ impl UsageWriteRepository for SqlxUsageReadRepository {
|
|||||||
) -> Result<StoredRequestUsageAudit, DataLayerError> {
|
) -> Result<StoredRequestUsageAudit, DataLayerError> {
|
||||||
Self::upsert(self, usage).await
|
Self::upsert(self, usage).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
|
||||||
|
Self::rebuild_provider_api_key_usage_stats(self).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_usage_by_request_id_in_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||||
|
request_id: &str,
|
||||||
|
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
|
||||||
|
sqlx::query(FIND_BY_REQUEST_ID_SQL)
|
||||||
|
.bind(request_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?
|
||||||
|
.map(|row| map_usage_row(&row, false))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lock_usage_request_id_in_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||||
|
request_id: &str,
|
||||||
|
) -> Result<(), DataLayerError> {
|
||||||
|
sqlx::query(LOCK_USAGE_REQUEST_ID_SQL)
|
||||||
|
.bind(request_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_provider_api_key_usage_delta_in_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||||
|
key_id: &str,
|
||||||
|
delta: &ProviderApiKeyUsageDelta,
|
||||||
|
) -> Result<(), DataLayerError> {
|
||||||
|
if key_id.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if delta.is_noop() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_cost_usd_delta = if delta.total_cost_usd.is_finite() {
|
||||||
|
delta.total_cost_usd
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(APPLY_PROVIDER_API_KEY_USAGE_DELTA_SQL)
|
||||||
|
.bind(key_id)
|
||||||
|
.bind(i32::try_from(delta.request_count).map_err(|_| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"provider_api_keys.request_count delta exceeds i32: {}",
|
||||||
|
delta.request_count
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.bind(i32::try_from(delta.success_count).map_err(|_| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"provider_api_keys.success_count delta exceeds i32: {}",
|
||||||
|
delta.success_count
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.bind(i32::try_from(delta.error_count).map_err(|_| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"provider_api_keys.error_count delta exceeds i32: {}",
|
||||||
|
delta.error_count
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.bind(delta.total_tokens)
|
||||||
|
.bind(total_cost_usd_delta)
|
||||||
|
.bind(i32::try_from(delta.total_response_time_ms).map_err(|_| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"provider_api_keys.total_response_time_ms delta exceeds i32: {}",
|
||||||
|
delta.total_response_time_ms
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.bind(
|
||||||
|
delta
|
||||||
|
.candidate_last_used_at_unix_secs
|
||||||
|
.map(|value| value as f64),
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
delta
|
||||||
|
.removed_last_used_at_unix_secs
|
||||||
|
.map(|value| value as f64),
|
||||||
|
)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the usage read model from the split storage layout.
|
// Build the usage read model from the split storage layout.
|
||||||
@@ -5848,6 +6109,26 @@ mod tests {
|
|||||||
assert!(super::SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL.contains("ANY($1::TEXT[])"));
|
assert!(super::SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL.contains("ANY($1::TEXT[])"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_sql_serializes_request_id_upserts_before_reading_previous_usage() {
|
||||||
|
assert!(super::LOCK_USAGE_REQUEST_ID_SQL.contains("pg_advisory_xact_lock"));
|
||||||
|
assert!(super::LOCK_USAGE_REQUEST_ID_SQL.contains("hashtext($1)::BIGINT"));
|
||||||
|
assert!(include_str!("sql.rs")
|
||||||
|
.contains("lock_usage_request_id_in_tx(tx, &usage.request_id).await?;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_sql_rebuild_matches_online_provider_key_usage_semantics() {
|
||||||
|
assert!(super::REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL
|
||||||
|
.contains("NULLIF(BTRIM(error_message), '') IS NULL"));
|
||||||
|
assert!(super::REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL.contains("COALESCE("));
|
||||||
|
assert!(super::REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL.contains("total_tokens,"));
|
||||||
|
assert!(super::REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL
|
||||||
|
.contains("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)"));
|
||||||
|
assert!(super::REBUILD_PROVIDER_API_KEY_USAGE_STATS_SQL
|
||||||
|
.contains("AND BTRIM(provider_api_key_id) <> ''"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_sql_supports_recent_usage_audits_query() {
|
fn usage_sql_supports_recent_usage_audits_query() {
|
||||||
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\""));
|
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\""));
|
||||||
|
|||||||
Reference in New Issue
Block a user