diff --git a/README.md b/README.md index fa7737cd5..dc8d05dbd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /v1/responses · /v1/chat/completions · /v1/models · 管理控制台

-Codex Proxy RS 是一个面向 ChatGPT/Codex 账号池的 Rust 模型网关。它把上游 Codex 能力整理成 OpenAI 兼容接口,同时提供账号调度、配额感知、请求追踪、事件日志和管理控制台。 +Codex Proxy RS 是一个面向 ChatGPT/Codex 账号池的 Rust 模型网关。它把上游 Codex 能力整理成 OpenAI 兼容接口,同时提供账号调度、配额感知、使用记录和管理控制台。 它不是一个臃肿的平台,也不是只做转发的薄代理。它更像一层安静但可靠的网关:把账号、模型、配额、Cookie、WebSocket、SSE 和错误恢复放在后端统一处理,让调用方只需要面对稳定、清晰的 OpenAI 风格接口。 @@ -26,7 +26,7 @@ Codex Proxy RS 关注这些细节: - 请求应该落到哪个账号,是否要保持会话亲和性 - 上游返回的是 HTTP SSE、WebSocket 还是错误事件 - Cookie、fingerprint、headers 和 Codex Desktop 行为是否足够接近 -- 管理端能否看清请求、用量、事件、延迟和失败原因 +- 管理端能否看清请求、用量、延迟和失败原因 ## 核心能力 @@ -51,9 +51,9 @@ Codex Proxy RS 关注这些细节: **可观测与管理** -- 管理控制台内置账号、密钥、事件日志、用量统计和系统设置 +- 管理控制台内置账号、密钥、使用记录、用量统计和系统设置 - SQLite 持久化,账号、Cookie 与访问密钥采用统一的本地存储策略 -- 结构化事件日志,便于定位请求失败、上游状态和调度路径 +- 结构化使用记录,便于定位请求失败、上游状态和调度路径 ## 快速开始 @@ -80,7 +80,7 @@ admin: ## 管理控制台 -前端位于 `web/`,用于账号管理、密钥管理、事件日志、仪表盘和系统设置。 +前端位于 `web/`,用于账号管理、密钥管理、使用记录、仪表盘和系统设置。 开发模式: @@ -143,7 +143,7 @@ flowchart LR http --> admin[admin
管理端 API] admin --> runtime[runtime
启动装配、状态和后台任务] runtime --> infra[infra
SQLite、路径和日志] - proxy --> telemetry[telemetry
事件日志和用量记录] + proxy --> telemetry[telemetry
使用记录和用量统计] admin --> telemetry http --> web[web
管理控制台静态资源] ``` diff --git a/src/admin/accounts/service/mod.rs b/src/admin/accounts/service/mod.rs index 1f5836a44..33ae02be4 100644 --- a/src/admin/accounts/service/mod.rs +++ b/src/admin/accounts/service/mod.rs @@ -15,8 +15,10 @@ use chrono::{DateTime, Duration}; use crate::{ upstream::accounts::{ - cookies::SqliteCookieStore, pool::RuntimeAccountPoolService, store::SqliteAccountStore, - token_refresh::TokenRefresher, + cookies::SqliteCookieStore, + pool::RuntimeAccountPoolService, + store::SqliteAccountStore, + token_refresh::{RuntimeRefreshPolicy, TokenRefresher}, }, upstream::transport::CodexBackendClient, }; @@ -35,7 +37,7 @@ pub struct AdminAccountService { pub(crate) account_pool: StdArc, pub(crate) token_refresher: StdArc, pub(crate) oauth: oauth::AccountOAuthService, - pub(crate) refresh_margin_seconds: u64, + pub(crate) refresh_policy: RuntimeRefreshPolicy, pub(crate) installation_id: Option, } @@ -46,7 +48,7 @@ pub struct AdminAccountServiceParts { pub account_pool: StdArc, pub token_refresher: StdArc, pub oauth: oauth::AccountOAuthService, - pub refresh_margin_seconds: u64, + pub refresh_policy: RuntimeRefreshPolicy, pub installation_id: Option, } @@ -59,7 +61,7 @@ impl AdminAccountService { account_pool: parts.account_pool, token_refresher: parts.token_refresher, oauth: parts.oauth, - refresh_margin_seconds: parts.refresh_margin_seconds, + refresh_policy: parts.refresh_policy, installation_id: parts.installation_id, } } @@ -68,7 +70,10 @@ impl AdminAccountService { &self, expires_at: DateTime, ) -> DateTime { - let margin_seconds = self.refresh_margin_seconds.min(i64::MAX as u64) as i64; + let margin_seconds = self + .refresh_policy + .refresh_margin_seconds() + .min(i64::MAX as u64) as i64; expires_at - Duration::seconds(margin_seconds) } diff --git a/src/admin/accounts/service/testing.rs b/src/admin/accounts/service/testing.rs index 276c5e788..c48d2785d 100644 --- a/src/admin/accounts/service/testing.rs +++ b/src/admin/accounts/service/testing.rs @@ -204,6 +204,8 @@ fn test_responses_request(model: String) -> CodexResponsesRequest { variant_identity: None, include: None, client_metadata: None, + client_ip: None, + client_user_agent: None, use_websocket: false, force_http_sse: true, turn_state: None, diff --git a/src/admin/monitoring/billing.rs b/src/admin/monitoring/billing.rs index 6dbb1f413..8593a248c 100644 --- a/src/admin/monitoring/billing.rs +++ b/src/admin/monitoring/billing.rs @@ -150,14 +150,37 @@ const MODEL_PRICING_RULES: &[ModelPricingRule] = &[ }, ]; -/// 按 codex2api 的 billing 引擎口径计算美元计费。 -pub(in crate::admin) fn calculate_cost( +/// 单次请求计费明细。 +#[derive(Debug, Clone, PartialEq)] +pub(in crate::admin) struct CostBreakdown { + /// 输入成本。 + pub input_cost: f64, + /// 输出成本。 + pub output_cost: f64, + /// 缓存读取成本。 + pub cache_read_cost: f64, + /// 总成本。 + pub total_cost: f64, + /// 输入单价,美元 / 1M token。 + pub input_price_per_mtoken: f64, + /// 输出单价,美元 / 1M token。 + pub output_price_per_mtoken: f64, + /// 缓存读取单价,美元 / 1M token。 + pub cache_read_price_per_mtoken: f64, + /// 服务档位。 + pub service_tier: Option, + /// 服务档位倍率。 + pub tier_multiplier: f64, +} + +/// 按 codex2api 的 billing 引擎口径计算美元计费明细。 +pub(in crate::admin) fn calculate_cost_breakdown( input_tokens: u64, output_tokens: u64, cached_tokens: u64, model: &str, service_tier: Option<&str>, -) -> f64 { +) -> CostBreakdown { let pricing = model_pricing(model); let is_long = input_tokens > LONG_CONTEXT_THRESHOLD; @@ -202,8 +225,37 @@ pub(in crate::admin) fn calculate_cost( let input_cost = uncached_input_tokens as f64 / 1_000_000.0 * input_price; let cache_read_cost = cached_tokens as f64 / 1_000_000.0 * cache_read_price; let output_cost = output_tokens as f64 / 1_000_000.0 * output_price; + let total_cost = (input_cost + cache_read_cost + output_cost) * tier_multiplier; - (input_cost + cache_read_cost + output_cost) * tier_multiplier + CostBreakdown { + input_cost, + output_cost, + cache_read_cost, + total_cost, + input_price_per_mtoken: input_price, + output_price_per_mtoken: output_price, + cache_read_price_per_mtoken: cache_read_price, + service_tier: normalize_service_tier(service_tier), + tier_multiplier, + } +} + +/// 按 codex2api 的 billing 引擎口径计算美元计费。 +pub(in crate::admin) fn calculate_cost( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + model: &str, + service_tier: Option<&str>, +) -> f64 { + calculate_cost_breakdown( + input_tokens, + output_tokens, + cached_tokens, + model, + service_tier, + ) + .total_cost } fn model_pricing(model: &str) -> ModelPricing { diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index 2d7891759..840d93487 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -19,21 +19,21 @@ use crate::{ diagnostics::{ fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics, }, - event_store::AdminLogFilter, - events::{EventLevel, EventLog}, service::{AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord}, + usage_record_store::AdminUsageRecordFilter, + usage_records::{account_email_map, usage_record_items, UsageRecordData}, }, response::{AdminEnvelope, AdminError, AdminResponse}, }, infra::time::{ china_datetime, china_datetime_rfc3339_str, china_day_start, china_hour, china_hour_start, - china_quarter_hour_start, china_relative_time, china_time, + china_quarter_hour_start, china_relative_time, }, runtime::state::AppState, upstream::accounts::model::{Account, AccountStatus}, }; -const DASHBOARD_LOG_LIMIT: u32 = 1000; +const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4; @@ -60,7 +60,7 @@ struct DashboardSummaryData { health_timeline: DashboardHealthTimelineData, account_usage: Vec, service_statuses: Vec, - event_logs: Vec, + usage_records: Vec, pool_summary: AccountPoolDiagnostics, capacity_info: AccountCapacityDiagnostics, rotation_strategy: Option, @@ -171,19 +171,6 @@ struct DashboardAccountUsageData { status: String, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct DashboardEventLogData { - id: String, - time: String, - level: EventLevel, - request_id: Option, - route: Option, - model: Option, - status_code: Option, - latency_ms: Option, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DashboardServiceStatusData { @@ -258,10 +245,14 @@ pub(crate) async fn dashboard_summary( .await .map(|page| page.items) .unwrap_or_default(); - let logs = state + let recent_events = state .services - .logs - .list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default()) + .usage_records + .list( + None, + DASHBOARD_USAGE_RECORD_LIMIT, + AdminUsageRecordFilter::default(), + ) .await .map(|page| page.items) .unwrap_or_default(); @@ -269,10 +260,16 @@ pub(crate) async fn dashboard_summary( let now = Utc::now(); let time_buckets = dashboard_time_buckets(&state, now).await; let pool_summary = account_pool_summary(&accounts); - let dashboard_logs = logs.iter().take(10).map(dashboard_event_log_data).collect(); let trend = dashboard_trend_data(&time_buckets, DashboardTrendKind::Usage); let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await; let settings = state.services.settings.current(); + let recent_usage_records = recent_events.into_iter().take(10).collect::>(); + let account_emails = account_email_map(&state, &recent_usage_records).await?; + let dashboard_usage_records = usage_record_items( + recent_usage_records, + &account_emails, + &settings.model_aliases, + ); Ok(AdminResponse::new( StatusCode::OK, @@ -282,7 +279,7 @@ pub(crate) async fn dashboard_summary( health_timeline: dashboard_health_timeline_data(&time_buckets), account_usage: account_usage_data(&accounts, &usage_records, "a_used_by_account), service_statuses: service_status_data(&state), - event_logs: dashboard_logs, + usage_records: dashboard_usage_records, pool_summary, capacity_info: AccountCapacityDiagnostics::from(capacity), rotation_strategy: Some(settings.auth.rotation_strategy.clone()), @@ -421,10 +418,10 @@ fn dashboard_trend_data_at( if record.bucket_start < start || record.bucket_start > now { continue; } - let log_hour = china_hour_start(record.bucket_start); + let record_hour = china_hour_start(record.bucket_start); if let Some((_, bucket)) = buckets .iter_mut() - .find(|(bucket_start, _)| *bucket_start == log_hour) + .find(|(bucket_start, _)| *bucket_start == record_hour) { apply_bucket(bucket, record); } @@ -487,10 +484,10 @@ fn dashboard_health_timeline_data_at( if record.bucket_start < start || record.bucket_start > now { continue; } - let log_slot = china_quarter_hour_start(record.bucket_start); + let record_slot = china_quarter_hour_start(record.bucket_start); if let Some((_, bucket)) = buckets .iter_mut() - .find(|(bucket_start, _)| *bucket_start == log_slot) + .find(|(bucket_start, _)| *bucket_start == record_slot) { apply_bucket(bucket, record); } @@ -798,19 +795,6 @@ fn account_usage_data( rows } -fn dashboard_event_log_data(log: &EventLog) -> DashboardEventLogData { - DashboardEventLogData { - id: log.id.clone(), - time: china_time(&log.created_at), - level: log.level, - request_id: log.request_id.clone(), - route: log.route.clone(), - model: log.model.clone(), - status_code: log.status_code, - latency_ms: log.latency_ms, - } -} - async fn account_quota_used_percent_by_id( state: &AppState, usage_records: &[AdminUsageRecord], diff --git a/src/admin/monitoring/event_store.rs b/src/admin/monitoring/event_store.rs deleted file mode 100644 index ee47eb498..000000000 --- a/src/admin/monitoring/event_store.rs +++ /dev/null @@ -1,743 +0,0 @@ -//! 事件日志存储实现(SQLite)。 - -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; -use thiserror::Error; - -use crate::admin::monitoring::events::{EventLevel, EventLog}; -use crate::infra::json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page}; -use crate::infra::time::china_quarter_hour_start; - -/// 默认保留最新事件日志数量。 -pub const DEFAULT_EVENT_LOG_CAPACITY: u32 = 2_000; -/// 默认不保存请求/响应体,避免把用户内容写入诊断日志。 -pub const DEFAULT_EVENT_LOG_CAPTURE_BODY: bool = false; - -/// SQLite 事件日志错误。 -#[derive(Debug, Error)] -pub enum SqliteEventLogStoreError { - /// 数据库错误。 - #[error("sqlite event log database error: {0}")] - Database(#[from] sqlx::Error), - /// JSON 错误。 - #[error("sqlite event log json error: {0}")] - Json(#[from] serde_json::Error), - /// 时间格式错误。 - #[error("sqlite event log timestamp error: {0}")] - Timestamp(#[from] chrono::ParseError), - /// 事件等级非法。 - #[error("invalid event level: {0}")] - InvalidLevel(String), - /// 分页游标非法。 - #[error("invalid event log pagination cursor")] - InvalidCursor, -} - -/// SQLite 事件日志结果。 -pub type SqliteEventLogStoreResult = Result; - -/// 事件日志查询过滤器。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EventLogFilter { - /// 事件类别。 - pub kind: Option, - /// 事件等级。 - pub level: Option, - /// 请求 ID。 - pub request_id: Option, - /// 账号 ID。 - pub account_id: Option, - /// 路由。 - pub route: Option, - /// 模型。 - pub model: Option, - /// HTTP 状态码。 - pub status_code: Option, - /// 上游传输方式。 - pub transport: Option, - /// 同一请求内的上游尝试序号。 - pub attempt_index: Option, - /// 上游 HTTP 状态码。 - pub upstream_status_code: Option, - /// 失败分类。 - pub failure_class: Option, - /// 上游响应 ID。 - pub response_id: Option, - /// 上游请求 ID。 - pub upstream_request_id: Option, - /// 搜索关键词。 - pub search: Option, -} - -/// SQLite 事件日志存储。 -#[derive(Clone)] -pub struct SqliteEventLogStore { - pool: SqlitePool, -} - -impl SqliteEventLogStore { - /// 构造存储。 - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - /// 写入事件日志。 - pub async fn append(&self, event: &EventLog) -> SqliteEventLogStoreResult<()> { - append_event(&self.pool, event).await - } - - /// 保留最新的指定数量事件日志。 - pub async fn trim_to_capacity(&self, capacity: u32) -> SqliteEventLogStoreResult { - trim_to_capacity(&self.pool, capacity).await - } - - /// 分页查询事件日志。 - pub async fn list( - &self, - filter: EventLogFilter, - cursor: Option, - limit: u32, - ) -> SqliteEventLogStoreResult> { - let fetch_limit = i64::from(limit) + 1; - let mut builder = QueryBuilder::::new( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs", - ); - push_filter(&mut builder, &filter, cursor.as_deref())?; - builder.push(" order by created_at desc, id desc limit "); - builder.push_bind(fetch_limit); - - let rows = builder.build().fetch_all(&self.pool).await?; - let has_next = rows.len() > limit as usize; - let take_count = rows.len().min(limit as usize); - let items = rows - .into_iter() - .take(take_count) - .map(|row| event_from_row(&row)) - .collect::>>()?; - let next_cursor = if has_next { - items - .last() - .map(|event| encode_cursor(&event.created_at.to_rfc3339(), &event.id)) - } else { - None - }; - - Ok(Page { items, next_cursor }) - } - - /// 按页码查询事件日志。 - pub async fn list_page( - &self, - filter: EventLogFilter, - page: u32, - page_size: u32, - ) -> SqliteEventLogStoreResult> { - let page_size = page_size.clamp(1, 200); - let total = count_events(&self.pool, &filter).await?; - let offset = page_offset(page, page_size); - let mut builder = QueryBuilder::::new( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs", - ); - push_filter(&mut builder, &filter, None)?; - builder.push(" order by created_at desc, id desc limit "); - builder.push_bind(i64::from(page_size)); - builder.push(" offset "); - builder.push_bind(offset.min(i64::MAX as u64) as i64); - - let rows = builder.build().fetch_all(&self.pool).await?; - let items = rows - .iter() - .map(event_from_row) - .collect::>>()?; - - Ok(NumberedPage { - items, - total, - page: page.max(1), - page_size, - }) - } - - /// 按 ID 读取事件日志。 - pub async fn get(&self, id: &str) -> SqliteEventLogStoreResult> { - let row = sqlx::query( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs where id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; - row.map(|row| event_from_row(&row)).transpose() - } - - /// 清空事件日志。 - pub async fn clear(&self) -> SqliteEventLogStoreResult { - let result = sqlx::query("delete from event_logs") - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } -} - -#[derive(Debug, Clone)] -struct EventLogSummaryFields { - transport: Option, - attempt_index: Option, - upstream_status_code: Option, - failure_class: Option, - response_id: Option, - upstream_request_id: Option, -} - -fn event_summary_fields(event: &EventLog) -> EventLogSummaryFields { - EventLogSummaryFields { - transport: event - .transport - .clone() - .or_else(|| metadata_string(&event.metadata, &["transport"])), - attempt_index: event - .attempt_index - .or_else(|| metadata_i64(&event.metadata, &["attemptIndex", "attempt_index"])), - upstream_status_code: event.upstream_status_code.or_else(|| { - metadata_i64( - &event.metadata, - &[ - "upstreamStatus", - "upstreamStatusCode", - "upstream_status_code", - ], - ) - }), - failure_class: event - .failure_class - .clone() - .or_else(|| metadata_string(&event.metadata, &["failureClass", "failure_class"])), - response_id: event - .response_id - .clone() - .or_else(|| metadata_string(&event.metadata, &["responseId", "response_id"])), - upstream_request_id: event.upstream_request_id.clone().or_else(|| { - metadata_string( - &event.metadata, - &[ - "upstreamRequestId", - "upstream_request_id", - "openaiRequestId", - "cfRay", - ], - ) - }), - } -} - -fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - metadata - .get(*key) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(ToString::to_string) - }) -} - -fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) -} - -async fn append_event(pool: &SqlitePool, event: &EventLog) -> SqliteEventLogStoreResult<()> { - let summary = event_summary_fields(event); - sqlx::query( - "insert into event_logs (id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&event.id) - .bind(&event.request_id) - .bind(&event.kind) - .bind(level_to_db(event.level)) - .bind(&event.account_id) - .bind(&event.route) - .bind(&event.model) - .bind(event.status_code) - .bind(summary.transport) - .bind(summary.attempt_index) - .bind(summary.upstream_status_code) - .bind(summary.failure_class) - .bind(summary.response_id) - .bind(summary.upstream_request_id) - .bind(event.latency_ms) - .bind(&event.message) - .bind(event.metadata.to_string()) - .bind(event.created_at.to_rfc3339()) - .execute(pool) - .await?; - append_usage_time_bucket(pool, event).await?; - Ok(()) -} - -async fn append_usage_time_bucket( - pool: &SqlitePool, - event: &EventLog, -) -> SqliteEventLogStoreResult<()> { - let bucket_start = china_quarter_hour_start(event.created_at); - let error_count = i64::from(is_error_event(event)); - let input_tokens = metadata_usage_i64(&event.metadata, "inputTokens"); - let output_tokens = metadata_usage_i64(&event.metadata, "outputTokens"); - let cached_tokens = metadata_usage_i64(&event.metadata, "cachedTokens"); - let first_token_latency = metadata_first_token_latency(&event.metadata).unwrap_or(0); - let first_token_latency_count = i64::from(first_token_latency > 0); - let latency = event.latency_ms.filter(|value| *value > 0).unwrap_or(0); - let latency_count = i64::from(latency > 0); - let now = Utc::now().to_rfc3339(); - - sqlx::query( - r" -insert into usage_time_buckets ( - bucket_start, - account_id, - model, - service_tier, - request_count, - error_count, - input_tokens, - output_tokens, - cached_tokens, - first_token_latency_sum, - first_token_latency_count, - latency_sum, - latency_count, - max_latency_ms, - min_latency_ms, - updated_at -) values (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -on conflict(bucket_start, account_id, model, service_tier) do update set - request_count = request_count + excluded.request_count, - error_count = error_count + excluded.error_count, - input_tokens = input_tokens + excluded.input_tokens, - output_tokens = output_tokens + excluded.output_tokens, - cached_tokens = cached_tokens + excluded.cached_tokens, - first_token_latency_sum = first_token_latency_sum + excluded.first_token_latency_sum, - first_token_latency_count = first_token_latency_count + excluded.first_token_latency_count, - latency_sum = latency_sum + excluded.latency_sum, - latency_count = latency_count + excluded.latency_count, - max_latency_ms = max(max_latency_ms, excluded.max_latency_ms), - min_latency_ms = case - when min_latency_ms = 0 then excluded.min_latency_ms - when excluded.min_latency_ms = 0 then min_latency_ms - else min(min_latency_ms, excluded.min_latency_ms) - end, - updated_at = excluded.updated_at", - ) - .bind(bucket_start.to_rfc3339()) - .bind(event.account_id.as_deref().unwrap_or_default()) - .bind(event.model.as_deref().unwrap_or_default()) - .bind(metadata_service_tier(&event.metadata).unwrap_or_default()) - .bind(error_count) - .bind(input_tokens) - .bind(output_tokens) - .bind(cached_tokens) - .bind(first_token_latency) - .bind(first_token_latency_count) - .bind(latency) - .bind(latency_count) - .bind(latency) - .bind(latency) - .bind(now) - .execute(pool) - .await?; - Ok(()) -} - -fn is_error_event(event: &EventLog) -> bool { - event.level == EventLevel::Error || event.status_code.is_some_and(|status| status >= 400) -} - -fn metadata_usage_i64(metadata: &Value, field: &str) -> i64 { - metadata - .get("usage") - .and_then(|usage| usage.get(field)) - .or_else(|| metadata.get(field)) - .and_then(Value::as_i64) - .filter(|value| *value > 0) - .unwrap_or(0) -} - -fn metadata_first_token_latency(metadata: &Value) -> Option { - [ - "firstTokenMs", - "first_token_ms", - "firstTokenLatencyMs", - "first_token_latency_ms", - ] - .into_iter() - .find_map(|field| { - metadata - .get(field) - .or_else(|| metadata.get("usage").and_then(|usage| usage.get(field))) - .and_then(Value::as_i64) - .filter(|value| *value > 0) - }) -} - -fn metadata_service_tier(metadata: &Value) -> Option<&str> { - metadata_service_tier_value(metadata) - .or_else(|| metadata.get("usage").and_then(metadata_service_tier_value)) -} - -fn metadata_service_tier_value(value: &Value) -> Option<&str> { - [ - "billingServiceTier", - "billing_service_tier", - "actualServiceTier", - "actual_service_tier", - "serviceTier", - "service_tier", - ] - .into_iter() - .find_map(|field| value.get(field).and_then(Value::as_str)) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -async fn trim_to_capacity(pool: &SqlitePool, capacity: u32) -> SqliteEventLogStoreResult { - let result = sqlx::query( - "delete from event_logs where id in ( - select id from event_logs - order by created_at desc, id desc - limit -1 offset ? - )", - ) - .bind(i64::from(capacity)) - .execute(pool) - .await?; - Ok(result.rows_affected()) -} - -async fn count_events( - pool: &SqlitePool, - filter: &EventLogFilter, -) -> SqliteEventLogStoreResult { - let mut builder = QueryBuilder::::new("select count(*) from event_logs"); - push_filter(&mut builder, filter, None)?; - let (total,): (i64,) = builder.build_query_as().fetch_one(pool).await?; - Ok(total.max(0).cast_unsigned()) -} - -fn push_filter( - builder: &mut QueryBuilder, - filter: &EventLogFilter, - cursor: Option<&str>, -) -> SqliteEventLogStoreResult<()> { - let mut separated = builder.separated(" and "); - separated.push(" where 1 = 1"); - - if let Some(kind) = filter.kind.as_deref() { - separated.push("kind = "); - separated.push_bind_unseparated(kind); - } - if let Some(level) = filter.level { - separated.push("level = "); - separated.push_bind_unseparated(level_to_db(level)); - } - if let Some(request_id) = filter.request_id.as_deref() { - separated.push("request_id = "); - separated.push_bind_unseparated(request_id); - } - if let Some(account_id) = filter.account_id.as_deref() { - separated.push("account_id = "); - separated.push_bind_unseparated(account_id); - } - if let Some(route) = filter.route.as_deref() { - separated.push("route = "); - separated.push_bind_unseparated(route); - } - if let Some(model) = filter.model.as_deref() { - separated.push("model = "); - separated.push_bind_unseparated(model); - } - if let Some(status_code) = filter.status_code { - separated.push("status_code = "); - separated.push_bind_unseparated(status_code); - } - if let Some(transport) = filter.transport.as_deref() { - separated.push("transport = "); - separated.push_bind_unseparated(transport); - } - if let Some(attempt_index) = filter.attempt_index { - separated.push("attempt_index = "); - separated.push_bind_unseparated(attempt_index); - } - if let Some(upstream_status_code) = filter.upstream_status_code { - separated.push("upstream_status_code = "); - separated.push_bind_unseparated(upstream_status_code); - } - if let Some(failure_class) = filter.failure_class.as_deref() { - separated.push("failure_class = "); - separated.push_bind_unseparated(failure_class); - } - if let Some(response_id) = filter.response_id.as_deref() { - separated.push("response_id = "); - separated.push_bind_unseparated(response_id); - } - if let Some(upstream_request_id) = filter.upstream_request_id.as_deref() { - separated.push("upstream_request_id = "); - separated.push_bind_unseparated(upstream_request_id); - } - if let Some(search) = filter.search.as_deref() { - let pattern = format!("%{search}%"); - separated.push("(message like "); - separated.push_bind_unseparated(pattern.clone()); - separated.push_unseparated(" or metadata_json like "); - separated.push_bind_unseparated(pattern); - separated.push_unseparated(")"); - } - if let Some(cursor) = cursor { - let (created_at, id) = - decode_cursor(cursor).ok_or(SqliteEventLogStoreError::InvalidCursor)?; - separated.push("(created_at < "); - separated.push_bind_unseparated(created_at.clone()); - separated.push_unseparated(" or (created_at = "); - separated.push_bind_unseparated(created_at); - separated.push_unseparated(" and id < "); - separated.push_bind_unseparated(id); - separated.push_unseparated("))"); - } - - Ok(()) -} - -fn event_from_row(row: &sqlx::sqlite::SqliteRow) -> SqliteEventLogStoreResult { - Ok(EventLog { - id: row.get("id"), - request_id: row.get("request_id"), - kind: row.get("kind"), - level: level_from_db(&row.get::("level"))?, - account_id: row.get("account_id"), - route: row.get("route"), - model: row.get("model"), - status_code: row.get("status_code"), - transport: row.get("transport"), - attempt_index: row.get("attempt_index"), - upstream_status_code: row.get("upstream_status_code"), - failure_class: row.get("failure_class"), - response_id: row.get("response_id"), - upstream_request_id: row.get("upstream_request_id"), - latency_ms: row.get("latency_ms"), - message: row.get("message"), - metadata: serde_json::from_str(&row.get::("metadata_json"))?, - created_at: parse_rfc3339(&row.get::("created_at"))?, - }) -} - -fn level_to_db(level: EventLevel) -> &'static str { - match level { - EventLevel::Debug => "debug", - EventLevel::Info => "info", - EventLevel::Warn => "warn", - EventLevel::Error => "error", - } -} - -fn level_from_db(value: &str) -> SqliteEventLogStoreResult { - match value { - "debug" => Ok(EventLevel::Debug), - "info" => Ok(EventLevel::Info), - "warn" => Ok(EventLevel::Warn), - "error" => Ok(EventLevel::Error), - other => Err(SqliteEventLogStoreError::InvalidLevel(other.to_string())), - } -} - -fn parse_rfc3339(value: &str) -> SqliteEventLogStoreResult> { - Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) -} - -/// 管理端日志服务相关类型。 -/// 日志查询过滤器。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AdminLogFilter { - /// 事件类别。 - pub kind: Option, - /// 事件等级。 - pub level: Option, - /// 请求 ID。 - pub request_id: Option, - /// 账号 ID。 - pub account_id: Option, - /// 路由。 - pub route: Option, - /// 模型。 - pub model: Option, - /// HTTP 状态码。 - pub status_code: Option, - /// 上游传输方式。 - pub transport: Option, - /// 同一请求内的上游尝试序号。 - pub attempt_index: Option, - /// 上游 HTTP 状态码。 - pub upstream_status_code: Option, - /// 失败分类。 - pub failure_class: Option, - /// 上游响应 ID。 - pub response_id: Option, - /// 上游请求 ID。 - pub upstream_request_id: Option, - /// 搜索关键词。 - pub search: Option, -} - -impl From for EventLogFilter { - fn from(filter: AdminLogFilter) -> Self { - Self { - kind: filter.kind, - level: filter.level, - request_id: filter.request_id, - account_id: filter.account_id, - route: filter.route, - model: filter.model, - status_code: filter.status_code, - transport: filter.transport, - attempt_index: filter.attempt_index, - upstream_status_code: filter.upstream_status_code, - failure_class: filter.failure_class, - response_id: filter.response_id, - upstream_request_id: filter.upstream_request_id, - search: filter.search, - } - } -} - -/// 清空日志结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdminClearLogs { - /// 清理数量。 - pub cleared: u64, -} - -/// 管理端日志错误。 -#[derive(Debug, Error)] -pub enum AdminLogError { - /// 列表失败。 - #[error("failed to list event logs")] - List, - /// 读取失败。 - #[error("failed to get event log")] - Get, - /// 清空失败。 - #[error("failed to clear event logs")] - Clear, - /// 写入失败。 - #[error("failed to append event log")] - Append, - /// 裁剪失败。 - #[error("failed to trim event logs")] - Trim, -} - -/// 管理端日志服务。 -#[derive(Clone)] -pub struct AdminLogService { - store: SqliteEventLogStore, - settings: std::sync::Arc>, -} - -#[derive(Debug, Clone, Copy)] -struct AdminLogSettings { - enabled: bool, - capacity: u32, - capture_body: bool, -} - -impl AdminLogService { - /// 构造管理端日志服务。 - pub fn new( - store: SqliteEventLogStore, - enabled: bool, - capacity: u32, - capture_body: bool, - ) -> Self { - Self { - store, - settings: std::sync::Arc::new(tokio::sync::RwLock::new(AdminLogSettings { - enabled, - capacity, - capture_body, - })), - } - } - - /// 分页查询日志。 - pub async fn list( - &self, - cursor: Option, - limit: u32, - filter: AdminLogFilter, - ) -> Result, AdminLogError> { - self.store - .list(filter.into(), cursor, limit) - .await - .map_err(|_| AdminLogError::List) - } - - /// 按页码查询日志。 - pub async fn list_page( - &self, - page: u32, - page_size: u32, - filter: AdminLogFilter, - ) -> Result, AdminLogError> { - self.store - .list_page(filter.into(), page, page_size) - .await - .map_err(|_| AdminLogError::List) - } - - /// 按 ID 读取日志。 - pub async fn get(&self, id: &str) -> Result, AdminLogError> { - self.store.get(id).await.map_err(|_| AdminLogError::Get) - } - - /// 清空日志。 - pub async fn clear(&self) -> Result { - self.store - .clear() - .await - .map(|cleared| AdminClearLogs { cleared }) - .map_err(|_| AdminLogError::Clear) - } - - /// 记录事件日志。 - pub async fn record(&self, mut event: EventLog) -> Result<(), AdminLogError> { - let settings = *self.settings.read().await; - if !settings.enabled && event.level != EventLevel::Error { - return Ok(()); - } - apply_capture_body_policy(&mut event, settings.capture_body); - self.store - .append(&event) - .await - .map_err(|_| AdminLogError::Append)?; - self.store - .trim_to_capacity(settings.capacity) - .await - .map_err(|_| AdminLogError::Trim)?; - Ok(()) - } -} - -fn apply_capture_body_policy(event: &mut EventLog, capture_body: bool) { - if capture_body { - return; - } - let Some(metadata) = event.metadata.as_object_mut() else { - return; - }; - for key in [ - "body", - "rawBody", - "requestBody", - "responseBody", - "upstreamBody", - ] { - metadata.remove(key); - } -} diff --git a/src/admin/monitoring/logs.rs b/src/admin/monitoring/logs.rs deleted file mode 100644 index b1f4886df..000000000 --- a/src/admin/monitoring/logs.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! 事件日志 HTTP 处理器。 - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::IntoResponse, -}; -use serde::{Deserialize, Serialize}; - -use crate::{ - admin::monitoring::{ - event_store::{AdminLogError, AdminLogFilter}, - events::{EventLevel, EventLog}, - }, - admin::{ - auth::session::require_admin_auth, - response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, - }, - infra::{ - json::{clamp_limit, clamp_page, NumberedPage, Page}, - time::china_datetime, - }, - runtime::state::AppState, -}; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct LogsQuery { - cursor: Option, - limit: Option, - page: Option, - page_size: Option, - kind: Option, - level: Option, - request_id: Option, - account_id: Option, - route: Option, - model: Option, - status_code: Option, - transport: Option, - attempt_index: Option, - upstream_status_code: Option, - failure_class: Option, - response_id: Option, - upstream_request_id: Option, - search: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct LogDetailQuery { - id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ClearLogsData { - cleared: u64, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct LogData { - #[serde(flatten)] - log: EventLog, - created_at_display: String, -} - -/// `GET /api/admin/logs` -pub(crate) async fn logs( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result { - require_admin_auth(&state, &headers).await?; - let limit = clamp_limit(query.page_size.or(query.limit).unwrap_or(50)); - let page = query.page; - let use_numbered_page = page.is_some() || query.page_size.is_some(); - let cursor = query.cursor.clone(); - let filter = filter_from_query(query)?; - - if use_numbered_page { - return match state - .services - .logs - .list_page(clamp_page(page.unwrap_or(1)), limit, filter) - .await - { - Ok(page) => { - let page = NumberedPage { - items: page.items.into_iter().map(LogData::from).collect(), - total: page.total, - page: page.page, - page_size: page.page_size, - }; - Ok(AdminResponse::new( - StatusCode::OK, - AdminPageEnvelope::numbered(page), - )) - } - Err(error) => Err(log_error(&error)), - }; - } - - match state.services.logs.list(cursor, limit, filter).await { - Ok(page) => { - let page = Page { - items: page.items.into_iter().map(LogData::from).collect(), - next_cursor: page.next_cursor, - }; - Ok(AdminResponse::new( - StatusCode::OK, - AdminPageEnvelope::ok(page, limit), - )) - } - Err(error) => Err(log_error(&error)), - } -} - -/// `GET /api/admin/logs/detail` -pub(crate) async fn log_detail( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result { - require_admin_auth(&state, &headers).await?; - match state.services.logs.get(&query.id).await { - Ok(Some(log)) => Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(LogData::from(log)), - )), - Ok(None) => Err(AdminError::new( - StatusCode::NOT_FOUND, - 40401, - "Log event not found", - )), - Err(error) => Err(log_error(&error)), - } -} - -/// `POST /api/admin/logs/delete` -pub(crate) async fn clear_logs( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - match state.services.logs.clear().await { - Ok(cleared) => Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(ClearLogsData { - cleared: cleared.cleared, - }), - )), - Err(error) => Err(log_error(&error)), - } -} - -fn filter_from_query(query: LogsQuery) -> Result { - Ok(AdminLogFilter { - kind: non_empty(query.kind), - level: level_from_query(query.level) - .map_err(|message| AdminError::new(StatusCode::BAD_REQUEST, 40001, message))?, - request_id: non_empty(query.request_id), - account_id: non_empty(query.account_id), - route: non_empty(query.route), - model: non_empty(query.model), - status_code: query.status_code, - transport: non_empty(query.transport), - attempt_index: query.attempt_index, - upstream_status_code: query.upstream_status_code, - failure_class: non_empty(query.failure_class), - response_id: non_empty(query.response_id), - upstream_request_id: non_empty(query.upstream_request_id), - search: non_empty(query.search), - }) -} - -fn log_error(error: &AdminLogError) -> AdminError { - match error { - AdminLogError::List - | AdminLogError::Get - | AdminLogError::Clear - | AdminLogError::Append - | AdminLogError::Trim => { - AdminError::new(StatusCode::INTERNAL_SERVER_ERROR, 50001, error.to_string()) - } - } -} - -fn level_from_query(value: Option) -> Result, String> { - let Some(value) = non_empty(value) else { - return Ok(None); - }; - match value.as_str() { - "debug" => Ok(Some(EventLevel::Debug)), - "info" => Ok(Some(EventLevel::Info)), - "warn" => Ok(Some(EventLevel::Warn)), - "error" => Ok(Some(EventLevel::Error)), - other => Err(format!("Unsupported log level: {other}")), - } -} - -fn non_empty(value: Option) -> Option { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -impl From for LogData { - fn from(log: EventLog) -> Self { - Self { - created_at_display: china_datetime(&log.created_at), - log, - } - } -} diff --git a/src/admin/monitoring/mod.rs b/src/admin/monitoring/mod.rs index bbdff1893..5f5931500 100644 --- a/src/admin/monitoring/mod.rs +++ b/src/admin/monitoring/mod.rs @@ -3,8 +3,8 @@ pub(crate) mod billing; pub mod dashboard; pub mod diagnostics; -pub mod event_store; -pub mod events; -pub mod logs; pub mod service; +pub mod usage_record; +pub mod usage_record_store; +pub mod usage_records; pub mod usage_store; diff --git a/src/admin/monitoring/events.rs b/src/admin/monitoring/usage_record.rs similarity index 77% rename from src/admin/monitoring/events.rs rename to src/admin/monitoring/usage_record.rs index b9578ae70..14b18fd64 100644 --- a/src/admin/monitoring/events.rs +++ b/src/admin/monitoring/usage_record.rs @@ -1,4 +1,4 @@ -//! 事件日志模型。 +//! 使用记录模型。 use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -8,7 +8,7 @@ use uuid::Uuid; /// 事件等级。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] -pub enum EventLevel { +pub enum UsageRecordLevel { /// 调试。 Debug, /// 信息。 @@ -19,10 +19,10 @@ pub enum EventLevel { Error, } -/// 结构化事件日志。 +/// 结构化使用记录。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct EventLog { +pub struct UsageRecord { /// 事件 ID。 pub id: String, /// 请求 ID。 @@ -30,7 +30,7 @@ pub struct EventLog { /// 事件类别。 pub kind: String, /// 事件等级。 - pub level: EventLevel, + pub level: UsageRecordLevel, /// 账号 ID。 pub account_id: Option, /// HTTP 路由。 @@ -62,11 +62,15 @@ pub struct EventLog { pub created_at: DateTime, } -impl EventLog { - /// 构造事件日志。 - pub fn new(kind: impl Into, level: EventLevel, message: impl Into) -> Self { +impl UsageRecord { + /// 构造使用记录。 + pub fn new( + kind: impl Into, + level: UsageRecordLevel, + message: impl Into, + ) -> Self { Self { - id: format!("log_{}", Uuid::new_v4().simple()), + id: format!("usage_{}", Uuid::new_v4().simple()), request_id: None, kind: kind.into(), level, @@ -89,15 +93,19 @@ impl EventLog { } /// 响应事件记录(供 dispatch 模块使用)。 -pub struct ResponseEventRecord<'a> { - pub logs: &'a crate::admin::monitoring::event_store::AdminLogService, +pub struct ResponseUsageRecord<'a> { + pub usage_records: &'a crate::admin::monitoring::usage_record_store::AdminUsageRecordService, pub request_id: &'a str, pub account_id: &'a str, pub route: &'a str, pub model: &'a str, + pub requested_model: Option<&'a str>, + pub client_ip: Option<&'a str>, + pub client_user_agent: Option<&'a str>, + pub reasoning_effort: Option<&'a str>, pub started_at: std::time::Instant, pub status_code: i64, - pub level: EventLevel, + pub level: UsageRecordLevel, pub message: &'a str, pub metadata: serde_json::Value, pub rate_limit_headers: &'a [(String, String)], diff --git a/src/admin/monitoring/usage_record_store.rs b/src/admin/monitoring/usage_record_store.rs new file mode 100644 index 000000000..f7ce2fcf7 --- /dev/null +++ b/src/admin/monitoring/usage_record_store.rs @@ -0,0 +1,1264 @@ +//! 使用记录存储实现(SQLite)。 + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; +use thiserror::Error; + +use crate::admin::monitoring::{ + billing, + usage_record::{UsageRecord, UsageRecordLevel}, +}; +use crate::infra::json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page}; +use crate::infra::time::china_quarter_hour_start; + +/// 默认保留最新使用记录数量。 +pub const DEFAULT_USAGE_RECORD_CAPACITY: u32 = 2_000; +/// 默认不保存请求/响应体,避免把用户内容写入诊断日志。 +pub const DEFAULT_USAGE_RECORD_CAPTURE_BODY: bool = false; + +/// SQLite 使用记录错误。 +#[derive(Debug, Error)] +pub enum SqliteUsageRecordStoreError { + /// 数据库错误。 + #[error("sqlite usage record database error: {0}")] + Database(#[from] sqlx::Error), + /// JSON 错误。 + #[error("sqlite usage record json error: {0}")] + Json(#[from] serde_json::Error), + /// 时间格式错误。 + #[error("sqlite usage record timestamp error: {0}")] + Timestamp(#[from] chrono::ParseError), + /// 事件等级非法。 + #[error("invalid event level: {0}")] + InvalidLevel(String), + /// 分页游标非法。 + #[error("invalid usage record pagination cursor")] + InvalidCursor, +} + +/// SQLite 使用记录结果。 +pub type SqliteUsageRecordStoreResult = Result; + +/// 使用记录查询过滤器。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UsageRecordFilter { + /// 事件类别。 + pub kind: Option, + /// 事件等级。 + pub level: Option, + /// 请求 ID。 + pub request_id: Option, + /// 账号 ID。 + pub account_id: Option, + /// 路由。 + pub route: Option, + /// 模型。 + pub model: Option, + /// HTTP 状态码。 + pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, + /// 搜索关键词。 + pub search: Option, + /// 起始时间。 + pub start_time: Option>, + /// 结束时间。 + pub end_time: Option>, +} + +/// SQLite 使用记录存储。 +#[derive(Clone)] +pub struct SqliteUsageRecordStore { + pool: SqlitePool, +} + +impl SqliteUsageRecordStore { + /// 构造存储。 + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + /// 写入使用记录。 + pub async fn append(&self, event: &UsageRecord) -> SqliteUsageRecordStoreResult<()> { + append_event(&self.pool, event).await + } + + /// 保留最新的指定数量使用记录。 + pub async fn trim_to_capacity(&self, capacity: u32) -> SqliteUsageRecordStoreResult { + trim_to_capacity(&self.pool, capacity).await + } + + /// 分页查询使用记录。 + pub async fn list( + &self, + filter: UsageRecordFilter, + cursor: Option, + limit: u32, + ) -> SqliteUsageRecordStoreResult> { + let fetch_limit = i64::from(limit) + 1; + let mut builder = QueryBuilder::::new( + "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records", + ); + push_filter(&mut builder, &filter, cursor.as_deref())?; + builder.push(" order by created_at desc, id desc limit "); + builder.push_bind(fetch_limit); + + let rows = builder.build().fetch_all(&self.pool).await?; + let has_next = rows.len() > limit as usize; + let take_count = rows.len().min(limit as usize); + let items = rows + .into_iter() + .take(take_count) + .map(|row| usage_record_from_row(&row)) + .collect::>>()?; + let next_cursor = if has_next { + items + .last() + .map(|event| encode_cursor(&event.created_at.to_rfc3339(), &event.id)) + } else { + None + }; + + Ok(Page { items, next_cursor }) + } + + /// 按页码查询使用记录。 + pub async fn list_page( + &self, + filter: UsageRecordFilter, + page: u32, + page_size: u32, + ) -> SqliteUsageRecordStoreResult> { + let page_size = page_size.clamp(1, 200); + let total = count_usage_records(&self.pool, &filter).await?; + let offset = page_offset(page, page_size); + let mut builder = QueryBuilder::::new( + "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records", + ); + push_filter(&mut builder, &filter, None)?; + builder.push(" order by created_at desc, id desc limit "); + builder.push_bind(i64::from(page_size)); + builder.push(" offset "); + builder.push_bind(offset.min(i64::MAX as u64) as i64); + + let rows = builder.build().fetch_all(&self.pool).await?; + let items = rows + .iter() + .map(usage_record_from_row) + .collect::>>()?; + + Ok(NumberedPage { + items, + total, + page: page.max(1), + page_size, + }) + } + + /// 按 ID 读取使用记录。 + pub async fn get(&self, id: &str) -> SqliteUsageRecordStoreResult> { + let row = sqlx::query( + "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records where id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + row.map(|row| usage_record_from_row(&row)).transpose() + } + + /// 汇总事件使用记录。 + pub async fn summary( + &self, + filter: UsageRecordFilter, + ) -> SqliteUsageRecordStoreResult { + usage_summary(&self.pool, &filter).await + } + + /// 聚合使用记录分析数据。 + pub async fn insights( + &self, + filter: UsageRecordFilter, + ) -> SqliteUsageRecordStoreResult { + usage_insights(&self.pool, &filter).await + } + + /// 清空使用记录。 + pub async fn clear(&self) -> SqliteUsageRecordStoreResult { + let result = sqlx::query("delete from usage_records") + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } +} + +#[derive(Debug, Clone)] +struct UsageRecordSummaryFields { + transport: Option, + attempt_index: Option, + upstream_status_code: Option, + failure_class: Option, + response_id: Option, + upstream_request_id: Option, +} + +fn usage_record_summary_fields(event: &UsageRecord) -> UsageRecordSummaryFields { + UsageRecordSummaryFields { + transport: event + .transport + .clone() + .or_else(|| metadata_string(&event.metadata, &["transport"])), + attempt_index: event + .attempt_index + .or_else(|| metadata_i64(&event.metadata, &["attemptIndex", "attempt_index"])), + upstream_status_code: event.upstream_status_code.or_else(|| { + metadata_i64( + &event.metadata, + &[ + "upstreamStatus", + "upstreamStatusCode", + "upstream_status_code", + ], + ) + }), + failure_class: event + .failure_class + .clone() + .or_else(|| metadata_string(&event.metadata, &["failureClass", "failure_class"])), + response_id: event + .response_id + .clone() + .or_else(|| metadata_string(&event.metadata, &["responseId", "response_id"])), + upstream_request_id: event.upstream_request_id.clone().or_else(|| { + metadata_string( + &event.metadata, + &[ + "upstreamRequestId", + "upstream_request_id", + "openaiRequestId", + "cfRay", + ], + ) + }), + } +} + +fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string) + }) +} + +fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) +} + +async fn append_event(pool: &SqlitePool, event: &UsageRecord) -> SqliteUsageRecordStoreResult<()> { + let summary = usage_record_summary_fields(event); + sqlx::query( + "insert into usage_records (id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&event.id) + .bind(&event.request_id) + .bind(&event.kind) + .bind(level_to_db(event.level)) + .bind(&event.account_id) + .bind(&event.route) + .bind(&event.model) + .bind(event.status_code) + .bind(summary.transport) + .bind(summary.attempt_index) + .bind(summary.upstream_status_code) + .bind(summary.failure_class) + .bind(summary.response_id) + .bind(summary.upstream_request_id) + .bind(event.latency_ms) + .bind(&event.message) + .bind(event.metadata.to_string()) + .bind(event.created_at.to_rfc3339()) + .execute(pool) + .await?; + append_usage_time_bucket(pool, event).await?; + Ok(()) +} + +async fn append_usage_time_bucket( + pool: &SqlitePool, + event: &UsageRecord, +) -> SqliteUsageRecordStoreResult<()> { + let bucket_start = china_quarter_hour_start(event.created_at); + let error_count = i64::from(is_error_usage_record(event)); + let input_tokens = metadata_usage_i64(&event.metadata, "inputTokens"); + let output_tokens = metadata_usage_i64(&event.metadata, "outputTokens"); + let cached_tokens = metadata_usage_i64(&event.metadata, "cachedTokens"); + let first_token_latency = metadata_first_token_latency(&event.metadata).unwrap_or(0); + let first_token_latency_count = i64::from(first_token_latency > 0); + let latency = event.latency_ms.filter(|value| *value > 0).unwrap_or(0); + let latency_count = i64::from(latency > 0); + let now = Utc::now().to_rfc3339(); + + sqlx::query( + r" +insert into usage_time_buckets ( + bucket_start, + account_id, + model, + service_tier, + request_count, + error_count, + input_tokens, + output_tokens, + cached_tokens, + first_token_latency_sum, + first_token_latency_count, + latency_sum, + latency_count, + max_latency_ms, + min_latency_ms, + updated_at +) values (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +on conflict(bucket_start, account_id, model, service_tier) do update set + request_count = request_count + excluded.request_count, + error_count = error_count + excluded.error_count, + input_tokens = input_tokens + excluded.input_tokens, + output_tokens = output_tokens + excluded.output_tokens, + cached_tokens = cached_tokens + excluded.cached_tokens, + first_token_latency_sum = first_token_latency_sum + excluded.first_token_latency_sum, + first_token_latency_count = first_token_latency_count + excluded.first_token_latency_count, + latency_sum = latency_sum + excluded.latency_sum, + latency_count = latency_count + excluded.latency_count, + max_latency_ms = max(max_latency_ms, excluded.max_latency_ms), + min_latency_ms = case + when min_latency_ms = 0 then excluded.min_latency_ms + when excluded.min_latency_ms = 0 then min_latency_ms + else min(min_latency_ms, excluded.min_latency_ms) + end, + updated_at = excluded.updated_at", + ) + .bind(bucket_start.to_rfc3339()) + .bind(event.account_id.as_deref().unwrap_or_default()) + .bind(event.model.as_deref().unwrap_or_default()) + .bind(metadata_service_tier(&event.metadata).unwrap_or_default()) + .bind(error_count) + .bind(input_tokens) + .bind(output_tokens) + .bind(cached_tokens) + .bind(first_token_latency) + .bind(first_token_latency_count) + .bind(latency) + .bind(latency_count) + .bind(latency) + .bind(latency) + .bind(now) + .execute(pool) + .await?; + Ok(()) +} + +fn is_error_usage_record(event: &UsageRecord) -> bool { + event.level == UsageRecordLevel::Error || event.status_code.is_some_and(|status| status >= 400) +} + +fn metadata_usage_i64(metadata: &Value, field: &str) -> i64 { + metadata + .get("usage") + .and_then(|usage| usage.get(field)) + .or_else(|| metadata.get(field)) + .and_then(Value::as_i64) + .filter(|value| *value > 0) + .unwrap_or(0) +} + +fn metadata_first_token_latency(metadata: &Value) -> Option { + [ + "firstTokenMs", + "first_token_ms", + "firstTokenLatencyMs", + "first_token_latency_ms", + ] + .into_iter() + .find_map(|field| { + metadata + .get(field) + .or_else(|| metadata.get("usage").and_then(|usage| usage.get(field))) + .and_then(Value::as_i64) + .filter(|value| *value > 0) + }) +} + +fn metadata_service_tier(metadata: &Value) -> Option<&str> { + metadata_service_tier_value(metadata) + .or_else(|| metadata.get("usage").and_then(metadata_service_tier_value)) +} + +fn metadata_service_tier_value(value: &Value) -> Option<&str> { + [ + "billingServiceTier", + "billing_service_tier", + "actualServiceTier", + "actual_service_tier", + "serviceTier", + "service_tier", + ] + .into_iter() + .find_map(|field| value.get(field).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +async fn trim_to_capacity(pool: &SqlitePool, capacity: u32) -> SqliteUsageRecordStoreResult { + let result = sqlx::query( + "delete from usage_records where id in ( + select id from usage_records + order by created_at desc, id desc + limit -1 offset ? + )", + ) + .bind(i64::from(capacity)) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +async fn count_usage_records( + pool: &SqlitePool, + filter: &UsageRecordFilter, +) -> SqliteUsageRecordStoreResult { + let mut builder = QueryBuilder::::new("select count(*) from usage_records"); + push_filter(&mut builder, filter, None)?; + let (total,): (i64,) = builder.build_query_as().fetch_one(pool).await?; + Ok(total.max(0).cast_unsigned()) +} + +async fn usage_summary( + pool: &SqlitePool, + filter: &UsageRecordFilter, +) -> SqliteUsageRecordStoreResult { + let mut builder = QueryBuilder::::new( + "select + count(*) as total_requests, + sum(case when level = 'error' or coalesce(status_code, 0) >= 400 then 1 else 0 end) as error_requests, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer), cast(json_extract(metadata_json, '$.inputTokens') as integer), 0)) as input_tokens, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer), cast(json_extract(metadata_json, '$.outputTokens') as integer), 0)) as output_tokens, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer), cast(json_extract(metadata_json, '$.cachedTokens') as integer), 0)) as cached_tokens, + avg(case when latency_ms > 0 then latency_ms else null end) as average_latency_ms + from usage_records", + ); + push_filter(&mut builder, filter, None)?; + + let row = builder.build().fetch_one(pool).await?; + let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); + let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); + let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + + Ok(UsageRecordSummary { + total_requests: nonnegative_i64(row.get::, _>("total_requests")), + error_requests: nonnegative_i64(row.get::, _>("error_requests")), + input_tokens, + output_tokens, + cached_tokens, + total_tokens: input_tokens + output_tokens, + average_latency_ms: row.get("average_latency_ms"), + }) +} + +fn nonnegative_i64(value: Option) -> u64 { + value.unwrap_or_default().max(0).cast_unsigned() +} + +async fn usage_insights( + pool: &SqlitePool, + filter: &UsageRecordFilter, +) -> SqliteUsageRecordStoreResult { + Ok(UsageRecordInsights { + models: usage_breakdown( + pool, + filter, + "coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(model), ''), '未知模型')", + "requested_model", + 8, + ) + .await?, + upstream_models: usage_model_breakdown(pool, filter, 8).await?, + model_mappings: usage_breakdown( + pool, + filter, + "case + when coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), '')) is not null + then coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), ''), '未知模型') + || ' -> ' || + coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''), nullif(trim(alias.value), ''), nullif(trim(usage_records.model), ''), '未知模型') + else '未知模型' + end", + "model_mapping", + 8, + ) + .await?, + endpoints: usage_breakdown( + pool, + filter, + "coalesce(nullif(route, ''), '未知端点')", + "endpoint", + 8, + ) + .await?, + upstream_endpoints: usage_breakdown( + pool, + filter, + "coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')", + "upstream_endpoint", + 8, + ) + .await?, + endpoint_paths: usage_breakdown( + pool, + filter, + "coalesce(nullif(route, ''), '未知端点') || ' -> ' || coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')", + "endpoint_path", + 8, + ) + .await?, + types: usage_breakdown( + pool, + filter, + "case when json_extract(metadata_json, '$.stream') = 1 then '流式' when json_extract(metadata_json, '$.stream') = 0 then '普通' else coalesce(nullif(transport, ''), '未知') end", + "record_type", + 8, + ) + .await?, + trend: usage_trend(pool, filter).await?, + }) +} + +async fn usage_model_breakdown( + pool: &SqlitePool, + filter: &UsageRecordFilter, + limit: u32, +) -> SqliteUsageRecordStoreResult> { + usage_breakdown( + pool, + filter, + "coalesce( + nullif(trim(json_extract(usage_records.metadata_json, '$.upstreamModel')), ''), + nullif(trim(alias.value), ''), + nullif(trim(usage_records.model), ''), + '未知模型' + )", + "model", + limit, + ) + .await +} + +async fn usage_breakdown( + pool: &SqlitePool, + filter: &UsageRecordFilter, + group_expr: &str, + group_alias: &str, + limit: u32, +) -> SqliteUsageRecordStoreResult> { + let mut builder = QueryBuilder::::new("select "); + builder.push(group_expr); + builder.push(" as "); + builder.push(group_alias); + builder.push( + ", count(*) as request_count, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer), cast(json_extract(metadata_json, '$.inputTokens') as integer), 0)) as input_tokens, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer), cast(json_extract(metadata_json, '$.outputTokens') as integer), 0)) as output_tokens, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer), cast(json_extract(metadata_json, '$.cachedTokens') as integer), 0)) as cached_tokens, + coalesce( + nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''), + nullif(trim(alias.value), ''), + nullif(trim(usage_records.model), ''), + '未知模型' + ) as billing_model, + coalesce(nullif(trim(json_extract(metadata_json, '$.billingServiceTier')), ''), nullif(trim(json_extract(metadata_json, '$.serviceTier')), ''), nullif(trim(json_extract(metadata_json, '$.usage.serviceTier')), '')) as service_tier, + avg(case when latency_ms > 0 then latency_ms else null end) as average_latency_ms + from usage_records + left join runtime_settings on runtime_settings.id = 1 + left join json_each(runtime_settings.model_aliases_json) as alias on alias.key = usage_records.model", + ); + push_filter(&mut builder, filter, None)?; + builder.push(" group by "); + builder.push(group_alias); + builder.push(", billing_model, service_tier"); + builder.push(" order by request_count desc, "); + builder.push(group_alias); + builder.push(" asc limit "); + builder.push_bind(i64::from(limit.clamp(1, 50) * 8)); + + let rows = builder.build().fetch_all(pool).await?; + let mut items = Vec::::new(); + for row in rows { + let name: String = row.get(group_alias); + let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); + let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); + let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + let cost = usage_breakdown_cost( + input_tokens, + output_tokens, + cached_tokens, + &row.get::("billing_model"), + row.get::, _>("service_tier").as_deref(), + ); + let request_count = nonnegative_i64(row.get::, _>("request_count")); + let average_latency_ms = row.get::, _>("average_latency_ms"); + + if let Some(item) = items.iter_mut().find(|item| item.name == name) { + let previous_latency_count = item.request_count as f64; + let next_latency_count = request_count as f64; + item.request_count += request_count; + item.input_tokens += input_tokens; + item.output_tokens += output_tokens; + item.cached_tokens += cached_tokens; + item.total_tokens += input_tokens + output_tokens; + item.cost += cost; + item.actual_cost += cost; + item.account_cost += cost; + item.average_latency_ms = weighted_average_latency( + item.average_latency_ms, + previous_latency_count, + average_latency_ms, + next_latency_count, + ); + } else { + items.push(UsageRecordBreakdown { + name, + request_count, + input_tokens, + output_tokens, + cached_tokens, + total_tokens: input_tokens + output_tokens, + cost, + actual_cost: cost, + account_cost: cost, + average_latency_ms, + }); + } + } + + items.sort_by(|left, right| { + right + .request_count + .cmp(&left.request_count) + .then_with(|| left.name.cmp(&right.name)) + }); + items.truncate(limit.clamp(1, 50) as usize); + Ok(items) +} + +async fn usage_trend( + pool: &SqlitePool, + filter: &UsageRecordFilter, +) -> SqliteUsageRecordStoreResult> { + let mut builder = QueryBuilder::::new( + "select + substr(created_at, 1, 10) as date, + coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer), cast(json_extract(metadata_json, '$.inputTokens') as integer), 0) as input_tokens, + coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer), cast(json_extract(metadata_json, '$.outputTokens') as integer), 0) as output_tokens, + coalesce(cast(json_extract(metadata_json, '$.usage.cacheCreationTokens') as integer), cast(json_extract(metadata_json, '$.cacheCreationTokens') as integer), cast(json_extract(metadata_json, '$.usage.cache_creation_tokens') as integer), cast(json_extract(metadata_json, '$.cache_creation_tokens') as integer), 0) as cache_creation_tokens, + coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer), cast(json_extract(metadata_json, '$.cachedTokens') as integer), 0) as cached_tokens, + coalesce( + nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''), + nullif(trim(alias.value), ''), + nullif(trim(usage_records.model), ''), + '未知模型' + ) as billing_model, + coalesce(nullif(trim(json_extract(metadata_json, '$.billingServiceTier')), ''), nullif(trim(json_extract(metadata_json, '$.serviceTier')), ''), nullif(trim(json_extract(metadata_json, '$.usage.serviceTier')), '')) as service_tier, + latency_ms + from usage_records + left join runtime_settings on runtime_settings.id = 1 + left join json_each(runtime_settings.model_aliases_json) as alias on alias.key = usage_records.model", + ); + push_filter(&mut builder, filter, None)?; + builder.push(" order by date asc, created_at asc"); + + let rows = builder.build().fetch_all(pool).await?; + let mut days = BTreeMap::::new(); + + for row in rows { + let date: String = row.get("date"); + let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); + let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); + let cache_creation_tokens = + nonnegative_i64(row.get::, _>("cache_creation_tokens")); + let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + let cost = usage_breakdown_cost( + input_tokens, + output_tokens, + cached_tokens, + &row.get::("billing_model"), + row.get::, _>("service_tier").as_deref(), + ); + + days.entry(date.clone()) + .or_insert_with(|| UsageTrendAccumulator::new(date)) + .push( + input_tokens, + output_tokens, + cache_creation_tokens, + cached_tokens, + cost, + row.get("latency_ms"), + ); + } + + let mut points = days + .into_values() + .map(UsageTrendAccumulator::into_point) + .collect::>(); + if points.len() > 60 { + points = points.split_off(points.len() - 60); + } + Ok(points) +} + +#[derive(Debug)] +struct UsageTrendAccumulator { + point: UsageRecordTrendPoint, + latency_sum: f64, + latency_count: u64, +} + +impl UsageTrendAccumulator { + fn new(date: String) -> Self { + Self { + point: UsageRecordTrendPoint { + date, + ..UsageRecordTrendPoint::default() + }, + latency_sum: 0.0, + latency_count: 0, + } + } + + fn push( + &mut self, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cached_tokens: u64, + cost: f64, + latency_ms: Option, + ) { + self.point.input_tokens += input_tokens; + self.point.output_tokens += output_tokens; + self.point.cache_creation_tokens += cache_creation_tokens; + self.point.cached_tokens += cached_tokens; + self.point.total_tokens += input_tokens + output_tokens; + self.point.cost += cost; + self.point.actual_cost += cost; + + if let Some(latency_ms) = latency_ms.filter(|latency_ms| *latency_ms > 0) { + self.latency_sum += latency_ms as f64; + self.latency_count += 1; + } + } + + fn into_point(mut self) -> UsageRecordTrendPoint { + self.point.average_latency_ms = if self.latency_count > 0 { + Some(self.latency_sum / self.latency_count as f64) + } else { + None + }; + self.point + } +} + +fn usage_breakdown_cost( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + model: &str, + service_tier: Option<&str>, +) -> f64 { + billing::calculate_cost( + input_tokens, + output_tokens, + cached_tokens, + model, + service_tier, + ) +} + +fn weighted_average_latency( + current_average: Option, + current_count: f64, + next_average: Option, + next_count: f64, +) -> Option { + match (current_average, next_average) { + (Some(current), Some(next)) if current_count > 0.0 && next_count > 0.0 => { + Some((current * current_count + next * next_count) / (current_count + next_count)) + } + (Some(current), _) => Some(current), + (_, Some(next)) => Some(next), + _ => None, + } +} + +fn push_filter( + builder: &mut QueryBuilder, + filter: &UsageRecordFilter, + cursor: Option<&str>, +) -> SqliteUsageRecordStoreResult<()> { + let mut separated = builder.separated(" and "); + separated.push(" where 1 = 1"); + + if let Some(kind) = filter.kind.as_deref() { + separated.push("kind = "); + separated.push_bind_unseparated(kind); + } + if let Some(level) = filter.level { + separated.push("level = "); + separated.push_bind_unseparated(level_to_db(level)); + } + if let Some(request_id) = filter.request_id.as_deref() { + separated.push("request_id = "); + separated.push_bind_unseparated(request_id); + } + if let Some(account_id) = filter.account_id.as_deref() { + separated.push("account_id = "); + separated.push_bind_unseparated(account_id); + } + if let Some(route) = filter.route.as_deref() { + separated.push("route = "); + separated.push_bind_unseparated(route); + } + if let Some(model) = filter.model.as_deref() { + separated.push("model = "); + separated.push_bind_unseparated(model); + } + if let Some(status_code) = filter.status_code { + separated.push("status_code = "); + separated.push_bind_unseparated(status_code); + } + if let Some(transport) = filter.transport.as_deref() { + separated.push("transport = "); + separated.push_bind_unseparated(transport); + } + if let Some(attempt_index) = filter.attempt_index { + separated.push("attempt_index = "); + separated.push_bind_unseparated(attempt_index); + } + if let Some(upstream_status_code) = filter.upstream_status_code { + separated.push("upstream_status_code = "); + separated.push_bind_unseparated(upstream_status_code); + } + if let Some(failure_class) = filter.failure_class.as_deref() { + separated.push("failure_class = "); + separated.push_bind_unseparated(failure_class); + } + if let Some(response_id) = filter.response_id.as_deref() { + separated.push("response_id = "); + separated.push_bind_unseparated(response_id); + } + if let Some(upstream_request_id) = filter.upstream_request_id.as_deref() { + separated.push("upstream_request_id = "); + separated.push_bind_unseparated(upstream_request_id); + } + if let Some(search) = filter.search.as_deref() { + let pattern = format!("%{search}%"); + separated.push("(message like "); + separated.push_bind_unseparated(pattern.clone()); + separated.push_unseparated(" or metadata_json like "); + separated.push_bind_unseparated(pattern); + separated.push_unseparated(")"); + } + if let Some(start_time) = filter.start_time { + separated.push("created_at >= "); + separated.push_bind_unseparated(start_time.to_rfc3339()); + } + if let Some(end_time) = filter.end_time { + separated.push("created_at <= "); + separated.push_bind_unseparated(end_time.to_rfc3339()); + } + if let Some(cursor) = cursor { + let (created_at, id) = + decode_cursor(cursor).ok_or(SqliteUsageRecordStoreError::InvalidCursor)?; + separated.push("(created_at < "); + separated.push_bind_unseparated(created_at.clone()); + separated.push_unseparated(" or (created_at = "); + separated.push_bind_unseparated(created_at); + separated.push_unseparated(" and id < "); + separated.push_bind_unseparated(id); + separated.push_unseparated("))"); + } + + Ok(()) +} + +fn usage_record_from_row( + row: &sqlx::sqlite::SqliteRow, +) -> SqliteUsageRecordStoreResult { + Ok(UsageRecord { + id: row.get("id"), + request_id: row.get("request_id"), + kind: row.get("kind"), + level: level_from_db(&row.get::("level"))?, + account_id: row.get("account_id"), + route: row.get("route"), + model: row.get("model"), + status_code: row.get("status_code"), + transport: row.get("transport"), + attempt_index: row.get("attempt_index"), + upstream_status_code: row.get("upstream_status_code"), + failure_class: row.get("failure_class"), + response_id: row.get("response_id"), + upstream_request_id: row.get("upstream_request_id"), + latency_ms: row.get("latency_ms"), + message: row.get("message"), + metadata: serde_json::from_str(&row.get::("metadata_json"))?, + created_at: parse_rfc3339(&row.get::("created_at"))?, + }) +} + +fn level_to_db(level: UsageRecordLevel) -> &'static str { + match level { + UsageRecordLevel::Debug => "debug", + UsageRecordLevel::Info => "info", + UsageRecordLevel::Warn => "warn", + UsageRecordLevel::Error => "error", + } +} + +fn level_from_db(value: &str) -> SqliteUsageRecordStoreResult { + match value { + "debug" => Ok(UsageRecordLevel::Debug), + "info" => Ok(UsageRecordLevel::Info), + "warn" => Ok(UsageRecordLevel::Warn), + "error" => Ok(UsageRecordLevel::Error), + other => Err(SqliteUsageRecordStoreError::InvalidLevel(other.to_string())), + } +} + +fn parse_rfc3339(value: &str) -> SqliteUsageRecordStoreResult> { + Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) +} + +/// 管理端使用记录服务相关类型。 +/// 使用记录查询过滤器。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AdminUsageRecordFilter { + /// 事件类别。 + pub kind: Option, + /// 事件等级。 + pub level: Option, + /// 请求 ID。 + pub request_id: Option, + /// 账号 ID。 + pub account_id: Option, + /// 路由。 + pub route: Option, + /// 模型。 + pub model: Option, + /// HTTP 状态码。 + pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, + /// 搜索关键词。 + pub search: Option, + /// 起始时间。 + pub start_time: Option>, + /// 结束时间。 + pub end_time: Option>, +} + +/// 使用记录汇总。 +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct UsageRecordSummary { + /// 请求总数。 + pub total_requests: u64, + /// 错误请求数。 + pub error_requests: u64, + /// 输入 Token。 + pub input_tokens: u64, + /// 输出 Token。 + pub output_tokens: u64, + /// 缓存命中 Token。 + pub cached_tokens: u64, + /// 总 Token。 + pub total_tokens: u64, + /// 平均耗时。 + pub average_latency_ms: Option, +} + +/// 使用记录分析。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UsageRecordInsights { + /// 模型分布。 + pub models: Vec, + /// 上游模型分布。 + pub upstream_models: Vec, + /// 模型映射分布。 + pub model_mappings: Vec, + /// 端点分布。 + pub endpoints: Vec, + /// 上游端点分布。 + pub upstream_endpoints: Vec, + /// 端点路径分布。 + pub endpoint_paths: Vec, + /// 请求类型分布。 + pub types: Vec, + /// Token 趋势。 + pub trend: Vec, +} + +/// 使用记录分布项。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UsageRecordBreakdown { + /// 名称。 + pub name: String, + /// 请求数。 + pub request_count: u64, + /// 输入 Token。 + pub input_tokens: u64, + /// 输出 Token。 + pub output_tokens: u64, + /// 缓存命中 Token。 + pub cached_tokens: u64, + /// 总 Token。 + pub total_tokens: u64, + /// 标准成本。 + pub cost: f64, + /// 实际成本。 + pub actual_cost: f64, + /// 账号成本。 + pub account_cost: f64, + /// 平均耗时。 + pub average_latency_ms: Option, +} + +/// 使用记录趋势点。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UsageRecordTrendPoint { + /// 日期。 + pub date: String, + /// 输入 Token。 + pub input_tokens: u64, + /// 输出 Token。 + pub output_tokens: u64, + /// 缓存创建 Token。 + pub cache_creation_tokens: u64, + /// 缓存命中 Token。 + pub cached_tokens: u64, + /// 总 Token。 + pub total_tokens: u64, + /// 标准成本。 + pub cost: f64, + /// 实际成本。 + pub actual_cost: f64, + /// 平均耗时。 + pub average_latency_ms: Option, +} + +impl From for UsageRecordFilter { + fn from(filter: AdminUsageRecordFilter) -> Self { + Self { + kind: filter.kind, + level: filter.level, + request_id: filter.request_id, + account_id: filter.account_id, + route: filter.route, + model: filter.model, + status_code: filter.status_code, + transport: filter.transport, + attempt_index: filter.attempt_index, + upstream_status_code: filter.upstream_status_code, + failure_class: filter.failure_class, + response_id: filter.response_id, + upstream_request_id: filter.upstream_request_id, + search: filter.search, + start_time: filter.start_time, + end_time: filter.end_time, + } + } +} + +/// 清空使用记录结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminClearUsageRecords { + /// 清理数量。 + pub cleared: u64, +} + +/// 管理端使用记录错误。 +#[derive(Debug, Error)] +pub enum AdminUsageRecordError { + /// 列表失败。 + #[error("failed to list usage records")] + List, + /// 读取失败。 + #[error("failed to get usage record")] + Get, + /// 清空失败。 + #[error("failed to clear usage records")] + Clear, + /// 写入失败。 + #[error("failed to append usage record")] + Append, + /// 裁剪失败。 + #[error("failed to trim usage records")] + Trim, +} + +/// 管理端使用记录服务。 +#[derive(Clone)] +pub struct AdminUsageRecordService { + store: SqliteUsageRecordStore, + settings: std::sync::Arc>, +} + +#[derive(Debug, Clone, Copy)] +struct AdminUsageRecordSettings { + enabled: bool, + capacity: u32, + capture_body: bool, +} + +impl AdminUsageRecordService { + /// 构造管理端使用记录服务。 + pub fn new( + store: SqliteUsageRecordStore, + enabled: bool, + capacity: u32, + capture_body: bool, + ) -> Self { + Self { + store, + settings: std::sync::Arc::new(tokio::sync::RwLock::new(AdminUsageRecordSettings { + enabled, + capacity, + capture_body, + })), + } + } + + /// 分页查询日志。 + pub async fn list( + &self, + cursor: Option, + limit: u32, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .list(filter.into(), cursor, limit) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按页码查询日志。 + pub async fn list_page( + &self, + page: u32, + page_size: u32, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .list_page(filter.into(), page, page_size) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按 ID 读取日志。 + pub async fn get(&self, id: &str) -> Result, AdminUsageRecordError> { + self.store + .get(id) + .await + .map_err(|_| AdminUsageRecordError::Get) + } + + /// 汇总使用记录。 + pub async fn summary( + &self, + filter: AdminUsageRecordFilter, + ) -> Result { + self.store + .summary(filter.into()) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 聚合使用记录分析数据。 + pub async fn insights( + &self, + filter: AdminUsageRecordFilter, + ) -> Result { + self.store + .insights(filter.into()) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 清空日志。 + pub async fn clear(&self) -> Result { + self.store + .clear() + .await + .map(|cleared| AdminClearUsageRecords { cleared }) + .map_err(|_| AdminUsageRecordError::Clear) + } + + /// 记录使用记录。 + pub async fn record(&self, mut event: UsageRecord) -> Result<(), AdminUsageRecordError> { + let settings = *self.settings.read().await; + if !settings.enabled && event.level != UsageRecordLevel::Error { + return Ok(()); + } + apply_capture_body_policy(&mut event, settings.capture_body); + self.store + .append(&event) + .await + .map_err(|_| AdminUsageRecordError::Append)?; + self.store + .trim_to_capacity(settings.capacity) + .await + .map_err(|_| AdminUsageRecordError::Trim)?; + Ok(()) + } +} + +fn apply_capture_body_policy(event: &mut UsageRecord, capture_body: bool) { + if capture_body { + return; + } + let Some(metadata) = event.metadata.as_object_mut() else { + return; + }; + for key in [ + "body", + "rawBody", + "requestBody", + "responseBody", + "upstreamBody", + ] { + metadata.remove(key); + } +} diff --git a/src/admin/monitoring/usage_records.rs b/src/admin/monitoring/usage_records.rs new file mode 100644 index 000000000..dfb9e67e6 --- /dev/null +++ b/src/admin/monitoring/usage_records.rs @@ -0,0 +1,828 @@ +//! 使用记录 HTTP 处理器。 + +use std::collections::{BTreeMap, HashMap}; + +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{QueryBuilder, Row, Sqlite}; + +use crate::{ + admin::monitoring::{ + billing, + usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_store::{ + AdminUsageRecordError, AdminUsageRecordFilter, UsageRecordBreakdown, + UsageRecordInsights, UsageRecordSummary, UsageRecordTrendPoint, + }, + }, + admin::{ + auth::session::require_admin_auth, + response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, + }, + infra::{ + json::{clamp_limit, clamp_page, NumberedPage, Page}, + time::china_datetime, + }, + runtime::state::AppState, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRecordsQuery { + cursor: Option, + limit: Option, + page: Option, + page_size: Option, + kind: Option, + level: Option, + request_id: Option, + account_id: Option, + route: Option, + model: Option, + status_code: Option, + transport: Option, + attempt_index: Option, + upstream_status_code: Option, + failure_class: Option, + response_id: Option, + upstream_request_id: Option, + search: Option, + start_time: Option, + end_time: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRecordDetailQuery { + id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClearUsageRecordsData { + cleared: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRecordData { + #[serde(flatten)] + record: UsageRecord, + created_at_display: String, + account_email: Option, + requested_model: Option, + upstream_model: Option, + client_ip: Option, + user_agent: Option, + reasoning_effort: Option, + token_details: UsageRecordTokenDetailsData, + cost_details: Option, + first_token_latency_ms: Option, + first_token_latency_ms_display: String, + latency_ms_display: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRecordTokenDetailsData { + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + reasoning_tokens: u64, + total_tokens: u64, + input_tokens_display: String, + output_tokens_display: String, + cached_tokens_display: String, + reasoning_tokens_display: String, + total_tokens_display: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRecordCostDetailsData { + input_cost: f64, + output_cost: f64, + cache_read_cost: f64, + total_cost: f64, + billed_cost: f64, + original_cost: f64, + input_price_per_mtoken: f64, + output_price_per_mtoken: f64, + cache_read_price_per_mtoken: f64, + service_tier: Option, + service_tier_display: String, + multiplier: f64, + input_cost_display: String, + output_cost_display: String, + cache_read_cost_display: String, + total_cost_display: String, + billed_cost_display: String, + original_cost_display: String, + input_price_display: String, + output_price_display: String, + cache_read_price_display: String, + multiplier_display: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageRecordSummaryData { + total_requests: u64, + error_requests: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + total_tokens: u64, + average_latency_ms: Option, + average_latency_ms_display: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageRecordInsightsData { + models: Vec, + upstream_models: Vec, + model_mappings: Vec, + endpoints: Vec, + upstream_endpoints: Vec, + endpoint_paths: Vec, + types: Vec, + trend: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageRecordBreakdownData { + name: String, + request_count: u64, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + total_tokens: u64, + cost: f64, + actual_cost: f64, + account_cost: f64, + cost_display: String, + actual_cost_display: String, + account_cost_display: String, + average_latency_ms: Option, + average_latency_ms_display: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageRecordTrendPointData { + date: String, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cached_tokens: u64, + total_tokens: u64, + cost: f64, + actual_cost: f64, + cost_display: String, + actual_cost_display: String, + average_latency_ms: Option, + average_latency_ms_display: String, +} + +/// `GET /api/admin/usage/records` +pub(crate) async fn usage_records( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + require_admin_auth(&state, &headers).await?; + let limit = clamp_limit(query.page_size.or(query.limit).unwrap_or(50)); + let page = query.page; + let use_numbered_page = page.is_some() || query.page_size.is_some(); + let cursor = query.cursor.clone(); + let filter = filter_from_query(query)?; + + if use_numbered_page { + return match state + .services + .usage_records + .list_page(clamp_page(page.unwrap_or(1)), limit, filter) + .await + { + Ok(page) => { + let account_emails = account_email_map(&state, &page.items).await?; + let model_aliases = state.services.settings.current().model_aliases.clone(); + let page = NumberedPage { + items: usage_record_items(page.items, &account_emails, &model_aliases), + total: page.total, + page: page.page, + page_size: page.page_size, + }; + Ok(AdminResponse::new( + StatusCode::OK, + AdminPageEnvelope::numbered(page), + )) + } + Err(error) => Err(log_error(&error)), + }; + } + + match state + .services + .usage_records + .list(cursor, limit, filter) + .await + { + Ok(page) => { + let account_emails = account_email_map(&state, &page.items).await?; + let model_aliases = state.services.settings.current().model_aliases.clone(); + let page = Page { + items: usage_record_items(page.items, &account_emails, &model_aliases), + next_cursor: page.next_cursor, + }; + Ok(AdminResponse::new( + StatusCode::OK, + AdminPageEnvelope::ok(page, limit), + )) + } + Err(error) => Err(log_error(&error)), + } +} + +/// `GET /api/admin/usage/records/summary` +pub(crate) async fn usage_records_summary( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + require_admin_auth(&state, &headers).await?; + let filter = filter_from_query(query)?; + match state.services.usage_records.summary(filter).await { + Ok(summary) => Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(UsageRecordSummaryData::from(summary)), + )), + Err(error) => Err(log_error(&error)), + } +} + +/// `GET /api/admin/usage/records/insights` +pub(crate) async fn usage_records_insights( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + require_admin_auth(&state, &headers).await?; + let filter = filter_from_query(query)?; + match state.services.usage_records.insights(filter).await { + Ok(insights) => Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(UsageRecordInsightsData::from(insights)), + )), + Err(error) => Err(log_error(&error)), + } +} + +/// `GET /api/admin/usage/records/detail` +pub(crate) async fn usage_record_detail( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + require_admin_auth(&state, &headers).await?; + match state.services.usage_records.get(&query.id).await { + Ok(Some(log)) => { + let account_emails = account_email_map(&state, std::slice::from_ref(&log)).await?; + let model_aliases = state.services.settings.current().model_aliases.clone(); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(usage_record_data(log, &account_emails, &model_aliases)), + )) + } + Ok(None) => Err(AdminError::new( + StatusCode::NOT_FOUND, + 40401, + "Usage record not found", + )), + Err(error) => Err(log_error(&error)), + } +} + +/// `POST /api/admin/usage/records/delete` +pub(crate) async fn clear_usage_records( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + match state.services.usage_records.clear().await { + Ok(cleared) => Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ClearUsageRecordsData { + cleared: cleared.cleared, + }), + )), + Err(error) => Err(log_error(&error)), + } +} + +fn filter_from_query(query: UsageRecordsQuery) -> Result { + Ok(AdminUsageRecordFilter { + kind: non_empty(query.kind), + level: level_from_query(query.level) + .map_err(|message| AdminError::new(StatusCode::BAD_REQUEST, 40001, message))?, + request_id: non_empty(query.request_id), + account_id: non_empty(query.account_id), + route: non_empty(query.route), + model: non_empty(query.model), + status_code: query.status_code, + transport: non_empty(query.transport), + attempt_index: query.attempt_index, + upstream_status_code: query.upstream_status_code, + failure_class: non_empty(query.failure_class), + response_id: non_empty(query.response_id), + upstream_request_id: non_empty(query.upstream_request_id), + search: non_empty(query.search), + start_time: optional_datetime(query.start_time)?, + end_time: optional_datetime(query.end_time)?, + }) +} + +fn optional_datetime( + value: Option, +) -> Result>, AdminError> { + let Some(value) = non_empty(value) else { + return Ok(None); + }; + chrono::DateTime::parse_from_rfc3339(&value) + .map(|value| Some(value.with_timezone(&chrono::Utc))) + .map_err(|_| AdminError::new(StatusCode::BAD_REQUEST, 40002, "Invalid time range")) +} + +fn log_error(error: &AdminUsageRecordError) -> AdminError { + match error { + AdminUsageRecordError::List + | AdminUsageRecordError::Get + | AdminUsageRecordError::Clear + | AdminUsageRecordError::Append + | AdminUsageRecordError::Trim => { + AdminError::new(StatusCode::INTERNAL_SERVER_ERROR, 50001, error.to_string()) + } + } +} + +fn level_from_query(value: Option) -> Result, String> { + let Some(value) = non_empty(value) else { + return Ok(None); + }; + match value.as_str() { + "debug" => Ok(Some(UsageRecordLevel::Debug)), + "info" => Ok(Some(UsageRecordLevel::Info)), + "warn" => Ok(Some(UsageRecordLevel::Warn)), + "error" => Ok(Some(UsageRecordLevel::Error)), + other => Err(format!("Unsupported log level: {other}")), + } +} + +fn non_empty(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(crate) async fn account_email_map( + state: &AppState, + items: &[UsageRecord], +) -> Result, AdminError> { + let mut account_ids = items + .iter() + .filter_map(|item| item.account_id.as_deref()) + .map(str::to_string) + .collect::>(); + account_ids.sort_unstable(); + account_ids.dedup(); + + if account_ids.is_empty() { + return Ok(HashMap::new()); + } + + let mut builder = QueryBuilder::::new("select id, email from accounts where id in ("); + let mut separated = builder.separated(", "); + for account_id in &account_ids { + separated.push_bind(account_id); + } + separated.push_unseparated(")"); + + let rows = builder + .build() + .fetch_all(state.services.background_tasks.accounts.pool()) + .await + .map_err(|error| { + AdminError::new( + StatusCode::INTERNAL_SERVER_ERROR, + 50002, + format!("Failed to load usage record accounts: {error}"), + ) + })?; + + Ok(rows + .into_iter() + .filter_map(|row| { + row.get::, _>("email") + .map(|email| email.trim().to_string()) + .filter(|email| !email.is_empty()) + .map(|email| (row.get::("id"), email)) + }) + .collect()) +} + +pub(crate) fn usage_record_items( + items: Vec, + account_emails: &HashMap, + model_aliases: &BTreeMap, +) -> Vec { + items + .into_iter() + .map(|record| usage_record_data(record, account_emails, model_aliases)) + .collect() +} + +fn usage_record_data( + record: UsageRecord, + account_emails: &HashMap, + model_aliases: &BTreeMap, +) -> UsageRecordData { + let account_email = record + .account_id + .as_deref() + .and_then(|account_id| account_emails.get(account_id)) + .cloned(); + let (requested_model, upstream_model) = usage_record_models(&record, model_aliases); + let client_ip = metadata_string(&record.metadata, &["clientIp", "ipAddress", "ip_address"]); + let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]); + let reasoning_effort = + metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]); + let token_details = usage_token_details(&record.metadata); + let cost_details = usage_cost_details(&record, upstream_model.as_deref(), &token_details); + let first_token_latency_ms = metadata_i64( + &record.metadata, + &[ + "firstTokenMs", + "first_token_ms", + "firstTokenLatencyMs", + "first_token_latency_ms", + ], + ); + let first_token_latency_ms_display = format_duration_ms(first_token_latency_ms); + let latency_ms_display = format_duration_ms(record.latency_ms); + + UsageRecordData { + created_at_display: china_datetime(&record.created_at), + account_email, + requested_model, + upstream_model, + client_ip, + user_agent, + reasoning_effort, + token_details, + cost_details, + first_token_latency_ms, + first_token_latency_ms_display, + latency_ms_display, + record, + } +} + +fn usage_record_models( + record: &UsageRecord, + model_aliases: &BTreeMap, +) -> (Option, Option) { + let stored_model = record + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()); + let alias_target = stored_model + .and_then(|model| model_aliases.get(model)) + .map(String::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()); + + let requested_model = metadata_string(&record.metadata, &["requestedModel"]) + .or_else(|| alias_target.and_then(|_| stored_model.map(ToString::to_string))); + let upstream_model = metadata_string(&record.metadata, &["upstreamModel"]) + .or_else(|| alias_target.map(ToString::to_string)) + .or_else(|| { + requested_model + .as_ref() + .and_then(|_| stored_model.map(ToString::to_string)) + }); + + (requested_model, upstream_model) +} + +fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData { + let input_tokens = metadata_usage_u64(metadata, "inputTokens"); + let output_tokens = metadata_usage_u64(metadata, "outputTokens"); + let cached_tokens = metadata_usage_u64(metadata, "cachedTokens"); + let reasoning_tokens = metadata_usage_u64(metadata, "reasoningTokens"); + let total_tokens = + metadata_usage_u64(metadata, "totalTokens").max(input_tokens.saturating_add(output_tokens)); + + UsageRecordTokenDetailsData { + input_tokens, + output_tokens, + cached_tokens, + reasoning_tokens, + total_tokens, + input_tokens_display: format_number(input_tokens), + output_tokens_display: format_number(output_tokens), + cached_tokens_display: format_compact_number(cached_tokens), + reasoning_tokens_display: format_number(reasoning_tokens), + total_tokens_display: format_number(total_tokens), + } +} + +fn usage_cost_details( + record: &UsageRecord, + upstream_model: Option<&str>, + tokens: &UsageRecordTokenDetailsData, +) -> Option { + if tokens.input_tokens == 0 && tokens.output_tokens == 0 && tokens.cached_tokens == 0 { + return None; + } + + let model = upstream_model + .or(record.model.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let service_tier = usage_service_tier(&record.metadata); + let breakdown = billing::calculate_cost_breakdown( + tokens.input_tokens, + tokens.output_tokens, + tokens.cached_tokens, + model, + service_tier.as_deref(), + ); + let original_cost = breakdown.input_cost + breakdown.output_cost + breakdown.cache_read_cost; + + Some(UsageRecordCostDetailsData { + input_cost: breakdown.input_cost, + output_cost: breakdown.output_cost, + cache_read_cost: breakdown.cache_read_cost, + total_cost: breakdown.total_cost, + billed_cost: breakdown.total_cost, + original_cost, + input_price_per_mtoken: breakdown.input_price_per_mtoken, + output_price_per_mtoken: breakdown.output_price_per_mtoken, + cache_read_price_per_mtoken: breakdown.cache_read_price_per_mtoken, + service_tier: breakdown.service_tier.clone(), + service_tier_display: breakdown + .service_tier + .as_deref() + .map(format_service_tier) + .unwrap_or_else(|| "Default".to_string()), + multiplier: breakdown.tier_multiplier, + input_cost_display: format_cost(breakdown.input_cost), + output_cost_display: format_cost(breakdown.output_cost), + cache_read_cost_display: format_cost(breakdown.cache_read_cost), + total_cost_display: format_cost(breakdown.total_cost), + billed_cost_display: format_cost(breakdown.total_cost), + original_cost_display: format_cost(original_cost), + input_price_display: format_token_price(breakdown.input_price_per_mtoken), + output_price_display: format_token_price(breakdown.output_price_per_mtoken), + cache_read_price_display: format_token_price(breakdown.cache_read_price_per_mtoken), + multiplier_display: format!("{:.2}x", breakdown.tier_multiplier), + }) +} + +fn usage_service_tier(metadata: &Value) -> Option { + metadata_string( + metadata, + &[ + "billingServiceTier", + "billing_service_tier", + "serviceTier", + "service_tier", + ], + ) +} + +fn metadata_usage_u64(metadata: &Value, key: &str) -> u64 { + metadata + .get("usage") + .and_then(|usage| metadata_u64_at(usage, key)) + .or_else(|| metadata_u64_at(metadata, key)) + .unwrap_or_default() +} + +fn metadata_u64_at(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(|value| value.as_u64().or_else(|| value.as_i64()?.try_into().ok())) +} + +fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) + .filter(|value| *value >= 0) +} + +fn format_duration_ms(value: Option) -> String { + let Some(value) = value.filter(|value| *value >= 0) else { + return "—".to_string(); + }; + + if value < 1_000 { + return format!("{value} ms"); + } + + if value < 60_000 { + let seconds = value as f64 / 1_000.0; + return if seconds >= 10.0 { + format!("{seconds:.1} s") + } else { + format!("{seconds:.2} s") + }; + } + + format!("{:.1} min", value as f64 / 60_000.0) +} + +fn format_duration_ms_f64(value: Option) -> String { + let Some(value) = value.filter(|value| value.is_finite() && *value >= 0.0) else { + return "—".to_string(); + }; + format_duration_ms(Some(value.round() as i64)) +} + +fn format_number(value: u64) -> String { + let text = value.to_string(); + let mut output = String::with_capacity(text.len() + text.len() / 3); + for (index, ch) in text.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + output.push(','); + } + output.push(ch); + } + output.chars().rev().collect() +} + +fn format_compact_number(value: u64) -> String { + if value < 1_000 { + return format_number(value); + } + + if value < 1_000_000 { + let value = value as f64 / 1_000.0; + return if value >= 100.0 { + format!("{value:.0}K") + } else { + format!("{value:.1}K") + }; + } + + let value = value as f64 / 1_000_000.0; + if value >= 100.0 { + format!("{value:.0}M") + } else { + format!("{value:.1}M") + } +} + +fn format_cost(value: f64) -> String { + if !value.is_finite() { + return "—".to_string(); + } + format!("${value:.6}") +} + +fn format_token_price(value: f64) -> String { + if !value.is_finite() { + return "—".to_string(); + } + format!("${value:.4} / 1M Token") +} + +fn format_service_tier(value: &str) -> String { + match value { + "priority" | "fast" => "Fast".to_string(), + "flex" => "Flex".to_string(), + "default" => "Default".to_string(), + other => other.to_string(), + } +} + +impl From for UsageRecordSummaryData { + fn from(summary: UsageRecordSummary) -> Self { + Self { + total_requests: summary.total_requests, + error_requests: summary.error_requests, + input_tokens: summary.input_tokens, + output_tokens: summary.output_tokens, + cached_tokens: summary.cached_tokens, + total_tokens: summary.total_tokens, + average_latency_ms: summary.average_latency_ms, + average_latency_ms_display: format_duration_ms_f64(summary.average_latency_ms), + } + } +} + +impl From for UsageRecordInsightsData { + fn from(insights: UsageRecordInsights) -> Self { + Self { + models: insights + .models + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + upstream_models: insights + .upstream_models + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + model_mappings: insights + .model_mappings + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + endpoints: insights + .endpoints + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + upstream_endpoints: insights + .upstream_endpoints + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + endpoint_paths: insights + .endpoint_paths + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + types: insights + .types + .into_iter() + .map(UsageRecordBreakdownData::from) + .collect(), + trend: insights + .trend + .into_iter() + .map(UsageRecordTrendPointData::from) + .collect(), + } + } +} + +impl From for UsageRecordBreakdownData { + fn from(item: UsageRecordBreakdown) -> Self { + Self { + name: item.name, + request_count: item.request_count, + input_tokens: item.input_tokens, + output_tokens: item.output_tokens, + cached_tokens: item.cached_tokens, + total_tokens: item.total_tokens, + cost: item.cost, + actual_cost: item.actual_cost, + account_cost: item.account_cost, + cost_display: format_cost(item.cost), + actual_cost_display: format_cost(item.actual_cost), + account_cost_display: format_cost(item.account_cost), + average_latency_ms: item.average_latency_ms, + average_latency_ms_display: format_duration_ms_f64(item.average_latency_ms), + } + } +} + +impl From for UsageRecordTrendPointData { + fn from(point: UsageRecordTrendPoint) -> Self { + Self { + date: point.date, + input_tokens: point.input_tokens, + output_tokens: point.output_tokens, + cache_creation_tokens: point.cache_creation_tokens, + cached_tokens: point.cached_tokens, + total_tokens: point.total_tokens, + cost: point.cost, + actual_cost: point.actual_cost, + cost_display: format_cost(point.cost), + actual_cost_display: format_cost(point.actual_cost), + average_latency_ms: point.average_latency_ms, + average_latency_ms_display: format_duration_ms_f64(point.average_latency_ms), + } + } +} diff --git a/src/admin/router.rs b/src/admin/router.rs index 1456fe8b3..0682e74b1 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -17,7 +17,10 @@ use super::{ keys::routes::{api_keys, batch_delete_api_keys, create_api_key, update_api_key}, monitoring::{ dashboard::{dashboard_summary, dashboard_trend}, - logs::{clear_logs, log_detail, logs}, + usage_records::{ + clear_usage_records, usage_record_detail, usage_records, usage_records_insights, + usage_records_summary, + }, }, settings::routes::{ admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings, @@ -59,9 +62,17 @@ pub fn router() -> Router { .route("/api/admin/accounts/update", post(update_account)) .route("/api/admin/accounts/refresh", post(refresh_account)) .route("/api/admin/accounts/quota", get(account_quota)) - .route("/api/admin/logs", get(logs)) - .route("/api/admin/logs/delete", post(clear_logs)) - .route("/api/admin/logs/detail", get(log_detail)) + .route("/api/admin/usage/records", get(usage_records)) + .route( + "/api/admin/usage/records/summary", + get(usage_records_summary), + ) + .route( + "/api/admin/usage/records/insights", + get(usage_records_insights), + ) + .route("/api/admin/usage/records/delete", post(clear_usage_records)) + .route("/api/admin/usage/records/detail", get(usage_record_detail)) .route("/api/admin/keys", get(api_keys).post(create_api_key)) .route("/api/admin/keys/delete", post(batch_delete_api_keys)) .route("/api/admin/keys/update", post(update_api_key)) diff --git a/src/admin/settings/routes.rs b/src/admin/settings/routes.rs index 1ab086f89..b67b9e399 100644 --- a/src/admin/settings/routes.rs +++ b/src/admin/settings/routes.rs @@ -84,6 +84,12 @@ pub(crate) async fn update_settings( .update(patch) .await .map_err(settings_service_error)?; + state + .services + .models + .update_config(crate::upstream::models::ModelConfig { + model_aliases: config.model_aliases.clone(), + }); Ok(AdminResponse::new( StatusCode::OK, diff --git a/src/config/settings.rs b/src/config/settings.rs index 3c3cb1306..79cbc5649 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -13,8 +13,9 @@ use thiserror::Error; use crate::config::types::AppConfig; use crate::infra::identity::generate_admin_api_key; -use crate::upstream::accounts::pool::{ - AccountPoolOptions, RotationStrategy, RuntimeAccountPoolService, +use crate::upstream::accounts::{ + pool::{AccountPoolOptions, RotationStrategy, RuntimeAccountPoolService}, + token_refresh::{RefreshPolicy, RuntimeRefreshPolicy}, }; const ROTATION_STRATEGIES: [&str; 3] = ["least_used", "round_robin", "sticky"]; @@ -245,20 +246,23 @@ pub struct AdminApiKeyStatus { pub struct RuntimeSettingsService { current: Arc>>, pool: SqlitePool, - account_pool: Option>, + account_pool: Arc, + refresh_policy: RuntimeRefreshPolicy, } impl RuntimeSettingsService { - /// 构造带账号池同步的运行时设置服务。 - pub fn with_account_pool( + /// 构造运行时设置服务。 + pub fn new( config: AppConfig, pool: SqlitePool, account_pool: Arc, + refresh_policy: RuntimeRefreshPolicy, ) -> Self { Self { current: Arc::new(StdRwLock::new(Arc::new(config))), pool, - account_pool: Some(account_pool), + account_pool, + refresh_policy, } } @@ -292,14 +296,16 @@ impl RuntimeSettingsService { SettingsService::apply_patch(&mut settings, patch)?; save_runtime_settings(&self.pool, &settings).await?; apply_admin_settings_to_config(&mut next, settings); - if let Some(account_pool) = &self.account_pool { - account_pool - .apply_options( - account_pool_options_from_config(&next), - next.auth.request_interval_ms, - ) - .await; - } + self.account_pool + .apply_options( + account_pool_options_from_config(&next), + next.auth.request_interval_ms, + ) + .await; + self.refresh_policy.update(RefreshPolicy { + refresh_margin_seconds: next.auth.refresh_margin_seconds, + refresh_concurrency: next.auth.refresh_concurrency, + }); let next = Arc::new(next); *self .current diff --git a/src/http/middleware/request_id.rs b/src/http/middleware/request_id.rs index 6b3f16562..868e61c6a 100644 --- a/src/http/middleware/request_id.rs +++ b/src/http/middleware/request_id.rs @@ -1,6 +1,13 @@ -//! 请求 ID 中间件。 +//! 请求上下文中间件。 -use axum::{extract::Request, http::HeaderValue, middleware::Next, response::Response}; +use std::net::SocketAddr; + +use axum::{ + extract::{connect_info::ConnectInfo, Request}, + http::HeaderValue, + middleware::Next, + response::Response, +}; use uuid::Uuid; /// 请求 ID 头。 @@ -39,6 +46,7 @@ pub async fn attach_request_id(mut request: Request, next: Next) -> Response { .and_then(RequestId::from_header) .unwrap_or_else(RequestId::generate); + attach_real_ip_from_connection(&mut request); request.extensions_mut().insert(request_id.clone()); let mut response = next.run(request).await; if let Ok(value) = HeaderValue::from_str(request_id.as_str()) { @@ -46,3 +54,20 @@ pub async fn attach_request_id(mut request: Request, next: Next) -> Response { } response } + +fn attach_real_ip_from_connection(request: &mut Request) { + if request.headers().contains_key("cf-connecting-ip") + || request.headers().contains_key("x-real-ip") + || request.headers().contains_key("x-forwarded-for") + { + return; + } + + let Some(ConnectInfo(addr)) = request.extensions().get::>() else { + return; + }; + let Ok(value) = HeaderValue::from_str(&addr.ip().to_string()) else { + return; + }; + request.headers_mut().insert("x-real-ip", value); +} diff --git a/src/infra/schema.sql b/src/infra/schema.sql index 94cd0ff31..9e94da2eb 100644 --- a/src/infra/schema.sql +++ b/src/infra/schema.sql @@ -249,9 +249,9 @@ create index if not exists idx_fingerprint_update_history_created_id on fingerprint_update_history(created_at desc, id desc); -- ============================================ --- Event Logs +-- Usage Records -- ============================================ -create table if not exists event_logs ( +create table if not exists usage_records ( id text primary key, request_id text, kind text not null, @@ -272,19 +272,19 @@ create table if not exists event_logs ( created_at text not null ); -create index if not exists idx_event_logs_created_id on event_logs(created_at desc, id desc); -create index if not exists idx_event_logs_kind_created on event_logs(kind, created_at desc); -create index if not exists idx_event_logs_request_id on event_logs(request_id); -create index if not exists idx_event_logs_account on event_logs(account_id, created_at desc) where account_id is not null; -create index if not exists idx_event_logs_transport on event_logs(transport, created_at desc) where transport is not null; -create index if not exists idx_event_logs_failure_class on event_logs(failure_class, created_at desc) where failure_class is not null; -create index if not exists idx_event_logs_response_id on event_logs(response_id) where response_id is not null; -create index if not exists idx_event_logs_upstream_request_id on event_logs(upstream_request_id) where upstream_request_id is not null; -create index if not exists idx_event_logs_level_created on event_logs(level, created_at desc); -create index if not exists idx_event_logs_route_created on event_logs(route, created_at desc) where route is not null; -create index if not exists idx_event_logs_model_created on event_logs(model, created_at desc) where model is not null; -create index if not exists idx_event_logs_status_created on event_logs(status_code, created_at desc) where status_code is not null; -create index if not exists idx_event_logs_upstream_status_created on event_logs(upstream_status_code, created_at desc) where upstream_status_code is not null; +create index if not exists idx_usage_records_created_id on usage_records(created_at desc, id desc); +create index if not exists idx_usage_records_kind_created on usage_records(kind, created_at desc); +create index if not exists idx_usage_records_request_id on usage_records(request_id); +create index if not exists idx_usage_records_account on usage_records(account_id, created_at desc) where account_id is not null; +create index if not exists idx_usage_records_transport on usage_records(transport, created_at desc) where transport is not null; +create index if not exists idx_usage_records_failure_class on usage_records(failure_class, created_at desc) where failure_class is not null; +create index if not exists idx_usage_records_response_id on usage_records(response_id) where response_id is not null; +create index if not exists idx_usage_records_upstream_request_id on usage_records(upstream_request_id) where upstream_request_id is not null; +create index if not exists idx_usage_records_level_created on usage_records(level, created_at desc); +create index if not exists idx_usage_records_route_created on usage_records(route, created_at desc) where route is not null; +create index if not exists idx_usage_records_model_created on usage_records(model, created_at desc) where model is not null; +create index if not exists idx_usage_records_status_created on usage_records(status_code, created_at desc) where status_code is not null; +create index if not exists idx_usage_records_upstream_status_created on usage_records(upstream_status_code, created_at desc) where upstream_status_code is not null; -- ============================================ -- Model Plan Snapshots diff --git a/src/proxy/dispatch/chat.rs b/src/proxy/dispatch/chat.rs index 35e2f20cb..c3edb9412 100644 --- a/src/proxy/dispatch/chat.rs +++ b/src/proxy/dispatch/chat.rs @@ -9,8 +9,8 @@ use thiserror::Error; use crate::{ admin::monitoring::{ - event_store::AdminLogService, - events::{EventLevel, ResponseEventRecord}, + usage_record::{ResponseUsageRecord, UsageRecordLevel}, + usage_record_store::AdminUsageRecordService, }, proxy::dispatch::{ cloudflare::{ @@ -51,7 +51,7 @@ pub struct ChatDispatchService { account_pool: Arc, models: Arc, codex: Arc, - logs: Arc, + usage_records: Arc, installation_id: Option, cloudflare: CloudflareRecovery, } @@ -61,7 +61,7 @@ impl ChatDispatchService { account_pool: Arc, models: Arc, codex: Arc, - logs: Arc, + usage_records: Arc, installation_id: Option, cloudflare: CloudflareRecovery, ) -> Self { @@ -69,7 +69,7 @@ impl ChatDispatchService { account_pool, models, codex, - logs, + usage_records, installation_id, cloudflare, } @@ -379,15 +379,19 @@ impl ChatDispatchService { .record_token_usage(&account_id, &request.model, usage) .await; } - record_response_event(ResponseEventRecord { - logs: &self.logs, + record_response_event(ResponseUsageRecord { + usage_records: &self.usage_records, request_id, account_id: &account_id, route: "/v1/chat/completions", - model: requested_model, + model: &request.model, + requested_model: Some(requested_model), + client_ip: request.client_ip.as_deref(), + client_user_agent: request.client_user_agent.as_deref(), + reasoning_effort: reasoning_effort_from_request(&request), started_at, status_code: 200, - level: EventLevel::Info, + level: UsageRecordLevel::Info, message: "v1 chat completions completed", metadata: serde_json::json!({ "responseId": response_id, @@ -411,7 +415,7 @@ impl ChatDispatchService { error: &ChatDispatchError, ) { record_chat_dispatch_error_event(ChatDispatchErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id, route: "/v1/chat/completions", @@ -503,7 +507,7 @@ impl ChatDispatchError { // ==================================================================== struct ChatDispatchErrorEventRecord<'a> { - logs: &'a AdminLogService, + usage_records: &'a AdminUsageRecordService, request_id: &'a str, account_id: Option<&'a str>, route: &'a str, @@ -513,7 +517,7 @@ struct ChatDispatchErrorEventRecord<'a> { error: &'a ChatDispatchError, } -use crate::admin::monitoring::events::EventLog; +use crate::admin::monitoring::usage_record::UsageRecord; async fn record_chat_dispatch_error_event(record: ChatDispatchErrorEventRecord<'_>) { let mut metadata = serde_json::json!({ @@ -530,7 +534,7 @@ async fn record_chat_dispatch_error_event(record: ChatDispatchErrorEventRecord<' object.insert("transport".to_string(), serde_json::json!(transport)); } } - let mut event = EventLog::new("v1.chat", EventLevel::Error, "chat dispatch failed"); + let mut event = UsageRecord::new("v1.chat", UsageRecordLevel::Error, "chat dispatch failed"); event.request_id = Some(record.request_id.to_string()); event.account_id = record.account_id.map(ToString::to_string); event.route = Some(record.route.to_string()); @@ -538,13 +542,13 @@ async fn record_chat_dispatch_error_event(record: ChatDispatchErrorEventRecord<' event.status_code = Some(record.error.http_status_code() as i64); event.latency_ms = Some(elapsed_millis_i64(record.started_at)); event.metadata = metadata; - if let Err(error) = record.logs.record(event).await { + if let Err(error) = record.usage_records.record(event).await { tracing::warn!(error = %error, "failed to record chat dispatch error event"); } } -async fn record_response_event(record: ResponseEventRecord<'_>) { - let mut event = EventLog::new("v1.chat", record.level, record.message); +async fn record_response_event(record: ResponseUsageRecord<'_>) { + let mut event = UsageRecord::new("v1.chat", record.level, record.message); event.request_id = Some(record.request_id.to_string()); event.account_id = Some(record.account_id.to_string()); event.route = Some(record.route.to_string()); @@ -559,6 +563,14 @@ async fn record_response_event(record: ResponseEventRecord<'_>) { object .entry("apiKind".to_string()) .or_insert_with(|| serde_json::json!("chat")); + enrich_usage_record_identity( + object, + record.requested_model, + record.model, + record.client_ip, + record.client_user_agent, + record.reasoning_effort, + ); } let rate_limit_headers = record.rate_limit_headers; if !rate_limit_headers.is_empty() { @@ -569,11 +581,70 @@ async fn record_response_event(record: ResponseEventRecord<'_>) { ); } } - if let Err(error) = record.logs.record(event).await { + if let Err(error) = record.usage_records.record(event).await { tracing::warn!(account_id = %record.account_id, error = %error, "failed to record response event"); } } +fn enrich_usage_record_identity( + object: &mut serde_json::Map, + requested_model: Option<&str>, + upstream_model: &str, + client_ip: Option<&str>, + client_user_agent: Option<&str>, + reasoning_effort: Option<&str>, +) { + let upstream_model = upstream_model.trim(); + let requested_model = requested_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(upstream_model); + object.insert( + "requestedModel".to_string(), + Value::String(requested_model.to_string()), + ); + object.insert( + "upstreamModel".to_string(), + Value::String(upstream_model.to_string()), + ); + + if let Some(client_ip) = client_ip.map(str::trim).filter(|value| !value.is_empty()) { + object.insert("clientIp".to_string(), Value::String(client_ip.to_string())); + } + + if let Some(user_agent) = client_user_agent + .map(str::trim) + .filter(|value| !value.is_empty()) + { + object.insert( + "userAgent".to_string(), + Value::String(user_agent.to_string()), + ); + } + + if let Some(reasoning_effort) = reasoning_effort + .map(str::trim) + .filter(|value| !value.is_empty()) + { + object.insert( + "reasoningEffort".to_string(), + Value::String(reasoning_effort.to_string()), + ); + } +} + +fn reasoning_effort_from_request( + request: &crate::upstream::protocol::responses::CodexResponsesRequest, +) -> Option<&str> { + request + .reasoning + .as_ref()? + .get("effort") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + fn enrich_chat_error_metadata( object: &mut serde_json::Map, error: &ChatDispatchError, diff --git a/src/proxy/dispatch/responses.rs b/src/proxy/dispatch/responses.rs index 064ea4b2f..3bb5e8731 100644 --- a/src/proxy/dispatch/responses.rs +++ b/src/proxy/dispatch/responses.rs @@ -23,8 +23,8 @@ use tracing; use crate::{ admin::monitoring::{ - event_store::AdminLogService, - events::{EventLevel, EventLog, ResponseEventRecord}, + usage_record::{ResponseUsageRecord, UsageRecord, UsageRecordLevel}, + usage_record_store::AdminUsageRecordService, }, proxy::dispatch::{ cloudflare::{ @@ -104,7 +104,7 @@ pub struct ResponseDispatchService { codex: Arc, session_affinity: Arc, reasoning_replay: Arc>, - logs: Arc, + usage_records: Arc, installation_id: Option, cloudflare: CloudflareRecovery, } @@ -151,7 +151,7 @@ impl ResponseDispatchService { models: Arc, codex: Arc, session_affinity: Arc, - logs: Arc, + usage_records: Arc, installation_id: Option, cloudflare: CloudflareRecovery, ) -> Self { @@ -163,7 +163,7 @@ impl ResponseDispatchService { reasoning_replay: Arc::new(Mutex::new(ReasoningReplayCache::new(Duration::seconds( DEFAULT_REASONING_REPLAY_TTL_SECS, )))), - logs, + usage_records, installation_id, cloudflare, } @@ -830,7 +830,7 @@ impl ResponseDispatchService { } Err(error) => { record_response_upstream_error_event(ResponseUpstreamErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id: &release_account_id, route, @@ -871,15 +871,19 @@ impl ResponseDispatchService { response.usage, ) .await; - record_response_event(ResponseEventRecord { - logs: &self.logs, + record_response_event(ResponseUsageRecord { + usage_records: &self.usage_records, request_id, account_id: &account.id, route, - model: requested_model, + model: &request.model, + requested_model: Some(requested_model), + client_ip: request.client_ip.as_deref(), + client_user_agent: request.client_user_agent.as_deref(), + reasoning_effort: reasoning_effort_from_request(&request), started_at, status_code: 200, - level: EventLevel::Info, + level: UsageRecordLevel::Info, message: "v1 responses completed", metadata: json!({ "responseId": response_id, @@ -959,7 +963,7 @@ impl ResponseDispatchService { error: &ResponseDispatchError, ) { record_response_dispatch_error_event(ResponseDispatchErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id: details.account_id, route, @@ -1215,7 +1219,7 @@ impl ResponseDispatchService { if let ResponseDispatchError::Upstream(upstream_error) = &error { record_response_upstream_error_event( ResponseUpstreamErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id: &release_account_id, route, @@ -1326,11 +1330,12 @@ impl ResponseDispatchService { self.account_pool.release(&release_account_id).await; record_prefetched_response_stream_failure_event( ResponseStreamFailureEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id: &release_account_id, route, - model: requested_model, + model: &request.model, + requested_model, started_at, transport, request: &request, @@ -1347,12 +1352,14 @@ impl ResponseDispatchService { account_pool: Arc::clone(&self.account_pool), session_affinity: Arc::clone(&self.session_affinity), reasoning_replay: Arc::clone(&self.reasoning_replay), - logs: Arc::clone(&self.logs), + usage_records: Arc::clone(&self.usage_records), cloudflare: self.cloudflare.clone(), account_id: account.id, request_id: request_id.to_string(), route: route.to_string(), - model: requested_model.to_string(), + model: request.model.clone(), + requested_model: requested_model.to_string(), + client_ip: request.client_ip.clone(), request, tuple_schema, transport, @@ -1482,7 +1489,7 @@ impl ResponseDispatchService { Err(error) => { self.account_pool.release(&release_account_id).await; record_response_upstream_error_event(ResponseUpstreamErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id: &release_account_id, route, @@ -1744,15 +1751,19 @@ impl ResponseDispatchService { .record_token_usage(&account.id, &request.model, &usage) .await; } - record_response_event(ResponseEventRecord { - logs: &self.logs, + record_response_event(ResponseUsageRecord { + usage_records: &self.usage_records, request_id, account_id: &account.id, route: "/v1/responses/compact", - model: requested_model, + model: &request.model, + requested_model: Some(requested_model), + client_ip: request.client_ip.as_deref(), + client_user_agent: request.client_user_agent.as_deref(), + reasoning_effort: reasoning_effort_from_compact_request(&request), started_at, status_code: 200, - level: EventLevel::Info, + level: UsageRecordLevel::Info, message: "v1 responses compact completed", metadata: json!({ "stream": false, @@ -1878,7 +1889,7 @@ impl ResponseDispatchService { error: &ResponseDispatchError, ) { record_response_dispatch_error_event(ResponseDispatchErrorEventRecord { - logs: &self.logs, + usage_records: &self.usage_records, request_id, account_id, route: "/v1/responses/compact", @@ -2029,12 +2040,14 @@ struct LiveResponseStreamContext { account_pool: Arc, session_affinity: Arc, reasoning_replay: Arc>, - logs: Arc, + usage_records: Arc, cloudflare: CloudflareRecovery, account_id: String, request_id: String, route: String, model: String, + requested_model: String, + client_ip: Option, request: CodexResponsesRequest, tuple_schema: Option, transport: CodexBackendTransport, @@ -2058,6 +2071,7 @@ fn spawn_live_response_stream( .clone() .map(TupleSseEventTransformer::new); let mut body_bytes = Vec::new(); + let mut first_token_ms = None; if !send_live_response_stream_chunk( &sender, &mut body_bytes, @@ -2069,6 +2083,7 @@ fn spawn_live_response_stream( context.account_pool.release(&context.account_id).await; return; } + update_first_token_ms(&context, &body_bytes, &mut first_token_ms); loop { let next = tokio::select! { @@ -2094,6 +2109,7 @@ fn spawn_live_response_stream( context.account_pool.release(&context.account_id).await; return; } + update_first_token_ms(&context, &body_bytes, &mut first_token_ms); } Err(error) => { if !flush_live_response_stream_transformer( @@ -2114,7 +2130,7 @@ fn spawn_live_response_stream( context.account_pool.release(&context.account_id).await; return; }; - finalize_live_response_stream(context, body_text).await; + finalize_live_response_stream(context, body_text, first_token_ms).await; return; } } @@ -2136,7 +2152,7 @@ fn spawn_live_response_stream( return; }; - finalize_live_response_stream(context, body_text).await; + finalize_live_response_stream(context, body_text, first_token_ms).await; }); ResponseDispatchStream { @@ -2147,6 +2163,40 @@ fn spawn_live_response_stream( } } +fn update_first_token_ms( + context: &LiveResponseStreamContext, + body_bytes: &[u8], + first_token_ms: &mut Option, +) { + if first_token_ms.is_none() && response_body_has_first_token(body_bytes) { + *first_token_ms = Some(elapsed_millis_i64(context.started_at)); + } +} + +fn response_body_has_first_token(body_bytes: &[u8]) -> bool { + let body = String::from_utf8_lossy(body_bytes); + let Some(event_end) = body.rfind("\n\n") else { + return false; + }; + parse_sse_events(&body[..event_end + 2]).is_ok_and(|events| { + events.iter().any(|event| { + serde_json::from_str::(&event.data).is_ok_and(|value| { + let event_type = event + .event + .as_deref() + .or_else(|| value.get("type").and_then(Value::as_str)); + matches!( + event_type, + Some("response.output_text.delta" | "response.reasoning_summary_text.delta") + ) && value + .get("delta") + .and_then(Value::as_str) + .is_some_and(|delta| !delta.is_empty()) + }) + }) + }) +} + async fn send_live_response_stream_chunk( sender: &mpsc::Sender>, body_bytes: &mut Vec, @@ -2345,7 +2395,11 @@ fn premature_close_failed_event(response_id: Option<&str>, detail: Option<&str>) response_failed_sse_event_with_id(response_id, "server_error", "stream_disconnected", &message) } -async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: String) { +async fn finalize_live_response_stream( + context: LiveResponseStreamContext, + body: String, + first_token_ms: Option, +) { let rate_limit_headers = live_response_rate_limit_headers(&context).await; let turn_state = live_response_turn_state(&context).await; let usage = match extract_sse_usage(&body) { @@ -2383,12 +2437,13 @@ async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: record_live_response_stream_event( &context, 200, - EventLevel::Info, + UsageRecordLevel::Info, "v1 responses stream completed", serde_json::json!({ "stream": true, "completed": true, "responseId": response_id, + "firstTokenMs": first_token_ms, "usage": usage, }), &rate_limit_headers, @@ -2411,12 +2466,14 @@ async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: code = ?failure.upstream_code.as_deref(), "live upstream stream ended with response.failed" ); + let mut metadata = stream_failure_metadata(&failure, usage); + insert_first_token_ms(&mut metadata, first_token_ms); record_live_response_stream_event( &context, status_code_for_stream_failure(&failure), - EventLevel::Error, + UsageRecordLevel::Error, "v1 responses stream failed", - stream_failure_metadata(&failure, usage), + metadata, &rate_limit_headers, &body, ) @@ -2427,17 +2484,19 @@ async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: account_id = %context.account_id, "live upstream stream ended without response.completed" ); + let mut metadata = serde_json::json!({ + "stream": true, + "failed": true, + "upstreamCode": "missing_completed", + "usage": usage, + }); + insert_first_token_ms(&mut metadata, first_token_ms); record_live_response_stream_event( &context, 502, - EventLevel::Error, + UsageRecordLevel::Error, "v1 responses stream ended without response.completed", - serde_json::json!({ - "stream": true, - "failed": true, - "upstreamCode": "missing_completed", - "usage": usage, - }), + metadata, &rate_limit_headers, &body, ) @@ -2445,16 +2504,18 @@ async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: } Err(error) => { tracing::warn!(account_id = %context.account_id, error = %error, "failed to parse completed live stream"); + let mut metadata = serde_json::json!({ + "stream": true, + "sseParseError": error.to_string(), + "usage": usage, + }); + insert_first_token_ms(&mut metadata, first_token_ms); record_live_response_stream_event( &context, 502, - EventLevel::Warn, + UsageRecordLevel::Warn, "v1 responses stream SSE response invalid", - serde_json::json!({ - "stream": true, - "sseParseError": error.to_string(), - "usage": usage, - }), + metadata, &rate_limit_headers, &body, ) @@ -2470,7 +2531,7 @@ async fn finalize_live_response_stream(context: LiveResponseStreamContext, body: // ==================================================================== struct ResponseUpstreamErrorEventRecord<'a> { - logs: &'a AdminLogService, + usage_records: &'a AdminUsageRecordService, request_id: &'a str, account_id: &'a str, route: &'a str, @@ -2482,11 +2543,12 @@ struct ResponseUpstreamErrorEventRecord<'a> { } struct ResponseStreamFailureEventRecord<'a> { - logs: &'a AdminLogService, + usage_records: &'a AdminUsageRecordService, request_id: &'a str, account_id: &'a str, route: &'a str, model: &'a str, + requested_model: &'a str, started_at: Instant, transport: CodexBackendTransport, request: &'a CodexResponsesRequest, @@ -2496,7 +2558,7 @@ struct ResponseStreamFailureEventRecord<'a> { } struct ResponseDispatchErrorEventRecord<'a> { - logs: &'a AdminLogService, + usage_records: &'a AdminUsageRecordService, request_id: &'a str, account_id: Option<&'a str>, route: &'a str, @@ -2524,9 +2586,9 @@ async fn record_response_dispatch_error_event(record: ResponseDispatchErrorEvent ); enrich_response_dispatch_error_metadata(&mut metadata, record.error); enrich_event_route_metadata(&mut metadata, record.route); - let mut event = EventLog::new( + let mut event = UsageRecord::new( response_event_kind(record.route), - EventLevel::Error, + UsageRecordLevel::Error, "v1 responses dispatch failed", ); event.request_id = Some(record.request_id.to_string()); @@ -2536,7 +2598,7 @@ async fn record_response_dispatch_error_event(record: ResponseDispatchErrorEvent event.status_code = Some(i64::from(record.error.http_status_code())); event.latency_ms = Some(elapsed_millis_i64(record.started_at)); event.metadata = metadata; - if let Err(error) = record.logs.record(event).await { + if let Err(error) = record.usage_records.record(event).await { tracing::warn!( account_id = record.account_id.unwrap_or(""), error = %error, @@ -2553,9 +2615,9 @@ async fn record_response_upstream_error_event(record: ResponseUpstreamErrorEvent Some(backend_transport_name(record.transport)), ); enrich_event_route_metadata(&mut metadata, record.route); - let mut event = EventLog::new( + let mut event = UsageRecord::new( "v1.response", - EventLevel::Error, + UsageRecordLevel::Error, "v1 responses upstream request failed", ); event.request_id = Some(record.request_id.to_string()); @@ -2565,7 +2627,7 @@ async fn record_response_upstream_error_event(record: ResponseUpstreamErrorEvent event.status_code = Some(i64::from(upstream_error_http_status(record.error))); event.latency_ms = Some(elapsed_millis_i64(record.started_at)); event.metadata = metadata; - if let Err(error) = record.logs.record(event).await { + if let Err(error) = record.usage_records.record(event).await { tracing::warn!(account_id = %record.account_id, error = %error, "failed to record upstream error event"); } } @@ -2587,15 +2649,19 @@ async fn record_prefetched_response_stream_failure_event( Value::String(String::from_utf8_lossy(record.prefetched).to_string()), ); } - record_response_event(ResponseEventRecord { - logs: record.logs, + record_response_event(ResponseUsageRecord { + usage_records: record.usage_records, request_id: record.request_id, account_id: record.account_id, route: record.route, model: record.model, + requested_model: Some(record.requested_model), + client_ip: record.request.client_ip.as_deref(), + client_user_agent: record.request.client_user_agent.as_deref(), + reasoning_effort: reasoning_effort_from_request(record.request), started_at: record.started_at, status_code: status_code_for_stream_failure(record.failure), - level: EventLevel::Error, + level: UsageRecordLevel::Error, message: "v1 responses stream failed", metadata, rate_limit_headers: record.rate_limit_headers, @@ -2603,10 +2669,10 @@ async fn record_prefetched_response_stream_failure_event( .await; } -async fn record_response_event(record: ResponseEventRecord<'_>) { +async fn record_response_event(record: ResponseUsageRecord<'_>) { let mut metadata = record.metadata; enrich_event_route_metadata(&mut metadata, record.route); - let mut event = EventLog::new( + let mut event = UsageRecord::new( response_event_kind(record.route), record.level, record.message, @@ -2617,6 +2683,14 @@ async fn record_response_event(record: ResponseEventRecord<'_>) { event.model = Some(record.model.to_string()); event.status_code = Some(record.status_code); event.latency_ms = Some(elapsed_millis_i64(record.started_at)); + enrich_usage_record_identity( + &mut metadata, + record.requested_model, + record.model, + record.client_ip, + record.client_user_agent, + record.reasoning_effort, + ); event.metadata = metadata; let rate_limit_headers = record.rate_limit_headers; if !rate_limit_headers.is_empty() { @@ -2627,7 +2701,7 @@ async fn record_response_event(record: ResponseEventRecord<'_>) { ); } } - if let Err(error) = record.logs.record(event).await { + if let Err(error) = record.usage_records.record(event).await { tracing::warn!(account_id = %record.account_id, error = %error, "failed to record response event"); } } @@ -2660,6 +2734,73 @@ fn enrich_event_route_metadata(metadata: &mut Value, route: &str) { .or_insert_with(|| Value::String(response_api_kind(route).to_string())); } +fn enrich_usage_record_identity( + metadata: &mut Value, + requested_model: Option<&str>, + upstream_model: &str, + client_ip: Option<&str>, + client_user_agent: Option<&str>, + reasoning_effort: Option<&str>, +) { + let Some(object) = metadata.as_object_mut() else { + return; + }; + + let upstream_model = upstream_model.trim(); + let requested_model = requested_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(upstream_model); + object.insert( + "requestedModel".to_string(), + Value::String(requested_model.to_string()), + ); + object.insert( + "upstreamModel".to_string(), + Value::String(upstream_model.to_string()), + ); + + if let Some(client_ip) = client_ip.map(str::trim).filter(|value| !value.is_empty()) { + object.insert("clientIp".to_string(), Value::String(client_ip.to_string())); + } + + if let Some(user_agent) = client_user_agent + .map(str::trim) + .filter(|value| !value.is_empty()) + { + object.insert( + "userAgent".to_string(), + Value::String(user_agent.to_string()), + ); + } + + if let Some(reasoning_effort) = reasoning_effort + .map(str::trim) + .filter(|value| !value.is_empty()) + { + object.insert( + "reasoningEffort".to_string(), + Value::String(reasoning_effort.to_string()), + ); + } +} + +fn reasoning_effort_from_request(request: &CodexResponsesRequest) -> Option<&str> { + reasoning_effort_from_value(request.reasoning.as_ref()) +} + +fn reasoning_effort_from_compact_request(request: &CodexCompactRequest) -> Option<&str> { + reasoning_effort_from_value(request.reasoning.as_ref()) +} + +fn reasoning_effort_from_value(reasoning: Option<&Value>) -> Option<&str> { + reasoning? + .get("effort") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + fn ensure_stream_metadata_flag(metadata: &mut Value) { let Some(object) = metadata.as_object_mut() else { *metadata = serde_json::json!({ "stream": true }); @@ -2698,7 +2839,7 @@ fn enrich_live_response_stream_metadata( async fn record_live_response_stream_event( context: &LiveResponseStreamContext, status_code: i64, - level: EventLevel, + level: UsageRecordLevel, message: &str, mut metadata: Value, rate_limit_headers: &[(String, String)], @@ -2707,15 +2848,23 @@ async fn record_live_response_stream_event( ensure_stream_metadata_flag(&mut metadata); enrich_event_route_metadata(&mut metadata, &context.route); enrich_live_response_stream_metadata(context, rate_limit_headers, &mut metadata, body); - let mut event = EventLog::new(response_event_kind(&context.route), level, message); + let mut event = UsageRecord::new(response_event_kind(&context.route), level, message); event.request_id = Some(context.request_id.clone()); event.account_id = Some(context.account_id.clone()); event.route = Some(context.route.clone()); event.model = Some(context.model.clone()); event.status_code = Some(status_code); event.latency_ms = Some(elapsed_millis_i64(context.started_at)); + enrich_usage_record_identity( + &mut metadata, + Some(&context.requested_model), + &context.model, + context.client_ip.as_deref(), + context.request.client_user_agent.as_deref(), + reasoning_effort_from_request(&context.request), + ); event.metadata = metadata; - if let Err(error) = context.logs.record(event).await { + if let Err(error) = context.usage_records.record(event).await { tracing::warn!(account_id = %context.account_id, error = %error, "failed to record live response stream event"); } } @@ -2748,6 +2897,18 @@ fn stream_failure_metadata(failure: &ResponsesSseFailure, usage: Option) { + let Some(first_token_ms) = first_token_ms else { + return; + }; + if let Some(object) = metadata.as_object_mut() { + object.insert( + "firstTokenMs".to_string(), + Value::Number(first_token_ms.into()), + ); + } +} + fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 { let code = failure .upstream_code diff --git a/src/proxy/openai/chat.rs b/src/proxy/openai/chat.rs index b0f5fcf9e..8fff4331e 100644 --- a/src/proxy/openai/chat.rs +++ b/src/proxy/openai/chat.rs @@ -912,6 +912,7 @@ pub async fn chat_completions( let Ok(mut codex_request) = translate_chat_to_codex(chat_request) else { return invalid_chat_completion_request_response().into_response(); }; + super::responses::attach_client_context(&mut codex_request, &headers); apply_response_model_options(&mut codex_request, &parsed_model); let include_reasoning = codex_request .reasoning diff --git a/src/proxy/openai/responses.rs b/src/proxy/openai/responses.rs index 9cd705a9d..d81b8ded2 100644 --- a/src/proxy/openai/responses.rs +++ b/src/proxy/openai/responses.rs @@ -9,6 +9,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use std::net::IpAddr; use crate::{ http::middleware::request_id::RequestId, @@ -174,6 +175,8 @@ pub fn translate_response_to_compact(request: OpenAiResponsesRequest) -> CodexCo parallel_tool_calls: request.parallel_tool_calls, reasoning: compact_reasoning(request.reasoning), text: prepare_text_format(request.text, false).text, + client_ip: None, + client_user_agent: None, } } @@ -560,7 +563,8 @@ async fn handle_responses( } }; let stream = openai_request.stream; - let codex_request = translate_response_to_codex(openai_request); + let mut codex_request = translate_response_to_codex(openai_request); + attach_client_context(&mut codex_request, &headers); if stream { return match state @@ -607,7 +611,9 @@ async fn handle_compact_responses( return model_not_found_response().into_response(); } }; - let compact_request = translate_response_to_compact(openai_request); + let mut compact_request = translate_response_to_compact(openai_request); + compact_request.client_ip = client_ip_from_headers(&headers); + compact_request.client_user_agent = user_agent_from_headers(&headers); match state .services @@ -687,6 +693,65 @@ fn fill_string_from_header(field: &mut Option, headers: &HeaderMap, name *field = header_string(headers, name); } +pub(crate) fn attach_client_context(request: &mut CodexResponsesRequest, headers: &HeaderMap) { + request.client_ip = client_ip_from_headers(headers); + request.client_user_agent = user_agent_from_headers(headers); +} + +pub(crate) fn user_agent_from_headers(headers: &HeaderMap) -> Option { + header_string(headers, "user-agent") +} + +pub(crate) fn client_ip_from_headers(headers: &HeaderMap) -> Option { + header_string(headers, "cf-connecting-ip") + .or_else(|| header_string(headers, "x-real-ip")) + .or_else(|| forwarded_client_ip(headers)) + .and_then(|value| normalize_client_ip(&value)) +} + +fn forwarded_client_ip(headers: &HeaderMap) -> Option { + let value = header_string(headers, "x-forwarded-for")?; + let parts = value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>(); + parts + .iter() + .find(|part| !is_private_ip(part)) + .or_else(|| parts.first()) + .map(|part| (*part).to_string()) +} + +fn normalize_client_ip(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + trimmed + .parse::() + .map(|addr| addr.ip().to_string()) + .ok() + .or_else(|| Some(trimmed.to_string())) +} + +fn is_private_ip(value: &str) -> bool { + let Some(ip) = normalize_client_ip(value).and_then(|value| value.parse::().ok()) else { + return false; + }; + + match ip { + IpAddr::V4(ip) => { + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]) + } + IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(), + } +} + fn openai_subagent_from_headers(headers: &HeaderMap) -> Option { header_string(headers, OPENAI_SUBAGENT_HEADER).and_then(|value| { if matches!( diff --git a/src/runtime/bootstrap.rs b/src/runtime/bootstrap.rs index 02138ccbf..e53c242fd 100644 --- a/src/runtime/bootstrap.rs +++ b/src/runtime/bootstrap.rs @@ -22,9 +22,12 @@ pub async fn run() -> Result<(), Box> { let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; let (app, task_coordinator) = build_application(config).await?; - let serve_result = axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await; + let serve_result = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await; task_coordinator.shutdown().await; serve_result?; @@ -51,7 +54,9 @@ async fn build_application( pool.clone(), ), client_keys: crate::admin::keys::service::SqliteClientKeyStore::new(pool.clone()), - event_logs: crate::admin::monitoring::event_store::SqliteEventLogStore::new(pool), + usage_records: crate::admin::monitoring::usage_record_store::SqliteUsageRecordStore::new( + pool, + ), }; let default_admin_password = config.admin.default_password.clone(); diff --git a/src/runtime/services.rs b/src/runtime/services.rs index 105efd4b6..c21bcac35 100644 --- a/src/runtime/services.rs +++ b/src/runtime/services.rs @@ -4,11 +4,11 @@ use std::sync::Arc as StdArc; use crate::{ admin::monitoring::{ - event_store::{ - AdminLogService, SqliteEventLogStore, DEFAULT_EVENT_LOG_CAPACITY, - DEFAULT_EVENT_LOG_CAPTURE_BODY, - }, service::AdminUsageService, + usage_record_store::{ + AdminUsageRecordService, SqliteUsageRecordStore, DEFAULT_USAGE_RECORD_CAPACITY, + DEFAULT_USAGE_RECORD_CAPTURE_BODY, + }, usage_store::SqliteUsageStore, }, admin::{ @@ -29,7 +29,9 @@ use crate::{ cookies::SqliteCookieStore, pool::RuntimeAccountPoolService, store::{AccountStore as AccountStoreTrait, SqliteAccountStore}, - token_refresh::RefreshLeaseStore as SqliteRefreshLeaseStore, + token_refresh::{ + RefreshLeaseStore as SqliteRefreshLeaseStore, RefreshPolicy, RuntimeRefreshPolicy, + }, }, upstream::{ fingerprint::{Fingerprint, FingerprintRepository}, @@ -55,7 +57,7 @@ pub struct BackgroundTaskStores { pub session_affinity: SqliteSessionAffinityStore, pub refresh_leases: SqliteRefreshLeaseStore, pub client_keys: SqliteClientKeyStore, - pub event_logs: SqliteEventLogStore, + pub usage_records: SqliteUsageRecordStore, } // ============================================================================ @@ -71,8 +73,9 @@ pub struct Services { pub admin_client_keys: StdArc, pub admin_sessions: StdArc, pub settings: StdArc, + pub refresh_policy: RuntimeRefreshPolicy, pub admin_accounts: StdArc, - pub logs: StdArc, + pub usage_records: StdArc, pub usage: StdArc, pub account_pool: StdArc, pub chat: StdArc, @@ -84,20 +87,20 @@ pub struct Services { pub background_tasks: BackgroundTaskStores, } -/// 事件日志运行选项。 +/// 使用记录运行选项。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EventLogOptions { +pub struct UsageRecordOptions { pub enabled: bool, pub capacity: u32, pub capture_body: bool, } -impl EventLogOptions { +impl UsageRecordOptions { pub fn from_config(config: &AppConfig) -> Self { Self { enabled: config.logging.enabled, - capacity: DEFAULT_EVENT_LOG_CAPACITY, - capture_body: DEFAULT_EVENT_LOG_CAPTURE_BODY, + capacity: DEFAULT_USAGE_RECORD_CAPACITY, + capture_body: DEFAULT_USAGE_RECORD_CAPTURE_BODY, } } } @@ -122,21 +125,21 @@ impl Services { fingerprint: Fingerprint, installation_id: Option, ) -> Result { - Self::try_with_installation_id_and_log_options( + Self::try_with_installation_id_and_usage_record_options( config, stores, fingerprint, installation_id, - EventLogOptions::from_config(config), + UsageRecordOptions::from_config(config), ) } - pub fn try_with_installation_id_and_log_options( + pub fn try_with_installation_id_and_usage_record_options( config: &AppConfig, stores: BackgroundTaskStores, fingerprint: Fingerprint, installation_id: Option, - log_options: EventLogOptions, + usage_record_options: UsageRecordOptions, ) -> Result { let installation_id = installation_id.filter(|id| !id.trim().is_empty()); let account_store_trait = @@ -167,10 +170,15 @@ impl Services { account_pool_options_from_config(config), config.auth.request_interval_ms, )); - let settings = StdArc::new(RuntimeSettingsService::with_account_pool( + let refresh_policy = RuntimeRefreshPolicy::new(RefreshPolicy { + refresh_margin_seconds: config.auth.refresh_margin_seconds, + refresh_concurrency: config.auth.refresh_concurrency, + }); + let settings = StdArc::new(RuntimeSettingsService::new( config.clone(), stores.accounts.pool().clone(), account_pool.clone(), + refresh_policy.clone(), )); let admin_sessions = StdArc::new(AdminSessionService::new( stores.admin_sessions.clone(), @@ -193,14 +201,14 @@ impl Services { config.auth.oauth_client_id.clone(), config.auth.oauth_token_endpoint.clone(), ), - refresh_margin_seconds: config.auth.refresh_margin_seconds, + refresh_policy: refresh_policy.clone(), installation_id: installation_id.clone(), })); - let logs = StdArc::new(AdminLogService::new( - stores.event_logs.clone(), - log_options.enabled, - log_options.capacity, - log_options.capture_body, + let usage_records = StdArc::new(AdminUsageRecordService::new( + stores.usage_records.clone(), + usage_record_options.enabled, + usage_record_options.capacity, + usage_record_options.capture_body, )); let usage_store = SqliteUsageStore::new(stores.accounts.pool().clone()); let usage = StdArc::new(AdminUsageService::new(usage_store)); @@ -225,7 +233,7 @@ impl Services { account_pool.clone(), models.clone(), codex.clone(), - logs.clone(), + usage_records.clone(), installation_id.clone(), cloudflare_recovery.clone(), )); @@ -234,7 +242,7 @@ impl Services { models.clone(), codex.clone(), session_affinity.clone(), - logs.clone(), + usage_records.clone(), installation_id.clone(), cloudflare_recovery, )); @@ -246,8 +254,9 @@ impl Services { admin_client_keys, admin_sessions, settings, + refresh_policy, admin_accounts, - logs, + usage_records, usage, account_pool, chat, diff --git a/src/runtime/tasks/coordinator.rs b/src/runtime/tasks/coordinator.rs index 8de604052..46f21aad4 100644 --- a/src/runtime/tasks/coordinator.rs +++ b/src/runtime/tasks/coordinator.rs @@ -4,10 +4,7 @@ use std::path::PathBuf; use tokio::task::JoinHandle; -use crate::upstream::{ - accounts::token_refresh::RefreshPolicy, - token_client::{default_openai_token_client, TokenClientConfig}, -}; +use crate::upstream::token_client::{default_openai_token_client, TokenClientConfig}; use super::{ cookie_cleanup::CookieCleanupTask, @@ -77,10 +74,7 @@ impl TaskCoordinator { "token_refresh", TokenRefreshTask::new( stores.accounts.clone(), - RefreshPolicy { - refresh_margin_seconds: config.auth.refresh_margin_seconds, - refresh_concurrency: config.auth.refresh_concurrency, - }, + services.refresh_policy.clone(), default_openai_token_client(token_client_config(config)), ) .with_refresh_lease_store(stores.refresh_leases.clone()) diff --git a/src/runtime/tasks/token_refresh.rs b/src/runtime/tasks/token_refresh.rs index be23136f8..5cef19ae8 100644 --- a/src/runtime/tasks/token_refresh.rs +++ b/src/runtime/tasks/token_refresh.rs @@ -7,7 +7,9 @@ use tracing::{debug, info, warn}; use crate::upstream::accounts::{ store::SqliteAccountStore, - token_refresh::{RefreshLeaseStore, RefreshPolicy, RuntimeTokenRefreshService, TokenRefresher}, + token_refresh::{ + RefreshLeaseStore, RuntimeRefreshPolicy, RuntimeTokenRefreshService, TokenRefresher, + }, }; use super::coordinator::SchedulerHandle; @@ -28,7 +30,7 @@ where C: TokenRefresher, { /// 构造默认后台任务。 - pub fn new(store: SqliteAccountStore, policy: RefreshPolicy, client: C) -> Self { + pub fn new(store: SqliteAccountStore, policy: RuntimeRefreshPolicy, client: C) -> Self { Self { service: RuntimeTokenRefreshService::new(store, policy, client), interval_secs: DEFAULT_INTERVAL_SECS, diff --git a/src/upstream/accounts/token_refresh/mod.rs b/src/upstream/accounts/token_refresh/mod.rs index 886cee918..5da4331c8 100644 --- a/src/upstream/accounts/token_refresh/mod.rs +++ b/src/upstream/accounts/token_refresh/mod.rs @@ -1,6 +1,6 @@ //! 刷新租约存储、JWT 解码与账号 claims 验证。 -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use async_trait::async_trait; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -159,6 +159,64 @@ pub struct RefreshPolicy { pub refresh_concurrency: u32, } +/// 运行时共享刷新策略。 +#[derive(Clone)] +pub struct RuntimeRefreshPolicy { + policy: Arc>, + semaphore: Arc>>, +} + +impl RuntimeRefreshPolicy { + /// 构造共享刷新策略。 + pub fn new(policy: RefreshPolicy) -> Self { + Self { + policy: Arc::new(RwLock::new(policy)), + semaphore: Arc::new(RwLock::new(Arc::new(Semaphore::new( + policy.refresh_concurrency.max(1) as usize, + )))), + } + } + + /// 返回当前策略快照。 + pub fn snapshot(&self) -> RefreshPolicy { + *self + .policy + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// 更新当前策略。 + pub fn update(&self, policy: RefreshPolicy) { + *self + .policy + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = policy; + *self + .semaphore + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Arc::new(Semaphore::new(policy.refresh_concurrency.max(1) as usize)); + } + + /// 返回当前提前刷新秒数。 + pub fn refresh_margin_seconds(&self) -> u64 { + self.snapshot().refresh_margin_seconds + } + + fn semaphore(&self) -> Arc { + self.semaphore + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl From for RuntimeRefreshPolicy { + fn from(policy: RefreshPolicy) -> Self { + Self::new(policy) + } +} + /// 触发刷新动作的原因。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshTrigger { @@ -215,9 +273,8 @@ pub trait TokenRefresher: Send + Sync + 'static { /// 负责执行单账号刷新策略的调度器。 #[derive(Clone)] pub struct RefreshScheduler { - policy: RefreshPolicy, + policy: RuntimeRefreshPolicy, client: Arc, - semaphore: Arc, } impl RefreshScheduler @@ -225,15 +282,18 @@ where C: TokenRefresher, { /// 使用策略和上游刷新端口构造调度器。 - pub fn new(policy: RefreshPolicy, client: C) -> Self { - let concurrency = policy.refresh_concurrency.max(1) as usize; + pub fn new(policy: impl Into, client: C) -> Self { Self { - policy, + policy: policy.into(), client: Arc::new(client), - semaphore: Arc::new(Semaphore::new(concurrency)), } } + /// 更新刷新策略。 + pub fn update_policy(&self, policy: RefreshPolicy) { + self.policy.update(policy); + } + /// 在给定时间点按触发原因刷新账号。 /// /// 当账号不需要刷新时返回原账号快照;当刷新失败但属于可映射的领域错误时, @@ -258,8 +318,8 @@ where return Ok(expired); }; - let _permit = self - .semaphore + let semaphore = self.policy.semaphore(); + let _permit = semaphore .acquire() .await .map_err(|_| RefreshError::ConcurrencyClosed)?; @@ -301,7 +361,8 @@ where } fn refresh_margin(&self) -> Duration { - let seconds = self.policy.refresh_margin_seconds.min(86_400 * 7) as i64; + let policy = self.policy.snapshot(); + let seconds = policy.refresh_margin_seconds.min(86_400 * 7) as i64; Duration::seconds(seconds) } } diff --git a/src/upstream/accounts/token_refresh/runtime.rs b/src/upstream/accounts/token_refresh/runtime.rs index fc977a4fd..eb35427d2 100644 --- a/src/upstream/accounts/token_refresh/runtime.rs +++ b/src/upstream/accounts/token_refresh/runtime.rs @@ -23,7 +23,7 @@ use crate::upstream::accounts::{ }, token_refresh::{ jwt_expiration, RefreshError, RefreshLeaseStore, RefreshLeaseStoreError, RefreshPolicy, - RefreshScheduler, RefreshTrigger, TokenRefresher, + RefreshScheduler, RefreshTrigger, RuntimeRefreshPolicy, TokenRefresher, }, }; @@ -41,7 +41,7 @@ where { store: SqliteAccountStore, scheduler: Arc>, - refresh_margin_seconds: u64, + policy: RuntimeRefreshPolicy, refresh_leases: Option, lease_owner: String, retry_delays: Vec, @@ -70,7 +70,7 @@ where Self { store: self.store.clone(), scheduler: self.scheduler.clone(), - refresh_margin_seconds: self.refresh_margin_seconds, + policy: self.policy.clone(), refresh_leases: self.refresh_leases.clone(), lease_owner: self.lease_owner.clone(), retry_delays: self.retry_delays.clone(), @@ -85,11 +85,16 @@ where C: TokenRefresher, { /// 构造运行时 token refresh 服务。 - pub fn new(store: SqliteAccountStore, policy: RefreshPolicy, client: C) -> Self { + pub fn new( + store: SqliteAccountStore, + policy: impl Into, + client: C, + ) -> Self { + let policy = policy.into(); Self { store, - scheduler: Arc::new(RefreshScheduler::new(policy, client)), - refresh_margin_seconds: policy.refresh_margin_seconds, + scheduler: Arc::new(RefreshScheduler::new(policy.clone(), client)), + policy, refresh_leases: None, lease_owner: refresh_lease_owner(), retry_delays: default_refresh_retry_delays(), @@ -104,6 +109,11 @@ where self } + /// 更新刷新策略。 + pub fn update_policy(&self, policy: RefreshPolicy) { + self.policy.update(policy); + } + /// 使用自定义刷新重试延迟。 pub fn with_retry_delays(mut self, retry_delays: I) -> Self where @@ -300,7 +310,7 @@ where let expires_at = account .access_token_expires_at .or_else(|| jwt_expiration(&account.access_token))?; - let margin_seconds = self.refresh_margin_seconds.min(i64::MAX as u64) as i64; + let margin_seconds = self.policy.refresh_margin_seconds().min(i64::MAX as u64) as i64; Some(expires_at - chrono::Duration::seconds(margin_seconds)) } diff --git a/src/upstream/models/mod.rs b/src/upstream/models/mod.rs index d642cae39..f78d09233 100644 --- a/src/upstream/models/mod.rs +++ b/src/upstream/models/mod.rs @@ -1,6 +1,9 @@ //! 模型目录领域:数据类型、快照存储端口与服务。 -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, +}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -579,7 +582,7 @@ pub type ModelPlanAllowlist = BTreeMap>; /// 模型目录服务。 #[derive(Clone)] pub struct ModelService { - config: ModelConfig, + config: Arc>, snapshot_store: Option>, upstream_client: Option>, } @@ -592,26 +595,35 @@ impl ModelService { upstream_client: Option>, ) -> Self { Self { - config, + config: Arc::new(RwLock::new(config)), snapshot_store, upstream_client, } } + /// 更新模型服务配置。 + pub fn update_config(&self, config: ModelConfig) { + *self + .config + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + } + /// 构造当前对外暴露的模型目录。 pub async fn catalog(&self) -> ModelCatalog { + let config = self.config_snapshot(); let Some(snapshot_store) = self.snapshot_store.as_ref() else { - return ModelCatalog::from_config(&self.config); + return ModelCatalog::from_config(&config); }; match snapshot_store.list_plan_snapshots().await { Ok(snapshots) if !snapshots.is_empty() => { - ModelCatalog::from_config_and_snapshots(&self.config, &snapshots) + ModelCatalog::from_config_and_snapshots(&config, &snapshots) } - Ok(_) => ModelCatalog::from_config(&self.config), + Ok(_) => ModelCatalog::from_config(&config), Err(error) => { tracing::warn!(error = %error, "加载模型快照失败,回退到静态目录"); - ModelCatalog::from_config(&self.config) + ModelCatalog::from_config(&config) } } } @@ -699,10 +711,15 @@ impl ModelService { .list_plan_snapshots() .await .map_err(map_load_snapshots_error)?; - Ok( - ModelCatalog::from_config_and_snapshots(&self.config, &snapshots) - .model_plan_allowlist(), - ) + let config = self.config_snapshot(); + Ok(ModelCatalog::from_config_and_snapshots(&config, &snapshots).model_plan_allowlist()) + } + + fn config_snapshot(&self) -> ModelConfig { + self.config + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() } } diff --git a/src/upstream/protocol/responses.rs b/src/upstream/protocol/responses.rs index ad4e549ef..12c65f52b 100644 --- a/src/upstream/protocol/responses.rs +++ b/src/upstream/protocol/responses.rs @@ -70,6 +70,12 @@ pub struct CodexResponsesRequest { /// 传给上游的 client metadata。 pub client_metadata: Option, #[serde(skip)] + /// 代理侧识别的客户端 IP,仅用于管理端使用记录展示。 + pub client_ip: Option, + #[serde(skip)] + /// 客户端 User-Agent,仅用于管理端使用记录展示。 + pub client_user_agent: Option, + #[serde(skip)] /// 是否偏好 WebSocket 传输。 pub use_websocket: bool, #[serde(skip)] @@ -569,6 +575,8 @@ impl CodexResponsesRequest { variant_identity: None, include: None, client_metadata: None, + client_ip: None, + client_user_agent: None, use_websocket: false, force_http_sse: false, turn_state: None, @@ -612,4 +620,10 @@ pub struct CodexCompactRequest { #[serde(skip_serializing_if = "Option::is_none")] /// 输出文本格式配置。 pub text: Option, + #[serde(skip)] + /// 代理侧识别的客户端 IP,仅用于管理端使用记录展示。 + pub client_ip: Option, + #[serde(skip)] + /// 客户端 User-Agent,仅用于管理端使用记录展示。 + pub client_user_agent: Option, } diff --git a/tests/admin/accounts/importing.rs b/tests/admin/accounts/importing.rs index b8e50eaf4..4f0f8a454 100644 --- a/tests/admin/accounts/importing.rs +++ b/tests/admin/accounts/importing.rs @@ -27,7 +27,7 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); @@ -321,7 +321,7 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); @@ -538,7 +538,7 @@ async fn admin_accounts_import_should_require_admin_session_cookie() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/admin/accounts/lifecycle.rs b/tests/admin/accounts/lifecycle.rs index 19f655f1f..a69370565 100644 --- a/tests/admin/accounts/lifecycle.rs +++ b/tests/admin/accounts/lifecycle.rs @@ -20,7 +20,7 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); @@ -370,7 +370,7 @@ async fn admin_account_create_should_derive_claims_and_store_plain_tokens() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); @@ -414,7 +414,7 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index 1938080b9..85b234be2 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -20,7 +20,7 @@ async fn admin_accounts_list_should_not_expose_account_tokens() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/admin/accounts/mod.rs b/tests/admin/accounts/mod.rs index b9df27ac2..45b98c518 100644 --- a/tests/admin/accounts/mod.rs +++ b/tests/admin/accounts/mod.rs @@ -7,7 +7,7 @@ use axum::{ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, - admin::monitoring::event_store::SqliteEventLogStore, + admin::monitoring::usage_record_store::SqliteUsageRecordStore, infra::database::connect_sqlite, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::services::{BackgroundTaskStores, Services}, @@ -153,7 +153,7 @@ async fn admin_accounts_test_app_with_overrides( session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/admin/auth/session.rs b/tests/admin/auth/session.rs index dc4b9f9b5..56d965c83 100644 --- a/tests/admin/auth/session.rs +++ b/tests/admin/auth/session.rs @@ -34,9 +34,10 @@ async fn admin_login_should_issue_http_only_session_cookie() { pool.clone(), ), client_keys: codex_proxy_rs::admin::keys::service::SqliteClientKeyStore::new(pool.clone()), - event_logs: codex_proxy_rs::admin::monitoring::event_store::SqliteEventLogStore::new( - pool.clone(), - ), + usage_records: + codex_proxy_rs::admin::monitoring::usage_record_store::SqliteUsageRecordStore::new( + pool.clone(), + ), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( @@ -79,18 +80,18 @@ async fn admin_login_should_issue_http_only_session_cookie() { assert_eq!(body["code"], 200); assert!(body["data"]["expiresAt"].is_string()); - let logs_response = app + let usage_records_response = app .oneshot( Request::builder() .method("GET") - .uri("/api/admin/logs") + .uri("/api/admin/usage/records") .header("cookie", cookie.split(';').next().unwrap()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(logs_response.status(), StatusCode::OK); + assert_eq!(usage_records_response.status(), StatusCode::OK); } #[tokio::test] @@ -118,9 +119,10 @@ async fn admin_login_should_reject_client_api_key_as_password_or_authorization() pool.clone(), ), client_keys: codex_proxy_rs::admin::keys::service::SqliteClientKeyStore::new(pool.clone()), - event_logs: codex_proxy_rs::admin::monitoring::event_store::SqliteEventLogStore::new( - pool.clone(), - ), + usage_records: + codex_proxy_rs::admin::monitoring::usage_record_store::SqliteUsageRecordStore::new( + pool.clone(), + ), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( diff --git a/tests/admin/keys/mod.rs b/tests/admin/keys/mod.rs index d34c650bd..8d799f8b6 100644 --- a/tests/admin/keys/mod.rs +++ b/tests/admin/keys/mod.rs @@ -5,7 +5,7 @@ use axum::{ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, - admin::monitoring::event_store::SqliteEventLogStore, + admin::monitoring::usage_record_store::SqliteUsageRecordStore, infra::database::connect_sqlite, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::services::{BackgroundTaskStores, Services}, @@ -38,7 +38,7 @@ async fn admin_client_key_test_app(db_name: &str) -> (axum::Router, tempfile::Te session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/admin/monitoring/dashboard.rs b/tests/admin/monitoring/dashboard.rs index 0c78d5d21..91676c3d2 100644 --- a/tests/admin/monitoring/dashboard.rs +++ b/tests/admin/monitoring/dashboard.rs @@ -8,8 +8,8 @@ use codex_proxy_rs::{ auth::service::SqliteAdminSessionStore, keys::service::SqliteClientKeyStore, monitoring::{ - event_store::SqliteEventLogStore, - events::{EventLevel, EventLog}, + usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_store::SqliteUsageRecordStore, }, }, infra::{database::connect_sqlite, time::china_day_start}, @@ -71,23 +71,23 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_ .await; let now = Utc::now(); let today_start = china_day_start(now); - let today_log_at = if now - today_start > Duration::minutes(1) { + let today_record_at = if now - today_start > Duration::minutes(1) { today_start + Duration::minutes(1) } else { now - Duration::seconds(1) }; - let yesterday_log_at = today_start - Duration::minutes(1); + let yesterday_record_at = today_start - Duration::minutes(1); - let mut first = usage_log_with_tokens(today_log_at, 10); + let mut first = usage_record_with_tokens(today_record_at, 10); first.latency_ms = Some(1_000); first.metadata["firstTokenMs"] = json!(200u64); - let mut second = usage_log_with_tokens(today_log_at, 20); + let mut second = usage_record_with_tokens(today_record_at, 20); second.latency_ms = Some(2_000); second.metadata["firstTokenMs"] = json!(400u64); store.append(&first).await.unwrap(); store.append(&second).await.unwrap(); store - .append(&usage_log_with_tokens(yesterday_log_at, 30)) + .append(&usage_record_with_tokens(yesterday_record_at, 30)) .await .unwrap(); @@ -114,20 +114,20 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_ } #[tokio::test] -async fn dashboard_summary_should_keep_trend_after_event_logs_are_cleared() { +async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() { let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-time-buckets-survive-log-clear.sqlite", + "dashboard-time-buckets-survive-usage-record-clear.sqlite", crate::support::fingerprint::test_fingerprint(), ) .await; - let mut log = usage_log_with_tokens(Utc::now(), 12); - log.latency_ms = Some(900); - store.append(&log).await.unwrap(); + let mut record = usage_record_with_tokens(Utc::now(), 12); + record.latency_ms = Some(900); + store.append(&record).await.unwrap(); store.clear().await.unwrap(); let body = dashboard_summary(app).await; - assert_eq!(body["data"]["eventLogs"].as_array().unwrap().len(), 0); + assert_eq!(body["data"]["usageRecords"].as_array().unwrap().len(), 0); assert_eq!(body["data"]["cards"]["traffic"]["todayRequests"], 1); assert_eq!(body["data"]["cards"]["tokens"]["todayTokens"], 12); assert_eq!( @@ -143,7 +143,7 @@ async fn dashboard_summary_should_keep_trend_after_event_logs_are_cleared() { #[tokio::test] async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tier() { - let cost = dashboard_summary_total_cost_for_log( + let cost = dashboard_summary_total_cost_for_usage_record( "dashboard-cost-priority.sqlite", "gpt-5.5", json!({ @@ -162,7 +162,7 @@ async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tie #[tokio::test] async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() { - let cost = dashboard_summary_total_cost_for_log( + let cost = dashboard_summary_total_cost_for_usage_record( "dashboard-cost-cached.sqlite", "gpt-5.5", json!({ @@ -180,7 +180,7 @@ async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() { #[tokio::test] async fn dashboard_summary_should_apply_long_context_prices() { - let cost = dashboard_summary_total_cost_for_log( + let cost = dashboard_summary_total_cost_for_usage_record( "dashboard-cost-long-context.sqlite", "gpt-5.5", json!({ @@ -198,7 +198,7 @@ async fn dashboard_summary_should_apply_long_context_prices() { #[tokio::test] async fn dashboard_summary_should_normalize_codex_model_names_for_cost() { - let cost = dashboard_summary_total_cost_for_log( + let cost = dashboard_summary_total_cost_for_usage_record( "dashboard-cost-codex-model.sqlite", "codex-auto-review", json!({ @@ -241,7 +241,7 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { ) .await; store - .append(&usage_log_with_tokens( + .append(&usage_record_with_tokens( "2026-06-18T12:34:56Z".parse().unwrap(), 10, )) @@ -251,7 +251,10 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { let body = dashboard_summary(app).await; - assert_eq!(body["data"]["eventLogs"][0]["time"], "20:34:56"); + assert_eq!( + body["data"]["usageRecords"][0]["createdAtDisplay"], + "2026-06-18 20:34:56" + ); assert_eq!(body["data"]["accountUsage"][0]["lastUsed"], "2000-01-01"); } @@ -263,11 +266,14 @@ async fn dashboard_summary_should_return_seven_day_health_timeline() { ) .await; store - .append(&usage_log_with_tokens(Utc::now(), 10)) + .append(&usage_record_with_tokens(Utc::now(), 10)) .await .unwrap(); store - .append(&usage_log_with_tokens(Utc::now() - Duration::days(8), 10)) + .append(&usage_record_with_tokens( + Utc::now() - Duration::days(8), + 10, + )) .await .unwrap(); @@ -279,11 +285,15 @@ async fn dashboard_summary_should_return_seven_day_health_timeline() { assert_eq!(points.matches('1').count(), 1); } -async fn dashboard_summary_total_cost_for_log(db_name: &str, model: &str, metadata: Value) -> f64 { +async fn dashboard_summary_total_cost_for_usage_record( + db_name: &str, + model: &str, + metadata: Value, +) -> f64 { let (app, store, _pool, _dir) = dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await; store - .append(&usage_log(Utc::now(), model, metadata)) + .append(&usage_record(Utc::now(), model, metadata)) .await .unwrap(); @@ -298,7 +308,7 @@ async fn dashboard_test_app( fingerprint: Fingerprint, ) -> ( axum::Router, - SqliteEventLogStore, + SqliteUsageRecordStore, SqlitePool, tempfile::TempDir, ) { @@ -316,7 +326,7 @@ async fn dashboard_test_app( session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); let state = AppState { @@ -325,7 +335,7 @@ async fn dashboard_test_app( }; ( codex_proxy_rs::http::router::router().with_state(state), - SqliteEventLogStore::new(pool.clone()), + SqliteUsageRecordStore::new(pool.clone()), pool, dir, ) @@ -394,8 +404,8 @@ fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str { .unwrap() } -fn usage_log_with_tokens(created_at: chrono::DateTime, total_tokens: u64) -> EventLog { - usage_log( +fn usage_record_with_tokens(created_at: chrono::DateTime, total_tokens: u64) -> UsageRecord { + usage_record( created_at, "gpt-5.5", json!({ @@ -408,12 +418,12 @@ fn usage_log_with_tokens(created_at: chrono::DateTime, total_tokens: u64) - ) } -fn usage_log(created_at: chrono::DateTime, model: &str, metadata: Value) -> EventLog { - let mut log = EventLog::new("v1.response", EventLevel::Info, "completed"); - log.model = Some(model.to_string()); - log.created_at = created_at; - log.metadata = metadata; - log +fn usage_record(created_at: chrono::DateTime, model: &str, metadata: Value) -> UsageRecord { + let mut record = UsageRecord::new("v1.response", UsageRecordLevel::Info, "completed"); + record.model = Some(model.to_string()); + record.created_at = created_at; + record.metadata = metadata; + record } fn assert_f64_eq(actual: f64, expected: f64) { diff --git a/tests/admin/monitoring/logs_routes.rs b/tests/admin/monitoring/logs_routes.rs deleted file mode 100644 index 8b8904a9d..000000000 --- a/tests/admin/monitoring/logs_routes.rs +++ /dev/null @@ -1,274 +0,0 @@ -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; -use chrono::{Duration, Utc}; -use codex_proxy_rs::{ - admin::auth::service::SqliteAdminSessionStore, - admin::keys::service::SqliteClientKeyStore, - admin::monitoring::{ - event_store::SqliteEventLogStore, - events::{EventLevel, EventLog}, - }, - infra::database::connect_sqlite, - proxy::dispatch::session_affinity::SqliteSessionAffinityStore, - runtime::services::{BackgroundTaskStores, Services}, - runtime::state::AppState, - upstream::accounts::token_refresh::RefreshLeaseStore, - upstream::accounts::{cookies::SqliteCookieStore, store::SqliteAccountStore}, - upstream::fingerprint::FingerprintRepository, -}; -use tower::util::ServiceExt; - -use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; - -#[tokio::test] -async fn admin_logs_should_require_admin_session_cookie() { - let (app, _store, _dir) = admin_logs_test_app("admin-logs-auth.sqlite").await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs") - .header("x-request-id", "req_logs_auth") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert_eq!(response_json(response).await["code"], 40101); -} - -#[tokio::test] -async fn admin_logs_should_cursor_page_events_and_include_request_id() { - let (app, store, _dir) = admin_logs_test_app("admin-logs-cursor.sqlite").await; - let now = Utc::now(); - let mut older = EventLog::new("request", EventLevel::Info, "older"); - older.id = "log_older".to_string(); - older.created_at = now; - store.append(&older).await.unwrap(); - let mut newer = EventLog::new("request", EventLevel::Info, "newer"); - newer.id = "log_newer".to_string(); - newer.created_at = now + Duration::seconds(1); - store.append(&newer).await.unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs?limit=1") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_logs_cursor") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json(response).await; - assert_eq!(body["code"], 200); - assert_eq!(body["data"]["items"].as_array().unwrap().len(), 1); -} - -#[tokio::test] -async fn admin_logs_should_return_numbered_page_metadata() { - let (app, store, _dir) = admin_logs_test_app("admin-logs-numbered.sqlite").await; - let now = Utc::now(); - for (id, message, offset) in [ - ("log_old", "older timeout", 0), - ("log_new", "newer timeout", 1), - ] { - let mut event = EventLog::new("request", EventLevel::Error, message); - event.id = id.to_string(); - event.created_at = now + Duration::seconds(offset); - store.append(&event).await.unwrap(); - } - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs?page=1&pageSize=1&level=error&search=timeout") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_logs_numbered") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["items"].as_array().unwrap().len(), 1); - assert_eq!(body["data"]["items"][0]["id"], "log_new"); - assert_eq!(body["data"]["page"]["page"], 1); - assert_eq!(body["data"]["page"]["pageSize"], 1); - assert_eq!(body["data"]["page"]["total"], 2); - assert_eq!(body["data"]["page"]["totalPages"], 2); -} - -#[tokio::test] -async fn admin_logs_should_filter_and_cursor_page_events() { - let (app, store, _dir) = admin_logs_test_app("admin-logs.sqlite").await; - let mut matching = EventLog::new("request", EventLevel::Error, "upstream timeout"); - matching.id = "log_matching".to_string(); - matching.route = Some("/v1/responses".to_string()); - store.append(&matching).await.unwrap(); - store - .append(&EventLog::new( - "request", - EventLevel::Info, - "upstream timeout", - )) - .await - .unwrap(); - store - .append(&EventLog::new( - "account", - EventLevel::Error, - "upstream timeout", - )) - .await - .unwrap(); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs?kind=request&level=error&search=timeout&limit=1") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_logs_filter") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json(response).await; - assert_eq!(body["data"]["items"][0]["id"], "log_matching"); -} - -#[tokio::test] -async fn admin_logs_should_reject_unsupported_level_filter() { - let (app, _store, _dir) = admin_logs_test_app("admin-logs-invalid-level.sqlite").await; - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs?level=verbose") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_logs_invalid_level") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_logs_should_return_detail_and_clear_events() { - let (app, store, _dir) = admin_logs_test_app("admin-logs-state.sqlite").await; - let mut event = EventLog::new("request", EventLevel::Warn, "detail"); - event.id = "log_detail".to_string(); - event.request_id = Some("req_upstream".to_string()); - store.append(&event).await.unwrap(); - - let detail = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs/detail?id=log_detail") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(detail.status(), StatusCode::OK); - - let cleared = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/logs/delete") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response_json(cleared).await["data"]["cleared"], 1); - - let empty = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs?limit=50") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - response_json(empty).await["data"]["items"] - .as_array() - .unwrap() - .len(), - 0 - ); -} - -#[tokio::test] -async fn admin_logs_detail_should_return_not_found_for_missing_event() { - let (app, _store, _dir) = admin_logs_test_app("admin-logs-detail-missing.sqlite").await; - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/logs/detail?id=missing") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_logs_missing") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -async fn admin_logs_test_app( - db_name: &str, -) -> (axum::Router, SqliteEventLogStore, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join(db_name); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let config = test_config(url); - let stores = BackgroundTaskStores { - accounts: SqliteAccountStore::new(pool.clone()), - admin_sessions: SqliteAdminSessionStore::new(pool.clone()), - cookies: SqliteCookieStore::new(pool.clone()), - fingerprints: FingerprintRepository::new(pool.clone()), - session_affinity: SqliteSessionAffinityStore::new(pool.clone()), - refresh_leases: RefreshLeaseStore::new(pool.clone()), - client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), - }; - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); - let state = AppState { - config, - services: (*services).clone(), - }; - ( - codex_proxy_rs::http::router::router().with_state(state), - SqliteEventLogStore::new(pool), - dir, - ) -} diff --git a/tests/admin/monitoring/mod.rs b/tests/admin/monitoring/mod.rs index 603e9df78..4b2c0c5eb 100644 --- a/tests/admin/monitoring/mod.rs +++ b/tests/admin/monitoring/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod dashboard; -pub(crate) mod events_store; -pub(crate) mod logs_routes; +pub(crate) mod usage_record_store; +pub(crate) mod usage_records_routes; diff --git a/tests/admin/monitoring/events_store.rs b/tests/admin/monitoring/usage_record_store.rs similarity index 56% rename from tests/admin/monitoring/events_store.rs rename to tests/admin/monitoring/usage_record_store.rs index 1182dd6c9..5dbd81dfb 100644 --- a/tests/admin/monitoring/events_store.rs +++ b/tests/admin/monitoring/usage_record_store.rs @@ -1,32 +1,32 @@ use codex_proxy_rs::{ admin::monitoring::{ - event_store::{EventLogFilter, SqliteEventLogStore}, - events::{EventLevel, EventLog}, + usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_store::{SqliteUsageRecordStore, UsageRecordFilter}, }, infra::database::connect_sqlite, }; use serde_json::json; #[tokio::test] -async fn event_log_store_should_filter_and_cursor_page_events() { - let (store, _dir) = event_log_store("event-logs.sqlite").await; - let mut matching = EventLog::new("request", EventLevel::Error, "upstream timeout"); - matching.id = "log_matching".to_string(); +async fn usage_record_store_should_filter_and_cursor_page_events() { + let (store, _dir) = usage_record_store("usage-records.sqlite").await; + let mut matching = UsageRecord::new("request", UsageRecordLevel::Error, "upstream timeout"); + matching.id = "usage_matching".to_string(); matching.request_id = Some("req_1".to_string()); matching.route = Some("/v1/responses".to_string()); store.append(&matching).await.unwrap(); store - .append(&EventLog::new( + .append(&UsageRecord::new( "request", - EventLevel::Info, + UsageRecordLevel::Info, "upstream timeout", )) .await .unwrap(); store - .append(&EventLog::new( + .append(&UsageRecord::new( "account", - EventLevel::Error, + UsageRecordLevel::Error, "upstream timeout", )) .await @@ -34,11 +34,11 @@ async fn event_log_store_should_filter_and_cursor_page_events() { let page = store .list( - EventLogFilter { + UsageRecordFilter { kind: Some("request".to_string()), - level: Some(EventLevel::Error), + level: Some(UsageRecordLevel::Error), search: Some("timeout".to_string()), - ..EventLogFilter::default() + ..UsageRecordFilter::default() }, None, 1, @@ -47,16 +47,16 @@ async fn event_log_store_should_filter_and_cursor_page_events() { .unwrap(); assert_eq!(page.items.len(), 1); - assert_eq!(page.items[0].id, "log_matching"); + assert_eq!(page.items[0].id, "usage_matching"); assert_eq!(page.items[0].request_id.as_deref(), Some("req_1")); assert_eq!(page.items[0].route.as_deref(), Some("/v1/responses")); } #[tokio::test] -async fn event_log_store_should_promote_diagnostic_metadata_fields() { - let (store, _dir) = event_log_store("event-logs-diagnostics.sqlite").await; - let mut event = EventLog::new("request", EventLevel::Error, "upstream failed"); - event.id = "log_diagnostic".to_string(); +async fn usage_record_store_should_promote_diagnostic_metadata_fields() { + let (store, _dir) = usage_record_store("usage-records-diagnostics.sqlite").await; + let mut event = UsageRecord::new("request", UsageRecordLevel::Error, "upstream failed"); + event.id = "usage_diagnostic".to_string(); event.metadata = json!({ "transport": "websocket", "attemptIndex": 2, @@ -69,11 +69,11 @@ async fn event_log_store_should_promote_diagnostic_metadata_fields() { store.append(&event).await.unwrap(); let page = store .list( - EventLogFilter { + UsageRecordFilter { transport: Some("websocket".to_string()), failure_class: Some("rate_limited".to_string()), upstream_status_code: Some(429), - ..EventLogFilter::default() + ..UsageRecordFilter::default() }, None, 10, @@ -82,7 +82,7 @@ async fn event_log_store_should_promote_diagnostic_metadata_fields() { .unwrap(); assert_eq!(page.items.len(), 1); - assert_eq!(page.items[0].id, "log_diagnostic"); + assert_eq!(page.items[0].id, "usage_diagnostic"); assert_eq!(page.items[0].attempt_index, Some(2)); assert_eq!(page.items[0].response_id.as_deref(), Some("resp_1")); assert_eq!( @@ -92,27 +92,27 @@ async fn event_log_store_should_promote_diagnostic_metadata_fields() { } #[tokio::test] -async fn event_log_store_should_get_and_clear_events() { - let (store, _dir) = event_log_store("event-logs-clear.sqlite").await; - let mut event = EventLog::new("request", EventLevel::Warn, "detail"); - event.id = "log_detail".to_string(); +async fn usage_record_store_should_get_and_clear_events() { + let (store, _dir) = usage_record_store("usage-records-clear.sqlite").await; + let mut event = UsageRecord::new("request", UsageRecordLevel::Warn, "detail"); + event.id = "usage_detail".to_string(); store.append(&event).await.unwrap(); - let loaded = store.get("log_detail").await.unwrap().unwrap(); - assert_eq!(loaded.id, "log_detail"); + let loaded = store.get("usage_detail").await.unwrap().unwrap(); + assert_eq!(loaded.id, "usage_detail"); assert_eq!(store.clear().await.unwrap(), 1); let page = store - .list(EventLogFilter::default(), None, 10) + .list(UsageRecordFilter::default(), None, 10) .await .unwrap(); assert!(page.items.is_empty()); } -async fn event_log_store(db_name: &str) -> (SqliteEventLogStore, tempfile::TempDir) { +async fn usage_record_store(db_name: &str) -> (SqliteUsageRecordStore, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join(db_name); let pool = connect_sqlite(&format!("sqlite://{}", db.display())) .await .unwrap(); - (SqliteEventLogStore::new(pool), dir) + (SqliteUsageRecordStore::new(pool), dir) } diff --git a/tests/admin/monitoring/usage_records_routes.rs b/tests/admin/monitoring/usage_records_routes.rs new file mode 100644 index 000000000..58ccbfb50 --- /dev/null +++ b/tests/admin/monitoring/usage_records_routes.rs @@ -0,0 +1,601 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use chrono::{Duration, SecondsFormat, Utc}; +use codex_proxy_rs::{ + admin::auth::service::SqliteAdminSessionStore, + admin::keys::service::SqliteClientKeyStore, + admin::monitoring::{ + usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_store::SqliteUsageRecordStore, + }, + config::types::AppConfig, + infra::database::connect_sqlite, + proxy::dispatch::session_affinity::SqliteSessionAffinityStore, + runtime::services::{BackgroundTaskStores, Services}, + runtime::state::AppState, + upstream::accounts::token_refresh::RefreshLeaseStore, + upstream::accounts::{cookies::SqliteCookieStore, store::SqliteAccountStore}, + upstream::fingerprint::FingerprintRepository, +}; +use serde_json::json; +use sqlx::SqlitePool; +use tower::util::ServiceExt; + +use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; + +#[tokio::test] +async fn admin_usage_records_should_require_admin_session_cookie() { + let (app, _store, _dir) = admin_usage_records_test_app("admin-usage-records-auth.sqlite").await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records") + .header("x-request-id", "req_usage_records_auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response_json(response).await["code"], 40101); +} + +#[tokio::test] +async fn admin_usage_records_should_cursor_page_events_and_include_request_id() { + let (app, store, _dir) = + admin_usage_records_test_app("admin-usage-records-cursor.sqlite").await; + let now = Utc::now(); + let mut older = UsageRecord::new("request", UsageRecordLevel::Info, "older"); + older.id = "usage_older".to_string(); + older.created_at = now; + store.append(&older).await.unwrap(); + let mut newer = UsageRecord::new("request", UsageRecordLevel::Info, "newer"); + newer.id = "usage_newer".to_string(); + newer.created_at = now + Duration::seconds(1); + store.append(&newer).await.unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?limit=1") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_cursor") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + assert_eq!(body["code"], 200); + assert_eq!(body["data"]["items"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn admin_usage_records_should_return_numbered_page_metadata() { + let (app, store, _dir) = + admin_usage_records_test_app("admin-usage-records-numbered.sqlite").await; + let now = Utc::now(); + for (id, message, offset) in [ + ("usage_old", "older timeout", 0), + ("usage_new", "newer timeout", 1), + ] { + let mut event = UsageRecord::new("request", UsageRecordLevel::Error, message); + event.id = id.to_string(); + event.created_at = now + Duration::seconds(offset); + store.append(&event).await.unwrap(); + } + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?page=1&pageSize=1&level=error&search=timeout") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_numbered") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["items"].as_array().unwrap().len(), 1); + assert_eq!(body["data"]["items"][0]["id"], "usage_new"); + assert_eq!(body["data"]["page"]["page"], 1); + assert_eq!(body["data"]["page"]["pageSize"], 1); + assert_eq!(body["data"]["page"]["total"], 2); + assert_eq!(body["data"]["page"]["totalPages"], 2); +} + +#[tokio::test] +async fn admin_usage_records_should_return_table_display_fields() { + let (app, store, pool, _dir) = admin_usage_records_test_app_with_config( + "admin-usage-records-display-fields.sqlite", + |config| { + config + .model_aliases + .insert("client-alias".to_string(), "gpt-5.5".to_string()); + }, + ) + .await; + let now = Utc::now().to_rfc3339(); + sqlx::query("insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)") + .bind("acct_display") + .bind("display@example.com") + .bind("access-token") + .bind("disabled") + .bind(&now) + .bind(&now) + .execute(&pool) + .await + .unwrap(); + + let mut event = UsageRecord::new("request", UsageRecordLevel::Info, "display fields"); + event.id = "usage_display_fields".to_string(); + event.account_id = Some("acct_display".to_string()); + event.model = Some("client-alias".to_string()); + event.latency_ms = Some(12_345); + event.metadata = json!({ + "clientIp": "203.0.113.8", + "userAgent": "codex-tui/0.142.2 (Ubuntu 24.4.0; aarch64) xterm-256color", + "reasoningEffort": "xhigh", + "firstTokenMs": 342, + "usage": { + "inputTokens": 1000, + "outputTokens": 200, + "cachedTokens": 100, + "reasoningTokens": 12, + "totalTokens": 1200 + } + }); + store.append(&event).await.unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?page=1&pageSize=20") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_display_fields") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + + assert_eq!(body["code"], 200); + let row = &body["data"]["items"][0]; + assert_eq!(row["accountEmail"], "display@example.com"); + assert_eq!(row["requestedModel"], "client-alias"); + assert_eq!(row["upstreamModel"], "gpt-5.5"); + assert_eq!(row["clientIp"], "203.0.113.8"); + assert_eq!( + row["userAgent"], + "codex-tui/0.142.2 (Ubuntu 24.4.0; aarch64) xterm-256color" + ); + assert_eq!(row["reasoningEffort"], "xhigh"); + assert_eq!(row["tokenDetails"]["inputTokens"], 1000); + assert_eq!(row["tokenDetails"]["outputTokens"], 200); + assert_eq!(row["tokenDetails"]["cachedTokens"], 100); + assert_eq!(row["tokenDetails"]["reasoningTokens"], 12); + assert_eq!(row["tokenDetails"]["totalTokens"], 1200); + assert_eq!(row["tokenDetails"]["inputTokensDisplay"], "1,000"); + assert_eq!(row["costDetails"]["inputCostDisplay"], "$0.004500"); + assert_eq!(row["costDetails"]["outputCostDisplay"], "$0.006000"); + assert_eq!(row["costDetails"]["cacheReadCostDisplay"], "$0.000050"); + assert_eq!(row["costDetails"]["totalCostDisplay"], "$0.010550"); + assert_eq!( + row["costDetails"]["inputPriceDisplay"], + "$5.0000 / 1M Token" + ); + assert_eq!(row["firstTokenLatencyMs"], 342); + assert_eq!(row["firstTokenLatencyMsDisplay"], "342 ms"); + assert_eq!(row["latencyMsDisplay"], "12.3 s"); +} + +#[tokio::test] +async fn admin_usage_records_summary_should_aggregate_filtered_usage() { + let (app, store, _dir) = + admin_usage_records_test_app("admin-usage-records-summary.sqlite").await; + let mut success = UsageRecord::new("request", UsageRecordLevel::Info, "summary success"); + success.id = "usage_summary_success".to_string(); + success.model = Some("gpt-5.5".to_string()); + success.latency_ms = Some(1200); + success.metadata = json!({ + "usage": { + "inputTokens": 100, + "outputTokens": 40, + "cachedTokens": 20 + } + }); + store.append(&success).await.unwrap(); + + let mut failure = UsageRecord::new("request", UsageRecordLevel::Error, "summary failure"); + failure.id = "usage_summary_failure".to_string(); + failure.model = Some("gpt-5.5-mini".to_string()); + failure.status_code = Some(429); + failure.latency_ms = Some(600); + failure.metadata = json!({ + "usage": { + "inputTokens": 7, + "outputTokens": 3 + } + }); + store.append(&failure).await.unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records/summary?search=summary") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_summary") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + + assert_eq!(body["code"], 200); + assert_eq!(body["data"]["totalRequests"], 2); + assert_eq!(body["data"]["errorRequests"], 1); + assert_eq!(body["data"]["inputTokens"], 107); + assert_eq!(body["data"]["outputTokens"], 43); + assert_eq!(body["data"]["cachedTokens"], 20); + assert_eq!(body["data"]["totalTokens"], 150); + assert_eq!(body["data"]["averageLatencyMs"], 900.0); +} + +#[tokio::test] +async fn admin_usage_records_insights_should_aggregate_filtered_usage_dimensions() { + let (app, store, pool, _dir) = + admin_usage_records_test_app_with_pool("admin-usage-records-insights.sqlite").await; + let mut first = UsageRecord::new("request", UsageRecordLevel::Info, "insight success"); + first.id = "usage_insight_success".to_string(); + first.route = Some("/v1/responses".to_string()); + first.model = Some("client-alias".to_string()); + first.latency_ms = Some(240); + first.metadata = json!({ + "stream": true, + "requestedModel": "client-alias", + "upstreamModel": "gpt-5.5", + "upstreamEndpoint": "/backend/responses", + "endpointPath": "/v1/responses", + "usage": { + "inputTokens": 30, + "outputTokens": 12, + "cachedTokens": 6 + } + }); + store.append(&first).await.unwrap(); + sqlx::query( + "insert into runtime_settings (id, model_aliases_json, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, ?, 300, 2, 3, 50, 'least_used', ?)", + ) + .bind(r#"{"client-alias":"gpt-5.5"}"#) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&pool) + .await + .unwrap(); + + let mut second = UsageRecord::new("request", UsageRecordLevel::Error, "insight failure"); + second.id = "usage_insight_failure".to_string(); + second.route = Some("/v1/chat/completions".to_string()); + second.model = Some("gpt-5.5-mini".to_string()); + second.status_code = Some(500); + second.latency_ms = Some(120); + second.metadata = json!({ + "stream": false, + "requestedModel": "gpt-5.5-mini", + "upstreamModel": "gpt-5.5-mini", + "upstreamEndpoint": "/backend/chat", + "endpointPath": "/v1/chat/completions", + "usage": { + "inputTokens": 5, + "outputTokens": 1 + } + }); + store.append(&second).await.unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records/insights?search=insight") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_insights") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + + assert_eq!(body["code"], 200); + assert_eq!(body["data"]["models"][0]["name"], "client-alias"); + assert_eq!(body["data"]["models"][0]["totalTokens"], 42); + assert_eq!(body["data"]["models"][0]["actualCostDisplay"], "$0.000483"); + assert_eq!(body["data"]["upstreamModels"][0]["name"], "gpt-5.5"); + assert_eq!( + body["data"]["modelMappings"][0]["name"], + "client-alias -> gpt-5.5" + ); + assert_eq!( + body["data"]["modelMappings"][1]["name"], + "gpt-5.5-mini -> gpt-5.5-mini" + ); + assert_eq!(body["data"]["endpoints"].as_array().unwrap().len(), 2); + assert_eq!( + body["data"]["upstreamEndpoints"].as_array().unwrap().len(), + 2 + ); + assert_eq!(body["data"]["endpointPaths"].as_array().unwrap().len(), 2); + let endpoint_paths = body["data"]["endpointPaths"] + .as_array() + .unwrap() + .iter() + .map(|item| item["name"].as_str().unwrap()) + .collect::>(); + assert!(endpoint_paths.contains(&"/v1/responses -> /backend/responses")); + assert!(endpoint_paths.contains(&"/v1/chat/completions -> /backend/chat")); + assert_eq!(body["data"]["types"].as_array().unwrap().len(), 2); + assert_eq!(body["data"]["trend"][0]["inputTokens"], 35); + assert_eq!(body["data"]["trend"][0]["outputTokens"], 13); + assert_eq!(body["data"]["trend"][0]["cacheCreationTokens"], 0); + assert_eq!(body["data"]["trend"][0]["cachedTokens"], 6); + assert_eq!(body["data"]["trend"][0]["actualCostDisplay"], "$0.000538"); + assert_eq!(body["data"]["trend"][0]["costDisplay"], "$0.000538"); + assert_eq!(body["data"]["trend"][0]["averageLatencyMs"], 180.0); +} + +#[tokio::test] +async fn admin_usage_records_should_filter_summary_and_table_by_time_range() { + let (app, store, _dir) = + admin_usage_records_test_app("admin-usage-records-time-range.sqlite").await; + let now = Utc::now(); + let mut older = UsageRecord::new("request", UsageRecordLevel::Info, "outside range"); + older.id = "usage_outside_range".to_string(); + older.created_at = now - Duration::days(3); + store.append(&older).await.unwrap(); + + let mut current = UsageRecord::new("request", UsageRecordLevel::Info, "inside range"); + current.id = "usage_inside_range".to_string(); + current.created_at = now; + store.append(¤t).await.unwrap(); + + let start_time = (now - Duration::hours(1)).to_rfc3339_opts(SecondsFormat::Secs, true); + let end_time = (now + Duration::hours(1)).to_rfc3339_opts(SecondsFormat::Secs, true); + let list_uri = format!( + "/api/admin/usage/records?page=1&pageSize=20&startTime={start_time}&endTime={end_time}" + ); + let list = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(list_uri) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_body = response_json(list).await; + assert_eq!(list_body["data"]["page"]["total"], 1); + assert_eq!(list_body["data"]["items"][0]["id"], "usage_inside_range"); + + let summary_uri = + format!("/api/admin/usage/records/summary?startTime={start_time}&endTime={end_time}"); + let summary = app + .oneshot( + Request::builder() + .method("GET") + .uri(summary_uri) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response_json(summary).await["data"]["totalRequests"], 1); +} + +#[tokio::test] +async fn admin_usage_records_should_filter_and_cursor_page_events() { + let (app, store, _dir) = admin_usage_records_test_app("admin-usage-records.sqlite").await; + let mut matching = UsageRecord::new("request", UsageRecordLevel::Error, "upstream timeout"); + matching.id = "usage_matching".to_string(); + matching.route = Some("/v1/responses".to_string()); + store.append(&matching).await.unwrap(); + store + .append(&UsageRecord::new( + "request", + UsageRecordLevel::Info, + "upstream timeout", + )) + .await + .unwrap(); + store + .append(&UsageRecord::new( + "account", + UsageRecordLevel::Error, + "upstream timeout", + )) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?kind=request&level=error&search=timeout&limit=1") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_filter") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + assert_eq!(body["data"]["items"][0]["id"], "usage_matching"); +} + +#[tokio::test] +async fn admin_usage_records_should_reject_unsupported_level_filter() { + let (app, _store, _dir) = + admin_usage_records_test_app("admin-usage-records-invalid-level.sqlite").await; + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?level=verbose") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_invalid_level") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_usage_records_should_return_detail_and_clear_events() { + let (app, store, _dir) = admin_usage_records_test_app("admin-usage-records-state.sqlite").await; + let mut event = UsageRecord::new("request", UsageRecordLevel::Warn, "detail"); + event.id = "usage_detail".to_string(); + event.request_id = Some("req_upstream".to_string()); + store.append(&event).await.unwrap(); + + let detail = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records/detail?id=usage_detail") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(detail.status(), StatusCode::OK); + + let cleared = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/usage/records/delete") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response_json(cleared).await["data"]["cleared"], 1); + + let empty = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records?limit=50") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response_json(empty).await["data"]["items"] + .as_array() + .unwrap() + .len(), + 0 + ); +} + +#[tokio::test] +async fn admin_usage_records_detail_should_return_not_found_for_missing_event() { + let (app, _store, _dir) = + admin_usage_records_test_app("admin-usage-records-detail-missing.sqlite").await; + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage/records/detail?id=missing") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_records_missing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +async fn admin_usage_records_test_app( + db_name: &str, +) -> (axum::Router, SqliteUsageRecordStore, tempfile::TempDir) { + let (app, store, _pool, dir) = admin_usage_records_test_app_with_pool(db_name).await; + (app, store, dir) +} + +async fn admin_usage_records_test_app_with_pool( + db_name: &str, +) -> ( + axum::Router, + SqliteUsageRecordStore, + SqlitePool, + tempfile::TempDir, +) { + admin_usage_records_test_app_with_config(db_name, |_| {}).await +} + +async fn admin_usage_records_test_app_with_config( + db_name: &str, + configure: impl FnOnce(&mut AppConfig), +) -> ( + axum::Router, + SqliteUsageRecordStore, + SqlitePool, + tempfile::TempDir, +) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join(db_name); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let mut config = test_config(url); + configure(&mut config); + let stores = BackgroundTaskStores { + accounts: SqliteAccountStore::new(pool.clone()), + admin_sessions: SqliteAdminSessionStore::new(pool.clone()), + cookies: SqliteCookieStore::new(pool.clone()), + fingerprints: FingerprintRepository::new(pool.clone()), + session_affinity: SqliteSessionAffinityStore::new(pool.clone()), + refresh_leases: RefreshLeaseStore::new(pool.clone()), + client_keys: SqliteClientKeyStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), + }; + let fingerprint = crate::support::fingerprint::test_fingerprint(); + let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let state = AppState { + config, + services: (*services).clone(), + }; + ( + codex_proxy_rs::http::router::router().with_state(state), + SqliteUsageRecordStore::new(pool.clone()), + pool, + dir, + ) +} diff --git a/tests/admin/settings/routes.rs b/tests/admin/settings/routes.rs index a93976a5c..26c014561 100644 --- a/tests/admin/settings/routes.rs +++ b/tests/admin/settings/routes.rs @@ -1,20 +1,26 @@ -use std::collections::BTreeMap; +use std::{ + collections::BTreeMap, + time::{Duration as StdDuration, Instant}, +}; use axum::{ body::Body, http::{Request, StatusCode}, }; +use chrono::Utc; use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, - admin::monitoring::event_store::SqliteEventLogStore, + admin::monitoring::usage_record_store::SqliteUsageRecordStore, config::{settings::RuntimeSettingsService, types::AppConfig}, infra::database::connect_sqlite, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::services::{BackgroundTaskStores, Services}, runtime::state::AppState, - upstream::accounts::token_refresh::RefreshLeaseStore, - upstream::accounts::{cookies::SqliteCookieStore, store::SqliteAccountStore}, + upstream::accounts::{ + cookies::SqliteCookieStore, pool::AccountAcquireRequest, store::SqliteAccountStore, + token_refresh::RefreshLeaseStore, + }, upstream::fingerprint::FingerprintRepository, }; use serde_json::json; @@ -271,7 +277,7 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = Services::new(&config, stores, fingerprint); @@ -383,6 +389,122 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() { assert_eq!(restarted_config.database.url, config.database.url); } +#[tokio::test] +async fn admin_settings_update_should_apply_runtime_services() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-settings-runtime.sqlite"); + let url = format!("sqlite://{}", db.display()); + let config = test_config(url); + let pool = connect_sqlite(&config.database.url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + seed_schedulable_account(&pool, "acct_runtime_a").await; + seed_schedulable_account(&pool, "acct_runtime_b").await; + let stores = BackgroundTaskStores { + accounts: SqliteAccountStore::new(pool.clone()), + admin_sessions: SqliteAdminSessionStore::new(pool.clone()), + cookies: SqliteCookieStore::new(pool.clone()), + fingerprints: FingerprintRepository::new(pool.clone()), + session_affinity: SqliteSessionAffinityStore::new(pool.clone()), + refresh_leases: RefreshLeaseStore::new(pool.clone()), + client_keys: SqliteClientKeyStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), + }; + let fingerprint = crate::support::fingerprint::test_fingerprint(); + let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + services + .account_pool + .restore_from_repository() + .await + .unwrap(); + let state = AppState { + config, + services: (*services).clone(), + }; + let app = codex_proxy_rs::http::router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/settings") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_runtime") + .body(Body::from( + json!({ + "modelAliases": { + "runtime-alias": "gpt-5.5" + }, + "modelAccountRoutes": { + "gpt-5.5": ["acct_runtime_b"], + "gpt-5.6": ["acct_runtime_a", "acct_runtime_b"] + }, + "refreshMarginSeconds": 120, + "refreshConcurrency": 3, + "maxConcurrentPerAccount": 2, + "requestIntervalMs": 30, + "rotationStrategy": "round_robin" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let parsed = services + .models + .catalog() + .await + .parse_model_name("runtime-alias"); + assert_eq!(parsed.model_id, "gpt-5.5"); + + let refresh_policy = services.refresh_policy.snapshot(); + assert_eq!(refresh_policy.refresh_margin_seconds, 120); + assert_eq!(refresh_policy.refresh_concurrency, 3); + + let capacity = services.account_pool.capacity_summary(Utc::now()).await; + assert_eq!(capacity.total_slots, 4); + assert_eq!(capacity.max_concurrent_per_account, 2); + + let route_request = AccountAcquireRequest::new("gpt-5.5", Utc::now()); + let routed = services + .account_pool + .acquire_with(&route_request) + .await + .unwrap(); + assert_eq!(routed.account.id, "acct_runtime_b"); + services.account_pool.release(&routed.account.id).await; + + let rotation_request = AccountAcquireRequest::new("gpt-5.6", Utc::now()); + let first = services + .account_pool + .acquire_with(&rotation_request) + .await + .unwrap(); + let second = services + .account_pool + .acquire_with(&rotation_request) + .await + .unwrap(); + assert_eq!(first.account.id, "acct_runtime_a"); + assert_eq!(second.account.id, "acct_runtime_b"); + + let repeated = services + .account_pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", Utc::now())) + .await + .unwrap(); + assert_eq!(repeated.account.id, "acct_runtime_b"); + let started_at = Instant::now(); + services + .account_pool + .wait_for_request_interval(&repeated) + .await; + assert!(started_at.elapsed() >= StdDuration::from_millis(20)); +} + #[tokio::test] async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() { let dir = tempfile::tempdir().unwrap(); @@ -399,7 +521,7 @@ async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); @@ -442,7 +564,7 @@ async fn admin_settings_test_app(db_name: &str) -> (axum::Router, tempfile::Temp session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); @@ -476,6 +598,28 @@ insert into accounts ( .unwrap(); } +async fn seed_schedulable_account(pool: &SqlitePool, account_id: &str) { + sqlx::query( + r" +insert into accounts ( + id, + access_token, + access_token_expires_at, + status, + added_at, + updated_at +) values (?, ?, ?, 'active', ?, ?)", + ) + .bind(account_id) + .bind("access-token") + .bind("2099-01-01T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .execute(pool) + .await + .unwrap(); +} + fn test_config(database_url: String) -> AppConfig { let mut config = base_test_config(database_url); config diff --git a/tests/http/trace_middleware/mod.rs b/tests/http/trace_middleware/mod.rs index d8067931a..91b673ac1 100644 --- a/tests/http/trace_middleware/mod.rs +++ b/tests/http/trace_middleware/mod.rs @@ -1,10 +1,13 @@ use std::{ io::{self, Write}, + net::SocketAddr, sync::{Arc, Mutex}, }; use axum::{ body::{to_bytes, Body}, + extract::connect_info::ConnectInfo, + http::HeaderMap, http::{Request, StatusCode}, middleware::from_fn, routing::get, @@ -104,3 +107,60 @@ async fn http_trace_should_include_request_id_and_completion_fields() { "unexpected trace output: {output}" ); } + +#[tokio::test] +async fn request_context_should_attach_real_ip_from_connection() { + let app = Router::new() + .route( + "/ip", + get(|headers: HeaderMap| async move { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string() + }), + ) + .layer(from_fn(attach_request_id)); + let mut request = Request::builder() + .uri("/ip") + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234)))); + + let response = app.oneshot(request).await.expect("response"); + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); + + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "127.0.0.1"); +} + +#[tokio::test] +async fn request_context_should_not_override_forwarded_ip_headers() { + let app = Router::new() + .route( + "/ip", + get(|headers: HeaderMap| async move { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_string() + }), + ) + .layer(from_fn(attach_request_id)); + let mut request = Request::builder() + .uri("/ip") + .header("x-forwarded-for", "203.0.113.42, 10.0.0.2") + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234)))); + + let response = app.oneshot(request).await.expect("response"); + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); + + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "missing"); +} diff --git a/tests/http/web_assets/mod.rs b/tests/http/web_assets/mod.rs index 76ec70c39..a28b11be4 100644 --- a/tests/http/web_assets/mod.rs +++ b/tests/http/web_assets/mod.rs @@ -53,9 +53,10 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes pool.clone(), ), client_keys: codex_proxy_rs::admin::keys::service::SqliteClientKeyStore::new(pool.clone()), - event_logs: codex_proxy_rs::admin::monitoring::event_store::SqliteEventLogStore::new( - pool.clone(), - ), + usage_records: + codex_proxy_rs::admin::monitoring::usage_record_store::SqliteUsageRecordStore::new( + pool.clone(), + ), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( diff --git a/tests/infra/storage_schema/mod.rs b/tests/infra/storage_schema/mod.rs index f8444e261..e1e1783ec 100644 --- a/tests/infra/storage_schema/mod.rs +++ b/tests/infra/storage_schema/mod.rs @@ -8,7 +8,7 @@ async fn sqlite_schema_should_create_current_tables() { let pool = connect_sqlite(&url).await.unwrap(); let rows: Vec<(String,)> = sqlx::query_as( - "select name from sqlite_master where type = 'table' and name in ('admin_users', 'admin_sessions', 'client_api_keys', 'runtime_settings', 'accounts', 'account_refresh_leases', 'account_usage', 'account_model_usage', 'usage_time_buckets', 'model_account_routes', 'account_cookies', 'fingerprints', 'fingerprint_update_history', 'event_logs', 'model_plan_snapshots', 'session_affinities') order by name", + "select name from sqlite_master where type = 'table' and name in ('admin_users', 'admin_sessions', 'client_api_keys', 'runtime_settings', 'accounts', 'account_refresh_leases', 'account_usage', 'account_model_usage', 'usage_time_buckets', 'model_account_routes', 'account_cookies', 'fingerprints', 'fingerprint_update_history', 'usage_records', 'model_plan_snapshots', 'session_affinities') order by name", ) .fetch_all(&pool) .await @@ -24,13 +24,13 @@ async fn sqlite_schema_should_create_current_tables() { "admin_sessions", "admin_users", "client_api_keys", - "event_logs", "fingerprint_update_history", "fingerprints", "model_account_routes", "model_plan_snapshots", "runtime_settings", "session_affinities", + "usage_records", "usage_time_buckets", ] ); @@ -100,7 +100,7 @@ async fn sqlite_schema_should_persist_diagnostic_filter_indexes() { let pool = connect_sqlite(&url).await.unwrap(); let rows: Vec<(String,)> = sqlx::query_as( - "select name from sqlite_master where type = 'index' and name in ('idx_account_cookies_expires', 'idx_event_logs_level_created', 'idx_event_logs_model_created', 'idx_event_logs_route_created', 'idx_event_logs_status_created', 'idx_event_logs_upstream_status_created', 'idx_session_affinities_active_order', 'idx_model_account_routes_account', 'idx_model_account_routes_enabled_model', 'idx_usage_time_buckets_bucket', 'idx_usage_time_buckets_model_bucket') order by name", + "select name from sqlite_master where type = 'index' and name in ('idx_account_cookies_expires', 'idx_usage_records_level_created', 'idx_usage_records_model_created', 'idx_usage_records_route_created', 'idx_usage_records_status_created', 'idx_usage_records_upstream_status_created', 'idx_session_affinities_active_order', 'idx_model_account_routes_account', 'idx_model_account_routes_enabled_model', 'idx_usage_time_buckets_bucket', 'idx_usage_time_buckets_model_bucket') order by name", ) .fetch_all(&pool) .await @@ -110,14 +110,14 @@ async fn sqlite_schema_should_persist_diagnostic_filter_indexes() { rows.into_iter().map(|row| row.0).collect::>(), [ "idx_account_cookies_expires", - "idx_event_logs_level_created", - "idx_event_logs_model_created", - "idx_event_logs_route_created", - "idx_event_logs_status_created", - "idx_event_logs_upstream_status_created", "idx_model_account_routes_account", "idx_model_account_routes_enabled_model", "idx_session_affinities_active_order", + "idx_usage_records_level_created", + "idx_usage_records_model_created", + "idx_usage_records_route_created", + "idx_usage_records_status_created", + "idx_usage_records_upstream_status_created", "idx_usage_time_buckets_bucket", "idx_usage_time_buckets_model_bucket", ] @@ -424,7 +424,7 @@ async fn sqlite_schema_should_persist_structured_event_diagnostic_columns() { let pool = connect_sqlite(&url).await.unwrap(); let rows: Vec<(String,)> = sqlx::query_as( - "select name from pragma_table_info('event_logs') where name in ('transport', 'attempt_index', 'upstream_status_code', 'failure_class', 'response_id', 'upstream_request_id') order by name", + "select name from pragma_table_info('usage_records') where name in ('transport', 'attempt_index', 'upstream_status_code', 'failure_class', 'response_id', 'upstream_request_id') order by name", ) .fetch_all(&pool) .await @@ -460,14 +460,14 @@ async fn sqlite_schema_should_reject_invalid_account_status() { } #[tokio::test] -async fn sqlite_schema_should_reject_invalid_event_log_level() { +async fn sqlite_schema_should_reject_invalid_usage_record_level() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("event-level-check.sqlite"); let url = format!("sqlite://{}", db.display()); let pool = connect_sqlite(&url).await.unwrap(); let result = sqlx::query( - "insert into event_logs (id, kind, level, message, metadata_json, created_at) values ('log_bad', 'request', 'fatal', 'bad', '{}', '2026-06-14T00:00:00Z')", + "insert into usage_records (id, kind, level, message, metadata_json, created_at) values ('usage_bad', 'request', 'fatal', 'bad', '{}', '2026-06-14T00:00:00Z')", ) .execute(&pool) .await; diff --git a/tests/proxy/dispatch/chat_upstream/chat_routes.rs b/tests/proxy/dispatch/chat_upstream/chat_routes.rs index aec99c07b..da0c2b314 100644 --- a/tests/proxy/dispatch/chat_upstream/chat_routes.rs +++ b/tests/proxy/dispatch/chat_upstream/chat_routes.rs @@ -76,7 +76,7 @@ async fn chat_completions_should_dispatch_to_codex_and_return_openai_response() .await .unwrap(); assert_eq!(model_usage, ("gpt-5.5".to_string(), 1, 9, 3)); - let event = latest_event_log(&pool, "v1.chat").await; + let event = latest_usage_record(&pool, "v1.chat").await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.request_id.as_deref(), Some("req_chat_nonstream_log")); assert_eq!(event.account_id.as_deref(), Some("acct_chat")); @@ -169,7 +169,7 @@ async fn chat_completions_stream_should_translate_codex_sse_to_openai_chunks() { "data: [DONE]", ], ); - let event = latest_event_log(&pool, "v1.chat").await; + let event = latest_usage_record(&pool, "v1.chat").await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.request_id.as_deref(), Some("req_chat_stream_log")); assert_eq!(event.route.as_deref(), Some("/v1/chat/completions")); @@ -382,7 +382,7 @@ async fn chat_completions_should_return_rate_limit_error_when_429_fallback_is_ex assert_eq!(body["error"]["code"], "upstream_error"); assert_eq!(quota_state.0, 1); assert!(quota_state.1.is_some()); - let event = latest_event_log(&pool, "v1.chat").await; + let event = latest_usage_record(&pool, "v1.chat").await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.level, "error"); assert!(event diff --git a/tests/proxy/dispatch/chat_upstream/compact_routes.rs b/tests/proxy/dispatch/chat_upstream/compact_routes.rs index c20f21e0b..3a8555a09 100644 --- a/tests/proxy/dispatch/chat_upstream/compact_routes.rs +++ b/tests/proxy/dispatch/chat_upstream/compact_routes.rs @@ -50,7 +50,7 @@ async fn responses_review_route_should_force_review_subagent_upstream() { } #[tokio::test] -async fn responses_review_route_should_record_review_route_in_event_log() { +async fn responses_review_route_should_record_review_route_in_usage_record() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/codex/responses")) @@ -88,7 +88,7 @@ async fn responses_review_route_should_record_review_route_in_event_log() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; assert_eq!(event.request_id.as_deref(), Some("req_review_route_log")); assert_eq!(event.route.as_deref(), Some("/v1/responses/review")); } @@ -211,7 +211,7 @@ async fn responses_compact_should_post_json_to_codex_compact_upstream() { upstream_body["input"][2]["encrypted_content"], "enc_compact" ); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.request_id.as_deref(), Some("req_compact_success_log")); assert_eq!(event.account_id.as_deref(), Some("acct_chat")); @@ -272,7 +272,7 @@ async fn responses_compact_should_return_rate_limit_error_when_fallback_is_exhau assert_eq!(body["error"]["code"], "rate_limit_exceeded"); assert!(message.contains("All accounts exhausted (1 rate-limited)")); assert!(message.contains("compact quota reached")); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!( event.request_id.as_deref(), @@ -338,7 +338,7 @@ async fn responses_compact_should_preserve_upstream_client_error_status() { assert_eq!(body["type"], "error"); assert_eq!(body["error"]["type"], "invalid_request_error"); assert_eq!(body["error"]["code"], "codex_api_error"); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!( event.request_id.as_deref(), diff --git a/tests/proxy/dispatch/chat_upstream/mod.rs b/tests/proxy/dispatch/chat_upstream/mod.rs index 970e39aa6..837f65f2d 100644 --- a/tests/proxy/dispatch/chat_upstream/mod.rs +++ b/tests/proxy/dispatch/chat_upstream/mod.rs @@ -15,8 +15,10 @@ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, admin::monitoring::{ - event_store::{EventLogFilter, SqliteEventLogStore, DEFAULT_EVENT_LOG_CAPACITY}, - events::EventLevel, + usage_record::UsageRecordLevel, + usage_record_store::{ + SqliteUsageRecordStore, UsageRecordFilter, DEFAULT_USAGE_RECORD_CAPACITY, + }, }, config::types::AppConfig, http::router, @@ -24,7 +26,7 @@ use codex_proxy_rs::{ proxy::dispatch::session_affinity::{ build_conversation_identity, SessionAffinityEntry, SqliteSessionAffinityStore, }, - runtime::services::{BackgroundTaskStores, EventLogOptions, Services}, + runtime::services::{BackgroundTaskStores, Services, UsageRecordOptions}, runtime::state::AppState, upstream::accounts::{ cookies::SqliteCookieStore, store::SqliteAccountStore, token_refresh::RefreshLeaseStore, @@ -240,19 +242,19 @@ fn test_app_state_with_pool_and_installation_id( pool: SqlitePool, installation_id: String, ) -> AppState { - test_app_state_with_pool_installation_id_and_log_options( + test_app_state_with_pool_installation_id_and_usage_record_options( config.clone(), pool, installation_id, - EventLogOptions::from_config(config), + UsageRecordOptions::from_config(config), ) } -fn test_app_state_with_pool_installation_id_and_log_options( +fn test_app_state_with_pool_installation_id_and_usage_record_options( config: AppConfig, pool: SqlitePool, installation_id: String, - log_options: EventLogOptions, + usage_record_options: UsageRecordOptions, ) -> AppState { let stores = BackgroundTaskStores { accounts: SqliteAccountStore::new(pool.clone()), @@ -262,14 +264,14 @@ fn test_app_state_with_pool_installation_id_and_log_options( session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool), + usage_records: SqliteUsageRecordStore::new(pool), }; - let services = Services::try_with_installation_id_and_log_options( + let services = Services::try_with_installation_id_and_usage_record_options( &config, stores, crate::support::fingerprint::test_fingerprint(), Some(installation_id), - log_options, + usage_record_options, ) .expect("failed to build runtime services with configured TLS transport"); AppState { config, services } @@ -373,16 +375,16 @@ async fn test_app_with_account_pool_and_logging_capture_body( insert_account(&pool).await; let mut config = test_config(url, base_url); config.logging.enabled = true; - let log_options = EventLogOptions { + let usage_record_options = UsageRecordOptions { enabled: true, - capacity: DEFAULT_EVENT_LOG_CAPACITY, + capacity: DEFAULT_USAGE_RECORD_CAPACITY, capture_body: true, }; - let state = test_app_state_with_pool_installation_id_and_log_options( + let state = test_app_state_with_pool_installation_id_and_usage_record_options( config, pool.clone(), TEST_INSTALLATION_ID.to_string(), - log_options, + usage_record_options, ); state .services @@ -1350,26 +1352,27 @@ async fn wait_for_session_affinity_turn_state( .flatten() } -struct ResponseEventLog { +struct ResponseUsageRecordSnapshot { level: String, request_id: Option, account_id: Option, route: Option, + model: Option, status_code: Option, response_id: Option, metadata_json: String, } -async fn latest_response_event_log(pool: &SqlitePool) -> ResponseEventLog { - latest_event_log(pool, "v1.response").await +async fn latest_response_usage_record(pool: &SqlitePool) -> ResponseUsageRecordSnapshot { + latest_usage_record(pool, "v1.response").await } -async fn latest_event_log(pool: &SqlitePool, kind: &str) -> ResponseEventLog { - let page = SqliteEventLogStore::new(pool.clone()) +async fn latest_usage_record(pool: &SqlitePool, kind: &str) -> ResponseUsageRecordSnapshot { + let page = SqliteUsageRecordStore::new(pool.clone()) .list( - EventLogFilter { + UsageRecordFilter { kind: Some(kind.to_string()), - ..EventLogFilter::default() + ..UsageRecordFilter::default() }, None, 1, @@ -1380,20 +1383,21 @@ async fn latest_event_log(pool: &SqlitePool, kind: &str) -> ResponseEventLog { .items .into_iter() .next() - .unwrap_or_else(|| panic!("expected a {kind} event log")); - ResponseEventLog { - level: event_level_name(event.level).to_string(), + .unwrap_or_else(|| panic!("expected a {kind} usage record")); + ResponseUsageRecordSnapshot { + level: usage_record_level_name(event.level).to_string(), request_id: event.request_id, account_id: event.account_id, route: event.route, + model: event.model, status_code: event.status_code, response_id: event.response_id, metadata_json: event.metadata.to_string(), } } -async fn response_event_log_count(pool: &SqlitePool) -> i64 { - let (count,): (i64,) = sqlx::query_as("select count(*) from event_logs where kind = ?") +async fn response_usage_record_count(pool: &SqlitePool) -> i64 { + let (count,): (i64,) = sqlx::query_as("select count(*) from usage_records where kind = ?") .bind("v1.response") .fetch_one(pool) .await @@ -1401,12 +1405,12 @@ async fn response_event_log_count(pool: &SqlitePool) -> i64 { count } -fn event_level_name(level: EventLevel) -> &'static str { +fn usage_record_level_name(level: UsageRecordLevel) -> &'static str { match level { - EventLevel::Debug => "debug", - EventLevel::Info => "info", - EventLevel::Warn => "warn", - EventLevel::Error => "error", + UsageRecordLevel::Debug => "debug", + UsageRecordLevel::Info => "info", + UsageRecordLevel::Warn => "warn", + UsageRecordLevel::Error => "error", } } diff --git a/tests/proxy/dispatch/chat_upstream/responses_http.rs b/tests/proxy/dispatch/chat_upstream/responses_http.rs index 7a97e34a1..68c704536 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_http.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_http.rs @@ -249,7 +249,7 @@ async fn responses_non_stream_should_record_websocket_transport_metadata() { assert_eq!(body["id"], "resp_ws_non_stream_log"); assert_eq!(captured.payload["type"], "response.create"); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(metadata["stream"], false); assert_eq!(metadata["transport"], "websocket"); diff --git a/tests/proxy/dispatch/chat_upstream/responses_recovery.rs b/tests/proxy/dispatch/chat_upstream/responses_recovery.rs index a2c1bbad1..3aace6227 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_recovery.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_recovery.rs @@ -2119,7 +2119,7 @@ async fn responses_should_return_rate_limit_error_when_429_fallback_is_exhausted assert_eq!(body["error"]["code"], "upstream_error"); assert_eq!(quota_state.0, 1); assert!(quota_state.1.is_some()); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.level, "error"); assert_eq!( @@ -2203,7 +2203,7 @@ async fn responses_stream_should_return_rate_limit_error_when_429_fallback_is_ex assert_eq!(quota_state.0, 1); assert!(quota_state.1.is_some()); assert!(!response_request_id.is_empty()); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.level, "error"); assert_eq!( diff --git a/tests/proxy/dispatch/chat_upstream/responses_websocket.rs b/tests/proxy/dispatch/chat_upstream/responses_websocket.rs index 38391f50b..fbc2b000c 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_websocket.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_websocket.rs @@ -2116,7 +2116,7 @@ async fn responses_stream_with_previous_response_id_should_record_websocket_audi .expect("test should be able to finish upstream websocket"); let body = response_text(response).await; let captured = upstream.await.unwrap(); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert!(body.contains("resp_live_websocket_stream")); diff --git a/tests/proxy/dispatch/chat_upstream/usage_logging.rs b/tests/proxy/dispatch/chat_upstream/usage_logging.rs index 508f0f01d..31d1c1395 100644 --- a/tests/proxy/dispatch/chat_upstream/usage_logging.rs +++ b/tests/proxy/dispatch/chat_upstream/usage_logging.rs @@ -1,7 +1,7 @@ use super::*; #[tokio::test] -async fn responses_should_use_imported_account_record_usage_cookie_and_event_log() { +async fn responses_should_use_imported_account_record_usage_cookie_and_usage_record() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/codex/responses")) @@ -70,7 +70,7 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_event_log .await .unwrap(); assert_eq!(cookie_header.as_deref(), Some("cf_clearance=new")); - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(event.level, "info"); assert_eq!( @@ -86,7 +86,60 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_event_log } #[tokio::test] -async fn responses_should_skip_event_log_when_logging_disabled() { +async fn responses_usage_record_should_store_resolved_upstream_model_after_alias_mapping() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_pool_config(server.uri(), |config| { + config.logging.enabled = true; + config + .model_aliases + .insert("client-alias".to_string(), "gpt-5.5".to_string()); + }) + .await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_alias_usage_model") + .body(Body::from( + json!({ + "model": "client-alias", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let event = latest_response_usage_record(&pool).await; + assert_eq!(event.request_id.as_deref(), Some("req_alias_usage_model")); + assert_eq!(event.model.as_deref(), Some("gpt-5.5")); + + let received = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&received[0].body).unwrap(); + assert_eq!(upstream_body["model"], "gpt-5.5"); +} + +#[tokio::test] +async fn responses_should_skip_usage_record_when_logging_disabled() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/codex/responses")) @@ -124,7 +177,7 @@ async fn responses_should_skip_event_log_when_logging_disabled() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_event_log_count(&pool).await, 0); + assert_eq!(response_usage_record_count(&pool).await, 0); } #[tokio::test] @@ -430,7 +483,7 @@ async fn responses_stream_should_proxy_sse_and_record_usage() { } #[tokio::test] -async fn responses_stream_should_record_event_log_after_completed_stream() { +async fn responses_stream_should_record_usage_record_after_completed_stream() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/codex/responses")) @@ -467,7 +520,7 @@ async fn responses_stream_should_record_event_log_after_completed_stream() { .await .unwrap(); let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert!(body.contains("resp_stream_usage")); @@ -527,7 +580,7 @@ async fn responses_stream_should_preserve_body_metadata_when_capture_body_enable .await .unwrap(); let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert!(body.contains("resp_stream_usage")); @@ -544,7 +597,7 @@ async fn responses_stream_should_preserve_body_metadata_when_capture_body_enable } #[tokio::test] -async fn responses_stream_should_record_event_log_after_late_disconnect() { +async fn responses_stream_should_record_usage_record_after_late_disconnect() { let first_frame = r#"event: response.output_text.delta data: {"delta":"partial before logged disconnect"} @@ -586,7 +639,7 @@ data: {"delta":"partial before logged disconnect"} let response = response_task.await.unwrap(); close_upstream.send(()).unwrap(); let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; + let event = latest_response_usage_record(&pool).await; let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert!(body.contains("stream_disconnected")); diff --git a/tests/proxy/openai/models_routes.rs b/tests/proxy/openai/models_routes.rs index 8c35a2961..c33e235d2 100644 --- a/tests/proxy/openai/models_routes.rs +++ b/tests/proxy/openai/models_routes.rs @@ -5,7 +5,7 @@ use axum::{ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, - admin::monitoring::event_store::SqliteEventLogStore, + admin::monitoring::usage_record_store::SqliteUsageRecordStore, infra::database::connect_sqlite, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::services::{BackgroundTaskStores, Services}, @@ -33,7 +33,7 @@ async fn models_route_should_reject_unknown_client_api_key() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); @@ -73,7 +73,7 @@ async fn models_route_should_accept_stored_client_api_key() { session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/proxy/openai/responses_routes.rs b/tests/proxy/openai/responses_routes.rs index 9723497c5..15eeca2ec 100644 --- a/tests/proxy/openai/responses_routes.rs +++ b/tests/proxy/openai/responses_routes.rs @@ -5,7 +5,7 @@ use axum::{ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, - admin::monitoring::event_store::SqliteEventLogStore, + admin::monitoring::usage_record_store::SqliteUsageRecordStore, infra::database::connect_sqlite, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::services::{BackgroundTaskStores, Services}, @@ -145,7 +145,7 @@ async fn test_app_with_client_api_key() -> (axum::Router, String, tempfile::Temp session_affinity: SqliteSessionAffinityStore::new(pool.clone()), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); diff --git a/tests/runtime/services.rs b/tests/runtime/services.rs index 0c224788f..9a67b03e9 100644 --- a/tests/runtime/services.rs +++ b/tests/runtime/services.rs @@ -3,7 +3,7 @@ use std::process::Command; use codex_proxy_rs::{ admin::{ auth::service::SqliteAdminSessionStore, keys::service::SqliteClientKeyStore, - monitoring::event_store::SqliteEventLogStore, + monitoring::usage_record_store::SqliteUsageRecordStore, }, runtime::services::{BackgroundTaskStores, Services}, upstream::{ @@ -38,7 +38,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { ), refresh_leases: RefreshLeaseStore::new(pool.clone()), client_keys: SqliteClientKeyStore::new(pool.clone()), - event_logs: SqliteEventLogStore::new(pool), + usage_records: SqliteUsageRecordStore::new(pool), }; let Err(error) = Services::try_new( diff --git a/tests/upstream/transport/headers.rs b/tests/upstream/transport/headers.rs index bc1406928..f57bd31f4 100644 --- a/tests/upstream/transport/headers.rs +++ b/tests/upstream/transport/headers.rs @@ -727,6 +727,8 @@ async fn codex_backend_client_should_send_compact_headers_in_fingerprint_order() parallel_tool_calls: None, reasoning: None, text: None, + client_ip: None, + client_user_agent: None, }, CodexRequestContext { access_token: "access-token", diff --git a/web/docs/frontend-optimization-plan.md b/web/docs/frontend-optimization-plan.md deleted file mode 100644 index 1eaf08bcb..000000000 --- a/web/docs/frontend-optimization-plan.md +++ /dev/null @@ -1,120 +0,0 @@ -# Frontend Optimization Plan - -## 目标 - -前端优化先解决结构坏味道,再处理局部实现细节。核心目标是让页面入口保持清晰,让业务流程、表格配置、弹窗和局部展示各自有稳定位置。 - -这份目录是第一批目标结构,不代表一次性创建所有空文件。只有当代码真实迁移进去时,才新增对应文件,避免为了目录完整而制造空壳。 - -## 原则 - -- 页面出口统一使用 `index.vue`。 -- 大页面先拆业务流程,再拆展示组件。 -- `constants.ts` 只放稳定配置,例如表格列、固定选项、状态映射。 -- `composables/` 放页面内可复用的状态与副作用流程,例如筛选、分页、请求、批量操作。 -- `components/` 放页面私有展示块或弹窗,不放跨页面基础组件。 -- 不为了抽象而抽象。组件提取必须减少页面职责或消除真实重复。 -- API 模块继续保持轻量,避免为每个接口参数声明大量类型。 -- 日期时间统一使用 `dayjs` 封装后的工具,不在页面里手写格式化。 - -## 第一批目标目录 - -```text -web/src/views/accounts/ - index.vue - constants.ts - composables/ - useAccountConnectionTest.ts - useAccountFilters.ts - useAccountMutations.ts - useAccountsTable.ts - components/ - AccountOverviewCards.vue - AccountIdentityCell.vue - AccountStatusBadge.vue - AccountCreateModal.vue - AccountEditModal.vue - AccountConnectionTestModal.vue - AccountQuotaPanel.vue - AccountQuotaSummaryCell.vue - AccountTableActions.vue - AccountUsagePanel.vue - -web/src/views/api-keys/ - index.vue - constants.ts - composables/ - useApiKeyFilters.ts - useApiKeyMutations.ts - useApiKeysTable.ts - components/ - ApiKeyFilters.vue - ApiKeyCreateModal.vue - ApiKeyIdentityCell.vue - ApiKeyPrefixCell.vue - ApiKeyStatusToggle.vue - -web/src/views/logs/ - index.vue - constants.ts - composables/ - useLogFilters.ts - useLogDetail.ts - useLogsTable.ts - components/ - LogDetailModal.vue - LogFilters.vue - LogLevelBadge.vue - LogStatusCodeBadge.vue - -web/src/components/base/BaseTable/ - index.vue - columns.ts - pagination.ts -``` - -## 落地顺序 - -1. `accounts` - -先处理最大页面。连接测试、筛选分页、账号变更请求、表格配置分别收口。弹窗和展开内容在逻辑稳定后再拆组件,避免过早组件化。 - -2. `api-keys` - -拆出表格列、筛选分页、创建/删除/状态/标签更新流程。弹窗组件只在页面脚本明显变轻后再迁移。 - -3. `logs` - -事件日志先整理筛选、表格和详情弹窗。详情弹窗里的元数据滚动与展示规则需要保持当前设计系统一致。 - -4. `BaseTable` - -最后处理基础表格。先抽列归一化和分页逻辑,再决定是否把 `BaseTable.vue` 迁移为目录入口 `BaseTable/index.vue`。基础组件变更影响面大,必须在页面侧坏味道收敛后再动。 - -## 当前进度 - -第一批已完成真实迁移: - -- `accounts` 已拆出连接测试、筛选分页、表格状态、账号变更流程、创建/编辑/测试弹窗、额度面板和用量面板。 -- `api-keys` 已拆出筛选分页、表格选择、密钥变更流程、表格列配置和创建弹窗。 -- `logs` 已拆出筛选区、详情弹窗、日志加载/刷新/清空流程、详情状态和表格列配置。 -- `components/base/BaseTable` 已迁移为目录入口,列解析与分页逻辑分别收口到 `columns.ts` 和 `pagination.ts`。 -- `utils/date.ts` 已落地,日期时间格式化统一走工具函数。 - -已完成的结构修正: - -- 筛选分页 composable 显式接收 `total` ref,不通过闭包读取外部总数。 -- 表格选择状态统一使用替换 `Set` 的方式更新,避免依赖原地 mutation。 -- 弹窗表单组件使用字段级 computed setter,避免子组件直接改父级表单对象的嵌套字段。 -- 日志列表命名回到 `logs`,避免把后端筛选结果误导为前端二次筛选。 -- 账号概览卡片、表格身份、额度摘要和行操作已拆为页面私有展示组件,让 `accounts/index.vue` 继续收敛为页面编排层。 -- 连接测试弹窗复用账号身份展示组件,不再维护第二套头像、标题和副标题展示逻辑。 -- 账号状态展示已收敛为页面私有 badge 组件,表格与连接测试弹窗共享同一套状态文案和色值。 -- API Key 筛选与顶部操作区已拆为页面私有组件,`api-keys/index.vue` 只保留状态接线和表格编排。 -- API Key 名称/标签编辑和完整密钥复制已拆为页面私有单元组件,标签编辑 composable 不再接收 DOM 事件。 -- API Key 启用状态切换已拆为页面私有单元组件,入口页不再直接维护状态按钮样式。 -- 日志级别与状态码展示已拆为页面私有 badge 组件,列表和详情弹窗共享同一套展示规则。 -- 账号表格不再保留 `filteredAccounts` 这类无意义身份别名,页面直接使用后端返回的 `accounts`。 -- 筛选 composable 不再向页面暴露未消费的内部派生状态,保持对外 API 更小。 - -后续继续优化时,必须按本文件的目标目录和落地顺序推进。 diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 09368ea88..2803f0d51 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -7,8 +7,8 @@ export * from './modules/accounts' // API Keys 管理 export * from './modules/api-keys' -// 事件日志 -export * from './modules/logs' +// 使用记录 +export * from './modules/usage' // Dashboard 聚合 export * from './modules/dashboard' diff --git a/web/src/api/modules/logs.ts b/web/src/api/modules/logs.ts deleted file mode 100644 index eb4ebd0ef..000000000 --- a/web/src/api/modules/logs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import request from '../request' - -export function getLogs(params?: any) { - return request({ - url: '/api/admin/logs', - method: 'GET', - params, - }) -} - -export function getLogDetail(params: any) { - return request({ - url: '/api/admin/logs/detail', - method: 'GET', - params, - }) -} - -export function clearLogs() { - return request({ - url: '/api/admin/logs/delete', - method: 'POST', - }) -} diff --git a/web/src/api/modules/usage.ts b/web/src/api/modules/usage.ts new file mode 100644 index 000000000..f1a260ecb --- /dev/null +++ b/web/src/api/modules/usage.ts @@ -0,0 +1,33 @@ +import request from '../request' + +export function getUsageRecords(params?: any) { + return request({ + url: '/api/admin/usage/records', + method: 'GET', + params, + }) +} + +export function getUsageRecordDetail(params: any) { + return request({ + url: '/api/admin/usage/records/detail', + method: 'GET', + params, + }) +} + +export function getUsageRecordSummary(params?: any) { + return request({ + url: '/api/admin/usage/records/summary', + method: 'GET', + params, + }) +} + +export function getUsageRecordInsights(params?: any) { + return request({ + url: '/api/admin/usage/records/insights', + method: 'GET', + params, + }) +} diff --git a/web/src/components/base/BasePopover.vue b/web/src/components/base/BasePopover.vue index d93b5c61d..1208a679a 100644 --- a/web/src/components/base/BasePopover.vue +++ b/web/src/components/base/BasePopover.vue @@ -7,10 +7,29 @@ import { whenever, } from '@vueuse/core' import { clamp } from 'es-toolkit' -import { computed, nextTick, ref, useAttrs } from 'vue' +import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs } from 'vue' import type { CSSProperties } from 'vue' -type PopoverPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' +type PopoverPlacement = + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' +type PopoverTrigger = 'click' | 'hover' + +interface PopoverPoint { + left: number + top: number +} + +interface PopoverPosition { + placement: PopoverPlacement + point: PopoverPoint +} defineOptions({ inheritAttrs: false, @@ -19,6 +38,7 @@ defineOptions({ const props = withDefaults( defineProps<{ placement?: PopoverPlacement + trigger?: PopoverTrigger offset?: number width?: number | string disabled?: boolean @@ -26,6 +46,7 @@ const props = withDefaults( }>(), { placement: 'bottom-end', + trigger: 'click', offset: 6, disabled: false, panelClass: '', @@ -39,6 +60,8 @@ const rootRef = ref(null) const triggerRef = ref(null) const popoverRef = ref(null) const popoverStyle = ref({}) +const popoverArrowStyle = ref({}) +const hoverCloseTimer = shallowRef() const popoverClasses = computed(() => [ 'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)', @@ -70,22 +93,18 @@ function updatePopoverPosition() { toCssLength(props.width) ? 0 : triggerRect.width, ) const panelHeight = panelRect?.height ?? 0 - const belowSpace = window.innerHeight - triggerRect.bottom - props.offset - const aboveSpace = triggerRect.top - props.offset - const prefersTop = props.placement.startsWith('top') - const placeAbove = prefersTop - ? !(aboveSpace < panelHeight && belowSpace > aboveSpace) - : belowSpace < panelHeight && aboveSpace > belowSpace - const rawTop = placeAbove - ? triggerRect.top - panelHeight - props.offset - : triggerRect.bottom + props.offset - const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding) - const top = panelHeight > 0 ? clamp(rawTop, viewportPadding, maxTop) : rawTop - const rawLeft = props.placement.endsWith('end') - ? triggerRect.right - panelWidth - : triggerRect.left const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding) - const left = clamp(rawLeft, viewportPadding, maxLeft) + const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding) + const position = choosePopoverPosition({ + placement: props.placement, + triggerRect, + panelWidth, + panelHeight, + offset: props.offset, + viewportPadding, + }) + const left = clamp(position.point.left, viewportPadding, maxLeft) + const top = clamp(position.point.top, viewportPadding, maxTop) popoverStyle.value = { left: `${left}px`, @@ -93,6 +112,147 @@ function updatePopoverPosition() { maxWidth: `calc(100vw - ${viewportPadding * 2}px)`, ...(toCssLength(props.width) ? {} : { minWidth: `${triggerRect.width}px` }), } + popoverArrowStyle.value = popoverArrowPosition({ + placement: position.placement, + triggerRect, + panelWidth, + panelHeight, + left, + top, + }) +} + +function choosePopoverPosition(options: { + placement: PopoverPlacement + triggerRect: DOMRect + panelWidth: number + panelHeight: number + offset: number + viewportPadding: number +}): PopoverPosition { + const placements = placementCandidates(options.placement) + + for (const placement of placements) { + const point = popoverPoint(placement, options) + if (isPointInViewport(point, options)) { + return { placement, point } + } + } + + return { + placement: options.placement, + point: popoverPoint(options.placement, options), + } +} + +function placementCandidates(placement: PopoverPlacement): PopoverPlacement[] { + const all: PopoverPlacement[] = [ + 'bottom-end', + 'bottom-start', + 'bottom', + 'top-end', + 'top-start', + 'top', + 'right', + 'left', + ] + const opposite: Record = { + top: 'bottom', + 'top-start': 'bottom-start', + 'top-end': 'bottom-end', + right: 'left', + bottom: 'top', + 'bottom-start': 'top-start', + 'bottom-end': 'top-end', + left: 'right', + } + + return [ + placement, + opposite[placement], + ...all.filter((item) => item !== placement && item !== opposite[placement]), + ] +} + +function popoverPoint( + placement: PopoverPlacement, + options: { + triggerRect: DOMRect + panelWidth: number + panelHeight: number + offset: number + }, +): PopoverPoint { + const { triggerRect, panelWidth, panelHeight, offset } = options + const centerLeft = triggerRect.left + triggerRect.width / 2 - panelWidth / 2 + const centerTop = triggerRect.top + triggerRect.height / 2 - panelHeight / 2 + + const points: Record = { + top: { left: centerLeft, top: triggerRect.top - panelHeight - offset }, + 'top-start': { left: triggerRect.left, top: triggerRect.top - panelHeight - offset }, + 'top-end': { + left: triggerRect.right - panelWidth, + top: triggerRect.top - panelHeight - offset, + }, + right: { left: triggerRect.right + offset, top: centerTop }, + bottom: { left: centerLeft, top: triggerRect.bottom + offset }, + 'bottom-start': { left: triggerRect.left, top: triggerRect.bottom + offset }, + 'bottom-end': { left: triggerRect.right - panelWidth, top: triggerRect.bottom + offset }, + left: { left: triggerRect.left - panelWidth - offset, top: centerTop }, + } + + return points[placement] +} + +function isPointInViewport( + point: PopoverPoint, + options: { + panelWidth: number + panelHeight: number + viewportPadding: number + }, +) { + const { panelWidth, panelHeight, viewportPadding } = options + + return ( + point.left >= viewportPadding && + point.top >= viewportPadding && + point.left + panelWidth <= window.innerWidth - viewportPadding && + point.top + panelHeight <= window.innerHeight - viewportPadding + ) +} + +function popoverArrowPosition(options: { + placement: PopoverPlacement + triggerRect: DOMRect + panelWidth: number + panelHeight: number + left: number + top: number +}): CSSProperties { + const arrowSize = 8 + const arrowHalf = arrowSize / 2 + const arrowPadding = 12 + const { placement, triggerRect, panelWidth, panelHeight, left, top } = options + const side = placement.split('-')[0] + const centerX = triggerRect.left + triggerRect.width / 2 - left + const centerY = triggerRect.top + triggerRect.height / 2 - top + + if (side === 'top' || side === 'bottom') { + const arrowLeft = clamp(centerX - arrowHalf, arrowPadding, panelWidth - arrowPadding) + + return { + left: `${arrowLeft}px`, + top: side === 'bottom' ? `${-arrowHalf}px` : `${panelHeight - arrowHalf}px`, + } + } + + const arrowTop = clamp(centerY - arrowHalf, arrowPadding, panelHeight - arrowPadding) + + return { + left: side === 'right' ? `${-arrowHalf}px` : `${panelWidth - arrowHalf}px`, + top: `${arrowTop}px`, + } } const updatePopoverPositionThrottled = useThrottleFn(updatePopoverPosition, 32, true) @@ -100,16 +260,22 @@ const updatePopoverPositionThrottled = useThrottleFn(updatePopoverPosition, 32, async function openPopover() { if (props.disabled || open.value) return + clearHoverCloseTimer() open.value = true await nextTick() updatePopoverPosition() } function closePopover() { + clearHoverCloseTimer() open.value = false } function togglePopover() { + if (props.trigger !== 'click') { + return + } + if (open.value) { closePopover() return @@ -118,6 +284,33 @@ function togglePopover() { void openPopover() } +function clearHoverCloseTimer() { + if (hoverCloseTimer.value === undefined) { + return + } + + window.clearTimeout(hoverCloseTimer.value) + hoverCloseTimer.value = undefined +} + +function handleHoverEnter() { + if (props.trigger !== 'hover') { + return + } + + clearHoverCloseTimer() + void openPopover() +} + +function handleHoverLeave() { + if (props.trigger !== 'hover') { + return + } + + clearHoverCloseTimer() + hoverCloseTimer.value = window.setTimeout(closePopover, 90) +} + function handleTriggerKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { closePopover() @@ -137,6 +330,7 @@ onKeyStroke('Escape', () => { }) useEventListener(window, 'resize', updatePopoverPositionThrottled) useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: true }) +onBeforeUnmount(clearHoverCloseTimer)