Files
Aether/crates/aether-data/adapters/mysql/src/usage.rs
T
elky 778cfb1a5c feat(data): complete portable SQL backend parity
Align MySQL and SQLite schemas, migrations, usage, stats, export, and backfill behavior with the shared data contracts. Extend gateway startup and maintenance support across all SQL drivers.
2026-07-25 21:28:21 +08:00

1874 lines
73 KiB
Rust

use std::collections::{BTreeMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH};
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use aether_data_contracts::repository::usage::{
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,
};
use aether_data_contracts::DataLayerError;
use crate::error::SqlResultExt;
use crate::MysqlPool;
mod cleanup;
mod counters;
mod http_capture;
mod read;
mod snapshots;
pub use read::MysqlUsageReadFilter;
const USAGE_COLUMNS: &str = r#"
SELECT
id,
`usage`.request_id,
user_id,
api_key_id,
`usage`.username,
`usage`.api_key_name,
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,
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,
is_stream,
upstream_is_stream,
input_tokens,
COALESCE(usage_settlement_snapshots.billing_output_tokens, `usage`.output_tokens, 0)
AS output_tokens,
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,
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,
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,
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
"#;
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,
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 (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?
)
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
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
"#;
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
"#;
#[derive(Debug, Clone)]
pub struct MysqlUsageWriteRepository {
pool: MysqlPool,
}
#[derive(Debug, Clone)]
pub struct MysqlUsageStorage {
pool: MysqlPool,
}
impl MysqlUsageStorage {
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
}
pub async fn read_usage_counter_pending_health(
&self,
) -> Result<
aether_data_contracts::repository::usage::UsageCounterPendingHealthSnapshot,
DataLayerError,
> {
counters::read_pending_health(&self.pool).await
}
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,
CAST(COUNT(*) AS SIGNED) AS requests,
CAST(COALESCE(SUM({canonical_total_tokens_expr}), 0) AS SIGNED) AS total_tokens,
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
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
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,
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,
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,
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
) 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,
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())
}
}
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),
}
}
}
#[async_trait]
impl UsageWriteRepository for MysqlUsageWriteRepository {
async fn upsert(
&self,
usage: UpsertUsageRecord,
) -> Result<StoredRequestUsageAudit, DataLayerError> {
let mut usage = strip_deprecated_usage_display_fields(usage);
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() {
if (existing.billing_status == "settled" || existing.billing_status == "void")
&& !recovers_terminal_failure
{
let existing = existing.clone();
tx.rollback().await.map_sql_err()?;
return http_capture::hydrate_usage_body_refs(&self.pool, existing).await;
}
}
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()?;
bind_upsert(sqlx::query(UPSERT_USAGE_SQL), &usage)?
.execute(&mut *tx)
.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()?;
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()?;
sqlx::query(
r#"
UPDATE api_keys
SET total_requests = 0,
total_tokens = 0,
total_cost_usd = 0,
last_used_at = NULL
"#,
)
.execute(&mut *tx)
.await
.map_sql_err()?;
let rows_affected = sqlx::query(&format!(
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
"#,
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)
}
async fn rebuild_provider_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
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)
.await
.map_sql_err()?;
let rows_affected = sqlx::query(&format!(
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
"#,
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)
}
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")?)
.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);
if row.billing_status == "pending" {
sqlx::query(
r#"
UPDATE `usage`
SET status = 'failed',
status_code = ?,
error_message = ?,
billing_status = 'void',
finalized_at = ?,
total_cost_usd = 0,
actual_total_cost_usd = 0
WHERE request_id = ?
"#,
)
.bind(status_code_i64)
.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 = ?,
error_message = ?
WHERE request_id = ?
"#,
)
.bind(status_code_i64)
.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
}
}
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,
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,
}))
}
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);
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))
.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> {
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()?,
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()))?;
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)?;
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));
}
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()
}
#[cfg(test)]
mod tests;