feat(gateway): 重构 usage 数据层、迁移系统与系统导入

数据库迁移:
- 引入 baseline v2 bootstrap,空库首次启动自动初始化
- 服务启动不再自动执行迁移,需显式 `--migrate` 运行
- 新增 pending migration 检测,schema 落后时拒绝启动

Usage 数据层:
- usage body 存储外部化为独立 blob 表
- 新增 HTTP audit 表拆分存储请求/响应头与 body ref
- 后台清理任务支持 legacy body ref 元数据迁移
- usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈)

系统导入/导出:
- 支持用户、API Keys、钱包数据的完整导入
- 兼容 legacy 与 v1.3+ 两种导出格式

其他改进:
- executor outcome 增加 runtime miss 诊断上下文
- 主 tokio runtime 栈大小调整为 8MB
- 前端 provider 管理支持 base URL 配置
- dev.sh 支持 --migrate 参数
This commit is contained in:
fawney19
2026-04-13 14:01:22 +08:00
parent 3698e5a833
commit 5bb08e6aa4
106 changed files with 21736 additions and 1529 deletions

View File

@@ -13,6 +13,7 @@ aether-wallet.workspace = true
async-trait.workspace = true
chrono.workspace = true
futures-util.workspace = true
flate2.workspace = true
redis.workspace = true
serde.workspace = true
serde_json.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
-- Squashed unreleased usage schema split:
-- 20260412000000_add_usage_body_blobs.sql
-- 20260412010000_add_usage_http_audits.sql
-- 20260412020000_add_usage_routing_snapshots.sql
-- 20260412030000_add_usage_settlement_snapshots.sql
-- 20260413000000_expand_usage_settlement_snapshots_for_pricing.sql
-- 20260413010000_mark_usage_legacy_columns_deprecated.sql
-- 20260413020000_add_candidate_index_to_usage_routing_snapshots.sql
CREATE TABLE IF NOT EXISTS public.usage_body_blobs (
body_ref character varying(160) NOT NULL,
request_id character varying(100) NOT NULL,
body_field character varying(50) NOT NULL,
payload_gzip bytea NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT usage_body_blobs_pkey PRIMARY KEY (body_ref),
CONSTRAINT usage_body_blobs_request_id_field_key UNIQUE (request_id, body_field),
CONSTRAINT usage_body_blobs_request_id_fkey
FOREIGN KEY (request_id)
REFERENCES public.usage(request_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_usage_body_blobs_request_id
ON public.usage_body_blobs USING btree (request_id);
CREATE TABLE IF NOT EXISTS public.usage_http_audits (
request_id character varying(100) NOT NULL,
request_headers json,
provider_request_headers json,
response_headers json,
client_response_headers json,
request_body_ref character varying(160),
provider_request_body_ref character varying(160),
response_body_ref character varying(160),
client_response_body_ref character varying(160),
body_capture_mode character varying(32) DEFAULT 'none' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT usage_http_audits_pkey PRIMARY KEY (request_id),
CONSTRAINT usage_http_audits_request_id_fkey
FOREIGN KEY (request_id)
REFERENCES public.usage(request_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_usage_http_audits_updated_at
ON public.usage_http_audits USING btree (updated_at);
CREATE TABLE IF NOT EXISTS public.usage_routing_snapshots (
request_id character varying(100) NOT NULL,
candidate_id character varying(160),
candidate_index integer,
key_name character varying(255),
planner_kind character varying(120),
route_family character varying(80),
route_kind character varying(80),
execution_path character varying(80),
local_execution_runtime_miss_reason character varying(120),
selected_provider_id character varying(100),
selected_endpoint_id character varying(100),
selected_provider_api_key_id character varying(100),
has_format_conversion boolean,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT usage_routing_snapshots_pkey PRIMARY KEY (request_id),
CONSTRAINT usage_routing_snapshots_request_id_fkey
FOREIGN KEY (request_id)
REFERENCES public.usage(request_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_usage_routing_snapshots_route_family_kind
ON public.usage_routing_snapshots USING btree (route_family, route_kind);
CREATE INDEX IF NOT EXISTS ix_usage_routing_snapshots_candidate_id
ON public.usage_routing_snapshots USING btree (candidate_id);
CREATE TABLE IF NOT EXISTS public.usage_settlement_snapshots (
request_id character varying(100) NOT NULL,
billing_status character varying(20) NOT NULL,
wallet_id character varying(36),
wallet_balance_before numeric(20,8),
wallet_balance_after numeric(20,8),
wallet_recharge_balance_before numeric(20,8),
wallet_recharge_balance_after numeric(20,8),
wallet_gift_balance_before numeric(20,8),
wallet_gift_balance_after numeric(20,8),
provider_monthly_used_usd numeric(20,8),
finalized_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT usage_settlement_snapshots_pkey PRIMARY KEY (request_id),
CONSTRAINT usage_settlement_snapshots_request_id_fkey
FOREIGN KEY (request_id)
REFERENCES public.usage(request_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_wallet_id
ON public.usage_settlement_snapshots USING btree (wallet_id);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_billing_status
ON public.usage_settlement_snapshots USING btree (billing_status);
ALTER TABLE IF EXISTS public.usage_settlement_snapshots
ADD COLUMN IF NOT EXISTS billing_snapshot_schema_version character varying(20),
ADD COLUMN IF NOT EXISTS billing_snapshot_status character varying(20),
ADD COLUMN IF NOT EXISTS rate_multiplier numeric(10,6),
ADD COLUMN IF NOT EXISTS is_free_tier boolean,
ADD COLUMN IF NOT EXISTS input_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS output_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS cache_creation_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS cache_read_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS price_per_request numeric(20,8);
COMMENT ON COLUMN public.usage.wallet_id IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_id. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_balance_before IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_balance_before. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_balance_after IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_balance_after. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_recharge_balance_before IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_recharge_balance_before. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_recharge_balance_after IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_recharge_balance_after. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_gift_balance_before IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_gift_balance_before. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.wallet_gift_balance_after IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_gift_balance_after. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.rate_multiplier IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.rate_multiplier. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.input_price_per_1m IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.input_price_per_1m. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.output_price_per_1m IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.output_price_per_1m. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.cache_creation_price_per_1m IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.cache_creation_price_per_1m. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.cache_read_price_per_1m IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.cache_read_price_per_1m. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.price_per_request IS
'DEPRECATED: settlement pricing owner moved to public.usage_settlement_snapshots.price_per_request. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.username IS
'DEPRECATED: display cache only. Prefer join-time lookup from user/auth records. Legacy compatibility only.';
COMMENT ON COLUMN public.usage.api_key_name IS
'DEPRECATED: display cache only. Prefer join-time lookup from API key records. Legacy compatibility only.';

View File

@@ -2,11 +2,44 @@ use std::collections::{HashMap, HashSet};
use sqlx::{
migrate::{Migrate, MigrateError, Migrator},
PgPool,
query, query_scalar, Connection, PgConnection, PgPool,
};
use tracing::{error, info, warn};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260413020000;
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
const EMPTY_DATABASE_USER_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name <> '_sqlx_migrations'
"#;
const INSERT_APPLIED_MIGRATION_SQL: &str = r#"
INSERT INTO _sqlx_migrations (
version,
description,
success,
checksum,
execution_time
) VALUES (
$1,
$2,
TRUE,
$3,
0
)
ON CONFLICT (version) DO NOTHING
"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingMigrationInfo {
pub version: i64,
pub description: String,
}
/// Run all pending migrations embedded at compile time from `migrations/`.
pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
@@ -16,7 +49,7 @@ pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
conn.lock().await?;
}
let result = run_migrations_locked(&mut *conn).await;
let result = run_migrations_locked(&mut conn).await;
if MIGRATOR.locking {
match conn.unlock().await {
@@ -34,11 +67,41 @@ pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
result
}
async fn run_migrations_locked<C>(conn: &mut C) -> Result<(), MigrateError>
where
C: Migrate,
{
pub async fn pending_migrations(pool: &PgPool) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
let mut conn = pool.acquire().await?;
pending_migrations_locked(&mut conn).await
}
pub async fn prepare_database_for_startup(
pool: &PgPool,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
let mut conn = pool.acquire().await?;
if MIGRATOR.locking {
conn.lock().await?;
}
let result = prepare_database_for_startup_locked(&mut conn).await;
if MIGRATOR.locking {
match conn.unlock().await {
Ok(()) => {}
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
Err(unlock_error) => {
warn!(
error = %unlock_error,
"database migration lock release failed after startup preparation error"
);
}
}
}
result
}
async fn run_migrations_locked(conn: &mut PgConnection) -> Result<(), MigrateError> {
conn.ensure_migrations_table().await?;
bootstrap_empty_database_to_baseline_v2(conn).await?;
if let Some(version) = conn.dirty_version().await? {
error!(version, "database migration state is dirty");
@@ -118,6 +181,125 @@ where
Ok(())
}
async fn prepare_database_for_startup_locked(
conn: &mut PgConnection,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
conn.ensure_migrations_table().await?;
bootstrap_empty_database_to_baseline_v2(conn).await?;
pending_migrations_locked(conn).await
}
async fn pending_migrations_locked(
conn: &mut PgConnection,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
if !migrations_table_exists(conn).await? {
return Ok(all_up_migrations());
}
if let Some(version) = conn.dirty_version().await? {
error!(version, "database migration state is dirty");
return Err(MigrateError::Dirty(version));
}
let applied_migrations = conn.list_applied_migrations().await?;
validate_applied_migrations(&applied_migrations)?;
Ok(pending_migrations_from_applied(&applied_migrations))
}
async fn bootstrap_empty_database_to_baseline_v2(
conn: &mut PgConnection,
) -> Result<(), MigrateError> {
if !should_bootstrap_baseline_v2(conn).await? {
return Ok(());
}
let migrations = baseline_v2_migrations()?;
info!(
cutoff_version = BASELINE_V2_CUTOFF_VERSION,
stamped_migrations = migrations.len(),
"bootstrapping empty database from baseline_v2"
);
let mut tx = conn.begin().await?;
sqlx::raw_sql(BASELINE_V2_SQL).execute(&mut *tx).await?;
for migration in migrations {
query(INSERT_APPLIED_MIGRATION_SQL)
.bind(migration.version)
.bind(migration.description.as_ref())
.bind(migration.checksum.as_ref())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn migrations_table_exists(conn: &mut PgConnection) -> Result<bool, MigrateError> {
let exists: bool = query_scalar(MIGRATIONS_TABLE_EXISTS_SQL)
.fetch_one(&mut *conn)
.await?;
Ok(exists)
}
async fn should_bootstrap_baseline_v2(conn: &mut PgConnection) -> Result<bool, MigrateError> {
let applied_migrations = conn.list_applied_migrations().await?;
if !applied_migrations.is_empty() {
return Ok(false);
}
let user_table_count: i64 = query_scalar(EMPTY_DATABASE_USER_TABLE_COUNT_SQL)
.fetch_one(&mut *conn)
.await?;
Ok(user_table_count == 0)
}
fn baseline_v2_migrations() -> Result<Vec<&'static sqlx::migrate::Migration>, MigrateError> {
let migrations = MIGRATOR
.iter()
.filter(|migration| migration.migration_type.is_up_migration())
.filter(|migration| migration.version <= BASELINE_V2_CUTOFF_VERSION)
.collect::<Vec<_>>();
if migrations.is_empty() {
return Err(MigrateError::Source(Box::new(std::io::Error::other(
"baseline_v2 cutoff does not match any embedded migrations",
))));
}
Ok(migrations)
}
fn all_up_migrations() -> Vec<PendingMigrationInfo> {
MIGRATOR
.iter()
.filter(|migration| migration.migration_type.is_up_migration())
.map(|migration| PendingMigrationInfo {
version: migration.version,
description: migration.description.to_string(),
})
.collect()
}
fn pending_migrations_from_applied(
applied_migrations: &[sqlx::migrate::AppliedMigration],
) -> Vec<PendingMigrationInfo> {
let applied_versions: HashSet<_> = applied_migrations
.iter()
.map(|migration| migration.version)
.collect();
MIGRATOR
.iter()
.filter(|migration| migration.migration_type.is_up_migration())
.filter(|migration| !applied_versions.contains(&migration.version))
.map(|migration| PendingMigrationInfo {
version: migration.version,
description: migration.description.to_string(),
})
.collect()
}
fn validate_applied_migrations(
applied_migrations: &[sqlx::migrate::AppliedMigration],
) -> Result<(), MigrateError> {
@@ -165,7 +347,14 @@ fn validate_applied_migrations(
#[cfg(test)]
mod tests {
use super::MIGRATOR;
use std::borrow::Cow;
use sqlx::migrate::AppliedMigration;
use super::{
all_up_migrations, baseline_v2_migrations, pending_migrations_from_applied,
BASELINE_V2_SQL, MIGRATOR,
};
#[test]
fn baseline_migration_restores_search_path_for_sqlx_bookkeeping() {
@@ -199,4 +388,109 @@ mod tests {
"baseline migration must not persist a restored search_path at session scope",
);
}
#[test]
fn baseline_v2_bootstrap_covers_current_cutoff_versions() {
let versions = baseline_v2_migrations()
.expect("baseline_v2 migrations should resolve")
.into_iter()
.map(|migration| migration.version)
.collect::<Vec<_>>();
assert_eq!(
versions,
vec![
20260403000000,
20260406000000,
20260410000000,
20260413020000,
]
);
}
#[test]
fn baseline_v2_sql_includes_usage_body_blobs() {
assert!(BASELINE_V2_SQL.contains("CREATE TABLE IF NOT EXISTS public.usage_body_blobs"));
assert!(BASELINE_V2_SQL.contains("ix_usage_body_blobs_request_id"));
assert!(BASELINE_V2_SQL.contains("CREATE TABLE IF NOT EXISTS public.usage_http_audits"));
assert!(
BASELINE_V2_SQL.contains("CREATE TABLE IF NOT EXISTS public.usage_routing_snapshots")
);
assert!(BASELINE_V2_SQL
.contains("CREATE TABLE IF NOT EXISTS public.usage_settlement_snapshots"));
assert!(BASELINE_V2_SQL.contains("billing_snapshot_schema_version"));
assert!(BASELINE_V2_SQL.contains("price_per_request"));
assert!(BASELINE_V2_SQL.contains("candidate_index integer"));
}
#[test]
fn deprecation_migration_and_baseline_mark_legacy_usage_columns() {
let migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260413020000)
.expect("deprecation migration should be embedded");
assert!(migration
.sql
.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
assert!(migration
.sql
.contains("COMMENT ON COLUMN public.usage.wallet_id"));
assert!(migration
.sql
.contains("COMMENT ON COLUMN public.usage.username"));
assert!(migration
.sql
.contains("COMMENT ON COLUMN public.usage.api_key_name"));
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.wallet_id"));
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.username"));
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.api_key_name"));
}
#[test]
fn pending_migrations_from_applied_returns_all_versions_when_none_applied() {
let pending = pending_migrations_from_applied(&[]);
assert_eq!(pending, all_up_migrations());
}
#[test]
fn pending_migrations_from_applied_skips_versions_already_applied() {
let applied = vec![
AppliedMigration {
version: 20260403000000,
checksum: Cow::Borrowed(&[]),
},
AppliedMigration {
version: 20260406000000,
checksum: Cow::Borrowed(&[]),
},
];
let pending_versions = pending_migrations_from_applied(&applied)
.into_iter()
.map(|migration| migration.version)
.collect::<Vec<_>>();
assert_eq!(pending_versions, vec![20260410000000, 20260413020000]);
}
#[test]
fn pending_migrations_from_applied_is_empty_after_baseline_v2_stamp() {
let applied = baseline_v2_migrations()
.expect("baseline_v2 migrations should resolve")
.into_iter()
.map(|migration| AppliedMigration {
version: migration.version,
checksum: migration.checksum.clone(),
})
.collect::<Vec<_>>();
let pending = pending_migrations_from_applied(&applied);
assert!(
pending.is_empty(),
"baseline_v2-stamped empty databases should not require a manual migration before first startup"
);
}
}

View File

@@ -385,15 +385,15 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
StoredAuthApiKeySnapshot {
api_key_id: record.api_key_id.clone(),
api_key_name: record.name.clone(),
api_key_is_active: true,
api_key_is_active: record.is_active,
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: Some(record.rate_limit),
api_key_concurrent_limit: Some(record.concurrent_limit),
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
api_key_allowed_api_formats: None,
api_key_allowed_models: None,
api_key_expires_at_unix_secs: record.expires_at_unix_secs,
api_key_allowed_providers: record.allowed_providers.clone(),
api_key_allowed_api_formats: record.allowed_api_formats.clone(),
api_key_allowed_models: record.allowed_models.clone(),
..template
}
} else {
@@ -413,15 +413,24 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
None,
record.api_key_id.clone(),
record.name.clone(),
true,
record.is_active,
false,
false,
Some(record.rate_limit),
Some(record.concurrent_limit),
None,
None,
None,
None,
record.expires_at_unix_secs.map(|value| value as i64),
record
.allowed_providers
.as_ref()
.map(|value| serde_json::json!(value)),
record
.allowed_api_formats
.as_ref()
.map(|value| serde_json::json!(value)),
record
.allowed_models
.as_ref()
.map(|value| serde_json::json!(value)),
)?
};
@@ -431,17 +440,26 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
record.key_hash.clone(),
record.key_encrypted,
record.name,
None,
None,
None,
record
.allowed_providers
.as_ref()
.map(|value| serde_json::json!(value)),
record
.allowed_api_formats
.as_ref()
.map(|value| serde_json::json!(value)),
record
.allowed_models
.as_ref()
.map(|value| serde_json::json!(value)),
Some(record.rate_limit),
Some(record.concurrent_limit),
None,
true,
None,
false,
0,
0.0,
record.force_capabilities,
record.is_active,
record.expires_at_unix_secs.map(|value| value as i64),
record.auto_delete_on_expiry,
record.total_requests as i64,
record.total_cost_usd,
false,
)?;
@@ -487,12 +505,12 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
StoredAuthApiKeySnapshot {
api_key_id: record.api_key_id.clone(),
api_key_name: record.name.clone(),
api_key_is_active: true,
api_key_is_active: record.is_active,
api_key_is_locked: false,
api_key_is_standalone: true,
api_key_rate_limit: Some(record.rate_limit),
api_key_concurrent_limit: Some(record.concurrent_limit),
api_key_expires_at_unix_secs: None,
api_key_expires_at_unix_secs: record.expires_at_unix_secs,
api_key_allowed_providers: record.allowed_providers.clone(),
api_key_allowed_api_formats: record.allowed_api_formats.clone(),
api_key_allowed_models: record.allowed_models.clone(),
@@ -515,12 +533,12 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
None,
record.api_key_id.clone(),
record.name.clone(),
true,
record.is_active,
false,
true,
Some(record.rate_limit),
Some(record.concurrent_limit),
None,
record.expires_at_unix_secs.map(|value| value as i64),
record
.allowed_providers
.as_ref()
@@ -556,12 +574,12 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
.map(|value| serde_json::json!(value)),
Some(record.rate_limit),
Some(record.concurrent_limit),
None,
true,
None,
false,
0,
0.0,
record.force_capabilities,
record.is_active,
record.expires_at_unix_secs.map(|value| value as i64),
record.auto_delete_on_expiry,
record.total_requests as i64,
record.total_cost_usd,
true,
)?;

View File

@@ -311,10 +311,14 @@ INSERT INTO api_keys (
key_hash,
key_encrypted,
name,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
concurrent_limit,
force_capabilities,
is_active,
expires_at,
is_locked,
is_standalone,
auto_delete_on_expiry,
@@ -331,13 +335,17 @@ VALUES (
$5,
$6,
$7,
NULL,
TRUE,
$8,
$9,
$10,
$11,
$12,
$13,
FALSE,
FALSE,
FALSE,
0,
0,
$14,
$15,
$16,
NOW(),
NOW()
)
@@ -375,6 +383,7 @@ INSERT INTO api_keys (
concurrent_limit,
force_capabilities,
is_active,
expires_at,
is_locked,
is_standalone,
auto_delete_on_expiry,
@@ -394,13 +403,14 @@ VALUES (
$8,
$9,
$10,
NULL,
TRUE,
$11,
$12,
$13,
FALSE,
TRUE,
FALSE,
0,
0,
$14,
$15,
$16,
NOW(),
NOW()
)
@@ -920,14 +930,46 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
&self,
record: CreateUserApiKeyRecord,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
let allowed_providers = record
.allowed_providers
.map(serde_json::to_value)
.transpose()
.map_err(|err| DataLayerError::UnexpectedValue(err.to_string()))?;
let allowed_api_formats = record
.allowed_api_formats
.map(serde_json::to_value)
.transpose()
.map_err(|err| DataLayerError::UnexpectedValue(err.to_string()))?;
let allowed_models = record
.allowed_models
.map(serde_json::to_value)
.transpose()
.map_err(|err| DataLayerError::UnexpectedValue(err.to_string()))?;
let expires_at = record
.expires_at_unix_secs
.map(|value| {
chrono::DateTime::<chrono::Utc>::from_timestamp(value as i64, 0).ok_or_else(|| {
DataLayerError::UnexpectedValue(format!("invalid api_keys.expires_at: {value}"))
})
})
.transpose()?;
let row = sqlx::query(CREATE_USER_API_KEY_SQL)
.bind(record.api_key_id)
.bind(record.user_id)
.bind(record.key_hash)
.bind(record.key_encrypted)
.bind(record.name)
.bind(allowed_providers)
.bind(allowed_api_formats)
.bind(allowed_models)
.bind(record.rate_limit)
.bind(record.concurrent_limit)
.bind(record.force_capabilities)
.bind(record.is_active)
.bind(expires_at)
.bind(record.auto_delete_on_expiry)
.bind(record.total_requests as i64)
.bind(record.total_cost_usd)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
@@ -953,6 +995,14 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
.map(serde_json::to_value)
.transpose()
.map_err(|err| DataLayerError::UnexpectedValue(err.to_string()))?;
let expires_at = record
.expires_at_unix_secs
.map(|value| {
chrono::DateTime::<chrono::Utc>::from_timestamp(value as i64, 0).ok_or_else(|| {
DataLayerError::UnexpectedValue(format!("invalid api_keys.expires_at: {value}"))
})
})
.transpose()?;
let row = sqlx::query(CREATE_STANDALONE_API_KEY_SQL)
.bind(record.api_key_id)
.bind(record.user_id)
@@ -964,6 +1014,12 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
.bind(allowed_models)
.bind(record.rate_limit)
.bind(record.concurrent_limit)
.bind(record.force_capabilities)
.bind(record.is_active)
.bind(expires_at)
.bind(record.auto_delete_on_expiry)
.bind(record.total_requests as i64)
.bind(record.total_cost_usd)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;

View File

@@ -352,15 +352,24 @@ pub struct StandaloneApiKeyExportListQuery {
pub is_active: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct CreateUserApiKeyRecord {
pub user_id: String,
pub api_key_id: String,
pub key_hash: String,
pub key_encrypted: Option<String>,
pub name: Option<String>,
pub allowed_providers: Option<Vec<String>>,
pub allowed_api_formats: Option<Vec<String>>,
pub allowed_models: Option<Vec<String>>,
pub rate_limit: i32,
pub concurrent_limit: i32,
pub force_capabilities: Option<serde_json::Value>,
pub is_active: bool,
pub expires_at_unix_secs: Option<u64>,
pub auto_delete_on_expiry: bool,
pub total_requests: u64,
pub total_cost_usd: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -371,7 +380,7 @@ pub struct UpdateUserApiKeyBasicRecord {
pub rate_limit: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct CreateStandaloneApiKeyRecord {
pub user_id: String,
pub api_key_id: String,
@@ -383,6 +392,12 @@ pub struct CreateStandaloneApiKeyRecord {
pub allowed_models: Option<Vec<String>>,
pub rate_limit: i32,
pub concurrent_limit: i32,
pub force_capabilities: Option<serde_json::Value>,
pub is_active: bool,
pub expires_at_unix_secs: Option<u64>,
pub auto_delete_on_expiry: bool,
pub total_requests: u64,
pub total_cost_usd: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@@ -46,6 +46,7 @@ impl InMemorySettlementWalletStore {
pub struct InMemorySettlementRepository {
wallets: InMemorySettlementWalletStore,
provider_monthly_used: RwLock<BTreeMap<String, f64>>,
settlements: RwLock<BTreeMap<String, StoredUsageSettlement>>,
}
impl InMemorySettlementRepository {
@@ -56,6 +57,7 @@ impl InMemorySettlementRepository {
Self {
wallets: InMemorySettlementWalletStore::seeded(items),
provider_monthly_used: RwLock::new(BTreeMap::new()),
settlements: RwLock::new(BTreeMap::new()),
}
}
@@ -63,6 +65,7 @@ impl InMemorySettlementRepository {
Self {
wallets: InMemorySettlementWalletStore::Shared(wallet_repository),
provider_monthly_used: RwLock::new(BTreeMap::new()),
settlements: RwLock::new(BTreeMap::new()),
}
}
}
@@ -75,7 +78,13 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
) -> Result<Option<StoredUsageSettlement>, DataLayerError> {
input.validate()?;
if input.billing_status != "pending" {
return Ok(Some(StoredUsageSettlement {
let existing = self
.settlements
.read()
.expect("settlement snapshot lock")
.get(&input.request_id)
.cloned();
return Ok(Some(existing.unwrap_or(StoredUsageSettlement {
request_id: input.request_id,
wallet_id: None,
billing_status: input.billing_status,
@@ -87,7 +96,7 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
wallet_gift_balance_after: None,
provider_monthly_used_usd: None,
finalized_at_unix_secs: input.finalized_at_unix_secs,
}));
})));
}
let final_billing_status = if input.status == "completed" {
@@ -172,6 +181,11 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
}
}
self.settlements
.write()
.expect("settlement snapshot lock")
.insert(settlement.request_id.clone(), settlement.clone());
Ok(Some(settlement))
}
}
@@ -225,4 +239,42 @@ mod tests {
assert_eq!(settlement.wallet_balance_after, Some(9.0));
assert_eq!(settlement.provider_monthly_used_usd, Some(1.5));
}
#[tokio::test]
async fn returns_stored_snapshot_when_usage_is_already_finalized() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
let settled = repository
.settle_usage(UsageSettlementInput {
request_id: "req-2".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
provider_id: Some("provider-1".to_string()),
status: "completed".to_string(),
billing_status: "pending".to_string(),
total_cost_usd: 2.0,
actual_total_cost_usd: 1.0,
finalized_at_unix_secs: Some(250),
})
.await
.expect("settlement should succeed")
.expect("settlement should exist");
let replay = repository
.settle_usage(UsageSettlementInput {
request_id: "req-2".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
provider_id: Some("provider-1".to_string()),
status: "completed".to_string(),
billing_status: "settled".to_string(),
total_cost_usd: 2.0,
actual_total_cost_usd: 1.0,
finalized_at_unix_secs: Some(250),
})
.await
.expect("replay should succeed")
.expect("snapshot should exist");
assert_eq!(replay, settled);
}
}

View File

@@ -6,6 +6,49 @@ use crate::error::SqlxResultExt;
use crate::postgres::PostgresTransactionRunner;
use crate::DataLayerError;
const FIND_USAGE_FOR_SETTLEMENT_SQL: &str = r#"
SELECT
usage_record.request_id,
COALESCE(usage_settlement_snapshots.wallet_id, usage_record.wallet_id) AS wallet_id,
usage_record.billing_status,
COALESCE(
CAST(usage_settlement_snapshots.wallet_balance_before AS DOUBLE PRECISION),
CAST(usage_record.wallet_balance_before AS DOUBLE PRECISION)
) AS wallet_balance_before,
COALESCE(
CAST(usage_settlement_snapshots.wallet_balance_after AS DOUBLE PRECISION),
CAST(usage_record.wallet_balance_after AS DOUBLE PRECISION)
) AS wallet_balance_after,
COALESCE(
CAST(usage_settlement_snapshots.wallet_recharge_balance_before AS DOUBLE PRECISION),
CAST(usage_record.wallet_recharge_balance_before AS DOUBLE PRECISION)
) AS wallet_recharge_balance_before,
COALESCE(
CAST(usage_settlement_snapshots.wallet_recharge_balance_after AS DOUBLE PRECISION),
CAST(usage_record.wallet_recharge_balance_after AS DOUBLE PRECISION)
) AS wallet_recharge_balance_after,
COALESCE(
CAST(usage_settlement_snapshots.wallet_gift_balance_before AS DOUBLE PRECISION),
CAST(usage_record.wallet_gift_balance_before AS DOUBLE PRECISION)
) AS wallet_gift_balance_before,
COALESCE(
CAST(usage_settlement_snapshots.wallet_gift_balance_after AS DOUBLE PRECISION),
CAST(usage_record.wallet_gift_balance_after AS DOUBLE PRECISION)
) AS wallet_gift_balance_after,
CAST(usage_settlement_snapshots.provider_monthly_used_usd AS DOUBLE PRECISION) AS provider_monthly_used_usd,
usage_record.provider_id,
CAST(
EXTRACT(
EPOCH FROM COALESCE(usage_settlement_snapshots.finalized_at, usage_record.finalized_at)
) AS BIGINT
) AS finalized_at_unix_secs
FROM "usage" AS usage_record
LEFT JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = usage_record.request_id
WHERE usage_record.request_id = $1
FOR UPDATE OF usage_record
"#;
const FINALIZE_USAGE_BILLING_SQL: &str = r#"
UPDATE "usage"
SET
@@ -14,6 +57,71 @@ SET
WHERE request_id = $1
"#;
const UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL: &str = r#"
INSERT INTO usage_settlement_snapshots (
request_id,
billing_status,
wallet_id,
wallet_balance_before,
wallet_balance_after,
wallet_recharge_balance_before,
wallet_recharge_balance_after,
wallet_gift_balance_before,
wallet_gift_balance_after,
provider_monthly_used_usd,
finalized_at
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
CASE
WHEN $11 IS NULL THEN NULL
ELSE TO_TIMESTAMP($11::double precision)
END
)
ON CONFLICT (request_id)
DO UPDATE SET
billing_status = EXCLUDED.billing_status,
wallet_id = COALESCE(EXCLUDED.wallet_id, usage_settlement_snapshots.wallet_id),
wallet_balance_before = COALESCE(
EXCLUDED.wallet_balance_before,
usage_settlement_snapshots.wallet_balance_before
),
wallet_balance_after = COALESCE(
EXCLUDED.wallet_balance_after,
usage_settlement_snapshots.wallet_balance_after
),
wallet_recharge_balance_before = COALESCE(
EXCLUDED.wallet_recharge_balance_before,
usage_settlement_snapshots.wallet_recharge_balance_before
),
wallet_recharge_balance_after = COALESCE(
EXCLUDED.wallet_recharge_balance_after,
usage_settlement_snapshots.wallet_recharge_balance_after
),
wallet_gift_balance_before = COALESCE(
EXCLUDED.wallet_gift_balance_before,
usage_settlement_snapshots.wallet_gift_balance_before
),
wallet_gift_balance_after = COALESCE(
EXCLUDED.wallet_gift_balance_after,
usage_settlement_snapshots.wallet_gift_balance_after
),
provider_monthly_used_usd = COALESCE(
EXCLUDED.provider_monthly_used_usd,
usage_settlement_snapshots.provider_monthly_used_usd
),
finalized_at = COALESCE(EXCLUDED.finalized_at, usage_settlement_snapshots.finalized_at),
updated_at = NOW()
"#;
#[derive(Debug, Clone)]
pub struct SqlxSettlementRepository {
tx_runner: PostgresTransactionRunner,
@@ -26,6 +134,62 @@ impl SqlxSettlementRepository {
}
}
fn settlement_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<StoredUsageSettlement, DataLayerError> {
Ok(StoredUsageSettlement {
request_id: row.try_get("request_id").map_postgres_err()?,
wallet_id: row.try_get("wallet_id").map_postgres_err()?,
billing_status: row.try_get("billing_status").map_postgres_err()?,
wallet_balance_before: row.try_get("wallet_balance_before").map_postgres_err()?,
wallet_balance_after: row.try_get("wallet_balance_after").map_postgres_err()?,
wallet_recharge_balance_before: row
.try_get("wallet_recharge_balance_before")
.map_postgres_err()?,
wallet_recharge_balance_after: row
.try_get("wallet_recharge_balance_after")
.map_postgres_err()?,
wallet_gift_balance_before: row
.try_get("wallet_gift_balance_before")
.map_postgres_err()?,
wallet_gift_balance_after: row
.try_get("wallet_gift_balance_after")
.map_postgres_err()?,
provider_monthly_used_usd: row
.try_get("provider_monthly_used_usd")
.map_postgres_err()?,
finalized_at_unix_secs: row
.try_get::<Option<i64>, _>("finalized_at_unix_secs")
.map_postgres_err()?
.map(|value| value as u64),
})
}
async fn sync_usage_settlement_snapshot<'e, E>(
executor: E,
settlement: &StoredUsageSettlement,
) -> Result<(), DataLayerError>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as f64))
.execute(executor)
.await
.map_postgres_err()?;
Ok(())
}
#[async_trait]
impl SettlementWriteRepository for SqlxSettlementRepository {
async fn settle_usage(
@@ -36,29 +200,11 @@ impl SettlementWriteRepository for SqlxSettlementRepository {
self.tx_runner
.run_read_write(|tx| {
Box::pin(async move {
let row = sqlx::query(
r#"
SELECT
request_id,
wallet_id,
billing_status,
CAST(wallet_balance_before AS DOUBLE PRECISION) AS wallet_balance_before,
CAST(wallet_balance_after AS DOUBLE PRECISION) AS wallet_balance_after,
CAST(wallet_recharge_balance_before AS DOUBLE PRECISION) AS wallet_recharge_balance_before,
CAST(wallet_recharge_balance_after AS DOUBLE PRECISION) AS wallet_recharge_balance_after,
CAST(wallet_gift_balance_before AS DOUBLE PRECISION) AS wallet_gift_balance_before,
CAST(wallet_gift_balance_after AS DOUBLE PRECISION) AS wallet_gift_balance_after,
provider_id,
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
FROM "usage"
WHERE request_id = $1
FOR UPDATE
"#,
)
.bind(&input.request_id)
.fetch_optional(&mut **tx)
.await
.map_postgres_err()?;
let row = sqlx::query(FIND_USAGE_FOR_SETTLEMENT_SQL)
.bind(&input.request_id)
.fetch_optional(&mut **tx)
.await
.map_postgres_err()?;
let Some(usage_row) = row else {
return Ok(None);
@@ -67,34 +213,7 @@ FOR UPDATE
let current_billing_status: String =
usage_row.try_get("billing_status").map_postgres_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
return Ok(Some(StoredUsageSettlement {
request_id: usage_row.try_get("request_id").map_postgres_err()?,
wallet_id: usage_row.try_get("wallet_id").map_postgres_err()?,
billing_status: current_billing_status,
wallet_balance_before: usage_row
.try_get("wallet_balance_before")
.map_postgres_err()?,
wallet_balance_after: usage_row
.try_get("wallet_balance_after")
.map_postgres_err()?,
wallet_recharge_balance_before: usage_row
.try_get("wallet_recharge_balance_before")
.map_postgres_err()?,
wallet_recharge_balance_after: usage_row
.try_get("wallet_recharge_balance_after")
.map_postgres_err()?,
wallet_gift_balance_before: usage_row
.try_get("wallet_gift_balance_before")
.map_postgres_err()?,
wallet_gift_balance_after: usage_row
.try_get("wallet_gift_balance_after")
.map_postgres_err()?,
provider_monthly_used_usd: None,
finalized_at_unix_secs: usage_row
.try_get::<Option<i64>, _>("finalized_at_unix_secs")
.map_postgres_err()?
.map(|value| value as u64),
}));
return settlement_from_row(&usage_row).map(Some);
}
let final_billing_status = if input.status == "completed" {
@@ -223,32 +342,6 @@ WHERE id = $1
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
sqlx::query(
r#"
UPDATE "usage"
SET
wallet_id = $2,
wallet_balance_before = $3,
wallet_balance_after = $4,
wallet_recharge_balance_before = $5,
wallet_recharge_balance_after = $6,
wallet_gift_balance_before = $7,
wallet_gift_balance_after = $8
WHERE request_id = $1
"#,
)
.bind(&input.request_id)
.bind(&wallet_id)
.bind(before_total)
.bind(after_recharge + after_gift)
.bind(before_recharge)
.bind(after_recharge)
.bind(before_gift)
.bind(after_gift)
.execute(&mut **tx)
.await
.map_postgres_err()?;
}
if let Some(provider_id) = input
@@ -283,6 +376,7 @@ RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
.execute(&mut **tx)
.await
.map_postgres_err()?;
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
Ok(Some(settlement))
})
@@ -297,4 +391,28 @@ mod tests {
fn finalize_usage_billing_sql_does_not_require_usage_updated_at_column() {
assert!(!super::FINALIZE_USAGE_BILLING_SQL.contains("updated_at"));
}
#[test]
fn settlement_sql_reads_settlement_snapshots_before_legacy_usage_columns() {
assert!(
super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("LEFT JOIN usage_settlement_snapshots")
);
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("COALESCE("));
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("FOR UPDATE OF usage_record"));
}
#[test]
fn settlement_sql_dual_writes_usage_settlement_snapshots() {
assert!(super::UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL
.contains("INSERT INTO usage_settlement_snapshots"));
assert!(super::UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL.contains("provider_monthly_used_usd"));
assert!(super::UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL
.contains("TO_TIMESTAMP($11::double precision)"));
}
#[test]
fn settlement_sql_no_longer_dual_writes_wallet_snapshots_to_usage_rows() {
let source = include_str!("sql.rs");
assert!(!source.contains("UPDATE \"usage\"\nSET\n wallet_id = $2"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,3 +9,92 @@ pub(crate) use aether_data_contracts::repository::usage::{
};
pub use memory::InMemoryUsageReadRepository;
pub use sql::SqlxUsageReadRepository;
pub(crate) fn strip_deprecated_usage_display_fields(
mut usage: UpsertUsageRecord,
) -> UpsertUsageRecord {
usage.username = None;
usage.api_key_name = None;
usage
}
#[cfg(test)]
mod tests {
use super::{strip_deprecated_usage_display_fields, UpsertUsageRecord};
#[test]
fn strip_deprecated_usage_display_fields_clears_legacy_display_columns() {
let usage = strip_deprecated_usage_display_fields(UpsertUsageRecord {
request_id: "req-1".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
username: Some("alice".to_string()),
api_key_name: Some("default".to_string()),
provider_name: "OpenAI".to_string(),
model: "gpt-5".to_string(),
target_model: None,
provider_id: None,
provider_endpoint_id: None,
provider_api_key_id: None,
request_type: Some("chat".to_string()),
api_format: Some("openai:chat".to_string()),
api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()),
endpoint_api_format: Some("openai:chat".to_string()),
provider_api_family: Some("openai".to_string()),
provider_endpoint_kind: Some("chat".to_string()),
has_format_conversion: Some(false),
is_stream: Some(false),
input_tokens: Some(10),
output_tokens: Some(20),
total_tokens: Some(30),
cache_creation_input_tokens: None,
cache_creation_ephemeral_5m_input_tokens: None,
cache_creation_ephemeral_1h_input_tokens: None,
cache_read_input_tokens: None,
cache_creation_cost_usd: None,
cache_read_cost_usd: None,
output_price_per_1m: None,
total_cost_usd: Some(0.25),
actual_total_cost_usd: Some(0.15),
status_code: Some(200),
error_message: None,
error_category: None,
response_time_ms: Some(120),
first_byte_time_ms: Some(40),
status: "completed".to_string(),
billing_status: "pending".to_string(),
request_headers: None,
request_body: None,
request_body_ref: None,
provider_request_headers: None,
provider_request_body: None,
provider_request_body_ref: None,
response_headers: None,
response_body: None,
response_body_ref: None,
client_response_headers: None,
client_response_body: None,
client_response_body_ref: None,
candidate_id: None,
candidate_index: None,
key_name: None,
planner_kind: None,
route_family: None,
route_kind: None,
execution_path: None,
local_execution_runtime_miss_reason: None,
request_metadata: None,
finalized_at_unix_secs: None,
created_at_unix_ms: Some(100),
updated_at_unix_secs: 101,
});
assert_eq!(usage.user_id.as_deref(), Some("user-1"));
assert_eq!(usage.api_key_id.as_deref(), Some("key-1"));
assert_eq!(usage.username, None);
assert_eq!(usage.api_key_name, None);
assert_eq!(usage.provider_name, "OpenAI");
assert_eq!(usage.model, "gpt-5");
}
}

File diff suppressed because it is too large Load Diff