feat: add usage records analytics

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 23:13:17 +08:00
co-authored by Codex
parent 93ba705636
commit d3a4b92d3f
96 changed files with 6564 additions and 2440 deletions
+6 -6
View File
@@ -12,7 +12,7 @@
<code>/v1/responses</code> · <code>/v1/chat/completions</code> · <code>/v1/models</code> · 管理控制台
</p>
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<br/>管理端 API]
admin --> runtime[runtime<br/>启动装配、状态和后台任务]
runtime --> infra[infra<br/>SQLite、路径和日志]
proxy --> telemetry[telemetry<br/>事件日志和用量记录]
proxy --> telemetry[telemetry<br/>使用记录和用量统计]
admin --> telemetry
http --> web[web<br/>管理控制台静态资源]
```
+11 -6
View File
@@ -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<RuntimeAccountPoolService>,
pub(crate) token_refresher: StdArc<dyn TokenRefresher>,
pub(crate) oauth: oauth::AccountOAuthService,
pub(crate) refresh_margin_seconds: u64,
pub(crate) refresh_policy: RuntimeRefreshPolicy,
pub(crate) installation_id: Option<String>,
}
@@ -46,7 +48,7 @@ pub struct AdminAccountServiceParts {
pub account_pool: StdArc<RuntimeAccountPoolService>,
pub token_refresher: StdArc<dyn TokenRefresher>,
pub oauth: oauth::AccountOAuthService,
pub refresh_margin_seconds: u64,
pub refresh_policy: RuntimeRefreshPolicy,
pub installation_id: Option<String>,
}
@@ -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<chrono::Utc>,
) -> DateTime<chrono::Utc> {
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)
}
+2
View File
@@ -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,
+56 -4
View File
@@ -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<String>,
/// 服务档位倍率。
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 {
+24 -40
View File
@@ -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<DashboardAccountUsageData>,
service_statuses: Vec<DashboardServiceStatusData>,
event_logs: Vec<DashboardEventLogData>,
usage_records: Vec<UsageRecordData>,
pool_summary: AccountPoolDiagnostics,
capacity_info: AccountCapacityDiagnostics,
rotation_strategy: Option<String>,
@@ -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<String>,
route: Option<String>,
model: Option<String>,
status_code: Option<i64>,
latency_ms: Option<i64>,
}
#[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::<Vec<_>>();
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, &quota_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],
-743
View File
@@ -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<T> = Result<T, SqliteEventLogStoreError>;
/// 事件日志查询过滤器。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventLogFilter {
/// 事件类别。
pub kind: Option<String>,
/// 事件等级。
pub level: Option<EventLevel>,
/// 请求 ID。
pub request_id: Option<String>,
/// 账号 ID。
pub account_id: Option<String>,
/// 路由。
pub route: Option<String>,
/// 模型。
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
/// 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<u64> {
trim_to_capacity(&self.pool, capacity).await
}
/// 分页查询事件日志。
pub async fn list(
&self,
filter: EventLogFilter,
cursor: Option<String>,
limit: u32,
) -> SqliteEventLogStoreResult<Page<EventLog>> {
let fetch_limit = i64::from(limit) + 1;
let mut builder = QueryBuilder::<Sqlite>::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::<SqliteEventLogStoreResult<Vec<_>>>()?;
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<NumberedPage<EventLog>> {
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::<Sqlite>::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::<SqliteEventLogStoreResult<Vec<_>>>()?;
Ok(NumberedPage {
items,
total,
page: page.max(1),
page_size,
})
}
/// 按 ID 读取事件日志。
pub async fn get(&self, id: &str) -> SqliteEventLogStoreResult<Option<EventLog>> {
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<u64> {
let result = sqlx::query("delete from event_logs")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
#[derive(Debug, Clone)]
struct EventLogSummaryFields {
transport: Option<String>,
attempt_index: Option<i64>,
upstream_status_code: Option<i64>,
failure_class: Option<String>,
response_id: Option<String>,
upstream_request_id: Option<String>,
}
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<String> {
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<i64> {
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<i64> {
[
"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<u64> {
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<u64> {
let mut builder = QueryBuilder::<Sqlite>::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<Sqlite>,
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<EventLog> {
Ok(EventLog {
id: row.get("id"),
request_id: row.get("request_id"),
kind: row.get("kind"),
level: level_from_db(&row.get::<String, _>("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::<String, _>("metadata_json"))?,
created_at: parse_rfc3339(&row.get::<String, _>("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<EventLevel> {
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<DateTime<Utc>> {
Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc))
}
/// 管理端日志服务相关类型。
/// 日志查询过滤器。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdminLogFilter {
/// 事件类别。
pub kind: Option<String>,
/// 事件等级。
pub level: Option<EventLevel>,
/// 请求 ID。
pub request_id: Option<String>,
/// 账号 ID。
pub account_id: Option<String>,
/// 路由。
pub route: Option<String>,
/// 模型。
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
impl From<AdminLogFilter> 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<tokio::sync::RwLock<AdminLogSettings>>,
}
#[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<String>,
limit: u32,
filter: AdminLogFilter,
) -> Result<Page<EventLog>, 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<NumberedPage<EventLog>, AdminLogError> {
self.store
.list_page(filter.into(), page, page_size)
.await
.map_err(|_| AdminLogError::List)
}
/// 按 ID 读取日志。
pub async fn get(&self, id: &str) -> Result<Option<EventLog>, AdminLogError> {
self.store.get(id).await.map_err(|_| AdminLogError::Get)
}
/// 清空日志。
pub async fn clear(&self) -> Result<AdminClearLogs, AdminLogError> {
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);
}
}
-216
View File
@@ -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<String>,
limit: Option<u32>,
page: Option<u32>,
page_size: Option<u32>,
kind: Option<String>,
level: Option<String>,
request_id: Option<String>,
account_id: Option<String>,
route: Option<String>,
model: Option<String>,
status_code: Option<i64>,
transport: Option<String>,
attempt_index: Option<i64>,
upstream_status_code: Option<i64>,
failure_class: Option<String>,
response_id: Option<String>,
upstream_request_id: Option<String>,
search: Option<String>,
}
#[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<AppState>,
headers: HeaderMap,
Query(query): Query<LogsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
Query(query): Query<LogDetailQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
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<AdminLogFilter, AdminError> {
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<String>) -> Result<Option<EventLevel>, 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<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
impl From<EventLog> for LogData {
fn from(log: EventLog) -> Self {
Self {
created_at_display: china_datetime(&log.created_at),
log,
}
}
}
+3 -3
View File
@@ -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;
@@ -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<String>,
/// HTTP 路由。
@@ -62,11 +62,15 @@ pub struct EventLog {
pub created_at: DateTime<Utc>,
}
impl EventLog {
/// 构造事件日志。
pub fn new(kind: impl Into<String>, level: EventLevel, message: impl Into<String>) -> Self {
impl UsageRecord {
/// 构造使用记录。
pub fn new(
kind: impl Into<String>,
level: UsageRecordLevel,
message: impl Into<String>,
) -> 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)],
File diff suppressed because it is too large Load Diff
+828
View File
@@ -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<String>,
limit: Option<u32>,
page: Option<u32>,
page_size: Option<u32>,
kind: Option<String>,
level: Option<String>,
request_id: Option<String>,
account_id: Option<String>,
route: Option<String>,
model: Option<String>,
status_code: Option<i64>,
transport: Option<String>,
attempt_index: Option<i64>,
upstream_status_code: Option<i64>,
failure_class: Option<String>,
response_id: Option<String>,
upstream_request_id: Option<String>,
search: Option<String>,
start_time: Option<String>,
end_time: Option<String>,
}
#[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<String>,
requested_model: Option<String>,
upstream_model: Option<String>,
client_ip: Option<String>,
user_agent: Option<String>,
reasoning_effort: Option<String>,
token_details: UsageRecordTokenDetailsData,
cost_details: Option<UsageRecordCostDetailsData>,
first_token_latency_ms: Option<i64>,
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<String>,
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<f64>,
average_latency_ms_display: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageRecordInsightsData {
models: Vec<UsageRecordBreakdownData>,
upstream_models: Vec<UsageRecordBreakdownData>,
model_mappings: Vec<UsageRecordBreakdownData>,
endpoints: Vec<UsageRecordBreakdownData>,
upstream_endpoints: Vec<UsageRecordBreakdownData>,
endpoint_paths: Vec<UsageRecordBreakdownData>,
types: Vec<UsageRecordBreakdownData>,
trend: Vec<UsageRecordTrendPointData>,
}
#[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<f64>,
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<f64>,
average_latency_ms_display: String,
}
/// `GET /api/admin/usage/records`
pub(crate) async fn usage_records(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordsQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordDetailQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
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<AdminUsageRecordFilter, AdminError> {
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<String>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, 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<String>) -> Result<Option<UsageRecordLevel>, 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<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(crate) async fn account_email_map(
state: &AppState,
items: &[UsageRecord],
) -> Result<HashMap<String, String>, AdminError> {
let mut account_ids = items
.iter()
.filter_map(|item| item.account_id.as_deref())
.map(str::to_string)
.collect::<Vec<_>>();
account_ids.sort_unstable();
account_ids.dedup();
if account_ids.is_empty() {
return Ok(HashMap::new());
}
let mut builder = QueryBuilder::<Sqlite>::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::<Option<String>, _>("email")
.map(|email| email.trim().to_string())
.filter(|email| !email.is_empty())
.map(|email| (row.get::<String, _>("id"), email))
})
.collect())
}
pub(crate) fn usage_record_items(
items: Vec<UsageRecord>,
account_emails: &HashMap<String, String>,
model_aliases: &BTreeMap<String, String>,
) -> Vec<UsageRecordData> {
items
.into_iter()
.map(|record| usage_record_data(record, account_emails, model_aliases))
.collect()
}
fn usage_record_data(
record: UsageRecord,
account_emails: &HashMap<String, String>,
model_aliases: &BTreeMap<String, String>,
) -> 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<String, String>,
) -> (Option<String>, Option<String>) {
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<UsageRecordCostDetailsData> {
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<String> {
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<u64> {
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<String> {
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<i64> {
keys.iter()
.find_map(|key| metadata.get(*key).and_then(Value::as_i64))
.filter(|value| *value >= 0)
}
fn format_duration_ms(value: Option<i64>) -> 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<f64>) -> 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<UsageRecordSummary> 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<UsageRecordInsights> 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<UsageRecordBreakdown> 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<UsageRecordTrendPoint> 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),
}
}
}
+15 -4
View File
@@ -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<AppState> {
.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))
+6
View File
@@ -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,
+20 -14
View File
@@ -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<StdRwLock<Arc<AppConfig>>>,
pool: SqlitePool,
account_pool: Option<Arc<RuntimeAccountPoolService>>,
account_pool: Arc<RuntimeAccountPoolService>,
refresh_policy: RuntimeRefreshPolicy,
}
impl RuntimeSettingsService {
/// 构造带账号池同步的运行时设置服务。
pub fn with_account_pool(
/// 构造运行时设置服务。
pub fn new(
config: AppConfig,
pool: SqlitePool,
account_pool: Arc<RuntimeAccountPoolService>,
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
+27 -2
View File
@@ -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::<ConnectInfo<SocketAddr>>() else {
return;
};
let Ok(value) = HeaderValue::from_str(&addr.ip().to_string()) else {
return;
};
request.headers_mut().insert("x-real-ip", value);
}
+15 -15
View File
@@ -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
+88 -17
View File
@@ -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<RuntimeAccountPoolService>,
models: Arc<crate::upstream::models::ModelService>,
codex: Arc<CodexBackendClient>,
logs: Arc<AdminLogService>,
usage_records: Arc<AdminUsageRecordService>,
installation_id: Option<String>,
cloudflare: CloudflareRecovery,
}
@@ -61,7 +61,7 @@ impl ChatDispatchService {
account_pool: Arc<RuntimeAccountPoolService>,
models: Arc<crate::upstream::models::ModelService>,
codex: Arc<CodexBackendClient>,
logs: Arc<AdminLogService>,
usage_records: Arc<AdminUsageRecordService>,
installation_id: Option<String>,
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<String, Value>,
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<String, Value>,
error: &ChatDispatchError,
+221 -60
View File
@@ -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<CodexBackendClient>,
session_affinity: Arc<RuntimeSessionAffinityService>,
reasoning_replay: Arc<Mutex<ReasoningReplayCache>>,
logs: Arc<AdminLogService>,
usage_records: Arc<AdminUsageRecordService>,
installation_id: Option<String>,
cloudflare: CloudflareRecovery,
}
@@ -151,7 +151,7 @@ impl ResponseDispatchService {
models: Arc<crate::upstream::models::ModelService>,
codex: Arc<CodexBackendClient>,
session_affinity: Arc<RuntimeSessionAffinityService>,
logs: Arc<AdminLogService>,
usage_records: Arc<AdminUsageRecordService>,
installation_id: Option<String>,
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<RuntimeAccountPoolService>,
session_affinity: Arc<RuntimeSessionAffinityService>,
reasoning_replay: Arc<Mutex<ReasoningReplayCache>>,
logs: Arc<AdminLogService>,
usage_records: Arc<AdminUsageRecordService>,
cloudflare: CloudflareRecovery,
account_id: String,
request_id: String,
route: String,
model: String,
requested_model: String,
client_ip: Option<String>,
request: CodexResponsesRequest,
tuple_schema: Option<Value>,
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<i64>,
) {
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::<Value>(&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<Result<Bytes, ResponseDispatchStreamError>>,
body_bytes: &mut Vec<u8>,
@@ -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<i64>,
) {
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<TokenUsa
})
}
fn insert_first_token_ms(metadata: &mut Value, first_token_ms: Option<i64>) {
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
+1
View File
@@ -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
+67 -2
View File
@@ -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<String>, 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<String> {
header_string(headers, "user-agent")
}
pub(crate) fn client_ip_from_headers(headers: &HeaderMap) -> Option<String> {
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<String> {
let value = header_string(headers, "x-forwarded-for")?;
let parts = value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
parts
.iter()
.find(|part| !is_private_ip(part))
.or_else(|| parts.first())
.map(|part| (*part).to_string())
}
fn normalize_client_ip(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
trimmed
.parse::<std::net::SocketAddr>()
.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::<IpAddr>().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<String> {
header_string(headers, OPENAI_SUBAGENT_HEADER).and_then(|value| {
if matches!(
+9 -4
View File
@@ -22,9 +22,12 @@ pub async fn run() -> Result<(), Box<dyn Error + Send + Sync>> {
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::<std::net::SocketAddr>(),
)
.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();
+35 -26
View File
@@ -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<AdminClientKeyService>,
pub admin_sessions: StdArc<AdminSessionService>,
pub settings: StdArc<RuntimeSettingsService>,
pub refresh_policy: RuntimeRefreshPolicy,
pub admin_accounts: StdArc<AdminAccountService>,
pub logs: StdArc<AdminLogService>,
pub usage_records: StdArc<AdminUsageRecordService>,
pub usage: StdArc<AdminUsageService>,
pub account_pool: StdArc<RuntimeAccountPoolService>,
pub chat: StdArc<ChatDispatchService>,
@@ -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<String>,
) -> Result<Self, CustomCaError> {
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<String>,
log_options: EventLogOptions,
usage_record_options: UsageRecordOptions,
) -> Result<Self, CustomCaError> {
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,
+2 -8
View File
@@ -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())
+4 -2
View File
@@ -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,
+71 -10
View File
@@ -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<RwLock<RefreshPolicy>>,
semaphore: Arc<RwLock<Arc<Semaphore>>>,
}
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<Semaphore> {
self.semaphore
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl From<RefreshPolicy> 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<C> {
policy: RefreshPolicy,
policy: RuntimeRefreshPolicy,
client: Arc<C>,
semaphore: Arc<Semaphore>,
}
impl<C> RefreshScheduler<C>
@@ -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<RuntimeRefreshPolicy>, 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)
}
}
+17 -7
View File
@@ -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<RefreshScheduler<C>>,
refresh_margin_seconds: u64,
policy: RuntimeRefreshPolicy,
refresh_leases: Option<RefreshLeaseStore>,
lease_owner: String,
retry_delays: Vec<Duration>,
@@ -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<RuntimeRefreshPolicy>,
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<I>(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))
}
+28 -11
View File
@@ -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<String, Vec<String>>;
/// 模型目录服务。
#[derive(Clone)]
pub struct ModelService {
config: ModelConfig,
config: Arc<RwLock<ModelConfig>>,
snapshot_store: Option<Arc<dyn ModelSnapshotStore>>,
upstream_client: Option<Arc<dyn CodexModelCatalogClient>>,
}
@@ -592,26 +595,35 @@ impl ModelService {
upstream_client: Option<Arc<dyn CodexModelCatalogClient>>,
) -> 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()
}
}
+14
View File
@@ -70,6 +70,12 @@ pub struct CodexResponsesRequest {
/// 传给上游的 client metadata。
pub client_metadata: Option<Value>,
#[serde(skip)]
/// 代理侧识别的客户端 IP,仅用于管理端使用记录展示。
pub client_ip: Option<String>,
#[serde(skip)]
/// 客户端 User-Agent,仅用于管理端使用记录展示。
pub client_user_agent: Option<String>,
#[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<Value>,
#[serde(skip)]
/// 代理侧识别的客户端 IP,仅用于管理端使用记录展示。
pub client_ip: Option<String>,
#[serde(skip)]
/// 客户端 User-Agent,仅用于管理端使用记录展示。
pub client_user_agent: Option<String>,
}
+3 -3
View File
@@ -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));
+3 -3
View File
@@ -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));
+1 -1
View File
@@ -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));
+2 -2
View File
@@ -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));
+11 -9
View File
@@ -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(
+2 -2
View File
@@ -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));
+44 -34
View File
@@ -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<Utc>, total_tokens: u64) -> EventLog {
usage_log(
fn usage_record_with_tokens(created_at: chrono::DateTime<Utc>, 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<Utc>, total_tokens: u64) -
)
}
fn usage_log(created_at: chrono::DateTime<Utc>, 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<Utc>, 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) {
-274
View File
@@ -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,
)
}
+2 -2
View File
@@ -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;
@@ -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)
}
@@ -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::<Vec<_>>();
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(&current).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,
)
}
+151 -7
View File
@@ -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
+60
View File
@@ -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");
}
+4 -3
View File
@@ -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(
+11 -11
View File
@@ -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::<Vec<_>>(),
[
"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;
@@ -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
@@ -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(),
+35 -31
View File
@@ -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<String>,
account_id: Option<String>,
route: Option<String>,
model: Option<String>,
status_code: Option<i64>,
response_id: Option<String>,
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",
}
}
@@ -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");
@@ -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!(
@@ -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"));
@@ -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"));
+3 -3
View File
@@ -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));
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -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(
+2
View File
@@ -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",
-120
View File
@@ -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 更小。
后续继续优化时,必须按本文件的目标目录和落地顺序推进。
+2 -2
View File
@@ -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'
-24
View File
@@ -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',
})
}
+33
View File
@@ -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,
})
}
+219 -17
View File
@@ -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<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref<CSSProperties>({})
const popoverArrowStyle = ref<CSSProperties>({})
const hoverCloseTimer = shallowRef<number>()
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<PopoverPlacement, PopoverPlacement> = {
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<PopoverPlacement, PopoverPoint> = {
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)
</script>
<template>
@@ -146,6 +340,8 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
class="inline-flex"
@click.stop="togglePopover"
@keydown="handleTriggerKeydown"
@mouseenter="handleHoverEnter"
@mouseleave="handleHoverLeave"
>
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
</div>
@@ -164,7 +360,13 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
ref="popoverRef"
:class="popoverClasses"
:style="[sizeStyle, popoverStyle]"
@mouseenter="handleHoverEnter"
@mouseleave="handleHoverLeave"
>
<span
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
:style="popoverArrowStyle"
/>
<slot :open="open" :close="closePopover" :toggle="togglePopover" />
</div>
</Transition>
+162 -15
View File
@@ -8,11 +8,15 @@ const props = withDefaults(
viewClass?: string
maxHeight?: string
forceVisible?: boolean
horizontal?: boolean
vertical?: boolean
}>(),
{
viewClass: '',
maxHeight: undefined,
forceVisible: false,
horizontal: false,
vertical: true,
},
)
@@ -24,25 +28,47 @@ const wrapRef = useTemplateRef<HTMLDivElement>('wrap')
const viewRef = useTemplateRef<HTMLElement>('view')
const thumbHeight = shallowRef(0)
const thumbTop = shallowRef(0)
const horizontalThumbWidth = shallowRef(0)
const horizontalThumbLeft = shallowRef(0)
const visible = shallowRef(false)
const dragging = shallowRef(false)
const horizontalDragging = shallowRef(false)
let dragStartY = 0
let dragStartScrollTop = 0
let horizontalDragStartX = 0
let horizontalDragStartScrollLeft = 0
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 900, {
immediate: false,
})
const canScrollY = computed(() => thumbHeight.value > 0)
const scrollbarVisible = computed(() => props.forceVisible || dragging.value || visible.value)
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
const scrollbarVisible = computed(
() => props.forceVisible || dragging.value || horizontalDragging.value || visible.value,
)
const thumbStyle = computed(() => ({
height: `${thumbHeight.value}px`,
transform: `translateY(${thumbTop.value}px)`,
}))
const horizontalThumbStyle = computed(() => ({
width: `${horizontalThumbWidth.value}px`,
transform: `translateX(${horizontalThumbLeft.value}px)`,
}))
const rootClasses = computed(() => [
'relative min-h-0 overflow-hidden',
props.maxHeight ? undefined : 'h-full',
])
const wrapClasses = computed(() => [
'min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent',
props.maxHeight ? undefined : 'h-full',
])
const verticalTrackClass = computed(() =>
props.horizontal && canScrollX.value ? 'bottom-3' : 'bottom-1',
)
const horizontalTrackClass = computed(() =>
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
)
function trackHeight(wrap: HTMLElement) {
return clamp(wrap.clientHeight - 8, 0, Number.POSITIVE_INFINITY)
@@ -52,10 +78,22 @@ function maxScrollTop(wrap: HTMLElement) {
return clamp(wrap.scrollHeight - wrap.clientHeight, 0, Number.POSITIVE_INFINITY)
}
function trackWidth(wrap: HTMLElement) {
return clamp(wrap.clientWidth - 8, 0, Number.POSITIVE_INFINITY)
}
function maxScrollLeft(wrap: HTMLElement) {
return clamp(wrap.scrollWidth - wrap.clientWidth, 0, Number.POSITIVE_INFINITY)
}
function maxThumbTop(wrap: HTMLElement) {
return clamp(trackHeight(wrap) - thumbHeight.value, 0, Number.POSITIVE_INFINITY)
}
function maxHorizontalThumbLeft(wrap: HTMLElement) {
return clamp(trackWidth(wrap) - horizontalThumbWidth.value, 0, Number.POSITIVE_INFINITY)
}
function showScrollbar() {
visible.value = true
}
@@ -66,7 +104,7 @@ function clearHideTimer() {
function scheduleHideScrollbar() {
clearHideTimer()
if (props.forceVisible || dragging.value) {
if (props.forceVisible || dragging.value || horizontalDragging.value) {
return
}
@@ -75,7 +113,7 @@ function scheduleHideScrollbar() {
function hideScrollbar() {
clearHideTimer()
if (!props.forceVisible && !dragging.value) {
if (!props.forceVisible && !dragging.value && !horizontalDragging.value) {
visible.value = false
}
}
@@ -91,21 +129,48 @@ function update() {
return
}
updateVerticalScrollbar(wrap)
updateHorizontalScrollbar(wrap)
}
function updateVerticalScrollbar(wrap: HTMLElement) {
if (!props.vertical) {
thumbHeight.value = 0
thumbTop.value = 0
return
}
const scrollRange = maxScrollTop(wrap)
const availableTrackHeight = trackHeight(wrap)
if (scrollRange <= 0 || availableTrackHeight <= 0) {
thumbHeight.value = 0
thumbTop.value = 0
visible.value = false
clearHideTimer()
return
}
const ratio = wrap.clientHeight / wrap.scrollHeight
thumbHeight.value = clamp(availableTrackHeight * ratio, 32, availableTrackHeight)
thumbTop.value = (wrap.scrollTop / scrollRange) * maxThumbTop(wrap)
}
function updateHorizontalScrollbar(wrap: HTMLElement) {
if (!props.horizontal) {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
return
}
const scrollRange = maxScrollLeft(wrap)
const availableTrackWidth = trackWidth(wrap)
if (scrollRange <= 0 || availableTrackWidth <= 0) {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
return
}
const ratio = wrap.clientWidth / wrap.scrollWidth
horizontalThumbWidth.value = clamp(availableTrackWidth * ratio, 32, availableTrackWidth)
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
}
async function scrollToTop() {
const wrap = wrapRef.value
if (wrap) {
@@ -113,6 +178,7 @@ async function scrollToTop() {
wrap.scrollLeft = 0
}
dragging.value = false
horizontalDragging.value = false
visible.value = false
clearHideTimer()
await nextTick()
@@ -152,6 +218,25 @@ function handleTrackPointerDown(event: PointerEvent) {
thumbRange > 0 ? (clamp(nextThumbTop, 0, thumbRange) / thumbRange) * scrollRange : 0
}
function handleHorizontalTrackPointerDown(event: PointerEvent) {
if (event.target !== event.currentTarget) {
return
}
const wrap = wrapRef.value
if (!wrap) {
return
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
activateScrollbar()
const nextThumbLeft = event.clientX - rect.left - horizontalThumbWidth.value / 2
const scrollRange = maxScrollLeft(wrap)
const thumbRange = maxHorizontalThumbLeft(wrap)
wrap.scrollLeft =
thumbRange > 0 ? (clamp(nextThumbLeft, 0, thumbRange) / thumbRange) * scrollRange : 0
}
function handleThumbPointerDown(event: PointerEvent) {
const wrap = wrapRef.value
if (!wrap) {
@@ -166,6 +251,20 @@ function handleThumbPointerDown(event: PointerEvent) {
dragStartScrollTop = wrap.scrollTop
}
function handleHorizontalThumbPointerDown(event: PointerEvent) {
const wrap = wrapRef.value
if (!wrap) {
return
}
event.preventDefault()
horizontalDragging.value = true
visible.value = true
clearHideTimer()
horizontalDragStartX = event.clientX
horizontalDragStartScrollLeft = wrap.scrollLeft
}
function handleThumbPointerMove(event: PointerEvent) {
if (!dragging.value) {
return
@@ -185,6 +284,27 @@ function handleThumbPointerMove(event: PointerEvent) {
wrap.scrollTop = dragStartScrollTop + ((event.clientY - dragStartY) / thumbRange) * scrollRange
}
function handleHorizontalThumbPointerMove(event: PointerEvent) {
if (!horizontalDragging.value) {
return
}
const wrap = wrapRef.value
if (!wrap) {
return
}
const thumbRange = maxHorizontalThumbLeft(wrap)
if (thumbRange <= 0) {
return
}
const scrollRange = maxScrollLeft(wrap)
wrap.scrollLeft =
horizontalDragStartScrollLeft +
((event.clientX - horizontalDragStartX) / thumbRange) * scrollRange
}
function handleThumbPointerUp() {
if (!dragging.value) {
return
@@ -194,6 +314,15 @@ function handleThumbPointerUp() {
activateScrollbar()
}
function handleHorizontalThumbPointerUp() {
if (!horizontalDragging.value) {
return
}
horizontalDragging.value = false
activateScrollbar()
}
onMounted(async () => {
await nextTick()
update()
@@ -203,6 +332,8 @@ useResizeObserver([wrapRef, viewRef], update)
useScroll(wrapRef, { onScroll: handleScroll })
useEventListener(document, 'pointermove', handleThumbPointerMove)
useEventListener(document, 'pointerup', handleThumbPointerUp)
useEventListener(document, 'pointermove', handleHorizontalThumbPointerMove)
useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
defineExpose({
update,
@@ -212,13 +343,13 @@ defineExpose({
</script>
<template>
<div :class="rootClasses" :style="{ maxHeight }">
<div
ref="wrap"
class="h-full min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent"
@mouseenter="activateScrollbar"
@mouseleave="hideScrollbar"
>
<div
:class="rootClasses"
:style="{ maxHeight }"
@mouseenter="activateScrollbar"
@mouseleave="hideScrollbar"
>
<div ref="wrap" :class="wrapClasses">
<div ref="view" :class="viewClass">
<slot />
</div>
@@ -226,8 +357,9 @@ defineExpose({
<div
v-show="canScrollY"
class="absolute top-1 right-1 bottom-1 z-10 w-1.5 rounded-full transition-opacity duration-200"
:class="scrollbarVisible ? 'opacity-100' : 'opacity-0'"
class="absolute top-1 right-1 z-10 w-1.5 rounded-full transition-opacity duration-200"
:class="[verticalTrackClass, scrollbarVisible ? 'opacity-100' : 'opacity-0']"
@mouseenter="activateScrollbar"
@pointerdown="handleTrackPointerDown"
>
<div
@@ -237,5 +369,20 @@ defineExpose({
@pointerdown="handleThumbPointerDown"
/>
</div>
<div
v-show="canScrollX"
class="absolute bottom-1 left-1 z-10 h-1.5 rounded-full transition-opacity duration-200"
:class="[horizontalTrackClass, scrollbarVisible ? 'opacity-100' : 'opacity-0']"
@mouseenter="activateScrollbar"
@pointerdown="handleHorizontalTrackPointerDown"
>
<div
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
/>
</div>
</div>
</template>
+1 -1
View File
@@ -8,7 +8,7 @@ export interface BaseTableColumn {
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left'
fixed?: 'left' | 'right'
align?: 'left' | 'right' | 'center'
ellipsis?: boolean
emptyText?: string
+26 -7
View File
@@ -95,6 +95,7 @@ const horizontalThumbLeft = shallowRef(0)
const horizontalHovering = shallowRef(false)
const horizontalDragging = shallowRef(false)
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
let horizontalDragStartX = 0
let horizontalDragStartScrollLeft = 0
@@ -108,24 +109,40 @@ const horizontalThumbStyle = computed(() => ({
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
function fixedHeaderClass(column: BaseTableColumn) {
if (column.fixed !== 'left') {
if (!column.fixed) {
return undefined
}
const showShadow =
column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
return [
'sticky left-0 z-30 bg-(--cp-bg-subtle)',
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]' : undefined,
'sticky z-30 bg-(--cp-bg-subtle)',
column.fixed === 'left' ? 'left-0' : 'right-0',
showShadow
? column.fixed === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: undefined,
]
}
function fixedBodyClass(column: BaseTableColumn, row: TableRow, index: number) {
if (column.fixed !== 'left') {
if (!column.fixed) {
return undefined
}
const showShadow =
column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
return [
'sticky left-0 z-20',
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]' : undefined,
'sticky z-20',
column.fixed === 'left' ? 'left-0' : 'right-0',
showShadow
? column.fixed === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: undefined,
isRowSelected(row, index)
? 'bg-(--cp-bg-tertiary)'
: props.stripe && index % 2 === 1
@@ -188,6 +205,7 @@ function updateHorizontalScrollbar() {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
return
}
@@ -198,6 +216,7 @@ function updateHorizontalScrollbar() {
)
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
horizontalScrolled.value = wrap.scrollLeft > 0
horizontalCanScrollRight.value = wrap.scrollLeft < scrollRange - 1
}
function handleTableScroll() {
@@ -456,7 +475,7 @@ function paginationPageClass(page: number) {
</div>
<footer
v-if="pagination && pagination.total > 0"
v-if="pagination"
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
@@ -12,10 +12,14 @@ export type PagerItem = number | 'ellipsis'
const DEFAULT_PAGE_SIZES = [10, 20, 50, 100]
export function getTotalPages(pagination?: BaseTablePagination) {
if (!pagination || pagination.total <= 0) {
if (!pagination) {
return 0
}
if (pagination.total <= 0) {
return 1
}
return clamp(Math.ceil(pagination.total / pagination.pageSize), 1, Number.POSITIVE_INFINITY)
}
+31 -3
View File
@@ -2,9 +2,11 @@
import { useRafFn, useResizeObserver } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
import { init, type EChartsType } from 'echarts/core'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import '@/plugins/echarts'
import { useUiStore } from '@/stores/modules/ui'
const props = withDefaults(
defineProps<{
@@ -20,6 +22,7 @@ const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const pendingInitElement = shallowRef<HTMLElement>()
const { themeRevision } = storeToRefs(useUiStore())
const style = computed(() => ({
height: `${props.height}px`,
@@ -37,7 +40,7 @@ function cancelPendingInit() {
function ensureChart(element: HTMLElement) {
if (chart.value || !elementHasSize(element)) return
chart.value = init(element, undefined, { renderer: 'canvas' })
chart.value.setOption(chartOption.value)
applyOption(chartOption.value)
}
function scheduleInit(element: HTMLElement) {
@@ -60,6 +63,22 @@ function resize() {
chart.value?.resize()
}
function applyOption(option: EChartsOption) {
if (!chart.value) return
chart.value.setOption(option, true)
}
async function recreateChartAfterThemeChange() {
await nextTick()
requestAnimationFrame(() => {
const element = chartElement.value
if (!element) return
dispose()
ensureChart(element)
resize()
})
}
function dispose() {
cancelPendingInit()
chart.value?.dispose()
@@ -71,13 +90,22 @@ watch(
(option) => {
chartOption.value = option
if (chart.value) {
chart.value.setOption(option, true)
applyOption(option)
return
}
if (chartElement.value) {
scheduleInit(chartElement.value)
}
},
{ flush: 'post' },
)
watch(
themeRevision,
() => {
recreateChartAfterThemeChange()
},
{ flush: 'post' },
)
watch(
+2 -2
View File
@@ -6,9 +6,9 @@ import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import {
Cat,
ChartNoAxesColumn,
KeyRound,
LayoutDashboard,
List,
LogOut,
Moon,
PanelLeftClose,
@@ -34,7 +34,7 @@ const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
{ label: '账号管理', icon: Users, path: '/accounts' },
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
{ label: '事件日志', icon: List, path: '/logs' },
{ label: '使用记录', icon: ChartNoAxesColumn, path: '/usage' },
{ label: '系统设置', icon: Settings, path: '/settings' },
]
+3 -3
View File
@@ -1,6 +1,6 @@
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, LegendPlainComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
use([LineChart, PieChart, GridComponent, LegendPlainComponent, TooltipComponent, CanvasRenderer])
+4 -4
View File
@@ -5,7 +5,7 @@ import AccountsView from '@/views/accounts/index.vue'
import ApiKeysView from '@/views/api-keys/index.vue'
import DashboardView from '@/views/dashboard/index.vue'
import LoginView from '@/views/login/index.vue'
import LogsView from '@/views/logs/index.vue'
import UsageView from '@/views/usage/index.vue'
import SettingsView from '@/views/settings/index.vue'
export const routes: RouteRecordRaw[] = [
@@ -34,9 +34,9 @@ export const routes: RouteRecordRaw[] = [
component: ApiKeysView,
},
{
path: 'logs',
name: 'logs',
component: LogsView,
path: 'usage',
name: 'usage',
component: UsageView,
},
{
path: 'settings',
+3
View File
@@ -19,6 +19,7 @@ export const useUiStore = defineStore(
() => {
const sidebarCollapsed = shallowRef(false)
const themeMode = shallowRef<ThemeMode>('system')
const themeRevision = shallowRef(0)
const prefersDark = usePreferredDark()
const preferredMotion = usePreferredReducedMotion()
const systemTheme = computed<ThemeName>(() => (prefersDark.value ? 'dark' : 'light'))
@@ -58,6 +59,7 @@ export const useUiStore = defineStore(
function commitTheme(theme: ThemeName) {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
themeRevision.value += 1
}
function runThemeTransition(theme: ThemeName) {
@@ -145,6 +147,7 @@ export const useUiStore = defineStore(
return {
sidebarCollapsed,
themeMode,
themeRevision,
effectiveTheme,
toggleSidebar,
initializeTheme,
+1 -1
View File
@@ -148,7 +148,7 @@ function planTypeClass(planType?: string) {
<BaseCard
:padded="false"
class="mt-4 flex min-h-0 flex-1 flex-col"
class="mt-4 flex h-[calc(100vh-248px)] min-h-125 flex-col"
header-class="px-4 pt-4 pb-2 md:px-5"
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
>
+3 -2
View File
@@ -72,9 +72,9 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<BaseCard
:padded="false"
class="mt-5 flex min-h-0 flex-1 flex-col"
class="mt-5 flex h-[calc(100vh-136px)] min-h-125 flex-col"
header-class="px-5 pt-4"
body-class="min-h-0 flex-1 px-5 pt-3"
body-class="flex min-h-0 flex-1 px-5 py-3"
>
<template #header>
<ApiKeyFilters
@@ -88,6 +88,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<template #body>
<BaseTable
class="min-h-0 flex-1"
:columns="apiKeyColumns"
:rows="pagedKeys"
:loading="loading"
@@ -1,116 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import BaseTable from '../../../components/base/BaseTable/index.vue'
const props = defineProps<{
rows: any[]
}>()
const activeFilter = ref('all')
const filters = [
{ label: '全部', value: 'all' },
{ label: '错误', value: 'error' },
]
const eventLogColumns = [
{
key: 'time',
label: '时间',
width: 86,
fixed: 'left' as const,
cellClass: 'font-mono text-[12px] font-[650] tabular-nums',
},
{ key: 'level', label: '级别' },
{
key: 'requestId',
label: '请求 ID',
cellClass: 'font-mono text-[12px] font-[650] tabular-nums',
},
{ key: 'route', label: '路由' },
{ key: 'model', label: '模型' },
{
key: 'statusCode',
label: '状态码',
cellClass: 'font-mono text-[12px] font-[650] tabular-nums',
},
{
key: 'latency',
label: '延迟',
cellClass: 'font-mono text-[12px] font-[650] tabular-nums',
},
]
const levelToneClasses: Record<string, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
info: 'bg-(--cp-info-bg) text-(--cp-info-text)',
success: 'bg-(--cp-success-bg) text-(--cp-success-text)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
}
const statusToneClasses: Record<string, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-success-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
function rowTone(row: object) {
return (row as any).tone
}
const filteredRows = computed(() => {
if (activeFilter.value === 'error') return props.rows.filter((row) => row.tone === 'danger')
return props.rows
})
</script>
<template>
<BaseCard
as="article"
variant="dashboard"
title="事件日志"
description="最近 50 条事件"
class="h-87.5 w-full"
>
<template #actions>
<BaseSegmented v-model="activeFilter" :options="filters" class="w-38" />
</template>
<template #body>
<div class="mt-4.25 flex h-60 w-full justify-between overflow-hidden">
<BaseEmpty
v-if="filteredRows.length === 0"
compact
class="w-full place-content-center"
:title="rows.length === 0 ? '暂无事件日志' : '没有匹配日志'"
:description="
rows.length === 0 ? '请求经过代理后会在这里显示最近事件。' : '调整筛选条件后再查看。'
"
/>
<BaseTable
v-else
class="min-w-0 flex-1"
:columns="eventLogColumns"
:rows="filteredRows"
min-width="760px"
>
<template #level="{ row, value }">
<span
class="inline-flex h-6 w-14.5 items-center justify-center rounded-full text-xs leading-[1.15] font-bold"
:class="levelToneClasses[rowTone(row)]"
>{{ value }}</span
>
</template>
<template #statusCode="{ row, value }">
<span class="font-bold" :class="statusToneClasses[rowTone(row)]">{{ value }}</span>
</template>
</BaseTable>
</div>
</template>
</BaseCard>
</template>
@@ -24,7 +24,7 @@ const tabs = [
{ label: '错误', value: '错误' },
]
const activeTab = ref('用量')
const { effectiveTheme } = storeToRefs(useUiStore())
const { themeRevision } = storeToRefs(useUiStore())
const hasSamples = computed(() =>
props.points.some(
@@ -85,7 +85,7 @@ const chartOption = computed<EChartsOption>(() => {
})
function themeColor(name: string, fallback: string) {
effectiveTheme.value
themeRevision.value
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
@@ -224,7 +224,6 @@ function formatLatency(ms: number): string {
<BaseSegmented
v-model="activeTab"
:options="tabs"
:disabled="loading"
class="w-full max-w-61.5 sm:w-61.5"
@update:model-value="emit('trendChange', $event)"
/>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import {
usageAccountText,
usageReasoningEffort,
usageRecordColumns,
usageRecordType,
usageRecordTypeClass,
} from '@/views/usage/constants'
import UsageClientIpCell from '@/views/usage/components/UsageClientIpCell.vue'
import UsageCostCell from '@/views/usage/components/UsageCostCell.vue'
import UsageModelCell from '@/views/usage/components/UsageModelCell.vue'
import UsageTokenCell from '@/views/usage/components/UsageTokenCell.vue'
const props = defineProps<{
rows: any[]
}>()
const activeFilter = shallowRef('all')
const filters = [
{ label: '全部', value: 'all' },
{ label: '错误', value: 'error' },
]
const dashboardUsageRecordColumns = usageRecordColumns.filter((column) => column.key !== 'actions')
const filteredRows = computed(() => {
if (activeFilter.value === 'error') {
return props.rows.filter((row) => row.level === 'error' || Number(row.statusCode || 0) >= 400)
}
return props.rows
})
</script>
<template>
<BaseCard
as="article"
variant="dashboard"
title="使用记录"
description="最近 10 条网关请求"
class="h-87.5 w-full"
>
<template #actions>
<BaseSegmented v-model="activeFilter" :options="filters" class="w-38" />
</template>
<template #body>
<div class="mt-4.25 flex h-60 w-full overflow-hidden">
<BaseTable
class="min-w-0 flex-1"
:columns="dashboardUsageRecordColumns"
:rows="filteredRows"
:empty-text="rows.length === 0 ? '暂无使用记录' : '没有匹配记录'"
min-width="1824px"
>
<template #accountEmail="{ row }">
<span
class="whitespace-nowrap font-mono text-[12px] leading-none font-[720] text-(--cp-text-primary)"
>
{{ usageAccountText(row) }}
</span>
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
<template #model="{ row }">
<UsageModelCell :record="row" />
</template>
<template #reasoningEffort="{ row }">
<span class="whitespace-nowrap text-[12px] font-[720] text-(--cp-text-primary)">
{{ usageReasoningEffort(row) }}
</span>
</template>
<template #recordType="{ row }">
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 text-[12px] leading-none font-bold"
:class="usageRecordTypeClass(row)"
>
{{ usageRecordType(row) }}
</span>
</template>
<template #tokenDetails="{ row }">
<UsageTokenCell :record="row" />
</template>
<template #costDetails="{ row }">
<UsageCostCell :record="row" />
</template>
</BaseTable>
</div>
</template>
</BaseCard>
</template>
@@ -24,7 +24,7 @@ export function useDashboard(): any {
const healthTimeline = ref<any>(emptyDashboardSummary().healthTimeline)
const accountUsage = ref<any[]>([])
const serviceStatuses = ref<any[]>([])
const eventLogs = ref<any[]>([])
const usageRecords = ref<any[]>([])
const poolSummary = ref<any>(null)
const capacityInfo = ref<any>(null)
const rotationStrategy = ref<any>(null)
@@ -85,17 +85,7 @@ export function useDashboard(): any {
healthTimeline.value = summary.healthTimeline
accountUsage.value = summary.accountUsage.map(accountUsageItem)
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
eventLogs.value = (summary.eventLogs as any[]).map((log) => ({
id: log.id,
time: log.time,
level: levelLabel(log.level),
requestId: log.requestId || '-',
route: log.route || '-',
model: log.model || '-',
statusCode: log.statusCode !== undefined ? String(log.statusCode) : '-',
latency: log.latencyMs !== undefined ? formatLatency(log.latencyMs) : '-',
tone: eventTone(log.level, log.statusCode),
}))
usageRecords.value = summary.usageRecords
poolSummary.value = summary.poolSummary
capacityInfo.value = summary.capacityInfo
rotationStrategy.value = summary.rotationStrategy ?? null
@@ -149,7 +139,7 @@ export function useDashboard(): any {
},
accountUsage: [],
serviceStatuses: [],
eventLogs: [],
usageRecords: [],
poolSummary: {
total: 0,
active: 0,
@@ -192,7 +182,7 @@ export function useDashboard(): any {
],
},
{
title: '今日流量',
title: '今日请求',
value: formatNumber(traffic.todayRequests),
icon: Activity,
tone: 'info',
@@ -204,8 +194,8 @@ export function useDashboard(): any {
details: [
{ label: '总请求', value: formatNumber(traffic.totalRequests), tone: 'info' },
{
label: 'RPM / TPM',
value: `${formatNumber(traffic.rpm)} / ${formatTokens(traffic.tpm)}`,
label: '首 Token',
value: formatLatency(cache.firstTokenLatencyMs),
tone: 'info',
},
],
@@ -345,23 +335,6 @@ export function useDashboard(): any {
return 'normal'
}
function eventTone(level: string, statusCode?: number) {
if (level === 'warn') return 'warning'
if (level === 'error' || (typeof statusCode === 'number' && statusCode >= 400)) return 'danger'
if (level === 'debug') return 'normal'
return 'info'
}
function levelLabel(level: string): string {
const labels: Record<string, string> = {
debug: '调试',
info: '信息',
warn: '警告',
error: '错误',
}
return labels[level] ?? level
}
function formatNumber(num: number): string {
if (num >= 1_000_000_000) return `${formatCompact(num / 1_000_000_000)}B`
if (num >= 1_000_000) return `${formatCompact(num / 1_000_000)}M`
@@ -414,7 +387,7 @@ export function useDashboard(): any {
healthTimeline,
accountUsage,
serviceStatuses,
eventLogs,
usageRecords,
poolSummary,
capacityInfo,
rotationStrategy,
+3 -3
View File
@@ -4,7 +4,7 @@ import { RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AccountOverviewCard from './components/AccountOverviewCard.vue'
import EventLogCard from './components/EventLogCard.vue'
import UsageRecordCard from './components/UsageRecordCard.vue'
import MetricCard from './components/MetricCard.vue'
import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue'
import RequestTrendCard from './components/RequestTrendCard.vue'
@@ -21,7 +21,7 @@ const {
healthTimeline,
accountUsage,
serviceStatuses,
eventLogs,
usageRecords,
poolSummary,
capacityInfo,
rotationStrategy,
@@ -83,6 +83,6 @@ const {
<RequestHealthTimelineCard :timeline="healthTimeline" class="mt-6" />
<EventLogCard :rows="eventLogs" class="mt-6" />
<UsageRecordCard :rows="usageRecords" class="mt-6" />
</div>
</template>
@@ -1,86 +0,0 @@
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import LogLevelBadge from './LogLevelBadge.vue'
import LogStatusCodeBadge from './LogStatusCodeBadge.vue'
const open = defineModel<boolean>({ default: false })
defineProps<{
log: any
}>()
</script>
<template>
<BaseModal
v-model="open"
title="日志详情"
description="查看单条事件的请求、状态和元数据。"
variant="info"
width="720px"
>
<div v-if="log" class="flex max-h-[min(70vh,760px)] flex-col gap-4 overflow-hidden">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">时间</label>
<p class="m-0 text-[13px] text-(--cp-text-primary)">
{{ log.createdAtDisplay }}
</p>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">级别</label>
<LogLevelBadge :level="log.level" />
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">请求 ID</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ log.requestId || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">状态码</label>
<LogStatusCodeBadge :status-code="log.statusCode" />
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">路由</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ log.route || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">延迟</label>
<span class="text-[13px] text-(--cp-text-primary)">
{{ log.latencyMs !== undefined ? `${log.latencyMs}ms` : '—' }}
</span>
</div>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">消息</label>
<p
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 text-[13px] text-(--cp-text-primary)"
>
{{ log.message }}
</p>
</div>
<div v-if="log.metadata" class="flex min-h-0 flex-1 flex-col">
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">元数据</label>
<BaseScrollbar
max-height="min(42vh, 420px)"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)"
>{{ JSON.stringify(log.metadata, null, 2) }}</pre
>
</BaseScrollbar>
</div>
</div>
<template #footer>
<BaseButton variant="primary" @click="open = false">关闭</BaseButton>
</template>
</BaseModal>
</template>
@@ -1,90 +0,0 @@
import { clamp } from 'es-toolkit'
import { onMounted, ref, type Ref } from 'vue'
import { clearLogs, getLogs } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
export function useLogsTable(options: {
page: Ref<number>
pageSize: Ref<number>
searchQuery: Ref<string>
filterLevel: Ref<string>
totalLogs: Ref<number>
}) {
const loading = ref(true)
const logs = ref<any[]>([])
const showClearModal = ref(false)
const refreshingList = ref(false)
const clearingLogs = ref(false)
async function loadLogs() {
try {
loading.value = true
const result = await getLogs({
page: options.page.value,
pageSize: options.pageSize.value,
level: options.filterLevel.value || undefined,
search: options.searchQuery.value || undefined,
})
logs.value = result.items
options.pageSize.value = result.page.pageSize ?? options.pageSize.value
options.totalLogs.value = result.page.total ?? result.items.length
options.page.value = result.page.page ?? options.page.value
if (logs.value.length === 0 && options.totalLogs.value > 0 && options.page.value > 1) {
options.page.value = clamp(
result.page.totalPages ?? options.page.value - 1,
1,
Number.POSITIVE_INFINITY,
)
await loadLogs()
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
}
}
async function refreshLogs() {
if (refreshingList.value || loading.value) return
refreshingList.value = true
try {
await withMinimumDuration(loadLogs)
} finally {
refreshingList.value = false
}
}
async function handleClearLogs() {
if (clearingLogs.value) return
try {
clearingLogs.value = true
await clearLogs()
showClearModal.value = false
options.page.value = 1
await loadLogs()
toast.success('日志已清空')
} catch (error: any) {
toast.error(error.message || '清空失败')
} finally {
clearingLogs.value = false
}
}
onMounted(() => {
loadLogs()
})
return {
loading,
logs,
showClearModal,
refreshingList,
clearingLogs,
loadLogs,
refreshLogs,
handleClearLogs,
}
}
-77
View File
@@ -1,77 +0,0 @@
export const logColumns = [
{
key: 'createdAtDisplay',
label: '时间',
width: '176px',
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{ key: 'level', label: '级别', width: '96px', ellipsis: false },
{
key: 'kind',
label: '类型',
width: '128px',
cellClass: 'font-[650] text-(--cp-text-secondary)',
},
{
key: 'requestId',
label: '请求 ID',
minWidth: '220px',
flex: 1.1,
cellClass: 'font-mono text-[12px] font-[650]',
},
{
key: 'route',
label: '路由',
minWidth: '156px',
flex: 0.8,
cellClass: 'font-mono text-[12px] font-[650]',
},
{
key: 'statusCode',
label: '状态',
width: '92px',
align: 'center' as const,
ellipsis: false,
cellClass: 'font-mono text-[12px] font-[650] tabular-nums',
},
{
key: 'latencyMs',
label: '延迟',
width: '96px',
align: 'right' as const,
ellipsis: false,
format: (value?: number | null) =>
value === undefined || value === null ? '—' : `${value} ms`,
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{ key: 'message', label: '消息', minWidth: '260px', flex: 1.35 },
{
key: 'actions',
label: '操作',
width: '92px',
align: 'center' as const,
ellipsis: false,
headerClass: '!px-4',
cellClass: '!px-4',
},
]
export const levelColors: Record<string, { bg: string; text: string }> = {
info: { bg: 'bg-(--cp-info-bg)', text: 'text-(--cp-info-text)' },
warn: { bg: 'bg-(--cp-warning-bg)', text: 'text-(--cp-warning-text)' },
error: { bg: 'bg-(--cp-danger-bg)', text: 'text-(--cp-danger-text)' },
debug: { bg: 'bg-(--cp-bg-subtle)', text: 'text-(--cp-text-secondary)' },
}
export const levelOptions = [
{ label: '全部级别', value: '' },
{ label: '信息', value: 'info' },
{ label: '错误', value: 'error' },
]
export const levelLabels: Record<string, string> = {
debug: '调试',
info: '信息',
warn: '警告',
error: '错误',
}
-136
View File
@@ -1,136 +0,0 @@
<script setup lang="ts">
import { Eye } from '@lucide/vue'
import { ref } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { levelOptions, logColumns } from './constants'
import { useLogDetail } from './composables/useLogDetail'
import { useLogFilters } from './composables/useLogFilters'
import { useLogsTable } from './composables/useLogsTable'
import LogDetailModal from './components/LogDetailModal.vue'
import LogFilters from './components/LogFilters.vue'
import LogLevelBadge from './components/LogLevelBadge.vue'
import LogStatusCodeBadge from './components/LogStatusCodeBadge.vue'
const totalLogs = ref(0)
const {
page,
pageSize,
searchQuery,
filterLevel,
logPagination,
bindLogLoader,
handlePageChange,
handlePageSizeChange,
} = useLogFilters(totalLogs)
const {
loading,
logs,
showClearModal,
refreshingList,
clearingLogs,
loadLogs,
refreshLogs,
handleClearLogs,
} = useLogsTable({
page,
pageSize,
searchQuery,
filterLevel,
totalLogs,
})
const { showDetailModal, selectedLog, handleViewDetail } = useLogDetail()
bindLogLoader(loadLogs)
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
<div>
<h1 class="mt-0 mb-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
事件日志
</h1>
<p class="mt-2.5 mb-0 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
追踪网关请求、上游响应与异常线索。
</p>
</div>
</header>
<BaseCard
:padded="false"
class="mt-5 flex min-h-0 flex-1 flex-col"
header-class="px-5 pt-4"
body-class="min-h-0 flex-1 px-5 pt-3"
>
<template #header>
<LogFilters
v-model:level="filterLevel"
v-model:search="searchQuery"
:clearing="clearingLogs"
:level-options="levelOptions"
:loading="loading"
:refreshing="refreshingList"
@clear="showClearModal = true"
@refresh="refreshLogs"
/>
</template>
<template #body>
<BaseTable
:columns="logColumns"
:rows="logs"
:loading="loading"
:pagination="logPagination"
empty-text="暂无日志记录"
min-width="1280px"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<template #level="{ row }">
<LogLevelBadge :level="row.level" compact />
</template>
<template #statusCode="{ row }">
<LogStatusCodeBadge :status-code="row.statusCode" />
</template>
<template #actions="{ row }">
<div class="flex items-center justify-center">
<BaseButton
icon-only
variant="ghost"
size="sm"
label="查看日志详情"
@click="handleViewDetail(row)"
>
<Eye class="size-3.5" />
</BaseButton>
</div>
</template>
</BaseTable>
</template>
</BaseCard>
<BaseConfirmModal
v-model="showClearModal"
title="确认清空日志"
description="清空后无法恢复,新的代理事件会继续记录。"
variant="danger"
confirm-text="确认清空"
:loading="clearingLogs"
width="480px"
@confirm="handleClearLogs"
>
<p class="m-0">确定要清空所有日志记录吗?此操作不可撤销。</p>
</BaseConfirmModal>
<LogDetailModal v-model="showDetailModal" :log="selectedLog" />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usageClientIp } from '../constants'
const props = defineProps<{
record: any
}>()
const clientIp = computed(() => usageClientIp(props.record))
</script>
<template>
<span
class="inline-flex h-6 max-w-full items-center rounded-full bg-(--cp-info-bg) px-2.5 font-mono text-[12px] leading-none font-bold text-(--cp-info-text)"
:title="clientIp"
>
<span class="min-w-0 truncate">
{{ clientIp }}
</span>
</span>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { Info } from '@lucide/vue'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { usageCostDetails, usageCostText } from '../constants'
const props = defineProps<{
record: any
}>()
const costDetails = computed(() => usageCostDetails(props.record))
const mainCostItems = computed(() => {
const details = costDetails.value
if (!details) return []
return [
{ label: '输入成本', value: details.inputCostDisplay, accent: false },
{ label: '输出成本', value: details.outputCostDisplay, accent: false },
{ label: '输入单价', value: details.inputPriceDisplay, accent: true },
{ label: '输出单价', value: details.outputPriceDisplay, accent: true },
{ label: '缓存读取成本', value: details.cacheReadCostDisplay, accent: false },
]
})
const billingItems = computed(() => {
const details = costDetails.value
if (!details) return []
return [
{ label: '服务档位', value: details.serviceTierDisplay, tone: 'info' },
{ label: '倍率', value: details.multiplierDisplay, tone: 'info' },
{ label: '原始', value: details.originalCostDisplay, tone: 'default' },
{ label: '计费', value: details.billedCostDisplay, tone: 'success' },
]
})
function itemValueClass(tone?: string, accent?: boolean) {
if (tone === 'success') return 'text-(--cp-success-text)'
if (tone === 'info' || accent) return 'text-[#93c5fd]'
return 'text-white'
}
</script>
<template>
<div class="flex items-center justify-end gap-1.5">
<span class="font-mono text-[12px] font-[760] tabular-nums text-(--cp-success-text)">
{{ usageCostText(record) }}
</span>
<BasePopover
v-if="costDetails"
trigger="hover"
placement="right"
width="248px"
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
>
<template #trigger>
<button
type="button"
class="inline-flex size-4 items-center justify-center rounded-full bg-(--cp-info-bg) text-(--cp-info) outline-none hover:bg-(--cp-default-bg-hover)"
aria-label="查看费用明细"
>
<Info class="size-3" />
</button>
</template>
<div class="grid gap-2 text-[12px] leading-none [&_span:first-child]:whitespace-nowrap">
<p class="m-0 font-[760] text-white">成本明细</p>
<div class="grid gap-1.5 text-[#cbd5e1]">
<div v-for="item in mainCostItems" :key="item.label" class="flex justify-between gap-4">
<span>{{ item.label }}</span>
<span class="font-mono font-[760]" :class="itemValueClass(undefined, item.accent)">
{{ item.value }}
</span>
</div>
</div>
<div class="mt-1 grid gap-1.5 border-t border-white/12 pt-2 text-[#cbd5e1]">
<div v-for="item in billingItems" :key="item.label" class="flex justify-between gap-4">
<span>{{ item.label }}</span>
<span class="font-mono font-[760]" :class="itemValueClass(item.tone)">
{{ item.value }}
</span>
</div>
</div>
</div>
</BasePopover>
</div>
</template>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { formatCostMetric, formatUsageMetric } from '../constants'
const props = defineProps<{
title: string
description: string
nameLabel: string
items: any[]
color: string
sourceOptions?: any[]
showCostColumn?: boolean
}>()
const source = defineModel<string>('source', { default: '' })
const sortedItems = computed(() =>
[...(props.items || [])].sort((left, right) => metricValue(right) - metricValue(left)),
)
const totalMetric = computed(() =>
sortedItems.value.reduce((sum, item) => sum + metricValue(item), 0),
)
const tableGridClass = computed(() =>
props.showCostColumn === false
? 'grid-cols-[128px_64px_76px_76px_76px]'
: 'grid-cols-[148px_64px_76px_76px_76px_76px]',
)
const tableWidthClass = computed(() =>
props.showCostColumn === false ? 'min-w-[420px] w-full' : 'min-w-[516px] w-full',
)
const tableMaxHeight = computed(() => {
const rowCount = Math.min(sortedItems.value.length, 4)
return `${32 + rowCount * 46}px`
})
const hasData = computed(() =>
sortedItems.value.some(
(item) => Number(item.requestCount || 0) > 0 || Number(item.totalTokens || 0) > 0,
),
)
const chartColors = computed(() => [
props.color,
'--cp-success',
'--cp-normal',
'--cp-warning',
'--cp-text-tertiary',
])
const donutStyle = computed(() => {
if (!totalMetric.value) {
return {
background: 'var(--cp-bg-muted)',
}
}
let cursor = 0
const stops = sortedItems.value.map((item, index) => {
const start = cursor
const size = (metricValue(item) / totalMetric.value) * 100
cursor += size
const color = `var(${chartColors.value[index % chartColors.value.length]})`
return `${color} ${start}% ${cursor}%`
})
return {
background: `conic-gradient(${stops.join(', ')})`,
}
})
function metricValue(item: any) {
return Number(item.totalTokens || 0)
}
function markerStyle(index: number) {
return {
backgroundColor: `var(${chartColors.value[index % chartColors.value.length]})`,
}
}
function formatCompactUsage(value: number) {
const number = Number(value || 0)
if (number >= 1_000_000_000) return `${trimFixed(number / 1_000_000_000, 1)}B`
if (number >= 1_000_000) return `${trimFixed(number / 1_000_000, 1)}M`
if (number >= 10_000) return `${trimFixed(number / 1_000, 1)}K`
if (number >= 1_000) return `${trimFixed(number / 1_000, 2)}K`
return new Intl.NumberFormat('zh-CN').format(number)
}
function formatCompactCost(value: number) {
const number = Number(value || 0)
if (!number) return '$0'
if (number >= 1_000_000) return `$${trimFixed(number / 1_000_000, 2)}M`
if (number >= 1_000) return `$${trimFixed(number / 1_000, 2)}K`
if (number >= 1) return `$${trimFixed(number, 2)}`
if (number >= 0.01) return `$${trimFixed(number, 4)}`
return `$${number.toFixed(6)}`
}
function trimFixed(value: number, digits: number) {
return value.toFixed(digits).replace(/\.?0+$/, '')
}
</script>
<template>
<BaseCard
:padded="false"
:title="title"
:description="description"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #actions>
<BaseSegmented
v-if="sourceOptions?.length"
v-model="source"
:options="sourceOptions"
class="w-42"
/>
</template>
<template #body>
<div
v-if="hasData"
class="grid h-70 min-h-0 grid-cols-1 gap-4 lg:h-64 lg:grid-cols-[190px_minmax(0,1fr)]"
>
<div class="relative flex h-34 min-h-0 shrink-0 items-center justify-center lg:h-auto">
<div
class="size-34 rounded-full transition-transform duration-200 hover:scale-[1.02] lg:size-42"
:style="donutStyle"
/>
<div class="absolute size-17 rounded-full bg-(--cp-bg-surface) lg:size-21" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="grid text-center">
<span class="text-[11px] font-bold text-(--cp-text-muted)"> Token </span>
<strong
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-(--cp-text-primary) lg:text-[18px]"
:title="formatUsageMetric(totalMetric)"
>
{{ formatCompactUsage(totalMetric) }}
</strong>
</div>
</div>
</div>
<div class="min-h-0 min-w-0 overflow-hidden">
<BaseScrollbar horizontal :max-height="tableMaxHeight" view-class="pb-2 pr-3">
<div class="flex min-h-0 flex-col" :class="tableWidthClass">
<div
class="grid shrink-0 gap-2 px-3 pb-2 text-[12px] font-bold text-(--cp-text-muted)"
:class="tableGridClass"
>
<span>{{ nameLabel }}</span>
<span class="text-right">请求</span>
<span class="text-right">Token</span>
<span class="text-right">实际</span>
<span v-if="showCostColumn !== false" class="text-right">成本</span>
<span class="text-right">标准</span>
</div>
<div class="grid gap-1.5">
<div
v-for="(item, index) in sortedItems"
:key="item.name"
class="grid items-center gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 text-[12px] font-[650]"
:class="tableGridClass"
>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<i class="size-1.75 shrink-0 rounded-full" :style="markerStyle(index)" />
<span class="truncate text-[13px] font-[720] text-(--cp-text-primary)">
{{ item.name || '未知' }}
</span>
</div>
</div>
<span class="text-right font-mono tabular-nums text-(--cp-text-primary)">
{{ formatCompactUsage(item.requestCount) }}
</span>
<span
class="text-right font-mono tabular-nums text-(--cp-text-secondary)"
:title="formatUsageMetric(item.totalTokens)"
>
{{ formatCompactUsage(item.totalTokens) }}
</span>
<span
class="text-right font-mono tabular-nums text-(--cp-success-text)"
:title="item.actualCostDisplay || formatCostMetric(item.actualCost)"
>
{{ formatCompactCost(item.actualCost) }}
</span>
<span
v-if="showCostColumn !== false"
class="text-right font-mono tabular-nums text-(--cp-warning-text)"
:title="item.accountCostDisplay || formatCostMetric(item.accountCost)"
>
{{ formatCompactCost(item.accountCost) }}
</span>
<span
class="text-right font-mono tabular-nums text-(--cp-text-secondary)"
:title="item.costDisplay || formatCostMetric(item.cost)"
>
{{ formatCompactCost(item.cost) }}
</span>
</div>
</div>
</div>
</BaseScrollbar>
</div>
</div>
<BaseEmpty
v-else
compact
plain
title="暂无分布数据"
description="当前筛选范围还没有可聚合的使用记录。"
class="h-70 place-content-center lg:h-64"
/>
</template>
</BaseCard>
</template>
@@ -1,32 +1,31 @@
<script setup lang="ts">
import { RefreshCw, Search, Trash2 } from '@lucide/vue'
import { RefreshCw, Search } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
defineProps<{
levelOptions: any[]
statusOptions: any[]
refreshing: boolean
loading: boolean
clearing: boolean
statusDisabled?: boolean
}>()
const search = defineModel<string>('search', { required: true })
const level = defineModel<string>('level', { required: true })
const status = defineModel<string>('status', { required: true })
const emit = defineEmits<{
refresh: []
clear: []
}>()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="日志筛选">
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="使用记录筛选">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<BaseInput
v-model="search"
placeholder="搜索消息、请求 ID 或路由"
placeholder="搜索请求 ID、端点、模型或消息"
class="min-w-64 flex-1 sm:max-w-96"
>
<template #prefix>
@@ -34,7 +33,12 @@ const emit = defineEmits<{
</template>
</BaseInput>
<BaseSelect v-model="level" :options="levelOptions" class="w-34" />
<BaseSelect
v-model="status"
:options="statusOptions"
:disabled="statusDisabled"
class="w-34"
/>
</div>
<div class="flex shrink-0 items-center gap-2">
@@ -42,19 +46,12 @@ const emit = defineEmits<{
icon-only
variant="ghost"
size="md"
label="刷新日志"
label="刷新使用记录"
:disabled="loading || refreshing"
@click="emit('refresh')"
>
<RefreshCw class="size-4.5" :class="refreshing ? 'animate-spin' : undefined" />
</BaseButton>
<BaseButton variant="danger" :disabled="clearing" @click="emit('clear')">
<template #icon>
<Trash2 class="size-4" />
</template>
清空日志
</BaseButton>
</div>
</div>
</template>
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUiStore } from '@/stores/modules/ui'
import { formatCostMetric } from '../constants'
import UsageDistributionPanel from './UsageDistributionPanel.vue'
const props = defineProps<{
insights: any
loading?: boolean
}>()
const chartHeight = 236
const modelSource = shallowRef('requested')
const endpointSource = shallowRef('inbound')
const { themeRevision } = storeToRefs(useUiStore())
const modelSourceOptions = [
{ label: '请求', value: 'requested' },
{ label: '上游', value: 'upstream' },
{ label: '映射', value: 'mapping' },
]
const endpointSourceOptions = [
{ label: '入站', value: 'inbound' },
{ label: '上游', value: 'upstream' },
{ label: '路径', value: 'path' },
]
const modelItems = computed(() => {
if (modelSource.value === 'upstream') return props.insights?.upstreamModels ?? []
if (modelSource.value === 'mapping') return props.insights?.modelMappings ?? []
return props.insights?.models ?? []
})
const endpointItems = computed(() => {
if (endpointSource.value === 'upstream') return props.insights?.upstreamEndpoints ?? []
if (endpointSource.value === 'path') return props.insights?.endpointPaths ?? []
return props.insights?.endpoints ?? []
})
const trendPoints = computed(() => props.insights?.trend ?? [])
const hasTrend = computed(() =>
trendPoints.value.some(
(point: any) =>
point.totalTokens > 0 ||
point.cacheCreationTokens > 0 ||
point.cachedTokens > 0 ||
point.averageLatencyMs > 0,
),
)
const trendOption = computed<EChartsOption>(() => ({
grid: { left: 8, right: 10, top: 36, bottom: 28 },
legend: {
top: 10,
right: 12,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
data: ['输入', '输出', '缓存写入', '缓存读取', '缓存命中率'],
textStyle: {
color: themeColor('--cp-text-secondary', '#64748B'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 700,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line',
lineStyle: { color: chartPointerLineColor(), type: 'dashed', width: 1 },
},
formatter: formatTooltip,
},
xAxis: {
type: 'category',
data: trendPoints.value.map((point: any) => point.date),
axisLabel: {
color: themeColor('--cp-text-muted', '#94A3B8'),
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
},
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { lineStyle: { color: chartGridLineColor(), width: 1 } },
},
{
type: 'value',
min: 0,
max: 100,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
series: trendSeries(),
}))
const latencyOption = computed<EChartsOption>(() => ({
grid: { left: 8, right: 10, top: 18, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line',
lineStyle: { color: chartPointerLineColor(), type: 'dashed', width: 1 },
},
formatter: formatLatencyTooltip,
},
xAxis: {
type: 'category',
data: trendPoints.value.map((point: any) => point.date),
axisLabel: {
color: themeColor('--cp-text-muted', '#94A3B8'),
fontSize: 10,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
},
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { lineStyle: { color: chartGridLineColor(), width: 1 } },
},
series: [
lineSeries(
'平均延迟',
trendPoints.value.map((point: any) => Number(point.averageLatencyMs || 0)),
themeColor('--cp-warning', '#D97706'),
true,
),
],
}))
function trendSeries() {
return [
lineSeries(
'输入',
trendPoints.value.map((point: any) => point.inputTokens),
themeColor('--cp-info', '#2563EB'),
true,
),
lineSeries(
'输出',
trendPoints.value.map((point: any) => point.outputTokens),
themeColor('--cp-success', '#10B981'),
),
lineSeries(
'缓存写入',
trendPoints.value.map((point: any) => point.cacheCreationTokens),
themeColor('--cp-warning', '#D97706'),
),
lineSeries(
'缓存读取',
trendPoints.value.map((point: any) => point.cachedTokens),
themeColor('--cp-info-text', '#0EA5E9'),
),
lineSeries(
'缓存命中率',
trendPoints.value.map((point: any) => cacheHitRate(point)),
themeColor('--cp-focus-ring', '#8B5CF6'),
false,
1,
),
]
}
function lineSeries(name: string, data: number[], color: string, area = false, yAxisIndex = 0) {
return {
name,
type: 'line' as const,
data,
smooth: true,
symbol: data.length <= 2 ? 'circle' : 'none',
symbolSize: 6,
yAxisIndex,
lineStyle: { color, width: 2.4 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}16` },
{ offset: 1, color: `${color}02` },
],
},
}
: undefined,
}
}
function cacheHitRate(point: any) {
const inputTokens = Number(point.inputTokens || 0)
const cacheCreationTokens = Number(point.cacheCreationTokens || 0)
const cachedTokens = Number(point.cachedTokens || 0)
const promptTokens = inputTokens + cacheCreationTokens + cachedTokens
if (!promptTokens) return 0
return Math.round((cachedTokens / promptTokens) * 100)
}
function formatTooltip(params: unknown) {
const rows = Array.isArray(params) ? params : [params]
const title = tooltipValue(rows[0], 'axisValueLabel')
const point = trendPoints.value[tooltipIndex(rows[0])]
const lines = rows.map((row) => {
const name = tooltipValue(row, 'seriesName')
const value = Number(tooltipValue(row, 'value') || 0)
const marker = tooltipValue(row, 'marker')
const display = name === '缓存命中率' ? `${value}%` : formatCompactNumber(value)
return `${marker}${name}: ${display}`
})
const costLine = point
? `实际: ${point.actualCostDisplay || formatCostMetric(point.actualCost)} | 标准: ${
point.costDisplay || formatCostMetric(point.cost)
}`
: ''
return [title, ...lines, costLine].filter(Boolean).join('<br/>')
}
function formatLatencyTooltip(params: unknown) {
const rows = Array.isArray(params) ? params : [params]
const title = tooltipValue(rows[0], 'axisValueLabel')
const value = Number(tooltipValue(rows[0], 'value') || 0)
return [title, `平均延迟: ${Math.round(value)} ms`].filter(Boolean).join('<br/>')
}
function tooltipValue(source: unknown, key: string): string {
if (typeof source !== 'object' || source === null || !(key in source)) return ''
const value = (source as Record<string, unknown>)[key]
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source)) return -1
const value = (source as Record<string, unknown>).dataIndex
return typeof value === 'number' ? value : -1
}
function formatCompactNumber(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return new Intl.NumberFormat('zh-CN').format(value || 0)
}
function chartGridLineColor() {
return themeColor('--cp-default-border', '#E7EDF5')
}
function chartPointerLineColor() {
return themeColor('--cp-default-border-hover', '#CBD5E1')
}
function themeColor(name: string, fallback: string) {
themeRevision.value
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
</script>
<template>
<section class="mt-5 grid grid-cols-1 gap-3 xl:grid-cols-2" aria-label="使用记录分析">
<UsageDistributionPanel
v-model:source="modelSource"
title="模型分布"
description="按请求模型、上游模型与映射关系聚合"
name-label="模型"
:items="modelItems"
color="--cp-info"
:source-options="modelSourceOptions"
/>
<UsageDistributionPanel
v-model:source="endpointSource"
title="端点分布"
description="按入站端点、上游端点与路径聚合"
name-label="端点"
:items="endpointItems"
color="--cp-normal"
:source-options="endpointSourceOptions"
:show-cost-column="false"
/>
<BaseCard
:padded="false"
title="Token 使用趋势"
description="按日期聚合输入、输出与缓存命中"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #body>
<div class="h-64 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 pt-3 pb-2">
<div v-if="hasTrend" class="flex h-full min-h-50 flex-col">
<BaseChart :option="trendOption" :height="chartHeight" />
</div>
<BaseEmpty
v-else
compact
title="暂无趋势数据"
description="当前筛选范围还没有可绘制的 Token 趋势。"
class="min-h-50 place-content-center bg-transparent"
/>
</div>
</template>
</BaseCard>
<BaseCard
:padded="false"
title="延迟分布"
description="按日期聚合平均响应耗时"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
>
<template #body>
<div class="h-64 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 pt-3 pb-2">
<BaseChart v-if="hasTrend" :option="latencyOption" :height="chartHeight" />
<BaseEmpty
v-else
compact
title="暂无延迟数据"
description="当前时间范围还没有可绘制的延迟趋势。"
class="min-h-50 place-content-center bg-transparent"
/>
</div>
</template>
</BaseCard>
</section>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import { usageModelDisplay } from '../constants'
const props = defineProps<{
record: any
}>()
const modelDisplay = computed(() => usageModelDisplay(props.record))
</script>
<template>
<div class="inline-grid max-w-full gap-1">
<code
class="block max-w-full truncate font-mono text-[12px] leading-none font-[760] text-(--cp-text-primary)"
>
{{ modelDisplay.primary }}
</code>
<div
v-if="modelDisplay.secondary"
class="flex min-w-0 items-center gap-1.25 text-(--cp-text-secondary)"
>
<CornerDownRight class="size-3.25 shrink-0 text-(--cp-info)" stroke-width="2.4" />
<code class="block truncate font-mono text-[11px] leading-none font-[700]">
{{ modelDisplay.secondary }}
</code>
</div>
</div>
</template>
@@ -0,0 +1,422 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUiStore } from '@/stores/modules/ui'
import {
usageAccountText,
usageClientIp,
usageCostDetails,
usageCostText,
usageModelDisplay,
usageReasoningEffort,
usageRecordType,
usageTokenDetails,
usageUserAgent,
visibleRequestText,
visibleResponseText,
} from '../constants'
import UsageLevelBadge from './UsageLevelBadge.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{
record: any
}>()
const { themeRevision } = storeToRefs(useUiStore())
const requestText = computed(() => visibleRequestText(props.record))
const responseText = computed(() => visibleResponseText(props.record))
const modelDisplay = computed(() => usageModelDisplay(props.record))
const tokenDetails = computed(() => usageTokenDetails(props.record))
const costDetails = computed(() => usageCostDetails(props.record))
const identityItems = computed(() => [
{ label: '账号', value: usageAccountText(props.record), mono: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
])
const metricItems = computed(() => [
{ label: '类型', value: usageRecordType(props.record) },
{ label: '耗时', value: props.record?.latencyMsDisplay, mono: true },
{ label: '首 Token', value: props.record?.firstTokenLatencyMsDisplay, mono: true },
{ label: '总 Token', value: tokenDetails.value.totalTokensDisplay, mono: true },
])
const detailGroups = computed(() => [
{
title: '模型与链路',
items: [
{ label: '端点', value: props.record?.route, mono: true },
{ label: '请求模型', value: modelDisplay.value.primary, mono: true },
{
label: '上游模型',
value: modelDisplay.value.secondary || props.record?.upstreamModel,
mono: true,
},
{ label: '存储模型', value: props.record?.model, mono: true },
{ label: '推理强度', value: usageReasoningEffort(props.record) },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
],
},
{
title: '客户端与上游',
items: [
{ label: '客户端 IP', value: usageClientIp(props.record), mono: true },
{ label: 'User-Agent', value: props.record ? usageUserAgent(props.record) : '', mono: true },
{ label: '传输方式', value: props.record?.transport, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: props.record?.attemptIndex },
{ label: '上游状态码', value: props.record?.upstreamStatusCode },
{ label: '失败分类', value: props.record?.failureClass, mono: true },
],
},
])
const costItems = computed(() => {
const details = costDetails.value
if (!details) {
return [{ label: '总费用', value: usageCostText(props.record), mono: true }]
}
return [
{ label: '总费用', value: details.totalCostDisplay, mono: true },
{ label: '输入', value: details.inputCostDisplay, mono: true },
{ label: '输出', value: details.outputCostDisplay, mono: true },
{ label: '缓存读取', value: details.cacheReadCostDisplay, mono: true },
{ label: '计费', value: details.billedCostDisplay, mono: true },
{ label: '原始', value: details.originalCostDisplay, mono: true },
{ label: '输入单价', value: details.inputPriceDisplay, mono: true },
{ label: '输出单价', value: details.outputPriceDisplay, mono: true },
{ label: '缓存单价', value: details.cacheReadPriceDisplay, mono: true },
{ label: '服务层级', value: details.serviceTierDisplay },
{ label: '倍率', value: details.multiplierDisplay, mono: true },
]
})
const tokenChartItems = computed(() => [
{
label: '输入',
value: Number(tokenDetails.value.inputTokens || 0),
display: tokenDetails.value.inputTokensDisplay,
color: themeColor('--cp-info', '#2563EB'),
},
{
label: '输出',
value: Number(tokenDetails.value.outputTokens || 0),
display: tokenDetails.value.outputTokensDisplay,
color: themeColor('--cp-success', '#10B981'),
},
{
label: '缓存读取',
value: Number(tokenDetails.value.cachedTokens || 0),
display: tokenDetails.value.cachedTokensDisplay,
color: themeColor('--cp-warning', '#D97706'),
},
{
label: '推理',
value: Number(tokenDetails.value.reasoningTokens || 0),
display: tokenDetails.value.reasoningTokensDisplay,
color: themeColor('--cp-focus-ring', '#8B5CF6'),
},
])
const tokenDonutOption = computed<EChartsOption>(() => {
const items = tokenChartItems.value.filter((item) => item.value > 0)
return {
tooltip: {
trigger: 'item',
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [9, 12],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
fontFamily: 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
formatter: (params: any) => {
const item = tokenChartItems.value.find((entry) => entry.label === params.name)
if (!item) return ''
return `${params.marker}${item.label}: ${item.display}`
},
},
series: [
{
type: 'pie',
radius: ['62%', '78%'],
center: ['50%', '50%'],
startAngle: 90,
minAngle: items.length ? 3 : 360,
avoidLabelOverlap: true,
silent: !items.length,
label: { show: false },
labelLine: { show: false },
data: items.length
? items.map((item) => ({
name: item.label,
value: item.value,
itemStyle: { color: item.color },
}))
: [
{
name: '暂无',
value: 1,
itemStyle: { color: themeColor('--cp-bg-muted', '#E5E7EB') },
},
],
emphasis: { scale: false },
},
],
}
})
function displayValue(value: unknown) {
if (value === undefined || value === null || value === '') return '—'
return String(value)
}
function themeColor(name: string, fallback: string) {
themeRevision.value
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
</script>
<template>
<BaseModal
v-model="open"
title="使用记录详情"
description="查看单次网关请求的模型、链路、Token、费用与上游响应。"
variant="info"
width="920px"
>
<div v-if="record" class="flex max-h-[min(76vh,820px)] flex-col overflow-hidden">
<BaseScrollbar view-class="grid gap-4 pr-3" max-height="min(76vh,820px)">
<section class="grid gap-3 lg:grid-cols-2">
<section class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3">
<h3 class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">
请求身份
</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-[minmax(0,1fr)_180px]">
<div v-for="item in identityItems" :key="item.label" class="min-w-0">
<dt class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}
</dt>
<dd
class="mt-1.5 mb-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] leading-none font-[700] text-(--cp-text-primary)"
:class="item.mono ? 'font-mono tabular-nums' : undefined"
:title="displayValue(item.value)"
>
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
<section class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3">
<h3 class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">
执行指标
</h3>
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2.5">
<div v-for="item in metricItems" :key="item.label" class="min-w-0">
<dt class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}
</dt>
<dd
class="mt-1.5 mb-0 truncate text-[12px] leading-none font-[700] text-(--cp-text-primary)"
:class="item.mono ? 'font-mono tabular-nums' : undefined"
:title="displayValue(item.value)"
>
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
</section>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5">
<span class="block text-[11px] leading-none font-bold text-(--cp-text-muted)">
结果
</span>
<div class="mt-1.5">
<UsageLevelBadge :level="record.level" />
</div>
</div>
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5">
<span class="block text-[11px] leading-none font-bold text-(--cp-text-muted)">
状态码
</span>
<div class="mt-1.5">
<UsageStatusCodeBadge :status-code="record.statusCode" />
</div>
</div>
<div
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 lg:col-span-2"
>
<span class="block text-[11px] leading-none font-bold text-(--cp-text-muted)">
消息
</span>
<p class="mt-2 mb-0 truncate text-[13px] font-[650] text-(--cp-text-primary)">
{{ displayValue(record.message) }}
</p>
</div>
</section>
<section class="grid gap-3 lg:grid-cols-2">
<section
v-for="group in detailGroups"
:key="group.title"
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3"
>
<h3 class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">
{{ group.title }}
</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-2">
<div v-for="item in group.items" :key="item.label" class="min-w-0">
<dt class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}
</dt>
<dd
class="mt-1.5 mb-0 truncate text-[12px] leading-none font-[700] text-(--cp-text-primary)"
:class="item.mono ? 'font-mono tabular-nums' : undefined"
:title="displayValue(item.value)"
>
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
</section>
<section class="grid gap-3 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<section
class="flex flex-col rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3"
>
<h3 class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">
Token
</h3>
<div
class="mt-3 grid min-h-38 flex-1 grid-cols-1 content-center items-center gap-3 sm:grid-cols-[150px_minmax(0,1fr)]"
>
<div class="relative mx-auto w-38 sm:mx-0">
<BaseChart :option="tokenDonutOption" :height="152" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="grid text-center">
<span class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
总计
</span>
<strong
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-(--cp-text-primary)"
>
{{ tokenDetails.totalTokensDisplay }}
</strong>
</div>
</div>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2.5">
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
<dt
class="flex min-w-0 items-center gap-1.5 text-[11px] leading-none font-bold text-(--cp-text-muted)"
>
<i
class="size-1.75 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</dt>
<dd
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-none font-[700] tabular-nums text-(--cp-text-primary)"
:title="displayValue(item.display)"
>
{{ displayValue(item.display) }}
</dd>
</div>
</dl>
</div>
</section>
<section class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3">
<h3 class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">费用</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-2">
<div v-for="item in costItems" :key="item.label" class="min-w-0">
<dt class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}
</dt>
<dd
class="mt-1.5 mb-0 truncate text-[12px] leading-none font-[700] text-(--cp-text-primary)"
:class="item.mono ? 'font-mono tabular-nums' : undefined"
:title="displayValue(item.value)"
>
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
</section>
<section
v-if="requestText || responseText"
class="grid min-h-0 grid-cols-1 gap-3 lg:grid-cols-2"
>
<div v-if="requestText" class="min-h-0">
<span class="mb-1 block text-[11px] font-bold text-(--cp-text-muted)">请求内容</span>
<BaseScrollbar
max-height="180px"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] text-(--cp-text-primary)"
>{{ requestText }}</pre
>
</BaseScrollbar>
</div>
<div v-if="responseText" class="min-h-0">
<span class="mb-1 block text-[11px] font-bold text-(--cp-text-muted)">响应内容</span>
<BaseScrollbar
max-height="180px"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] text-(--cp-text-primary)"
>{{ responseText }}</pre
>
</BaseScrollbar>
</div>
</section>
<section v-if="record.metadata" class="min-h-0">
<span class="mb-1 block text-[11px] font-bold text-(--cp-text-muted)">元数据</span>
<BaseScrollbar
max-height="min(32vh, 340px)"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)"
>{{ JSON.stringify(record.metadata, null, 2) }}</pre
>
</BaseScrollbar>
</section>
</BaseScrollbar>
</div>
<template #footer>
<BaseButton variant="primary" @click="open = false">关闭</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { Activity, CircleAlert, FileText, Timer } from '@lucide/vue'
import { formatUsageMetric } from '../constants'
const props = defineProps<{
summary: any
}>()
const items = [
{
key: 'requests',
label: '总请求',
icon: Activity,
value: () => formatUsageMetric(props.summary.totalRequests),
detail: () => '筛选范围内',
tone: 'text-(--cp-info-text) bg-(--cp-info-bg)',
},
{
key: 'tokens',
label: '总 Token',
icon: FileText,
value: () => formatUsageMetric(props.summary.totalTokens),
detail: () =>
`输入 ${formatUsageMetric(props.summary.inputTokens)} / 输出 ${formatUsageMetric(
props.summary.outputTokens,
)}`,
tone: 'text-(--cp-success-text) bg-(--cp-success-bg)',
},
{
key: 'errors',
label: '错误请求',
icon: CircleAlert,
value: () => formatUsageMetric(props.summary.errorRequests),
detail: () => errorRateText(props.summary),
tone: 'text-(--cp-danger-text) bg-(--cp-danger-bg)',
},
{
key: 'latency',
label: '平均耗时',
icon: Timer,
value: () => props.summary.averageLatencyMsDisplay || '—',
detail: () => `缓存 ${formatUsageMetric(props.summary.cachedTokens)}`,
tone: 'text-(--cp-normal-text) bg-(--cp-normal-bg)',
},
]
function errorRateText(summary: any) {
if (!summary.totalRequests) {
return '错误率 0%'
}
return `错误率 ${((summary.errorRequests / summary.totalRequests) * 100).toFixed(1)}%`
}
</script>
<template>
<section
class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"
aria-label="使用概览"
>
<article
v-for="item in items"
:key="item.key"
class="flex min-h-23 items-start gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-4 py-3 shadow-(--cp-shadow-card)"
>
<span
class="mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-input-radius-base)"
:class="item.tone"
>
<component :is="item.icon" class="size-4.5" />
</span>
<div class="min-w-0">
<span class="block text-[12px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}
</span>
<strong
class="mt-2 block truncate text-[22px] leading-none font-[800] text-(--cp-text-primary)"
>
{{ item.value() }}
</strong>
<span
class="mt-1.5 block truncate text-[12px] leading-none font-[650] text-(--cp-text-secondary)"
>
{{ item.detail() }}
</span>
</div>
</article>
</section>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { Archive, ArrowDown, ArrowUp, Info } from '@lucide/vue'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { usageTokenDetails } from '../constants'
const props = defineProps<{
record: any
}>()
const tokenDetails = computed(() => usageTokenDetails(props.record))
const tokenItems = computed(() => [
{ label: '输入 Token', value: tokenDetails.value.inputTokensDisplay },
{ label: '输出 Token', value: tokenDetails.value.outputTokensDisplay },
{ label: '缓存读取 Token', value: tokenDetails.value.cachedTokensDisplay },
{ label: '推理 Token', value: tokenDetails.value.reasoningTokensDisplay },
])
</script>
<template>
<div class="flex items-center justify-end gap-1.5">
<div
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1 font-mono text-[12px] leading-none font-bold tabular-nums"
>
<span class="inline-flex items-center gap-1 text-(--cp-success-text)">
<ArrowDown class="size-3" />
{{ tokenDetails.inputTokensDisplay }}
</span>
<span class="inline-flex items-center gap-1 text-(--cp-info)">
<ArrowUp class="size-3" />
{{ tokenDetails.outputTokensDisplay }}
</span>
<span class="col-span-2 inline-flex items-center justify-end gap-1 text-(--cp-info)">
<Archive class="size-3" />
{{ tokenDetails.cachedTokensDisplay }}
</span>
</div>
<BasePopover
trigger="hover"
placement="right"
width="168px"
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
>
<template #trigger>
<button
type="button"
class="inline-flex size-4 items-center justify-center rounded-full bg-(--cp-info-bg) text-(--cp-info) outline-none hover:bg-(--cp-default-bg-hover)"
aria-label="查看 Token 明细"
>
<Info class="size-3" />
</button>
</template>
<div class="grid gap-2 text-[12px] leading-none">
<p class="m-0 font-[760] text-white">Token 明细</p>
<div class="grid gap-1.5 text-[#cbd5e1]">
<div v-for="item in tokenItems" :key="item.label" class="flex justify-between gap-4">
<span>{{ item.label }}</span>
<span class="font-mono font-[760] text-white">
{{ item.value }}
</span>
</div>
</div>
<div class="mt-1 flex justify-between border-t border-white/12 pt-2">
<span class="text-[#cbd5e1]">总 Token</span>
<span class="font-mono font-[760] text-[#93c5fd]">
{{ tokenDetails.totalTokensDisplay }}
</span>
</div>
</div>
</BasePopover>
</div>
</template>
@@ -2,27 +2,27 @@
import { watchDebounced } from '@vueuse/core'
import { computed, ref, type Ref } from 'vue'
export function useLogFilters(totalLogs: Ref<number>) {
export function useUsageFilters(totalRecords: Ref<number>) {
const page = ref(1)
const pageSize = ref(20)
const pageSize = ref(10)
const searchQuery = ref('')
const filterLevel = ref('')
let loadLogs: (() => Promise<void> | void) | undefined
const filterStatus = ref('')
let loadUsageRecords: ((scope?: 'all' | 'table') => Promise<void> | void) | undefined
const logPagination = computed(() => ({
const usagePagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: totalLogs.value,
total: totalRecords.value,
pageSizes: [10, 20, 50, 100],
}))
function bindLogLoader(loader: () => Promise<void> | void) {
loadLogs = loader
function bindUsageRecordLoader(loader: (scope?: 'all' | 'table') => Promise<void> | void) {
loadUsageRecords = loader
}
function requestLoad() {
if (loadLogs) {
void loadLogs()
function requestLoad(scope: 'all' | 'table' = 'table') {
if (loadUsageRecords) {
void loadUsageRecords(scope)
}
}
@@ -38,10 +38,10 @@ export function useLogFilters(totalLogs: Ref<number>) {
}
watchDebounced(
[searchQuery, filterLevel],
[searchQuery, filterStatus],
() => {
page.value = 1
requestLoad()
requestLoad('table')
},
{ debounce: 250 },
)
@@ -50,9 +50,9 @@ export function useLogFilters(totalLogs: Ref<number>) {
page,
pageSize,
searchQuery,
filterLevel,
logPagination,
bindLogLoader,
filterStatus,
usagePagination,
bindUsageRecordLoader,
handlePageChange,
handlePageSizeChange,
}
@@ -1,16 +1,16 @@
import { ref } from 'vue'
import { getLogDetail } from '@/api'
import { getUsageRecordDetail } from '@/api'
import { toast } from '@/components/base/BaseToast'
export function useLogDetail() {
export function useUsageRecordDetail() {
const showDetailModal = ref(false)
const selectedLog = ref<any>(null)
const selectedUsageRecord = ref<any>(null)
async function handleViewDetail(log: any) {
async function handleViewDetail(record: any) {
try {
const detail = await getLogDetail({ id: log.id })
selectedLog.value = detail
const detail = await getUsageRecordDetail({ id: record.id })
selectedUsageRecord.value = detail
showDetailModal.value = true
} catch (error: any) {
toast.error(error.message || '加载详情失败')
@@ -19,7 +19,7 @@ export function useLogDetail() {
return {
showDetailModal,
selectedLog,
selectedUsageRecord,
handleViewDetail,
}
}
@@ -0,0 +1,131 @@
import { clamp } from 'es-toolkit'
import { onMounted, ref, type Ref } from 'vue'
import { getUsageRecordInsights, getUsageRecordSummary, getUsageRecords } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
export function useUsageRecordsTable(options: {
page: Ref<number>
pageSize: Ref<number>
searchQuery: Ref<string>
filterStatus: Ref<string>
timeRangeParams: Ref<Record<string, string>>
totalRecords: Ref<number>
}) {
const loading = ref(true)
const analyticsLoading = ref(true)
const records = ref<any[]>([])
const summary = ref(emptySummary())
const insights = ref(emptyInsights())
const refreshingList = ref(false)
const filterParams = () => ({
level: options.filterStatus.value || undefined,
search: options.searchQuery.value || undefined,
})
async function loadUsageRecords(scope: 'all' | 'table' = 'all') {
try {
loading.value = true
if (scope === 'all') {
analyticsLoading.value = true
}
const globalParams = options.timeRangeParams.value
const tableParams = {
...globalParams,
...filterParams(),
}
const resultPromise = getUsageRecords({
page: options.page.value,
pageSize: options.pageSize.value,
...tableParams,
})
const [result, nextSummary, nextInsights] =
scope === 'all'
? await Promise.all([
resultPromise,
getUsageRecordSummary(globalParams),
getUsageRecordInsights(globalParams),
])
: await Promise.all([
resultPromise,
Promise.resolve(summary.value),
Promise.resolve(insights.value),
])
records.value = result.items
summary.value = nextSummary
insights.value = nextInsights
options.pageSize.value = result.page.pageSize ?? options.pageSize.value
options.totalRecords.value = result.page.total ?? result.items.length
options.page.value = result.page.page ?? options.page.value
if (records.value.length === 0 && options.totalRecords.value > 0 && options.page.value > 1) {
options.page.value = clamp(
result.page.totalPages ?? options.page.value - 1,
1,
Number.POSITIVE_INFINITY,
)
await loadUsageRecords(scope)
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
if (scope === 'all') {
analyticsLoading.value = false
}
}
}
async function refreshUsageRecords() {
if (refreshingList.value || loading.value) return
refreshingList.value = true
try {
await withMinimumDuration(loadUsageRecords)
} finally {
refreshingList.value = false
}
}
onMounted(() => {
loadUsageRecords()
})
return {
loading,
analyticsLoading,
records,
summary,
insights,
refreshingList,
loadUsageRecords,
refreshUsageRecords,
}
}
function emptySummary() {
return {
totalRequests: 0,
errorRequests: 0,
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
averageLatencyMs: null,
}
}
function emptyInsights() {
return {
models: [],
upstreamModels: [],
modelMappings: [],
endpoints: [],
upstreamEndpoints: [],
endpointPaths: [],
types: [],
trend: [],
}
}
+334
View File
@@ -0,0 +1,334 @@
export const usageRecordColumns = [
{
key: 'accountEmail',
label: '账号',
width: '220px',
fixed: 'left' as const,
ellipsis: false,
},
{
key: 'model',
label: '模型',
width: '178px',
ellipsis: false,
},
{
key: 'reasoningEffort',
label: '推理强度',
width: '98px',
ellipsis: false,
cellClass: 'whitespace-nowrap text-[12px] font-[700] text-(--cp-text-primary)',
},
{
key: 'route',
label: '端点',
width: '136px',
ellipsis: false,
cellClass: 'font-mono text-[12px] font-[650]',
},
{
key: 'recordType',
label: '类型',
width: '78px',
align: 'center' as const,
ellipsis: false,
},
{
key: 'tokenDetails',
label: 'TOKEN',
width: '146px',
align: 'right' as const,
ellipsis: false,
},
{
key: 'costDetails',
label: '费用',
width: '132px',
align: 'right' as const,
ellipsis: false,
},
{
key: 'firstTokenLatencyMsDisplay',
label: '首 Token',
width: '104px',
align: 'right' as const,
ellipsis: false,
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{
key: 'latencyMsDisplay',
label: '耗时',
width: '96px',
align: 'right' as const,
ellipsis: false,
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{
key: 'createdAtDisplay',
label: '时间',
width: '190px',
ellipsis: false,
cellClass:
'whitespace-nowrap font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{
key: 'clientIp',
label: 'IP',
width: '146px',
ellipsis: false,
},
{
key: 'userAgent',
label: 'User-Agent',
width: '280px',
ellipsis: false,
cellClass:
'whitespace-normal break-words text-[12px] leading-[1.45] font-[650] text-(--cp-text-secondary)',
},
{
key: 'actions',
label: '操作',
width: '92px',
align: 'center' as const,
ellipsis: false,
fixed: 'right' as const,
headerClass: '!px-4',
cellClass: '!px-4',
},
]
export const levelColors: Record<string, { bg: string; text: string }> = {
info: { bg: 'bg-(--cp-success-bg)', text: 'text-(--cp-success-text)' },
error: { bg: 'bg-(--cp-danger-bg)', text: 'text-(--cp-danger-text)' },
}
export const statusOptions = [
{ label: '全部记录', value: '' },
{ label: '正常', value: 'info' },
{ label: '错误', value: 'error' },
]
export const usageTimeRangeOptions = [
{ label: '今天', value: 'today' },
{ label: '最近 7 天', value: '7d' },
{ label: '最近 30 天', value: '30d' },
{ label: '全部时间', value: 'all' },
]
export const levelLabels: Record<string, string> = {
info: '正常',
error: '错误',
}
export function tokenTotal(record: any) {
const tokenDetails = record?.tokenDetails
if (tokenDetails) {
return numberValue(tokenDetails.totalTokens)
}
const usage = record?.metadata?.usage
if (!usage) {
return 0
}
return (
numberValue(usage.totalTokens) ||
numberValue(usage.inputTokens) + numberValue(usage.outputTokens)
)
}
export function formatTokenCount(value: number) {
if (!value) {
return '—'
}
return new Intl.NumberFormat('zh-CN').format(value)
}
export function formatUsageMetric(value: number) {
return new Intl.NumberFormat('zh-CN').format(value || 0)
}
export function formatLatencyAverage(value: number | null) {
if (value === null || value === undefined) {
return '—'
}
return `${Math.round(value)} ms`
}
export function formatCostMetric(value: number) {
if (!value) {
return '$0.000000'
}
return `$${Number(value || 0).toFixed(6)}`
}
export function usageRecordType(record: any) {
if (record?.transport === 'websocket') {
return 'WS'
}
if (record?.metadata?.stream === true || record?.transport === 'http_sse') {
return 'SSE'
}
if (record?.metadata?.stream === false) {
return 'HTTP'
}
return record?.metadata?.apiKind === 'chat' ? 'Chat' : 'HTTP'
}
export function usageRecordTypeClass(record: any) {
const type = usageRecordType(record)
if (type === 'WS') return 'bg-(--cp-info-bg) text-(--cp-info-text)'
if (type === 'SSE') return 'bg-(--cp-success-bg) text-(--cp-success-text)'
if (type === 'Chat') return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
}
export function usageAccountText(record: any) {
return record?.accountEmail || '—'
}
export function usageClientIp(record: any) {
return record?.clientIp || record?.metadata?.clientIp || '—'
}
export function usageUserAgent(record: any) {
return record?.userAgent || record?.metadata?.userAgent || '—'
}
export function usageReasoningEffort(record: any) {
return record?.reasoningEffort || record?.metadata?.reasoningEffort || '—'
}
export function usageModelDisplay(record: any) {
const requestedModel = record?.requestedModel || record?.metadata?.requestedModel || ''
const upstreamModel = record?.upstreamModel || record?.metadata?.upstreamModel || ''
const storedModel = record?.model || ''
const primary = requestedModel || storedModel || upstreamModel || '—'
const secondary =
upstreamModel && upstreamModel !== primary
? upstreamModel
: requestedModel && storedModel && storedModel !== requestedModel
? storedModel
: ''
return { primary, secondary }
}
export function usageTokenDetails(record: any) {
const details = record?.tokenDetails
if (details) {
return details
}
const usage = record?.metadata?.usage || {}
const inputTokens = numberValue(usage.inputTokens)
const outputTokens = numberValue(usage.outputTokens)
const cachedTokens = numberValue(usage.cachedTokens)
const reasoningTokens = numberValue(usage.reasoningTokens)
const totalTokens = numberValue(usage.totalTokens) || inputTokens + outputTokens
return {
inputTokens,
outputTokens,
cachedTokens,
reasoningTokens,
totalTokens,
inputTokensDisplay: formatTokenCount(inputTokens),
outputTokensDisplay: formatTokenCount(outputTokens),
cachedTokensDisplay: formatCompactTokenCount(cachedTokens),
reasoningTokensDisplay: formatTokenCount(reasoningTokens),
totalTokensDisplay: formatTokenCount(totalTokens),
}
}
export function usageCostDetails(record: any) {
return record?.costDetails || null
}
export function usageCostText(record: any) {
return usageCostDetails(record)?.totalCostDisplay || '—'
}
function formatCompactTokenCount(value: number) {
if (!value) return '—'
if (value < 1000) return formatTokenCount(value)
if (value < 1_000_000) return `${Number((value / 1000).toFixed(value >= 100_000 ? 0 : 1))}K`
return `${Number((value / 1_000_000).toFixed(value >= 100_000_000 ? 0 : 1))}M`
}
export function visibleRequestText(record: any) {
const body = record?.metadata?.requestBody
if (!body) {
return ''
}
return extractInputText(body) || JSON.stringify(body, null, 2)
}
export function visibleResponseText(record: any) {
const body = record?.metadata?.responseBody
if (!body) {
return ''
}
if (typeof body === 'string') {
return body
}
return body.output_text || extractOutputText(body) || JSON.stringify(body, null, 2)
}
function numberValue(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function extractInputText(body: any) {
const input = body?.input
if (typeof input === 'string') {
return input
}
if (!Array.isArray(input)) {
return ''
}
return input
.flatMap((item) => {
if (typeof item?.content === 'string') {
return [item.content]
}
if (!Array.isArray(item?.content)) {
return []
}
return item.content
.filter((part: any) => part?.type === 'input_text' && typeof part.text === 'string')
.map((part: any) => part.text)
})
.filter(Boolean)
.join('\n')
}
function extractOutputText(body: any) {
if (!Array.isArray(body?.output)) {
return ''
}
return body.output
.flatMap((item: any) =>
Array.isArray(item?.content)
? item.content
.filter((part: any) => typeof part?.text === 'string')
.map((part: any) => part.text)
: [],
)
.filter(Boolean)
.join('\n')
}
+221
View File
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { CalendarDays, Eye } from '@lucide/vue'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import {
statusOptions,
usageTimeRangeOptions,
usageAccountText,
usageReasoningEffort,
usageRecordColumns,
usageRecordType,
usageRecordTypeClass,
} from './constants'
import { useUsageRecordDetail } from './composables/useUsageRecordDetail'
import { useUsageFilters } from './composables/useUsageFilters'
import { useUsageRecordsTable } from './composables/useUsageRecordsTable'
import UsageClientIpCell from './components/UsageClientIpCell.vue'
import UsageCostCell from './components/UsageCostCell.vue'
import UsageFilters from './components/UsageFilters.vue'
import UsageInsightsGrid from './components/UsageInsightsGrid.vue'
import UsageModelCell from './components/UsageModelCell.vue'
import UsageRecordDetailModal from './components/UsageRecordDetailModal.vue'
import UsageSummaryCards from './components/UsageSummaryCards.vue'
import UsageTokenCell from './components/UsageTokenCell.vue'
const totalRecords = ref(0)
const timeRange = ref('7d')
const timeRangeParams = computed<Record<string, string>>(() => {
const now = dayjs()
if (timeRange.value === 'today') {
return {
startTime: now.startOf('day').toISOString(),
endTime: now.toISOString(),
}
}
if (timeRange.value === '30d') {
return {
startTime: now.subtract(29, 'day').startOf('day').toISOString(),
endTime: now.toISOString(),
}
}
if (timeRange.value === 'all') {
return {} as Record<string, string>
}
return {
startTime: now.subtract(6, 'day').startOf('day').toISOString(),
endTime: now.toISOString(),
}
})
const {
page,
pageSize,
searchQuery,
filterStatus,
usagePagination,
bindUsageRecordLoader,
handlePageChange,
handlePageSizeChange,
} = useUsageFilters(totalRecords)
const {
loading,
analyticsLoading,
records,
summary,
insights,
refreshingList,
loadUsageRecords,
refreshUsageRecords,
} = useUsageRecordsTable({
page,
pageSize,
searchQuery,
filterStatus,
timeRangeParams,
totalRecords,
})
const { showDetailModal, selectedUsageRecord, handleViewDetail } = useUsageRecordDetail()
bindUsageRecordLoader(loadUsageRecords)
watch(timeRange, () => {
page.value = 1
void loadUsageRecords('all')
})
</script>
<template>
<div class="w-full">
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
<div>
<h1 class="mt-0 mb-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
使用记录
</h1>
<p class="mt-2.5 mb-0 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
查看网关请求的模型、端点、Token 与上游响应状态。
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<CalendarDays class="size-4 text-(--cp-text-muted)" />
<BaseSelect v-model="timeRange" :options="usageTimeRangeOptions" class="w-34" />
</div>
</header>
<UsageSummaryCards :summary="summary" />
<UsageInsightsGrid :insights="insights" :loading="analyticsLoading" />
<BaseCard
:padded="false"
class="mt-5 flex min-h-155 flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-[520px] flex-1 px-5 pt-3 pb-4"
>
<template #header>
<div class="grid gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">
请求明细
</h2>
<p
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>
按请求查看网关调用、上游状态与 Token 消耗。
</p>
</div>
</div>
<UsageFilters
v-model:status="filterStatus"
v-model:search="searchQuery"
:status-options="statusOptions"
:loading="loading"
:refreshing="refreshingList"
@refresh="refreshUsageRecords"
/>
</div>
</template>
<template #body>
<BaseTable
class="min-h-0 flex-1"
:columns="usageRecordColumns"
:rows="records"
:loading="loading"
:pagination="usagePagination"
empty-text="暂无使用记录"
min-width="1920px"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<template #accountEmail="{ row }">
<span
class="whitespace-nowrap font-mono text-[12px] leading-none font-[720] text-(--cp-text-primary)"
>
{{ usageAccountText(row) }}
</span>
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
<template #model="{ row }">
<UsageModelCell :record="row" />
</template>
<template #reasoningEffort="{ row }">
<span class="whitespace-nowrap text-[12px] font-[720] text-(--cp-text-primary)">
{{ usageReasoningEffort(row) }}
</span>
</template>
<template #recordType="{ row }">
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 text-[12px] leading-none font-bold"
:class="usageRecordTypeClass(row)"
>
{{ usageRecordType(row) }}
</span>
</template>
<template #tokenDetails="{ row }">
<UsageTokenCell :record="row" />
</template>
<template #costDetails="{ row }">
<UsageCostCell :record="row" />
</template>
<template #actions="{ row }">
<div class="flex items-center justify-center">
<BaseButton
icon-only
variant="ghost"
size="sm"
label="查看使用记录详情"
@click="handleViewDetail(row)"
>
<Eye class="size-3.5" />
</BaseButton>
</div>
</template>
</BaseTable>
</template>
</BaseCard>
<UsageRecordDetailModal v-model="showDetailModal" :record="selectedUsageRecord" />
</div>
</template>
+7 -2
View File
@@ -4,7 +4,7 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
function shouldIgnoreRolldownLog(log: { code?: string; id?: string; loc?: { file?: string } }) {
const source = String(log.id ?? log.loc?.file ?? '')
const source = `${String(log.id ?? '')} ${String(log.loc?.file ?? '')} ${JSON.stringify(log)}`
return log.code === 'INVALID_ANNOTATION' && source.includes('@vueuse/core')
}
@@ -29,6 +29,7 @@ export default defineConfig({
build: {
outDir: 'dist',
assetsDir: 'assets',
chunkSizeWarningLimit: 600,
rolldownOptions: {
onLog(level, log, handler) {
if (level === 'warn' && shouldIgnoreRolldownLog(log)) {
@@ -42,7 +43,11 @@ export default defineConfig({
groups: [
{
name: 'echarts',
test: /node_modules\/echarts|node_modules\/zrender/,
test: /node_modules\/echarts/,
},
{
name: 'zrender',
test: /node_modules\/zrender/,
},
],
},