mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: combine usage quota and pool stats updates
This commit is contained in:
@@ -247,6 +247,23 @@ pub fn admin_pool_key_account_quota_exhausted(
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
"chatgpt_web" => {
|
||||
if admin_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) {
|
||||
return true;
|
||||
}
|
||||
if admin_pool_json_f64(bucket.get("image_quota_remaining"))
|
||||
.is_some_and(|value| value <= 0.0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match (
|
||||
admin_pool_json_f64(bucket.get("image_quota_total")),
|
||||
admin_pool_json_f64(bucket.get("image_quota_used")),
|
||||
) {
|
||||
(Some(limit), Some(used)) if limit > 0.0 => used >= limit,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,9 +655,227 @@ pub fn parse_kiro_usage_response(
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
|
||||
coerce_json_string(
|
||||
value
|
||||
.get("feature_name")
|
||||
.or_else(|| value.get("featureName"))
|
||||
.or_else(|| value.get("feature"))
|
||||
.or_else(|| value.get("name")),
|
||||
)
|
||||
}
|
||||
|
||||
fn chatgpt_web_is_image_quota_feature(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"image_gen" | "image_generation" | "image_edit" | "img_gen"
|
||||
)
|
||||
}
|
||||
|
||||
fn chatgpt_web_feature_number(feature: &serde_json::Value, fields: &[&str]) -> Option<f64> {
|
||||
fields
|
||||
.iter()
|
||||
.find_map(|field| feature.get(*field).and_then(coerce_json_f64))
|
||||
}
|
||||
|
||||
fn parse_chatgpt_web_reset_timestamp(
|
||||
value: Option<&serde_json::Value>,
|
||||
observed_at: u64,
|
||||
) -> Option<u64> {
|
||||
let value = value?;
|
||||
if let Some(text) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
|
||||
return u64::try_from(parsed.timestamp()).ok();
|
||||
}
|
||||
if let Ok(parsed) = text.parse::<f64>() {
|
||||
return normalize_chatgpt_web_numeric_reset(parsed, observed_at);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
value
|
||||
.as_f64()
|
||||
.and_then(|parsed| normalize_chatgpt_web_numeric_reset(parsed, observed_at))
|
||||
}
|
||||
|
||||
fn normalize_chatgpt_web_numeric_reset(value: f64, observed_at: u64) -> Option<u64> {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if value > 1_000_000_000_000.0 {
|
||||
return Some((value / 1000.0).floor() as u64);
|
||||
}
|
||||
if value > 1_000_000_000.0 {
|
||||
return Some(value.floor() as u64);
|
||||
}
|
||||
Some(observed_at.saturating_add(value.floor() as u64))
|
||||
}
|
||||
|
||||
fn chatgpt_web_blocked_features(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
.get("blocked_features")
|
||||
.or_else(|| value.get("blockedFeatures"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn parse_chatgpt_web_conversation_init_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let root = value.as_object()?;
|
||||
let limits_progress = root
|
||||
.get("limits_progress")
|
||||
.or_else(|| root.get("limitsProgress"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let image_limit = limits_progress
|
||||
.iter()
|
||||
.find(|item| {
|
||||
chatgpt_web_quota_feature_name(item)
|
||||
.as_deref()
|
||||
.is_some_and(chatgpt_web_is_image_quota_feature)
|
||||
})
|
||||
.cloned();
|
||||
let blocked_features = chatgpt_web_blocked_features(value);
|
||||
let image_blocked = blocked_features
|
||||
.iter()
|
||||
.any(|feature| chatgpt_web_is_image_quota_feature(feature));
|
||||
|
||||
if image_limit.is_none() && !image_blocked {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
|
||||
if let Some(default_model_slug) = coerce_json_string(
|
||||
root.get("default_model_slug")
|
||||
.or_else(|| root.get("defaultModelSlug")),
|
||||
) {
|
||||
result.insert("default_model_slug".to_string(), json!(default_model_slug));
|
||||
}
|
||||
if let Some(plan_type) = coerce_json_string(
|
||||
root.get("plan_type")
|
||||
.or_else(|| root.get("planType"))
|
||||
.or_else(|| root.get("subscription_plan")),
|
||||
) {
|
||||
result.insert(
|
||||
"plan_type".to_string(),
|
||||
json!(plan_type.to_ascii_lowercase()),
|
||||
);
|
||||
}
|
||||
result.insert("blocked_features".to_string(), json!(blocked_features));
|
||||
result.insert(
|
||||
"limits_progress".to_string(),
|
||||
serde_json::Value::Array(limits_progress),
|
||||
);
|
||||
|
||||
if image_blocked {
|
||||
result.insert("image_quota_blocked".to_string(), json!(true));
|
||||
}
|
||||
|
||||
if let Some(image_limit) = image_limit.as_ref() {
|
||||
if let Some(feature_name) = chatgpt_web_quota_feature_name(image_limit) {
|
||||
result.insert("image_quota_feature_name".to_string(), json!(feature_name));
|
||||
}
|
||||
|
||||
let remaining = chatgpt_web_feature_number(
|
||||
image_limit,
|
||||
&[
|
||||
"remaining",
|
||||
"remaining_value",
|
||||
"remainingValue",
|
||||
"remaining_count",
|
||||
"remainingCount",
|
||||
],
|
||||
);
|
||||
let total = chatgpt_web_feature_number(
|
||||
image_limit,
|
||||
&[
|
||||
"max_value",
|
||||
"maxValue",
|
||||
"cap",
|
||||
"total",
|
||||
"limit",
|
||||
"quota",
|
||||
"usage_limit",
|
||||
"usageLimit",
|
||||
],
|
||||
);
|
||||
let used = chatgpt_web_feature_number(
|
||||
image_limit,
|
||||
&[
|
||||
"used",
|
||||
"used_value",
|
||||
"usedValue",
|
||||
"consumed",
|
||||
"current_usage",
|
||||
"currentUsage",
|
||||
],
|
||||
)
|
||||
.or_else(|| {
|
||||
total
|
||||
.zip(remaining)
|
||||
.map(|(total, remaining)| (total - remaining).max(0.0))
|
||||
});
|
||||
let reset_source = image_limit
|
||||
.get("reset_at")
|
||||
.or_else(|| image_limit.get("resetAt"))
|
||||
.or_else(|| image_limit.get("next_reset_at"))
|
||||
.or_else(|| image_limit.get("nextResetAt"))
|
||||
.or_else(|| image_limit.get("reset_after"))
|
||||
.or_else(|| image_limit.get("resetAfter"));
|
||||
let reset_at = parse_chatgpt_web_reset_timestamp(reset_source, updated_at_unix_secs);
|
||||
|
||||
if let Some(remaining) = remaining {
|
||||
result.insert("image_quota_remaining".to_string(), json!(remaining));
|
||||
} else if image_blocked {
|
||||
result.insert("image_quota_remaining".to_string(), json!(0.0));
|
||||
}
|
||||
if let Some(total) = total {
|
||||
result.insert("image_quota_total".to_string(), json!(total));
|
||||
}
|
||||
if let Some(used) = used {
|
||||
result.insert("image_quota_used".to_string(), json!(used));
|
||||
}
|
||||
if let Some(reset_at) = reset_at {
|
||||
result.insert("image_quota_reset_at".to_string(), json!(reset_at));
|
||||
}
|
||||
if let Some(reset_after) = coerce_json_string(
|
||||
image_limit
|
||||
.get("reset_after")
|
||||
.or_else(|| image_limit.get("resetAfter")),
|
||||
) {
|
||||
result.insert("image_quota_reset_after".to_string(), json!(reset_after));
|
||||
}
|
||||
} else if image_blocked {
|
||||
result.insert("image_quota_remaining".to_string(), json!(0.0));
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{codex_runtime_invalid_reason, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX};
|
||||
use super::{
|
||||
codex_runtime_invalid_reason, parse_chatgpt_web_conversation_init_response,
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn codex_runtime_invalid_reason_marks_401_as_expired() {
|
||||
@@ -681,4 +899,45 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_ignores_generic_403() {
|
||||
assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
&json!({
|
||||
"default_model_slug": "auto",
|
||||
"blocked_features": [],
|
||||
"limits_progress": [
|
||||
{
|
||||
"feature_name": "image_gen",
|
||||
"remaining": 24,
|
||||
"reset_after": "2026-05-07T12:32:52.826482+00:00"
|
||||
}
|
||||
]
|
||||
}),
|
||||
1_778_067_246,
|
||||
)
|
||||
.expect("chatgpt web quota should parse");
|
||||
|
||||
assert_eq!(parsed.get("default_model_slug"), Some(&json!("auto")));
|
||||
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(24.0)));
|
||||
assert_eq!(
|
||||
parsed.get("image_quota_reset_at"),
|
||||
Some(&json!(1_778_157_172u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_web_blocked_image_feature_as_zero_remaining() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
&json!({
|
||||
"blocked_features": ["image_generation"],
|
||||
"limits_progress": []
|
||||
}),
|
||||
1_778_067_246,
|
||||
)
|
||||
.expect("blocked image feature should produce metadata");
|
||||
|
||||
assert_eq!(parsed.get("image_quota_blocked"), Some(&json!(true)));
|
||||
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ mod types;
|
||||
|
||||
pub use types::{
|
||||
parse_usage_body_ref, usage_body_ref, PendingUsageCleanupSummary,
|
||||
StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||
ProviderApiKeyWindowUsageRequest, StoredProviderApiKeyUsageSummary,
|
||||
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
|
||||
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
|
||||
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
|
||||
|
||||
@@ -619,6 +619,23 @@ pub struct StoredProviderApiKeyUsageSummary {
|
||||
pub last_used_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProviderApiKeyWindowUsageRequest {
|
||||
pub provider_api_key_id: String,
|
||||
pub window_code: String,
|
||||
pub start_unix_secs: u64,
|
||||
pub end_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredProviderApiKeyWindowUsageSummary {
|
||||
pub provider_api_key_id: String,
|
||||
pub window_code: String,
|
||||
pub request_count: u64,
|
||||
pub total_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UsageAuditListQuery {
|
||||
pub created_from_unix_secs: Option<u64>,
|
||||
@@ -1479,6 +1496,11 @@ pub trait UsageReadRepository: Send + Sync {
|
||||
crate::DataLayerError,
|
||||
>;
|
||||
|
||||
async fn summarize_usage_by_provider_api_key_windows(
|
||||
&self,
|
||||
requests: &[ProviderApiKeyWindowUsageRequest],
|
||||
) -> Result<Vec<StoredProviderApiKeyWindowUsageSummary>, crate::DataLayerError>;
|
||||
|
||||
async fn summarize_provider_usage_since(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
|
||||
@@ -30,9 +30,10 @@ use super::{
|
||||
api_key_usage_contribution, provider_api_key_usage_contribution,
|
||||
strip_deprecated_usage_display_fields, usage_can_recover_terminal_failure,
|
||||
ApiKeyUsageContribution, ApiKeyUsageDelta, ProviderApiKeyUsageContribution,
|
||||
ProviderApiKeyUsageDelta, StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary,
|
||||
StoredProviderUsageWindow, StoredRequestUsageAudit, StoredUsageDailySummary, UpsertUsageRecord,
|
||||
UsageAuditListQuery, UsageDailyHeatmapQuery, UsageReadRepository, UsageWriteRepository,
|
||||
ProviderApiKeyUsageDelta, ProviderApiKeyWindowUsageRequest, StoredProviderApiKeyUsageSummary,
|
||||
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||
StoredRequestUsageAudit, StoredUsageDailySummary, UpsertUsageRecord, UsageAuditListQuery,
|
||||
UsageDailyHeatmapQuery, UsageReadRepository, UsageWriteRepository,
|
||||
};
|
||||
use crate::repository::auth::InMemoryAuthApiKeySnapshotRepository;
|
||||
use crate::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
@@ -2327,6 +2328,59 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
async fn summarize_usage_by_provider_api_key_windows(
|
||||
&self,
|
||||
requests: &[ProviderApiKeyWindowUsageRequest],
|
||||
) -> Result<Vec<StoredProviderApiKeyWindowUsageSummary>, DataLayerError> {
|
||||
let usage = self.by_request_id.read().expect("usage repository lock");
|
||||
let mut summaries = Vec::with_capacity(requests.len());
|
||||
|
||||
for request in requests {
|
||||
let provider_api_key_id = request.provider_api_key_id.trim();
|
||||
if provider_api_key_id.is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage provider_api_key_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let window_code = request.window_code.trim();
|
||||
if window_code.is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage window_code cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if request.start_unix_secs >= request.end_unix_secs {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage range must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut summary = StoredProviderApiKeyWindowUsageSummary {
|
||||
provider_api_key_id: provider_api_key_id.to_string(),
|
||||
window_code: window_code.to_string(),
|
||||
..StoredProviderApiKeyWindowUsageSummary::default()
|
||||
};
|
||||
|
||||
for item in usage.values() {
|
||||
if item.provider_api_key_id.as_deref() != Some(provider_api_key_id) {
|
||||
continue;
|
||||
}
|
||||
if item.created_at_unix_ms < request.start_unix_secs
|
||||
|| item.created_at_unix_ms >= request.end_unix_secs
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.request_count = summary.request_count.saturating_add(1);
|
||||
summary.total_tokens = summary.total_tokens.saturating_add(item.total_tokens);
|
||||
summary.total_cost_usd += item.total_cost_usd;
|
||||
}
|
||||
|
||||
summaries.push(summary);
|
||||
}
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
async fn summarize_provider_usage_since(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
@@ -2934,8 +2988,9 @@ mod tests {
|
||||
UsageWriteRepository,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
usage_body_ref, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageBodyField,
|
||||
UsageProviderPerformanceQuery, UsageTimeSeriesGranularity,
|
||||
usage_body_ref, ProviderApiKeyWindowUsageRequest, UsageAuditAggregationGroupBy,
|
||||
UsageAuditAggregationQuery, UsageBodyField, UsageProviderPerformanceQuery,
|
||||
UsageTimeSeriesGranularity,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -4533,6 +4588,44 @@ mod tests {
|
||||
assert_eq!(item.last_used_at_unix_secs, Some(1_711_000_250));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarizes_provider_api_key_window_usage_with_zero_rows() {
|
||||
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||
sample_usage("req-1", 1_711_000_000),
|
||||
sample_usage("req-2", 1_711_000_250),
|
||||
]);
|
||||
|
||||
let usage = repository
|
||||
.summarize_usage_by_provider_api_key_windows(&[
|
||||
ProviderApiKeyWindowUsageRequest {
|
||||
provider_api_key_id: "provider-key-1".to_string(),
|
||||
window_code: "5h".to_string(),
|
||||
start_unix_secs: 1_711_000_000,
|
||||
end_unix_secs: 1_711_000_300,
|
||||
},
|
||||
ProviderApiKeyWindowUsageRequest {
|
||||
provider_api_key_id: "provider-key-empty".to_string(),
|
||||
window_code: "weekly".to_string(),
|
||||
start_unix_secs: 1_711_000_000,
|
||||
end_unix_secs: 1_711_000_300,
|
||||
},
|
||||
])
|
||||
.await
|
||||
.expect("window summary should succeed");
|
||||
|
||||
assert_eq!(usage.len(), 2);
|
||||
assert_eq!(usage[0].provider_api_key_id, "provider-key-1");
|
||||
assert_eq!(usage[0].window_code, "5h");
|
||||
assert_eq!(usage[0].request_count, 2);
|
||||
assert_eq!(usage[0].total_tokens, 300);
|
||||
assert_eq!(usage[0].total_cost_usd, 0.24);
|
||||
assert_eq!(usage[1].provider_api_key_id, "provider-key-empty");
|
||||
assert_eq!(usage[1].window_code, "weekly");
|
||||
assert_eq!(usage[1].request_count, 0);
|
||||
assert_eq!(usage[1].total_tokens, 0);
|
||||
assert_eq!(usage[1].total_cost_usd, 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_usage_audits_applies_second_based_time_filters() {
|
||||
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||
|
||||
@@ -319,6 +319,17 @@ macro_rules! impl_materialized_usage_read_repository {
|
||||
<$crate::repository::usage::InMemoryUsageReadRepository as $crate::repository::usage::UsageReadRepository>::summarize_usage_by_provider_api_key_ids(&repository, provider_api_key_ids).await
|
||||
}
|
||||
|
||||
async fn summarize_usage_by_provider_api_key_windows(
|
||||
&self,
|
||||
requests: &[$crate::repository::usage::ProviderApiKeyWindowUsageRequest],
|
||||
) -> Result<
|
||||
Vec<$crate::repository::usage::StoredProviderApiKeyWindowUsageSummary>,
|
||||
$crate::DataLayerError,
|
||||
> {
|
||||
let repository = self.materialize_read_model().await?;
|
||||
<$crate::repository::usage::InMemoryUsageReadRepository as $crate::repository::usage::UsageReadRepository>::summarize_usage_by_provider_api_key_windows(&repository, requests).await
|
||||
}
|
||||
|
||||
async fn summarize_provider_usage_since(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
@@ -352,9 +363,10 @@ mod sqlite;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use aether_data_contracts::repository::usage::{
|
||||
PendingUsageCleanupSummary, StoredProviderApiKeyUsageSummary, StoredProviderUsageSummary,
|
||||
StoredProviderUsageWindow, StoredRequestUsageAudit, StoredUsageAuditAggregation,
|
||||
StoredUsageAuditSummary, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
|
||||
PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest, StoredProviderApiKeyUsageSummary,
|
||||
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
|
||||
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
|
||||
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
|
||||
StoredUsageDailySummary, StoredUsageDashboardDailyBreakdownRow,
|
||||
StoredUsageDashboardProviderCount, StoredUsageDashboardSummary,
|
||||
|
||||
@@ -38,7 +38,8 @@ use super::{
|
||||
api_key_usage_contribution, incoming_usage_can_recover_terminal_failure,
|
||||
model_usage_contribution, provider_api_key_usage_contribution,
|
||||
strip_deprecated_usage_display_fields, ApiKeyUsageDelta, ModelUsageDelta,
|
||||
PendingUsageCleanupSummary, ProviderApiKeyUsageDelta, StoredProviderApiKeyUsageSummary,
|
||||
PendingUsageCleanupSummary, ProviderApiKeyUsageDelta, ProviderApiKeyWindowUsageRequest,
|
||||
StoredProviderApiKeyUsageSummary, StoredProviderApiKeyWindowUsageSummary,
|
||||
StoredProviderUsageSummary, StoredRequestUsageAudit, StoredUsageDailySummary,
|
||||
UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery, UsageReadRepository,
|
||||
UsageWriteRepository,
|
||||
@@ -1314,6 +1315,9 @@ const SUMMARIZE_USAGE_TOTALS_BY_USER_IDS_SQL: &str =
|
||||
const SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL: &str =
|
||||
include_str!("queries/summarize_usage_by_provider_api_key_ids_sql.sql");
|
||||
|
||||
const SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL: &str =
|
||||
include_str!("queries/summarize_provider_api_key_window_usage_sql.sql");
|
||||
|
||||
const APPLY_API_KEY_USAGE_DELTA_SQL: &str =
|
||||
include_str!("queries/apply_api_key_usage_delta_sql.sql");
|
||||
|
||||
@@ -7225,6 +7229,98 @@ ORDER BY "usage".user_id ASC
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
pub async fn summarize_usage_by_provider_api_key_windows(
|
||||
&self,
|
||||
requests: &[ProviderApiKeyWindowUsageRequest],
|
||||
) -> Result<Vec<StoredProviderApiKeyWindowUsageSummary>, DataLayerError> {
|
||||
if requests.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut provider_api_key_ids = Vec::with_capacity(requests.len());
|
||||
let mut window_codes = Vec::with_capacity(requests.len());
|
||||
let mut start_unix_secs = Vec::with_capacity(requests.len());
|
||||
let mut end_unix_secs = Vec::with_capacity(requests.len());
|
||||
|
||||
for request in requests {
|
||||
let provider_api_key_id = request.provider_api_key_id.trim();
|
||||
if provider_api_key_id.is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage provider_api_key_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let window_code = request.window_code.trim();
|
||||
if window_code.is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage window_code cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if request.start_unix_secs >= request.end_unix_secs {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider api key window usage range must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
provider_api_key_ids.push(provider_api_key_id.to_string());
|
||||
window_codes.push(window_code.to_string());
|
||||
start_unix_secs.push(i64::try_from(request.start_unix_secs).map_err(|_| {
|
||||
DataLayerError::InvalidInput(
|
||||
"provider api key window usage start_unix_secs is out of range".to_string(),
|
||||
)
|
||||
})?);
|
||||
end_unix_secs.push(i64::try_from(request.end_unix_secs).map_err(|_| {
|
||||
DataLayerError::InvalidInput(
|
||||
"provider api key window usage end_unix_secs is out of range".to_string(),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut rows = sqlx::query(SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL)
|
||||
.bind(&provider_api_key_ids)
|
||||
.bind(&window_codes)
|
||||
.bind(&start_unix_secs)
|
||||
.bind(&end_unix_secs)
|
||||
.fetch(&self.pool);
|
||||
|
||||
let mut summaries = Vec::new();
|
||||
while let Some(row) = rows.try_next().await.map_postgres_err()? {
|
||||
let total_cost_usd = row.try_get::<f64, _>("total_cost_usd").map_postgres_err()?;
|
||||
if !total_cost_usd.is_finite() {
|
||||
return Err(DataLayerError::UnexpectedValue(
|
||||
"usage.total_cost_usd window aggregate is not finite".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
summaries.push(StoredProviderApiKeyWindowUsageSummary {
|
||||
provider_api_key_id: row
|
||||
.try_get::<String, _>("provider_api_key_id")
|
||||
.map_postgres_err()?,
|
||||
window_code: row.try_get::<String, _>("window_code").map_postgres_err()?,
|
||||
request_count: row
|
||||
.try_get::<i64, _>("request_count")
|
||||
.map_postgres_err()?
|
||||
.try_into()
|
||||
.map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"usage.request_count window aggregate is negative".to_string(),
|
||||
)
|
||||
})?,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_postgres_err()?
|
||||
.try_into()
|
||||
.map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"usage.total_tokens window aggregate is negative".to_string(),
|
||||
)
|
||||
})?,
|
||||
total_cost_usd,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
usage: UpsertUsageRecord,
|
||||
@@ -8044,6 +8140,13 @@ impl UsageReadRepository for SqlxUsageReadRepository {
|
||||
Self::summarize_usage_by_provider_api_key_ids(self, provider_api_key_ids).await
|
||||
}
|
||||
|
||||
async fn summarize_usage_by_provider_api_key_windows(
|
||||
&self,
|
||||
requests: &[ProviderApiKeyWindowUsageRequest],
|
||||
) -> Result<Vec<StoredProviderApiKeyWindowUsageSummary>, DataLayerError> {
|
||||
Self::summarize_usage_by_provider_api_key_windows(self, requests).await
|
||||
}
|
||||
|
||||
async fn summarize_provider_usage_since(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
WITH requested AS (
|
||||
SELECT
|
||||
request_row.provider_api_key_id,
|
||||
request_row.window_code,
|
||||
request_row.start_unix_secs,
|
||||
request_row.end_unix_secs,
|
||||
request_row.ordinality
|
||||
FROM UNNEST(
|
||||
$1::TEXT[],
|
||||
$2::TEXT[],
|
||||
$3::BIGINT[],
|
||||
$4::BIGINT[]
|
||||
) WITH ORDINALITY AS request_row(
|
||||
provider_api_key_id,
|
||||
window_code,
|
||||
start_unix_secs,
|
||||
end_unix_secs,
|
||||
ordinality
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
requested.provider_api_key_id,
|
||||
requested.window_code,
|
||||
COUNT("usage".id)::BIGINT AS request_count,
|
||||
COALESCE(SUM("usage".total_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM("usage".total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost_usd
|
||||
FROM requested
|
||||
LEFT JOIN usage_billing_facts AS "usage"
|
||||
ON "usage".provider_api_key_id = requested.provider_api_key_id
|
||||
AND "usage".created_at >= to_timestamp(requested.start_unix_secs::DOUBLE PRECISION)
|
||||
AND "usage".created_at < to_timestamp(requested.end_unix_secs::DOUBLE PRECISION)
|
||||
GROUP BY
|
||||
requested.provider_api_key_id,
|
||||
requested.window_code,
|
||||
requested.ordinality
|
||||
ORDER BY requested.ordinality ASC
|
||||
@@ -234,6 +234,21 @@ fn usage_sql_summarizes_usage_by_provider_api_key_ids_in_database() {
|
||||
assert!(super::SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL.contains("ANY($1::TEXT[])"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_summarizes_provider_key_window_usage_from_billing_facts() {
|
||||
assert!(super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL.contains("UNNEST"));
|
||||
assert!(super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL
|
||||
.contains("LEFT JOIN usage_billing_facts AS \"usage\""));
|
||||
assert!(
|
||||
super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL.contains("created_at >= to_timestamp")
|
||||
);
|
||||
assert!(
|
||||
super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL.contains("created_at < to_timestamp")
|
||||
);
|
||||
assert!(super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL
|
||||
.contains("COUNT(\"usage\".id)::BIGINT AS request_count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_serializes_request_id_upserts_before_reading_previous_usage() {
|
||||
assert!(super::LOCK_USAGE_REQUEST_ID_SQL.contains("pg_advisory_xact_lock"));
|
||||
@@ -455,6 +470,8 @@ fn usage_sql_raw_aggregates_use_canonical_billing_facts() {
|
||||
.contains("FROM usage_billing_facts AS \"usage\""));
|
||||
assert!(super::SUMMARIZE_USAGE_TOTALS_BY_USER_IDS_SQL
|
||||
.contains("FROM usage_billing_facts AS \"usage\""));
|
||||
assert!(super::SUMMARIZE_PROVIDER_API_KEY_WINDOW_USAGE_SQL
|
||||
.contains("usage_billing_facts AS \"usage\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user