Files
Aether/crates/aether-data/adapters/mysql/src/usage.rs
T

1874 lines
73 KiB
Rust
Raw Normal View History

2026-05-05 18:27:36 +08:00
use std::collections::{BTreeMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH};
2026-05-05 18:27:36 +08:00
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
2026-05-05 18:27:36 +08:00
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
2026-05-05 18:27:36 +08:00
use aether_data_contracts::repository::usage::{
2026-05-05 18:27:36 +08:00
strip_deprecated_usage_display_fields, usage_can_recover_terminal_failure,
usage_request_metadata_client_family, PendingUsageCleanupSummary, StoredRequestUsageAudit,
StoredUsageDailySummary, StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardSummary,
StoredUsageUserTotals, UpsertUsageRecord, UsageCleanupExecutionMode, UsageCleanupPreviewCounts,
UsageCleanupSummary, UsageCleanupTargets, UsageCleanupWindow, UsageDailyHeatmapQuery,
UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery, UsageWriteRepository,
2026-05-05 18:27:36 +08:00
};
use aether_data_contracts::DataLayerError;
2026-05-05 18:27:36 +08:00
use crate::error::SqlResultExt;
use crate::MysqlPool;
2026-05-05 18:27:36 +08:00
mod cleanup;
mod counters;
mod http_capture;
mod read;
mod snapshots;
pub use read::MysqlUsageReadFilter;
2026-05-05 18:27:36 +08:00
const USAGE_COLUMNS: &str = r#"
SELECT
id,
`usage`.request_id,
2026-05-05 18:27:36 +08:00
user_id,
api_key_id,
`usage`.username,
`usage`.api_key_name,
2026-05-05 18:27:36 +08:00
provider_name,
model,
target_model,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.selected_provider_id
ELSE `usage`.provider_id
END AS provider_id,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.selected_endpoint_id
ELSE `usage`.provider_endpoint_id
END AS provider_endpoint_id,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.selected_provider_api_key_id
ELSE `usage`.provider_api_key_id
END AS provider_api_key_id,
2026-05-05 18:27:36 +08:00
request_type,
api_format,
api_family,
endpoint_kind,
endpoint_api_format,
provider_api_family,
provider_endpoint_kind,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN COALESCE(usage_routing_snapshots.has_format_conversion, FALSE)
ELSE COALESCE(`usage`.has_format_conversion, FALSE)
END AS has_format_conversion,
2026-05-05 18:27:36 +08:00
is_stream,
upstream_is_stream,
2026-05-05 18:27:36 +08:00
input_tokens,
COALESCE(usage_settlement_snapshots.billing_output_tokens, `usage`.output_tokens, 0)
AS output_tokens,
2026-05-05 18:27:36 +08:00
total_tokens,
COALESCE(
usage_settlement_snapshots.billing_cache_creation_tokens,
CASE
WHEN usage_settlement_snapshots.billing_cache_creation_5m_tokens IS NOT NULL
OR usage_settlement_snapshots.billing_cache_creation_1h_tokens IS NOT NULL
THEN COALESCE(usage_settlement_snapshots.billing_cache_creation_5m_tokens, 0)
+ COALESCE(usage_settlement_snapshots.billing_cache_creation_1h_tokens, 0)
END,
`usage`.cache_creation_input_tokens,
0
) AS cache_creation_input_tokens,
COALESCE(
usage_settlement_snapshots.billing_cache_creation_5m_tokens,
`usage`.cache_creation_ephemeral_5m_input_tokens,
0
) AS cache_creation_ephemeral_5m_input_tokens,
COALESCE(
usage_settlement_snapshots.billing_cache_creation_1h_tokens,
`usage`.cache_creation_ephemeral_1h_input_tokens,
0
) AS cache_creation_ephemeral_1h_input_tokens,
COALESCE(
usage_settlement_snapshots.billing_cache_read_tokens,
`usage`.cache_read_input_tokens,
0
) AS cache_read_input_tokens,
CAST(COALESCE(
usage_settlement_snapshots.billing_cache_creation_cost_usd,
`usage`.cache_creation_cost_usd,
0
) AS DOUBLE) AS cache_creation_cost_usd,
CAST(COALESCE(
usage_settlement_snapshots.billing_cache_read_cost_usd,
`usage`.cache_read_cost_usd,
0
) AS DOUBLE) AS cache_read_cost_usd,
CAST(COALESCE(
usage_settlement_snapshots.output_price_per_1m,
`usage`.output_price_per_1m
) AS DOUBLE) AS output_price_per_1m,
CAST(COALESCE(
usage_settlement_snapshots.billing_total_cost_usd,
`usage`.total_cost_usd,
0
) AS DOUBLE) AS total_cost_usd,
CAST(COALESCE(
usage_settlement_snapshots.billing_actual_total_cost_usd,
`usage`.actual_total_cost_usd,
0
) AS DOUBLE) AS actual_total_cost_usd,
2026-05-05 18:27:36 +08:00
status_code,
error_message,
error_category,
response_time_ms,
first_byte_time_ms,
status,
COALESCE(usage_settlement_snapshots.billing_status, `usage`.billing_status)
AS billing_status,
CAST(COALESCE(usage_http_audits.request_headers, `usage`.request_headers) AS CHAR) AS request_headers,
CAST(`usage`.request_body AS CHAR) AS request_body,
`usage`.request_body_compressed,
CAST(COALESCE(
usage_http_audits.provider_request_headers,
`usage`.provider_request_headers
) AS CHAR) AS provider_request_headers,
CAST(`usage`.provider_request_body AS CHAR) AS provider_request_body,
`usage`.provider_request_body_compressed,
CAST(COALESCE(usage_http_audits.response_headers, `usage`.response_headers) AS CHAR) AS response_headers,
CAST(`usage`.response_body AS CHAR) AS response_body,
`usage`.response_body_compressed,
CAST(COALESCE(
usage_http_audits.client_response_headers,
`usage`.client_response_headers
) AS CHAR) AS client_response_headers,
CAST(`usage`.client_response_body AS CHAR) AS client_response_body,
`usage`.client_response_body_compressed,
usage_http_audits.request_body_ref AS http_request_body_ref,
usage_http_audits.provider_request_body_ref AS http_provider_request_body_ref,
usage_http_audits.response_body_ref AS http_response_body_ref,
usage_http_audits.client_response_body_ref AS http_client_response_body_ref,
usage_http_audits.request_body_state AS http_request_body_state,
usage_http_audits.provider_request_body_state AS http_provider_request_body_state,
usage_http_audits.response_body_state AS http_response_body_state,
usage_http_audits.client_response_body_state AS http_client_response_body_state,
2026-05-05 18:27:36 +08:00
request_metadata,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.candidate_id
ELSE `usage`.candidate_id
END AS routing_candidate_id,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.candidate_index
ELSE `usage`.candidate_index
END AS routing_candidate_index,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.key_name
ELSE `usage`.key_name
END AS routing_key_name,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.planner_kind
ELSE `usage`.planner_kind
END AS routing_planner_kind,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.route_family
ELSE `usage`.route_family
END AS routing_route_family,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.route_kind
ELSE `usage`.route_kind
END AS routing_route_kind,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.execution_path
ELSE `usage`.execution_path
END AS routing_execution_path,
CASE
WHEN usage_routing_snapshots.request_id IS NOT NULL
THEN usage_routing_snapshots.local_execution_runtime_miss_reason
ELSE `usage`.local_execution_runtime_miss_reason
END AS routing_local_execution_runtime_miss_reason,
usage_settlement_snapshots.billing_snapshot_schema_version
AS settlement_billing_snapshot_schema_version,
usage_settlement_snapshots.billing_snapshot_status AS settlement_billing_snapshot_status,
CAST(usage_settlement_snapshots.rate_multiplier AS DOUBLE) AS settlement_rate_multiplier,
usage_settlement_snapshots.is_free_tier AS settlement_is_free_tier,
CAST(usage_settlement_snapshots.input_price_per_1m AS DOUBLE)
AS settlement_input_price_per_1m,
CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE)
AS settlement_output_price_per_1m,
CAST(usage_settlement_snapshots.cache_creation_price_per_1m AS DOUBLE)
AS settlement_cache_creation_price_per_1m,
CAST(usage_settlement_snapshots.cache_read_price_per_1m AS DOUBLE)
AS settlement_cache_read_price_per_1m,
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE)
AS settlement_price_per_request,
usage_settlement_snapshots.settlement_snapshot_schema_version
AS settlement_snapshot_schema_version,
CAST(usage_settlement_snapshots.settlement_snapshot AS CHAR) AS settlement_snapshot,
CAST(usage_settlement_snapshots.billing_dimensions AS CHAR)
AS settlement_billing_dimensions,
usage_settlement_snapshots.billing_input_tokens AS settlement_billing_input_tokens,
usage_settlement_snapshots.billing_effective_input_tokens
AS settlement_billing_effective_input_tokens,
usage_settlement_snapshots.billing_output_tokens AS settlement_billing_output_tokens,
usage_settlement_snapshots.billing_cache_creation_tokens
AS settlement_billing_cache_creation_tokens,
usage_settlement_snapshots.billing_cache_creation_5m_tokens
AS settlement_billing_cache_creation_5m_tokens,
usage_settlement_snapshots.billing_cache_creation_1h_tokens
AS settlement_billing_cache_creation_1h_tokens,
usage_settlement_snapshots.billing_cache_read_tokens
AS settlement_billing_cache_read_tokens,
usage_settlement_snapshots.billing_total_input_context
AS settlement_billing_total_input_context,
CAST(usage_settlement_snapshots.billing_cache_creation_cost_usd AS DOUBLE)
AS settlement_billing_cache_creation_cost_usd,
CAST(usage_settlement_snapshots.billing_cache_read_cost_usd AS DOUBLE)
AS settlement_billing_cache_read_cost_usd,
CAST(usage_settlement_snapshots.billing_total_cost_usd AS DOUBLE)
AS settlement_billing_total_cost_usd,
CAST(usage_settlement_snapshots.billing_actual_total_cost_usd AS DOUBLE)
AS settlement_billing_actual_total_cost_usd,
usage_settlement_snapshots.billing_pricing_source AS settlement_billing_pricing_source,
usage_settlement_snapshots.billing_rule_id AS settlement_billing_rule_id,
usage_settlement_snapshots.billing_rule_version AS settlement_billing_rule_version,
COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)
AS finalized_at_unix_secs,
2026-05-05 18:27:36 +08:00
created_at_unix_ms,
updated_at_unix_secs
FROM `usage`
LEFT JOIN usage_http_audits
ON usage_http_audits.request_id = `usage`.request_id
LEFT JOIN usage_routing_snapshots
ON usage_routing_snapshots.request_id = `usage`.request_id
LEFT JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = `usage`.request_id
2026-05-05 18:27:36 +08:00
"#;
const UPSERT_USAGE_SQL: &str = r#"
INSERT INTO `usage` (
request_id,
id,
user_id,
api_key_id,
provider_name,
model,
target_model,
provider_id,
provider_endpoint_id,
provider_api_key_id,
request_type,
api_format,
api_family,
endpoint_kind,
endpoint_api_format,
provider_api_family,
provider_endpoint_kind,
has_format_conversion,
is_stream,
upstream_is_stream,
2026-05-05 18:27:36 +08:00
input_tokens,
output_tokens,
total_tokens,
cache_creation_input_tokens,
cache_creation_ephemeral_5m_input_tokens,
cache_creation_ephemeral_1h_input_tokens,
cache_read_input_tokens,
cache_creation_cost_usd,
cache_read_cost_usd,
output_price_per_1m,
total_cost_usd,
actual_total_cost_usd,
status_code,
error_message,
error_category,
response_time_ms,
first_byte_time_ms,
status,
billing_status,
request_metadata,
candidate_id,
candidate_index,
key_name,
planner_kind,
route_family,
route_kind,
execution_path,
local_execution_runtime_miss_reason,
finalized_at,
created_at_unix_ms,
updated_at_unix_secs
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?
2026-05-05 18:27:36 +08:00
)
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
api_key_id = VALUES(api_key_id),
provider_name = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_name ELSE VALUES(provider_name) END,
model = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN model ELSE VALUES(model) END,
target_model = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN target_model ELSE VALUES(target_model) END,
provider_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_id ELSE VALUES(provider_id) END,
provider_endpoint_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_endpoint_id ELSE VALUES(provider_endpoint_id) END,
provider_api_key_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_api_key_id ELSE VALUES(provider_api_key_id) END,
request_type = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN request_type ELSE VALUES(request_type) END,
api_format = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN api_format ELSE VALUES(api_format) END,
api_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN api_family ELSE VALUES(api_family) END,
endpoint_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN endpoint_kind ELSE VALUES(endpoint_kind) END,
endpoint_api_format = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN endpoint_api_format ELSE VALUES(endpoint_api_format) END,
provider_api_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_api_family ELSE VALUES(provider_api_family) END,
provider_endpoint_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_endpoint_kind ELSE VALUES(provider_endpoint_kind) END,
has_format_conversion = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN has_format_conversion ELSE VALUES(has_format_conversion) END,
is_stream = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN is_stream ELSE VALUES(is_stream) END,
upstream_is_stream = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN upstream_is_stream ELSE VALUES(upstream_is_stream) END,
input_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN input_tokens
ELSE VALUES(input_tokens)
END,
output_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN output_tokens
ELSE VALUES(output_tokens)
END,
total_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN total_tokens
ELSE VALUES(total_tokens)
END,
cache_creation_input_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_input_tokens
ELSE VALUES(cache_creation_input_tokens)
END,
cache_creation_ephemeral_5m_input_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_ephemeral_5m_input_tokens
ELSE VALUES(cache_creation_ephemeral_5m_input_tokens)
END,
cache_creation_ephemeral_1h_input_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_ephemeral_1h_input_tokens
ELSE VALUES(cache_creation_ephemeral_1h_input_tokens)
END,
cache_read_input_tokens = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_read_input_tokens
ELSE VALUES(cache_read_input_tokens)
END,
cache_creation_cost_usd = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_cost_usd
ELSE VALUES(cache_creation_cost_usd)
END,
cache_read_cost_usd = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_read_cost_usd
ELSE VALUES(cache_read_cost_usd)
END,
output_price_per_1m = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN output_price_per_1m
ELSE VALUES(output_price_per_1m)
END,
total_cost_usd = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN total_cost_usd
ELSE VALUES(total_cost_usd)
END,
actual_total_cost_usd = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN actual_total_cost_usd
ELSE VALUES(actual_total_cost_usd)
END,
status_code = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN status_code
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN status_code
2026-07-01 02:15:51 +08:00
WHEN status = 'streaming' AND VALUES(status) = 'streaming' AND VALUES(status_code) IS NULL THEN status_code
ELSE VALUES(status_code)
END,
error_message = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN error_message
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN error_message
ELSE VALUES(error_message)
END,
error_category = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN error_category
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN error_category
ELSE VALUES(error_category)
END,
response_time_ms = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN response_time_ms
WHEN VALUES(response_time_ms) IS NULL OR VALUES(response_time_ms) = 0
THEN COALESCE(response_time_ms, VALUES(response_time_ms))
ELSE VALUES(response_time_ms)
END,
first_byte_time_ms = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN first_byte_time_ms
WHEN VALUES(first_byte_time_ms) IS NULL OR VALUES(first_byte_time_ms) = 0
THEN COALESCE(first_byte_time_ms, VALUES(first_byte_time_ms))
ELSE VALUES(first_byte_time_ms)
END,
billing_status = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN billing_status
ELSE VALUES(billing_status)
END,
request_metadata = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN request_metadata ELSE VALUES(request_metadata) END,
candidate_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN candidate_id ELSE VALUES(candidate_id) END,
candidate_index = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN candidate_index ELSE VALUES(candidate_index) END,
key_name = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN key_name ELSE VALUES(key_name) END,
planner_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN planner_kind ELSE VALUES(planner_kind) END,
route_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN route_family ELSE VALUES(route_family) END,
route_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN route_kind ELSE VALUES(route_kind) END,
execution_path = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN execution_path ELSE VALUES(execution_path) END,
local_execution_runtime_miss_reason = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN local_execution_runtime_miss_reason ELSE VALUES(local_execution_runtime_miss_reason) END,
finalized_at = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN finalized_at
ELSE VALUES(finalized_at)
END,
updated_at_unix_secs = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN updated_at_unix_secs
ELSE VALUES(updated_at_unix_secs)
END,
status = CASE
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN status
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN status
ELSE VALUES(status)
END
2026-05-05 18:27:36 +08:00
"#;
const SELECT_STALE_PENDING_USAGE_BATCH_SQL: &str = r#"
SELECT
`usage`.request_id,
`usage`.status,
COALESCE(usage_settlement_snapshots.billing_status, `usage`.billing_status) AS billing_status
FROM `usage`
LEFT JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = `usage`.request_id
WHERE `usage`.status IN ('pending', 'streaming')
AND `usage`.created_at_unix_ms < ?
ORDER BY `usage`.created_at_unix_ms ASC, `usage`.request_id ASC
LIMIT ?
"#;
const SELECT_COMPLETED_REQUEST_CANDIDATES_SQL: &str = r#"
SELECT status, extra_data
FROM request_candidates
WHERE request_id = ?
AND status IN ('streaming', 'success')
"#;
const MYSQL_USAGE_CANONICAL_TOTAL_TOKENS_EXPR: &str = r#"
GREATEST(
COALESCE(
CASE
WHEN settlement.billing_effective_input_tokens IS NOT NULL THEN
GREATEST(settlement.billing_effective_input_tokens, 0)
+ GREATEST(COALESCE(settlement.billing_output_tokens, `usage`.output_tokens, 0), 0)
+ GREATEST(
COALESCE(
settlement.billing_cache_creation_tokens,
CASE
WHEN settlement.billing_cache_creation_5m_tokens IS NOT NULL
OR settlement.billing_cache_creation_1h_tokens IS NOT NULL
THEN COALESCE(settlement.billing_cache_creation_5m_tokens, 0)
+ COALESCE(settlement.billing_cache_creation_1h_tokens, 0)
END,
CASE
WHEN COALESCE(`usage`.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(`usage`.cache_creation_ephemeral_5m_input_tokens, 0)
+ COALESCE(`usage`.cache_creation_ephemeral_1h_input_tokens, 0)
) > 0
THEN COALESCE(`usage`.cache_creation_ephemeral_5m_input_tokens, 0)
+ COALESCE(`usage`.cache_creation_ephemeral_1h_input_tokens, 0)
ELSE COALESCE(`usage`.cache_creation_input_tokens, 0)
END,
0
),
0
)
+ GREATEST(
COALESCE(
settlement.billing_cache_read_tokens,
`usage`.cache_read_input_tokens,
0
),
0
)
WHEN settlement.billing_total_input_context IS NOT NULL THEN
GREATEST(settlement.billing_total_input_context, 0)
+ GREATEST(COALESCE(settlement.billing_output_tokens, `usage`.output_tokens, 0), 0)
END,
NULLIF(GREATEST(COALESCE(`usage`.total_tokens, 0), 0), 0),
(
CASE
WHEN (
LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) = 'openai'
OR LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) LIKE 'openai:%'
OR LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) = 'gemini'
OR LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) LIKE 'gemini:%'
OR LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) = 'google'
OR LOWER(COALESCE(`usage`.endpoint_api_format, `usage`.api_format, '')) LIKE 'google:%'
)
AND COALESCE(`usage`.input_tokens, 0) > 0
AND COALESCE(`usage`.cache_read_input_tokens, 0) > 0
THEN GREATEST(
COALESCE(`usage`.input_tokens, 0) - COALESCE(`usage`.cache_read_input_tokens, 0),
0
)
ELSE GREATEST(COALESCE(`usage`.input_tokens, 0), 0)
END
+ GREATEST(COALESCE(`usage`.output_tokens, 0), 0)
+ (
CASE
WHEN COALESCE(`usage`.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(`usage`.cache_creation_ephemeral_5m_input_tokens, 0)
+ COALESCE(`usage`.cache_creation_ephemeral_1h_input_tokens, 0)
) > 0
THEN COALESCE(`usage`.cache_creation_ephemeral_5m_input_tokens, 0)
+ COALESCE(`usage`.cache_creation_ephemeral_1h_input_tokens, 0)
ELSE GREATEST(COALESCE(`usage`.cache_creation_input_tokens, 0), 0)
END
)
+ GREATEST(COALESCE(`usage`.cache_read_input_tokens, 0), 0)
),
0
),
0
)
"#;
const MYSQL_PROVIDER_KEY_SUCCESS_FLAG_EXPR: &str = r#"
CASE
WHEN status IN ('completed', 'success', 'ok', 'billed', 'settled')
AND (status_code IS NULL OR status_code < 400)
AND (error_message IS NULL OR TRIM(error_message) = '')
THEN 1
ELSE 0
END
"#;
const MYSQL_PROVIDER_KEY_ERROR_FLAG_EXPR: &str = r#"
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 (error_message IS NULL OR TRIM(error_message) = '')
)
THEN 1
ELSE 0
END
"#;
2026-05-05 18:27:36 +08:00
#[derive(Debug, Clone)]
pub struct MysqlUsageWriteRepository {
pool: MysqlPool,
}
#[derive(Debug, Clone)]
pub struct MysqlUsageStorage {
2026-05-05 18:27:36 +08:00
pool: MysqlPool,
}
impl MysqlUsageStorage {
2026-05-05 18:27:36 +08:00
pub fn new(pool: MysqlPool) -> Self {
Self { pool }
}
pub async fn read_usage_counter_health(
&self,
) -> Result<aether_data_contracts::repository::usage::UsageCounterHealthSnapshot, DataLayerError>
{
counters::read_health(&self.pool).await
}
2026-05-05 18:27:36 +08:00
pub async fn read_usage_counter_pending_health(
&self,
) -> Result<
aether_data_contracts::repository::usage::UsageCounterPendingHealthSnapshot,
DataLayerError,
> {
counters::read_pending_health(&self.pool).await
2026-05-05 18:27:36 +08:00
}
async fn summarize_usage_daily_heatmap_raw_from_range(
&self,
created_from_unix_secs: u64,
created_until_unix_secs: u64,
user_id: Option<&str>,
) -> Result<Vec<StoredUsageDailySummary>, DataLayerError> {
let mut sql = format!(
r#"
SELECT
DATE_FORMAT(FROM_UNIXTIME(created_at_unix_ms), '%Y-%m-%d') AS date,
2026-05-25 03:40:29 +00:00
CAST(COUNT(*) AS SIGNED) AS requests,
CAST(COALESCE(SUM({canonical_total_tokens_expr}), 0) AS SIGNED) AS total_tokens,
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(COALESCE(total_cost_usd, 0)), 0) AS DOUBLE) AS total_cost_usd,
CAST(COALESCE(SUM(COALESCE(actual_total_cost_usd, 0)), 0) AS DOUBLE) AS actual_total_cost_usd
FROM `usage`
LEFT JOIN usage_settlement_snapshots AS settlement
ON settlement.request_id = `usage`.request_id
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
canonical_total_tokens_expr = MYSQL_USAGE_CANONICAL_TOTAL_TOKENS_EXPR,
);
if user_id.is_some() {
sql.push_str(" AND user_id = ?\n");
}
sql.push_str("GROUP BY date ORDER BY date ASC");
let mut query = sqlx::query(&sql)
.bind(to_i64(created_from_unix_secs, "usage.created_at_unix_ms")?)
.bind(to_i64(created_until_unix_secs, "usage.created_at_unix_ms")?);
if let Some(user_id) = user_id {
query = query.bind(user_id.to_string());
}
let rows = query.fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_mysql_usage_daily_summary).collect()
}
async fn summarize_usage_daily_heatmap_from_daily_aggregates(
&self,
created_from_unix_secs: u64,
created_until_unix_secs: u64,
user_id: Option<&str>,
) -> Result<Vec<StoredUsageDailySummary>, DataLayerError> {
let rows = if let Some(user_id) = user_id {
sqlx::query(
r#"
SELECT
DATE_FORMAT(FROM_UNIXTIME(`date`), '%Y-%m-%d') AS date,
total_requests AS requests,
input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens AS total_tokens,
total_cost AS total_cost_usd,
total_cost AS actual_total_cost_usd
FROM stats_user_daily
WHERE user_id = ?
AND `date` >= ?
AND `date` < ?
AND total_requests > 0
ORDER BY `date` ASC
"#,
)
.bind(user_id)
.bind(to_i64(created_from_unix_secs, "stats_user_daily.date")?)
.bind(to_i64(created_until_unix_secs, "stats_user_daily.date")?)
.fetch_all(&self.pool)
.await
.map_sql_err()?
} else {
sqlx::query(
r#"
SELECT
DATE_FORMAT(FROM_UNIXTIME(`date`), '%Y-%m-%d') AS date,
total_requests AS requests,
input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens AS total_tokens,
total_cost AS total_cost_usd,
actual_total_cost AS actual_total_cost_usd
FROM stats_daily
WHERE `date` >= ?
AND `date` < ?
AND total_requests > 0
ORDER BY `date` ASC
"#,
)
.bind(to_i64(created_from_unix_secs, "stats_daily.date")?)
.bind(to_i64(created_until_unix_secs, "stats_daily.date")?)
.fetch_all(&self.pool)
.await
.map_sql_err()?
};
rows.iter().map(map_mysql_usage_daily_summary).collect()
}
pub async fn summarize_usage_daily_heatmap(
&self,
query: &UsageDailyHeatmapQuery,
) -> Result<Vec<StoredUsageDailySummary>, DataLayerError> {
let created_until_unix_secs = usage_current_unix_secs().saturating_add(1);
let user_id = query.user_id.as_deref();
let mut summaries = BTreeMap::<String, StoredUsageDailySummary>::new();
for item in self
.summarize_usage_daily_heatmap_from_daily_aggregates(
query.created_from_unix_secs,
created_until_unix_secs,
user_id,
)
.await?
{
summaries.insert(item.date.clone(), item);
}
for item in self
.summarize_usage_daily_heatmap_raw_from_range(
query.created_from_unix_secs,
created_until_unix_secs,
user_id,
)
.await?
{
summaries.entry(item.date.clone()).or_insert(item);
}
Ok(summaries.into_values().collect())
}
pub async fn summarize_dashboard_usage_from_daily_aggregates(
&self,
query: &UsageDashboardSummaryQuery,
) -> Result<Option<StoredUsageDashboardSummary>, DataLayerError> {
let row = if let Some(user_id) = query.user_id.as_deref() {
sqlx::query(
r#"
SELECT
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS input_tokens,
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS effective_input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS SIGNED) AS output_tokens,
CAST(COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_tokens,
CAST(COALESCE(SUM(cache_creation_tokens), 0) AS SIGNED) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_tokens), 0) AS SIGNED) AS cache_read_tokens,
CAST(COALESCE(SUM(input_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_input_context,
CAST(0.0 AS DOUBLE) AS cache_creation_cost_usd,
CAST(0.0 AS DOUBLE) AS cache_read_cost_usd,
CAST(COALESCE(SUM(COALESCE(total_cost, 0)), 0) AS DOUBLE) AS total_cost_usd,
CAST(COALESCE(SUM(COALESCE(total_cost, 0)), 0) AS DOUBLE) AS actual_total_cost_usd,
CAST(COALESCE(SUM(error_requests), 0) AS SIGNED) AS error_requests,
CAST(0.0 AS DOUBLE) AS response_time_sum_ms,
CAST(0 AS SIGNED) AS response_time_samples
FROM stats_user_daily
WHERE user_id = ?
AND `date` >= ?
AND `date` < ?
"#,
)
.bind(user_id)
.bind(to_i64(
query.created_from_unix_secs,
"stats_user_daily.date",
)?)
.bind(to_i64(
query.created_until_unix_secs,
"stats_user_daily.date",
)?)
.fetch_one(&self.pool)
.await
.map_sql_err()?
} else {
sqlx::query(
r#"
SELECT
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS input_tokens,
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS effective_input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS SIGNED) AS output_tokens,
CAST(COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_tokens,
CAST(COALESCE(SUM(cache_creation_tokens), 0) AS SIGNED) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_tokens), 0) AS SIGNED) AS cache_read_tokens,
CAST(COALESCE(SUM(input_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_input_context,
CAST(COALESCE(SUM(COALESCE(cache_creation_cost, 0)), 0) AS DOUBLE) AS cache_creation_cost_usd,
CAST(COALESCE(SUM(COALESCE(cache_read_cost, 0)), 0) AS DOUBLE) AS cache_read_cost_usd,
CAST(COALESCE(SUM(COALESCE(total_cost, 0)), 0) AS DOUBLE) AS total_cost_usd,
CAST(COALESCE(SUM(COALESCE(actual_total_cost, 0)), 0) AS DOUBLE) AS actual_total_cost_usd,
CAST(COALESCE(SUM(error_requests), 0) AS SIGNED) AS error_requests,
CAST(0.0 AS DOUBLE) AS response_time_sum_ms,
CAST(0 AS SIGNED) AS response_time_samples
FROM stats_daily
WHERE `date` >= ?
AND `date` < ?
"#,
)
.bind(to_i64(query.created_from_unix_secs, "stats_daily.date")?)
.bind(to_i64(query.created_until_unix_secs, "stats_daily.date")?)
.fetch_one(&self.pool)
.await
.map_sql_err()?
};
let total_requests = row_u64(&row, "total_requests")?;
if total_requests == 0 {
return Ok(None);
}
Ok(Some(StoredUsageDashboardSummary {
total_requests,
input_tokens: row_u64(&row, "input_tokens")?,
effective_input_tokens: row_u64(&row, "effective_input_tokens")?,
output_tokens: row_u64(&row, "output_tokens")?,
total_tokens: row_u64(&row, "total_tokens")?,
cache_creation_tokens: row_u64(&row, "cache_creation_tokens")?,
cache_read_tokens: row_u64(&row, "cache_read_tokens")?,
total_input_context: row_u64(&row, "total_input_context")?,
cache_creation_cost_usd: row.try_get("cache_creation_cost_usd").map_sql_err()?,
cache_read_cost_usd: row.try_get("cache_read_cost_usd").map_sql_err()?,
total_cost_usd: row.try_get("total_cost_usd").map_sql_err()?,
actual_total_cost_usd: row.try_get("actual_total_cost_usd").map_sql_err()?,
error_requests: row_u64(&row, "error_requests")?,
response_time_sum_ms: row.try_get("response_time_sum_ms").map_sql_err()?,
response_time_samples: row_u64(&row, "response_time_samples")?,
}))
}
pub async fn list_dashboard_daily_breakdown_from_daily_aggregates(
&self,
query: &UsageDashboardDailyBreakdownQuery,
) -> Result<Vec<StoredUsageDashboardDailyBreakdownRow>, DataLayerError> {
let rows = if let Some(user_id) = query.user_id.as_deref() {
sqlx::query(
r#"
SELECT
DATE_FORMAT(FROM_UNIXTIME(`date`), '%Y-%m-%d') AS date,
'aggregate' AS model,
'aggregate' AS provider,
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS requests,
CAST(COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_tokens,
CAST(COALESCE(SUM(COALESCE(total_cost, 0)), 0) AS DOUBLE) AS total_cost_usd,
CAST(0.0 AS DOUBLE) AS response_time_sum_ms,
CAST(0 AS SIGNED) AS response_time_samples
FROM stats_user_daily
WHERE user_id = ?
AND `date` >= ?
AND `date` < ?
AND total_requests > 0
GROUP BY `date`
ORDER BY `date` ASC
"#,
)
.bind(user_id)
.bind(to_i64(
query.created_from_unix_secs,
"stats_user_daily.date",
)?)
.bind(to_i64(
query.created_until_unix_secs,
"stats_user_daily.date",
)?)
.fetch_all(&self.pool)
.await
.map_sql_err()?
} else {
sqlx::query(
r#"
SELECT
DATE_FORMAT(FROM_UNIXTIME(`date`), '%Y-%m-%d') AS date,
'aggregate' AS model,
'aggregate' AS provider,
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS requests,
CAST(COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS SIGNED) AS total_tokens,
CAST(COALESCE(SUM(COALESCE(total_cost, 0)), 0) AS DOUBLE) AS total_cost_usd,
CAST(0.0 AS DOUBLE) AS response_time_sum_ms,
CAST(0 AS SIGNED) AS response_time_samples
FROM stats_daily
WHERE `date` >= ?
AND `date` < ?
AND total_requests > 0
GROUP BY `date`
ORDER BY `date` ASC
"#,
)
.bind(to_i64(query.created_from_unix_secs, "stats_daily.date")?)
.bind(to_i64(query.created_until_unix_secs, "stats_daily.date")?)
.fetch_all(&self.pool)
.await
.map_sql_err()?
};
rows.iter()
.map(|row| {
Ok(StoredUsageDashboardDailyBreakdownRow {
date: row.try_get("date").map_sql_err()?,
model: row.try_get("model").map_sql_err()?,
provider: row.try_get("provider").map_sql_err()?,
requests: row_u64(row, "requests")?,
total_tokens: row_u64(row, "total_tokens")?,
total_cost_usd: row.try_get("total_cost_usd").map_sql_err()?,
response_time_sum_ms: row.try_get("response_time_sum_ms").map_sql_err()?,
response_time_samples: row_u64(row, "response_time_samples")?,
})
})
.collect()
}
pub async fn summarize_usage_totals_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUsageUserTotals>, DataLayerError> {
if user_ids.is_empty() {
return Ok(Vec::new());
}
let unique_user_ids = user_ids
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let mut totals = BTreeMap::<String, StoredUsageUserTotals>::new();
let mut aggregate_cutoffs = BTreeMap::<String, u64>::new();
let mut aggregate_builder = QueryBuilder::<MySql>::new(
r#"
SELECT
user_id,
2026-05-25 15:36:13 +08:00
CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS request_count,
CAST(COALESCE(
SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens),
0
2026-05-25 15:36:13 +08:00
) AS SIGNED) AS total_tokens,
MAX(`date`) AS latest_date
FROM stats_user_daily
WHERE user_id IN (
"#,
);
{
let mut separated = aggregate_builder.separated(", ");
for user_id in &unique_user_ids {
separated.push_bind(user_id.clone());
}
}
aggregate_builder.push(") GROUP BY user_id ORDER BY user_id ASC");
let aggregate_rows = aggregate_builder
.build()
.fetch_all(&self.pool)
.await
.map_sql_err()?;
for row in aggregate_rows {
let user_id: String = row.try_get("user_id").map_sql_err()?;
let latest_date = row.try_get::<i64, _>("latest_date").map_sql_err()?.max(0) as u64;
aggregate_cutoffs.insert(user_id.clone(), latest_date.saturating_add(86_400));
totals.insert(
user_id.clone(),
StoredUsageUserTotals {
user_id,
request_count: row_u64(&row, "request_count")?,
total_tokens: row_u64(&row, "total_tokens")?,
},
);
}
let mut raw_builder = QueryBuilder::<MySql>::new(format!(
r#"
SELECT
`usage`.user_id,
2026-05-25 03:40:29 +00:00
CAST(COUNT(*) AS SIGNED) AS request_count,
CAST(COALESCE(SUM({canonical_total_tokens_expr}), 0) AS SIGNED) AS total_tokens
FROM `usage`
JOIN (
"#,
canonical_total_tokens_expr = MYSQL_USAGE_CANONICAL_TOTAL_TOKENS_EXPR,
));
for (index, user_id) in unique_user_ids.iter().enumerate() {
if index > 0 {
raw_builder.push(" UNION ALL ");
}
let cutoff = aggregate_cutoffs.get(user_id).copied().unwrap_or_default();
raw_builder
.push("SELECT ")
.push_bind(user_id.clone())
.push(" AS user_id, ")
.push_bind(to_i64(cutoff, "usage aggregate cutoff")?)
.push(" AS cutoff_unix_secs");
}
raw_builder.push(
r#"
) AS requested ON requested.user_id = `usage`.user_id
LEFT JOIN usage_settlement_snapshots AS settlement
ON settlement.request_id = `usage`.request_id
WHERE `usage`.created_at_unix_ms >= requested.cutoff_unix_secs
AND `usage`.status NOT IN ('pending', 'streaming')
AND `usage`.provider_name NOT IN ('unknown', 'pending')
GROUP BY `usage`.user_id
ORDER BY `usage`.user_id ASC
"#,
);
let raw_rows = raw_builder
.build()
.fetch_all(&self.pool)
.await
.map_sql_err()?;
for row in raw_rows {
let user_id: String = row.try_get("user_id").map_sql_err()?;
let entry = totals
.entry(user_id.clone())
.or_insert_with(|| StoredUsageUserTotals {
user_id,
request_count: 0,
total_tokens: 0,
});
entry.request_count = entry
.request_count
.saturating_add(row_u64(&row, "request_count")?);
entry.total_tokens = entry
.total_tokens
.saturating_add(row_u64(&row, "total_tokens")?);
}
Ok(totals.into_values().collect())
}
2026-05-05 18:27:36 +08:00
}
impl MysqlUsageWriteRepository {
pub fn new(pool: MysqlPool) -> Self {
Self { pool }
}
pub async fn find_by_request_id(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
let row = sqlx::query(&format!(
"{USAGE_COLUMNS} WHERE `usage`.request_id = ? LIMIT 1"
))
.bind(request_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
let usage = row
.as_ref()
.map(|row| map_usage_row(row, true))
.transpose()?;
match usage {
Some(usage) => http_capture::hydrate_usage_body_refs(&self.pool, usage)
.await
.map(Some),
None => Ok(None),
}
2026-05-05 18:27:36 +08:00
}
}
#[async_trait]
impl UsageWriteRepository for MysqlUsageWriteRepository {
async fn upsert(
&self,
usage: UpsertUsageRecord,
) -> Result<StoredRequestUsageAudit, DataLayerError> {
let mut usage = strip_deprecated_usage_display_fields(usage);
2026-05-05 18:27:36 +08:00
usage.validate()?;
let prepared_capture = http_capture::prepare_usage_http_capture(&mut usage)?;
let mut tx = self.pool.begin().await.map_sql_err()?;
let existing = counters::lock_and_load_usage(&mut tx, &usage.request_id).await?;
let recovers_terminal_failure = existing.as_ref().is_some_and(|existing| {
usage_can_recover_terminal_failure(
&existing.status,
&existing.billing_status,
&usage.status,
&usage.billing_status,
)
});
if let Some(existing) = existing.as_ref() {
2026-05-05 18:27:36 +08:00
if (existing.billing_status == "settled" || existing.billing_status == "void")
&& !recovers_terminal_failure
2026-05-05 18:27:36 +08:00
{
let existing = existing.clone();
tx.rollback().await.map_sql_err()?;
return http_capture::hydrate_usage_body_refs(&self.pool, existing).await;
2026-05-05 18:27:36 +08:00
}
}
let capture_update_allowed = recovers_terminal_failure
|| http_capture::capture_update_allowed(existing.as_ref(), &usage.status);
if capture_update_allowed {
http_capture::apply_previous_metadata_tombstones(&mut usage, existing.as_ref());
}
let prepared_snapshots = capture_update_allowed
.then(|| snapshots::from_usage(&usage))
.transpose()?;
2026-05-05 18:27:36 +08:00
bind_upsert(sqlx::query(UPSERT_USAGE_SQL), &usage)?
.execute(&mut *tx)
2026-05-05 18:27:36 +08:00
.await
.map_sql_err()?;
if capture_update_allowed {
http_capture::sync_usage_http_capture(&mut tx, &usage.request_id, &prepared_capture)
.await?;
let (routing_snapshot, settlement_snapshot) = prepared_snapshots
.as_ref()
.expect("capture-allowed usage has prepared snapshots");
snapshots::sync(
&mut tx,
&usage.request_id,
routing_snapshot,
settlement_snapshot,
matches!(usage.status.as_str(), "completed" | "failed" | "cancelled"),
)
.await?;
}
counters::enqueue_usage_transition_for_request(
&mut tx,
&usage.request_id,
existing.as_ref(),
)
.await?;
tx.commit().await.map_sql_err()?;
2026-05-05 18:27:36 +08:00
self.find_by_request_id(&usage.request_id)
.await?
.ok_or_else(|| {
DataLayerError::UnexpectedValue("usage upsert returned no row".to_string())
})
}
async fn rebuild_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
2026-05-05 18:27:36 +08:00
sqlx::query(
r#"
UPDATE api_keys
SET total_requests = 0,
total_tokens = 0,
total_cost_usd = 0,
last_used_at = NULL
"#,
)
.execute(&mut *tx)
2026-05-05 18:27:36 +08:00
.await
.map_sql_err()?;
let rows_affected = sqlx::query(&format!(
2026-05-05 18:27:36 +08:00
r#"
UPDATE api_keys
JOIN (
SELECT
api_key_id,
COUNT(*) AS total_requests,
COALESCE(SUM({canonical_total_tokens_expr}), 0) AS total_tokens,
COALESCE(SUM(COALESCE(total_cost_usd, 0)), 0) AS total_cost_usd,
MAX(created_at_unix_ms) AS last_used_at
FROM `usage`
LEFT JOIN usage_settlement_snapshots AS settlement
ON settlement.request_id = `usage`.request_id
WHERE api_key_id IS NOT NULL
AND TRIM(api_key_id) <> ''
AND status NOT IN ('pending', 'streaming')
GROUP BY api_key_id
) AS aggregated ON aggregated.api_key_id = api_keys.id
SET api_keys.total_requests = aggregated.total_requests,
api_keys.total_tokens = aggregated.total_tokens,
api_keys.total_cost_usd = aggregated.total_cost_usd,
api_keys.last_used_at = aggregated.last_used_at
2026-05-05 18:27:36 +08:00
"#,
canonical_total_tokens_expr = MYSQL_USAGE_CANONICAL_TOTAL_TOKENS_EXPR,
))
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected)
2026-05-05 18:27:36 +08:00
}
async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
2026-05-05 18:27:36 +08:00
sqlx::query(
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
"#,
)
.execute(&mut *tx)
2026-05-05 18:27:36 +08:00
.await
.map_sql_err()?;
let rows_affected = sqlx::query(&format!(
2026-05-05 18:27:36 +08:00
r#"
UPDATE provider_api_keys
JOIN (
SELECT
provider_api_key_id,
COUNT(*) AS request_count,
COALESCE(SUM({success_flag_expr}), 0) AS success_count,
COALESCE(SUM({error_flag_expr}), 0) AS error_count,
COALESCE(SUM(CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE {canonical_total_tokens_expr}
END), 0) AS total_tokens,
COALESCE(SUM(CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE COALESCE(total_cost_usd, 0)
END), 0) AS total_cost_usd,
COALESCE(SUM(CASE
WHEN {success_flag_expr} = 1 AND response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)
ELSE 0
END), 0) AS total_response_time_ms,
MAX(created_at_unix_ms) AS last_used_at
FROM `usage`
LEFT JOIN usage_settlement_snapshots AS settlement
ON settlement.request_id = `usage`.request_id
WHERE provider_api_key_id IS NOT NULL
AND TRIM(provider_api_key_id) <> ''
GROUP BY provider_api_key_id
) AS aggregated ON aggregated.provider_api_key_id = provider_api_keys.id
SET provider_api_keys.request_count = aggregated.request_count,
provider_api_keys.success_count = aggregated.success_count,
provider_api_keys.error_count = aggregated.error_count,
provider_api_keys.total_tokens = aggregated.total_tokens,
provider_api_keys.total_cost_usd = aggregated.total_cost_usd,
provider_api_keys.total_response_time_ms = aggregated.total_response_time_ms,
provider_api_keys.last_used_at = aggregated.last_used_at
2026-05-05 18:27:36 +08:00
"#,
success_flag_expr = MYSQL_PROVIDER_KEY_SUCCESS_FLAG_EXPR,
error_flag_expr = MYSQL_PROVIDER_KEY_ERROR_FLAG_EXPR,
canonical_total_tokens_expr = MYSQL_USAGE_CANONICAL_TOTAL_TOKENS_EXPR,
))
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected)
2026-05-05 18:27:36 +08:00
}
async fn cleanup_stale_pending_requests(
&self,
cutoff_unix_secs: u64,
now_unix_secs: u64,
timeout_minutes: u64,
batch_size: usize,
) -> Result<PendingUsageCleanupSummary, DataLayerError> {
if batch_size == 0 {
return Ok(PendingUsageCleanupSummary::default());
}
let now_unix_ms = now_unix_secs.saturating_mul(1000);
let mut summary = PendingUsageCleanupSummary::default();
let batch_size_u64 = u64::try_from(batch_size).map_err(|_| {
DataLayerError::InvalidInput(format!(
"invalid stale pending usage batch size: {batch_size}"
))
})?;
loop {
let mut tx = self.pool.begin().await.map_sql_err()?;
let stale_rows = sqlx::query(SELECT_STALE_PENDING_USAGE_BATCH_SQL)
.bind(to_i64(cutoff_unix_secs, "stale pending usage cutoff")?)
2026-05-05 18:27:36 +08:00
.bind(to_i64(batch_size_u64, "stale pending usage batch size")?)
.fetch_all(&mut *tx)
.await
.map_sql_err()?;
if stale_rows.is_empty() {
tx.rollback().await.map_sql_err()?;
break;
}
let stale_rows = stale_rows
.iter()
.map(|row| {
Ok(StalePendingUsageRow {
request_id: row.try_get("request_id").map_sql_err()?,
status: row.try_get("status").map_sql_err()?,
billing_status: row.try_get("billing_status").map_sql_err()?,
})
})
.collect::<Result<Vec<_>, DataLayerError>>()?;
let completed_request_ids =
completed_request_ids_mysql(&mut tx, stale_rows.iter().map(|row| &row.request_id))
.await?;
for row in stale_rows {
if completed_request_ids.contains(&row.request_id) {
sqlx::query(
r#"
UPDATE `usage`
SET status = 'completed',
status_code = 200,
error_message = NULL
WHERE request_id = ?
"#,
)
.bind(&row.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(
r#"
UPDATE request_candidates
SET status = 'success',
finished_at = ?
WHERE request_id = ?
AND status = 'streaming'
"#,
)
.bind(to_i64(now_unix_ms, "request candidate finished_at")?)
.bind(&row.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
summary.recovered += 1;
continue;
}
let candidate_info =
latest_failed_candidate_mysql(&mut tx, &row.request_id).await?;
let (status_code, error_message) = resolve_stale_pending_failure(
candidate_info.as_ref(),
&row.status,
timeout_minutes,
);
let status_code_i64 = i64::from(status_code);
2026-05-05 18:27:36 +08:00
if row.billing_status == "pending" {
sqlx::query(
r#"
UPDATE `usage`
SET status = 'failed',
status_code = ?,
2026-05-05 18:27:36 +08:00
error_message = ?,
billing_status = 'void',
finalized_at = ?,
total_cost_usd = 0,
actual_total_cost_usd = 0
WHERE request_id = ?
"#,
)
.bind(status_code_i64)
2026-05-05 18:27:36 +08:00
.bind(&error_message)
.bind(to_i64(now_unix_secs, "usage finalized_at")?)
.bind(&row.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
upsert_void_usage_settlement_snapshot_mysql(
&mut tx,
&row.request_id,
now_unix_secs,
)
.await?;
} else {
sqlx::query(
r#"
UPDATE `usage`
SET status = 'failed',
status_code = ?,
2026-05-05 18:27:36 +08:00
error_message = ?
WHERE request_id = ?
"#,
)
.bind(status_code_i64)
2026-05-05 18:27:36 +08:00
.bind(&error_message)
.bind(&row.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
}
sqlx::query(
r#"
UPDATE request_candidates
SET status = 'failed',
finished_at = ?,
error_message = '请求超时(服务器可能已重启)'
WHERE request_id = ?
AND status IN ('pending', 'streaming')
"#,
)
.bind(to_i64(now_unix_ms, "request candidate finished_at")?)
.bind(&row.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
summary.failed += 1;
}
tx.commit().await.map_sql_err()?;
}
Ok(summary)
}
async fn flush_usage_counter_deltas(
&self,
batch_size: usize,
) -> Result<aether_data_contracts::repository::usage::UsageCounterFlushSummary, DataLayerError>
{
counters::flush(&self.pool, batch_size).await
}
async fn enqueue_proxy_node_counter_delta(
&self,
delta: aether_data_contracts::repository::usage::ProxyNodeCounterDelta,
) -> Result<bool, DataLayerError> {
counters::enqueue_proxy_node(&self.pool, delta).await
}
async fn enqueue_management_token_counter_delta(
&self,
delta: aether_data_contracts::repository::usage::ManagementTokenCounterDelta,
) -> Result<bool, DataLayerError> {
counters::enqueue_management_token(&self.pool, delta).await
}
async fn enqueue_api_key_last_used_delta(
&self,
delta: aether_data_contracts::repository::usage::ApiKeyLastUsedDelta,
) -> Result<bool, DataLayerError> {
counters::enqueue_api_key_last_used(&self.pool, delta).await
}
async fn cleanup_processed_usage_counter_deltas(
&self,
cutoff_unix_secs: u64,
batch_size: usize,
) -> Result<usize, DataLayerError> {
counters::cleanup_processed(&self.pool, cutoff_unix_secs, batch_size).await
}
async fn cleanup_usage(
&self,
window: &UsageCleanupWindow,
batch_size: usize,
auto_delete_expired_keys: bool,
targets: UsageCleanupTargets,
mode: UsageCleanupExecutionMode,
) -> Result<UsageCleanupSummary, DataLayerError> {
cleanup::cleanup_usage(
&self.pool,
window,
batch_size,
auto_delete_expired_keys,
targets,
mode,
)
.await
}
async fn preview_usage_cleanup(
&self,
window: &UsageCleanupWindow,
targets: UsageCleanupTargets,
mode: UsageCleanupExecutionMode,
) -> Result<UsageCleanupPreviewCounts, DataLayerError> {
cleanup::preview_usage_cleanup(&self.pool, window, targets, mode).await
}
2026-05-05 18:27:36 +08:00
}
struct StalePendingUsageRow {
request_id: String,
status: String,
billing_status: String,
}
async fn completed_request_ids_mysql<'a>(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
request_ids: impl Iterator<Item = &'a String>,
) -> Result<HashSet<String>, DataLayerError> {
let mut completed = HashSet::new();
for request_id in request_ids {
let rows = sqlx::query(SELECT_COMPLETED_REQUEST_CANDIDATES_SQL)
.bind(request_id)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
let mut is_completed = false;
for row in &rows {
if candidate_row_is_completed(row)? {
is_completed = true;
break;
}
}
if is_completed {
completed.insert(request_id.clone());
}
}
Ok(completed)
}
fn candidate_row_is_completed(row: &MySqlRow) -> Result<bool, DataLayerError> {
let status: String = row.try_get("status").map_sql_err()?;
if status == "streaming" {
return Ok(true);
}
if status != "success" {
return Ok(false);
}
let Some(extra_data) = row
.try_get::<Option<String>, _>("extra_data")
.map_sql_err()?
else {
return Ok(false);
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&extra_data) else {
return Ok(false);
};
Ok(value
.get("stream_completed")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false))
}
async fn upsert_void_usage_settlement_snapshot_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
request_id: &str,
now_unix_secs: u64,
) -> Result<(), DataLayerError> {
let now = to_i64(now_unix_secs, "usage settlement snapshot timestamp")?;
sqlx::query(
r#"
INSERT INTO usage_settlement_snapshots (
request_id,
billing_status,
finalized_at,
created_at,
updated_at
) VALUES (?, 'void', ?, ?, ?)
ON DUPLICATE KEY UPDATE
billing_status = VALUES(billing_status),
billing_snapshot_schema_version = NULL,
billing_snapshot_status = NULL,
settlement_snapshot_schema_version = NULL,
settlement_snapshot = NULL,
billing_dimensions = NULL,
billing_input_tokens = NULL,
billing_effective_input_tokens = NULL,
billing_output_tokens = NULL,
billing_cache_creation_tokens = NULL,
billing_cache_creation_5m_tokens = NULL,
billing_cache_creation_1h_tokens = NULL,
billing_cache_read_tokens = NULL,
billing_total_input_context = NULL,
billing_cache_creation_cost_usd = NULL,
billing_cache_read_cost_usd = NULL,
billing_total_cost_usd = NULL,
billing_actual_total_cost_usd = NULL,
billing_pricing_source = NULL,
billing_rule_id = NULL,
billing_rule_version = NULL,
rate_multiplier = NULL,
is_free_tier = NULL,
input_price_per_1m = NULL,
output_price_per_1m = NULL,
cache_creation_price_per_1m = NULL,
cache_read_price_per_1m = NULL,
price_per_request = NULL,
2026-05-05 18:27:36 +08:00
finalized_at = COALESCE(usage_settlement_snapshots.finalized_at, VALUES(finalized_at)),
updated_at = VALUES(updated_at)
"#,
)
.bind(request_id)
.bind(now)
.bind(now)
.bind(now)
.execute(&mut **tx)
.await
.map_sql_err()?;
Ok(())
}
fn stale_pending_error_message(status: &str, timeout_minutes: u64) -> String {
format!("请求超时: 状态 '{status}' 超过 {timeout_minutes} 分钟未完成")
}
struct FailedCandidateCleanupInfo {
status_code: Option<u16>,
error_message: Option<String>,
}
fn resolve_stale_pending_failure(
candidate: Option<&FailedCandidateCleanupInfo>,
status: &str,
timeout_minutes: u64,
) -> (u16, String) {
match candidate {
Some(info) => (
info.status_code.unwrap_or(502),
info.error_message
.clone()
.unwrap_or_else(|| stale_pending_error_message(status, timeout_minutes)),
),
None => (504, stale_pending_error_message(status, timeout_minutes)),
}
}
async fn latest_failed_candidate_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
request_id: &str,
) -> Result<Option<FailedCandidateCleanupInfo>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT status_code, error_message
FROM request_candidates
WHERE request_id = ?
AND status IN ('failed', 'cancelled')
ORDER BY
COALESCE(finished_at, started_at, created_at) DESC,
retry_index DESC,
candidate_index DESC
LIMIT 1
"#,
)
.bind(request_id)
.fetch_optional(&mut **tx)
.await
.map_sql_err()?;
let Some(row) = row else {
return Ok(None);
};
let status_code = row
.try_get::<Option<i64>, _>("status_code")
.map_sql_err()?
.and_then(|value| u16::try_from(value).ok());
let error_message = row
.try_get::<Option<String>, _>("error_message")
.map_sql_err()?
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(Some(FailedCandidateCleanupInfo {
status_code,
error_message,
}))
}
2026-05-05 18:27:36 +08:00
fn bind_upsert<'q>(
mut query: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
usage: &'q UpsertUsageRecord,
) -> Result<sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>, DataLayerError> {
let input_tokens = usage.input_tokens.unwrap_or_default();
let output_tokens = usage.output_tokens.unwrap_or_default();
let cache_creation_tokens = usage
.cache_creation_input_tokens
.or_else(|| {
Some(
usage
.cache_creation_ephemeral_5m_input_tokens
.unwrap_or_default()
+ usage
.cache_creation_ephemeral_1h_input_tokens
.unwrap_or_default(),
)
})
.unwrap_or_default();
let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or_default();
let total_tokens = usage
.total_tokens
.unwrap_or(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens);
let created_at = usage
.created_at_unix_ms
.unwrap_or(usage.updated_at_unix_secs);
2026-05-05 18:27:36 +08:00
let request_metadata = usage
.request_metadata
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|err| DataLayerError::InvalidInput(err.to_string()))?;
query = query
.bind(&usage.request_id)
.bind(&usage.request_id)
.bind(usage.user_id.as_deref())
.bind(usage.api_key_id.as_deref())
.bind(&usage.provider_name)
.bind(&usage.model)
.bind(usage.target_model.as_deref())
.bind(usage.provider_id.as_deref())
.bind(usage.provider_endpoint_id.as_deref())
.bind(usage.provider_api_key_id.as_deref())
.bind(usage.request_type.as_deref())
.bind(usage.api_format.as_deref())
.bind(usage.api_family.as_deref())
.bind(usage.endpoint_kind.as_deref())
.bind(usage.endpoint_api_format.as_deref())
.bind(usage.provider_api_family.as_deref())
.bind(usage.provider_endpoint_kind.as_deref())
.bind(usage.has_format_conversion.unwrap_or(false))
.bind(usage.is_stream.unwrap_or(false))
.bind(usage_upstream_is_stream(usage))
2026-05-05 18:27:36 +08:00
.bind(to_i64(input_tokens, "input_tokens")?)
.bind(to_i64(output_tokens, "output_tokens")?)
.bind(to_i64(total_tokens, "total_tokens")?)
.bind(to_i64(
cache_creation_tokens,
"cache_creation_input_tokens",
)?)
.bind(to_i64(
usage
.cache_creation_ephemeral_5m_input_tokens
.unwrap_or_default(),
"cache_creation_ephemeral_5m_input_tokens",
)?)
.bind(to_i64(
usage
.cache_creation_ephemeral_1h_input_tokens
.unwrap_or_default(),
"cache_creation_ephemeral_1h_input_tokens",
)?)
.bind(to_i64(cache_read_tokens, "cache_read_input_tokens")?)
.bind(usage.cache_creation_cost_usd.unwrap_or_default())
.bind(usage.cache_read_cost_usd.unwrap_or_default())
.bind(usage.output_price_per_1m)
.bind(usage.total_cost_usd.unwrap_or_default())
.bind(usage.actual_total_cost_usd.unwrap_or_default())
.bind(usage.status_code.map(i64::from))
.bind(usage.error_message.as_deref())
.bind(usage.error_category.as_deref())
.bind(usage.response_time_ms.map(|value| value as i64))
.bind(usage.first_byte_time_ms.map(|value| value as i64))
.bind(&usage.status)
.bind(&usage.billing_status)
.bind(request_metadata)
.bind(usage.candidate_id.as_deref())
.bind(usage.candidate_index.map(|value| value as i64))
.bind(usage.key_name.as_deref())
.bind(usage.planner_kind.as_deref())
.bind(usage.route_family.as_deref())
.bind(usage.route_kind.as_deref())
.bind(usage.execution_path.as_deref())
.bind(usage.local_execution_runtime_miss_reason.as_deref())
.bind(usage.finalized_at_unix_secs.map(|value| value as i64))
.bind(to_i64(created_at, "created_at_unix_ms")?)
.bind(to_i64(usage.updated_at_unix_secs, "updated_at_unix_secs")?);
Ok(query)
}
fn map_usage_row(
row: &MySqlRow,
resolve_legacy_compressed: bool,
) -> Result<StoredRequestUsageAudit, DataLayerError> {
2026-05-05 18:27:36 +08:00
let id = row
.try_get::<Option<String>, _>("id")
.map_sql_err()?
.unwrap_or_else(|| {
row.try_get::<String, _>("request_id")
.unwrap_or_else(|_| "unknown".to_string())
});
let mut audit = StoredRequestUsageAudit::new(
id,
row.try_get("request_id").map_sql_err()?,
row.try_get("user_id").map_sql_err()?,
row.try_get("api_key_id").map_sql_err()?,
row.try_get("username").map_sql_err()?,
row.try_get("api_key_name").map_sql_err()?,
2026-05-05 18:27:36 +08:00
row.try_get("provider_name").map_sql_err()?,
row.try_get("model").map_sql_err()?,
row.try_get("target_model").map_sql_err()?,
row.try_get("provider_id").map_sql_err()?,
row.try_get("provider_endpoint_id").map_sql_err()?,
row.try_get("provider_api_key_id").map_sql_err()?,
row.try_get("request_type").map_sql_err()?,
row.try_get("api_format").map_sql_err()?,
row.try_get("api_family").map_sql_err()?,
row.try_get("endpoint_kind").map_sql_err()?,
row.try_get("endpoint_api_format").map_sql_err()?,
row.try_get("provider_api_family").map_sql_err()?,
row.try_get("provider_endpoint_kind").map_sql_err()?,
row.try_get::<bool, _>("has_format_conversion")
.map_sql_err()?,
row.try_get::<bool, _>("is_stream").map_sql_err()?,
row_i32(row, "input_tokens")?,
row_i32(row, "output_tokens")?,
row_i32(row, "total_tokens")?,
row.try_get("total_cost_usd").map_sql_err()?,
row.try_get("actual_total_cost_usd").map_sql_err()?,
row_optional_i32(row, "status_code")?,
row.try_get("error_message").map_sql_err()?,
row.try_get("error_category").map_sql_err()?,
row_optional_i32(row, "response_time_ms")?,
row_optional_i32(row, "first_byte_time_ms")?,
row.try_get("status").map_sql_err()?,
row.try_get("billing_status").map_sql_err()?,
row.try_get("created_at_unix_ms").map_sql_err()?,
row.try_get("updated_at_unix_secs").map_sql_err()?,
row.try_get("finalized_at_unix_secs").map_sql_err()?,
)?;
audit.cache_creation_input_tokens = row_u64(row, "cache_creation_input_tokens")?;
audit.cache_creation_ephemeral_5m_input_tokens =
row_u64(row, "cache_creation_ephemeral_5m_input_tokens")?;
audit.cache_creation_ephemeral_1h_input_tokens =
row_u64(row, "cache_creation_ephemeral_1h_input_tokens")?;
audit.cache_read_input_tokens = row_u64(row, "cache_read_input_tokens")?;
audit.cache_creation_cost_usd = row.try_get("cache_creation_cost_usd").map_sql_err()?;
audit.cache_read_cost_usd = row.try_get("cache_read_cost_usd").map_sql_err()?;
audit.output_price_per_1m = row.try_get("output_price_per_1m").map_sql_err()?;
audit.request_metadata = row
.try_get::<Option<String>, _>("request_metadata")
.map_sql_err()?
.map(|raw| serde_json::from_str(&raw))
.transpose()
.map_err(|err| DataLayerError::UnexpectedValue(err.to_string()))?;
2026-05-18 19:28:36 +08:00
audit.client_family = usage_request_metadata_client_family(audit.request_metadata.as_ref())
.map(ToOwned::to_owned);
http_capture::hydrate_usage_row(row, &mut audit, resolve_legacy_compressed)?;
let upstream_is_stream = row
.try_get::<Option<bool>, _>("upstream_is_stream")
.map_sql_err()?;
merge_usage_stream_metadata(&mut audit.request_metadata, upstream_is_stream);
snapshots::hydrate_row(row, &mut audit)?;
2026-05-05 18:27:36 +08:00
Ok(audit)
}
fn to_i64(value: u64, field: &str) -> Result<i64, DataLayerError> {
i64::try_from(value).map_err(|_| DataLayerError::InvalidInput(format!("{field} overflow")))
}
fn usage_upstream_is_stream(usage: &UpsertUsageRecord) -> bool {
usage
.request_metadata
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get(UPSTREAM_IS_STREAM_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or_else(|| usage.is_stream.unwrap_or(false))
}
fn merge_usage_stream_metadata(metadata: &mut Option<serde_json::Value>, upstream: Option<bool>) {
let Some(upstream) = upstream else {
return;
};
let value = metadata.get_or_insert_with(|| serde_json::json!({}));
let Some(object) = value.as_object_mut() else {
return;
};
object
.entry(UPSTREAM_IS_STREAM_KEY)
.or_insert(serde_json::Value::Bool(upstream));
}
2026-05-05 18:27:36 +08:00
fn row_i32(row: &MySqlRow, field: &str) -> Result<i32, DataLayerError> {
let value: i64 = row.try_get(field).map_sql_err()?;
i32::try_from(value).map_err(|_| DataLayerError::UnexpectedValue(format!("{field} overflow")))
}
fn row_optional_i32(row: &MySqlRow, field: &str) -> Result<Option<i32>, DataLayerError> {
row.try_get::<Option<i64>, _>(field)
.map_sql_err()?
.map(|value| {
i32::try_from(value)
.map_err(|_| DataLayerError::UnexpectedValue(format!("{field} overflow")))
})
.transpose()
}
fn row_u64(row: &MySqlRow, field: &str) -> Result<u64, DataLayerError> {
let value: i64 = row.try_get(field).map_sql_err()?;
u64::try_from(value).map_err(|_| DataLayerError::UnexpectedValue(format!("{field} negative")))
}
fn map_mysql_usage_daily_summary(
row: &MySqlRow,
) -> Result<StoredUsageDailySummary, DataLayerError> {
Ok(StoredUsageDailySummary {
date: row.try_get("date").map_sql_err()?,
requests: row_u64(row, "requests")?,
total_tokens: row_u64(row, "total_tokens")?,
total_cost_usd: row.try_get("total_cost_usd").map_sql_err()?,
actual_total_cost_usd: row.try_get("actual_total_cost_usd").map_sql_err()?,
})
}
fn usage_current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
2026-05-05 18:27:36 +08:00
#[cfg(test)]
mod tests;