mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: add usage records analytics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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/>管理控制台静态资源]
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "a_used_by_account),
|
||||
service_statuses: service_status_data(&state),
|
||||
event_logs: dashboard_logs,
|
||||
usage_records: dashboard_usage_records,
|
||||
pool_summary,
|
||||
capacity_info: AccountCapacityDiagnostics::from(capacity),
|
||||
rotation_strategy: Some(settings.auth.rotation_strategy.clone()),
|
||||
@@ -421,10 +418,10 @@ fn dashboard_trend_data_at(
|
||||
if record.bucket_start < start || record.bucket_start > now {
|
||||
continue;
|
||||
}
|
||||
let log_hour = china_hour_start(record.bucket_start);
|
||||
let record_hour = china_hour_start(record.bucket_start);
|
||||
if let Some((_, bucket)) = buckets
|
||||
.iter_mut()
|
||||
.find(|(bucket_start, _)| *bucket_start == log_hour)
|
||||
.find(|(bucket_start, _)| *bucket_start == record_hour)
|
||||
{
|
||||
apply_bucket(bucket, record);
|
||||
}
|
||||
@@ -487,10 +484,10 @@ fn dashboard_health_timeline_data_at(
|
||||
if record.bucket_start < start || record.bucket_start > now {
|
||||
continue;
|
||||
}
|
||||
let log_slot = china_quarter_hour_start(record.bucket_start);
|
||||
let record_slot = china_quarter_hour_start(record.bucket_start);
|
||||
if let Some((_, bucket)) = buckets
|
||||
.iter_mut()
|
||||
.find(|(bucket_start, _)| *bucket_start == log_slot)
|
||||
.find(|(bucket_start, _)| *bucket_start == record_slot)
|
||||
{
|
||||
apply_bucket(bucket, record);
|
||||
}
|
||||
@@ -798,19 +795,6 @@ fn account_usage_data(
|
||||
rows
|
||||
}
|
||||
|
||||
fn dashboard_event_log_data(log: &EventLog) -> DashboardEventLogData {
|
||||
DashboardEventLogData {
|
||||
id: log.id.clone(),
|
||||
time: china_time(&log.created_at),
|
||||
level: log.level,
|
||||
request_id: log.request_id.clone(),
|
||||
route: log.route.clone(),
|
||||
model: log.model.clone(),
|
||||
status_code: log.status_code,
|
||||
latency_ms: log.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
async fn account_quota_used_percent_by_id(
|
||||
state: &AppState,
|
||||
usage_records: &[AdminUsageRecord],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+30
-30
@@ -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(¤t).await.unwrap();
|
||||
|
||||
let start_time = (now - Duration::hours(1)).to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||
let end_time = (now + Duration::hours(1)).to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||
let list_uri = format!(
|
||||
"/api/admin/usage/records?page=1&pageSize=20&startTime={start_time}&endTime={end_time}"
|
||||
);
|
||||
let list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(list_uri)
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let list_body = response_json(list).await;
|
||||
assert_eq!(list_body["data"]["page"]["total"], 1);
|
||||
assert_eq!(list_body["data"]["items"][0]["id"], "usage_inside_range");
|
||||
|
||||
let summary_uri =
|
||||
format!("/api/admin/usage/records/summary?startTime={start_time}&endTime={end_time}");
|
||||
let summary = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(summary_uri)
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response_json(summary).await["data"]["totalRequests"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_usage_records_should_filter_and_cursor_page_events() {
|
||||
let (app, store, _dir) = admin_usage_records_test_app("admin-usage-records.sqlite").await;
|
||||
let mut matching = UsageRecord::new("request", UsageRecordLevel::Error, "upstream timeout");
|
||||
matching.id = "usage_matching".to_string();
|
||||
matching.route = Some("/v1/responses".to_string());
|
||||
store.append(&matching).await.unwrap();
|
||||
store
|
||||
.append(&UsageRecord::new(
|
||||
"request",
|
||||
UsageRecordLevel::Info,
|
||||
"upstream timeout",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.append(&UsageRecord::new(
|
||||
"account",
|
||||
UsageRecordLevel::Error,
|
||||
"upstream timeout",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/usage/records?kind=request&level=error&search=timeout&limit=1")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_usage_records_filter")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["data"]["items"][0]["id"], "usage_matching");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_usage_records_should_reject_unsupported_level_filter() {
|
||||
let (app, _store, _dir) =
|
||||
admin_usage_records_test_app("admin-usage-records-invalid-level.sqlite").await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/usage/records?level=verbose")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_usage_records_invalid_level")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_usage_records_should_return_detail_and_clear_events() {
|
||||
let (app, store, _dir) = admin_usage_records_test_app("admin-usage-records-state.sqlite").await;
|
||||
let mut event = UsageRecord::new("request", UsageRecordLevel::Warn, "detail");
|
||||
event.id = "usage_detail".to_string();
|
||||
event.request_id = Some("req_upstream".to_string());
|
||||
store.append(&event).await.unwrap();
|
||||
|
||||
let detail = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/usage/records/detail?id=usage_detail")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail.status(), StatusCode::OK);
|
||||
|
||||
let cleared = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/usage/records/delete")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response_json(cleared).await["data"]["cleared"], 1);
|
||||
|
||||
let empty = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/usage/records?limit=50")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response_json(empty).await["data"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_usage_records_detail_should_return_not_found_for_missing_event() {
|
||||
let (app, _store, _dir) =
|
||||
admin_usage_records_test_app("admin-usage-records-detail-missing.sqlite").await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/usage/records/detail?id=missing")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_usage_records_missing")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
async fn admin_usage_records_test_app(
|
||||
db_name: &str,
|
||||
) -> (axum::Router, SqliteUsageRecordStore, tempfile::TempDir) {
|
||||
let (app, store, _pool, dir) = admin_usage_records_test_app_with_pool(db_name).await;
|
||||
(app, store, dir)
|
||||
}
|
||||
|
||||
async fn admin_usage_records_test_app_with_pool(
|
||||
db_name: &str,
|
||||
) -> (
|
||||
axum::Router,
|
||||
SqliteUsageRecordStore,
|
||||
SqlitePool,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
admin_usage_records_test_app_with_config(db_name, |_| {}).await
|
||||
}
|
||||
|
||||
async fn admin_usage_records_test_app_with_config(
|
||||
db_name: &str,
|
||||
configure: impl FnOnce(&mut AppConfig),
|
||||
) -> (
|
||||
axum::Router,
|
||||
SqliteUsageRecordStore,
|
||||
SqlitePool,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join(db_name);
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
seed_admin_session(&pool, "session_1").await;
|
||||
let mut config = test_config(url);
|
||||
configure(&mut config);
|
||||
let stores = BackgroundTaskStores {
|
||||
accounts: SqliteAccountStore::new(pool.clone()),
|
||||
admin_sessions: SqliteAdminSessionStore::new(pool.clone()),
|
||||
cookies: SqliteCookieStore::new(pool.clone()),
|
||||
fingerprints: FingerprintRepository::new(pool.clone()),
|
||||
session_affinity: SqliteSessionAffinityStore::new(pool.clone()),
|
||||
refresh_leases: RefreshLeaseStore::new(pool.clone()),
|
||||
client_keys: SqliteClientKeyStore::new(pool.clone()),
|
||||
usage_records: SqliteUsageRecordStore::new(pool.clone()),
|
||||
};
|
||||
let fingerprint = crate::support::fingerprint::test_fingerprint();
|
||||
let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint));
|
||||
let state = AppState {
|
||||
config,
|
||||
services: (*services).clone(),
|
||||
};
|
||||
(
|
||||
codex_proxy_rs::http::router::router().with_state(state),
|
||||
SqliteUsageRecordStore::new(pool.clone()),
|
||||
pool,
|
||||
dir,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 更小。
|
||||
|
||||
后续继续优化时,必须按本文件的目标目录和落地顺序推进。
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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: '错误',
|
||||
}
|
||||
@@ -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>
|
||||
+13
-16
@@ -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>
|
||||
+16
-16
@@ -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,
|
||||
}
|
||||
+7
-7
@@ -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: [],
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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/,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user