feat: refine usage analytics workflow

feat: refine usage analytics workflow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-29 13:18:01 +08:00
co-authored by Codex
parent d3a4b92d3f
commit 2db11c948e
24 changed files with 1211 additions and 591 deletions
+1 -5
View File
@@ -265,11 +265,7 @@ pub(crate) async fn dashboard_summary(
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,
);
let dashboard_usage_records = usage_record_items(recent_usage_records, &account_emails);
Ok(AdminResponse::new(
StatusCode::OK,
+131 -101
View File
@@ -187,12 +187,38 @@ impl SqliteUsageRecordStore {
usage_summary(&self.pool, &filter).await
}
/// 聚合使用记录分析数据。
pub async fn insights(
/// 按模型来源聚合使用记录分布。
pub async fn model_distribution(
&self,
filter: UsageRecordFilter,
) -> SqliteUsageRecordStoreResult<UsageRecordInsights> {
usage_insights(&self.pool, &filter).await
source: UsageRecordModelSource,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordBreakdown>> {
usage_model_distribution(&self.pool, &filter, source, 8).await
}
/// 按端点来源聚合使用记录分布。
pub async fn endpoint_distribution(
&self,
filter: UsageRecordFilter,
source: UsageRecordEndpointSource,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordBreakdown>> {
usage_endpoint_distribution(&self.pool, &filter, source, 8).await
}
/// 聚合 Token 使用趋势。
pub async fn token_trend(
&self,
filter: UsageRecordFilter,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordTrendPoint>> {
usage_trend(&self.pool, &filter).await
}
/// 聚合延迟趋势。
pub async fn latency_trend(
&self,
filter: UsageRecordFilter,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordTrendPoint>> {
usage_trend(&self.pool, &filter).await
}
/// 清空使用记录。
@@ -483,88 +509,60 @@ fn nonnegative_i64(value: Option<i64>) -> u64 {
value.unwrap_or_default().max(0).cast_unsigned()
}
async fn usage_insights(
async fn usage_model_distribution(
pool: &SqlitePool,
filter: &UsageRecordFilter,
) -> SqliteUsageRecordStoreResult<UsageRecordInsights> {
Ok(UsageRecordInsights {
models: usage_breakdown(
pool,
filter,
"coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(model), ''), '未知模型')",
source: UsageRecordModelSource,
limit: u32,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordBreakdown>> {
let (group_expr, group_alias) = match source {
UsageRecordModelSource::Requested => (
"coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), ''), '未知模型')",
"requested_model",
8,
)
.await?,
upstream_models: usage_model_breakdown(pool, filter, 8).await?,
model_mappings: usage_breakdown(
pool,
filter,
),
UsageRecordModelSource::Upstream => (
"coalesce(
nullif(trim(json_extract(usage_records.metadata_json, '$.upstreamModel')), ''),
nullif(trim(json_extract(usage_records.metadata_json, '$.requestedModel')), ''),
nullif(trim(usage_records.model), ''),
'未知模型'
)",
"model",
),
UsageRecordModelSource::Mapping => (
"case
when coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), '')) is not null
then coalesce(nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), ''), '未知模型')
|| ' -> ' ||
coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''), nullif(trim(alias.value), ''), nullif(trim(usage_records.model), ''), '未知模型')
coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''), nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''), nullif(trim(usage_records.model), ''), '未知模型')
else '未知模型'
end",
"model_mapping",
8,
)
.await?,
endpoints: usage_breakdown(
pool,
filter,
"coalesce(nullif(route, ''), '未知端点')",
"endpoint",
8,
)
.await?,
upstream_endpoints: usage_breakdown(
pool,
filter,
"coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')",
"upstream_endpoint",
8,
)
.await?,
endpoint_paths: usage_breakdown(
pool,
filter,
"coalesce(nullif(route, ''), '未知端点') || ' -> ' || coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')",
"endpoint_path",
8,
)
.await?,
types: usage_breakdown(
pool,
filter,
"case when json_extract(metadata_json, '$.stream') = 1 then '流式' when json_extract(metadata_json, '$.stream') = 0 then '普通' else coalesce(nullif(transport, ''), '未知') end",
"record_type",
8,
)
.await?,
trend: usage_trend(pool, filter).await?,
})
),
};
usage_breakdown(pool, filter, group_expr, group_alias, limit).await
}
async fn usage_model_breakdown(
async fn usage_endpoint_distribution(
pool: &SqlitePool,
filter: &UsageRecordFilter,
source: UsageRecordEndpointSource,
limit: u32,
) -> SqliteUsageRecordStoreResult<Vec<UsageRecordBreakdown>> {
usage_breakdown(
pool,
filter,
"coalesce(
nullif(trim(json_extract(usage_records.metadata_json, '$.upstreamModel')), ''),
nullif(trim(alias.value), ''),
nullif(trim(usage_records.model), ''),
'未知模型'
)",
"model",
limit,
)
.await
let (group_expr, group_alias) = match source {
UsageRecordEndpointSource::Inbound => {
("coalesce(nullif(route, ''), '未知端点')", "endpoint")
}
UsageRecordEndpointSource::Upstream => (
"coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')",
"upstream_endpoint",
),
UsageRecordEndpointSource::Path => (
"coalesce(nullif(route, ''), '未知端点') || ' -> ' || coalesce(nullif(trim(json_extract(metadata_json, '$.upstreamEndpoint')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamRoute')), ''), nullif(trim(json_extract(metadata_json, '$.upstreamPath')), ''), nullif(route, ''), '未知端点')",
"endpoint_path",
),
};
usage_breakdown(pool, filter, group_expr, group_alias, limit).await
}
async fn usage_breakdown(
@@ -585,15 +583,13 @@ async fn usage_breakdown(
sum(coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer), cast(json_extract(metadata_json, '$.cachedTokens') as integer), 0)) as cached_tokens,
coalesce(
nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''),
nullif(trim(alias.value), ''),
nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''),
nullif(trim(usage_records.model), ''),
'未知模型'
) as billing_model,
coalesce(nullif(trim(json_extract(metadata_json, '$.billingServiceTier')), ''), nullif(trim(json_extract(metadata_json, '$.serviceTier')), ''), nullif(trim(json_extract(metadata_json, '$.usage.serviceTier')), '')) as service_tier,
avg(case when latency_ms > 0 then latency_ms else null end) as average_latency_ms
from usage_records
left join runtime_settings on runtime_settings.id = 1
left join json_each(runtime_settings.model_aliases_json) as alias on alias.key = usage_records.model",
from usage_records",
);
push_filter(&mut builder, filter, None)?;
builder.push(" group by ");
@@ -677,15 +673,13 @@ async fn usage_trend(
coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer), cast(json_extract(metadata_json, '$.cachedTokens') as integer), 0) as cached_tokens,
coalesce(
nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''),
nullif(trim(alias.value), ''),
nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''),
nullif(trim(usage_records.model), ''),
'未知模型'
) as billing_model,
coalesce(nullif(trim(json_extract(metadata_json, '$.billingServiceTier')), ''), nullif(trim(json_extract(metadata_json, '$.serviceTier')), ''), nullif(trim(json_extract(metadata_json, '$.usage.serviceTier')), '')) as service_tier,
latency_ms
from usage_records
left join runtime_settings on runtime_settings.id = 1
left join json_each(runtime_settings.model_aliases_json) as alias on alias.key = usage_records.model",
from usage_records",
);
push_filter(&mut builder, filter, None)?;
builder.push(" order by date asc, created_at asc");
@@ -1010,25 +1004,26 @@ pub struct UsageRecordSummary {
pub average_latency_ms: Option<f64>,
}
/// 使用记录分析。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UsageRecordInsights {
/// 模型分布。
pub models: Vec<UsageRecordBreakdown>,
/// 上游模型分布。
pub upstream_models: Vec<UsageRecordBreakdown>,
/// 模型映射分布。
pub model_mappings: Vec<UsageRecordBreakdown>,
/// 端点分布。
pub endpoints: Vec<UsageRecordBreakdown>,
/// 上游端点分布。
pub upstream_endpoints: Vec<UsageRecordBreakdown>,
/// 端点路径分布。
pub endpoint_paths: Vec<UsageRecordBreakdown>,
/// 请求类型分布。
pub types: Vec<UsageRecordBreakdown>,
/// Token 趋势。
pub trend: Vec<UsageRecordTrendPoint>,
/// 模型分布来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageRecordModelSource {
/// 客户端请求模型。
Requested,
/// 实际上游模型。
Upstream,
/// 请求模型到上游模型的映射。
Mapping,
}
/// 端点分布来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageRecordEndpointSource {
/// 入站端点。
Inbound,
/// 上游端点。
Upstream,
/// 入站到上游路径映射。
Path,
}
/// 使用记录分布项。
@@ -1206,13 +1201,48 @@ impl AdminUsageRecordService {
.map_err(|_| AdminUsageRecordError::List)
}
/// 聚合使用记录分析数据。
pub async fn insights(
/// 按模型来源聚合使用记录分布。
pub async fn model_distribution(
&self,
filter: AdminUsageRecordFilter,
) -> Result<UsageRecordInsights, AdminUsageRecordError> {
source: UsageRecordModelSource,
) -> Result<Vec<UsageRecordBreakdown>, AdminUsageRecordError> {
self.store
.insights(filter.into())
.model_distribution(filter.into(), source)
.await
.map_err(|_| AdminUsageRecordError::List)
}
/// 按端点来源聚合使用记录分布。
pub async fn endpoint_distribution(
&self,
filter: AdminUsageRecordFilter,
source: UsageRecordEndpointSource,
) -> Result<Vec<UsageRecordBreakdown>, AdminUsageRecordError> {
self.store
.endpoint_distribution(filter.into(), source)
.await
.map_err(|_| AdminUsageRecordError::List)
}
/// 聚合 Token 趋势。
pub async fn token_trend(
&self,
filter: AdminUsageRecordFilter,
) -> Result<Vec<UsageRecordTrendPoint>, AdminUsageRecordError> {
self.store
.token_trend(filter.into())
.await
.map_err(|_| AdminUsageRecordError::List)
}
/// 聚合延迟趋势。
pub async fn latency_trend(
&self,
filter: AdminUsageRecordFilter,
) -> Result<Vec<UsageRecordTrendPoint>, AdminUsageRecordError> {
self.store
.latency_trend(filter.into())
.await
.map_err(|_| AdminUsageRecordError::List)
}
+138 -95
View File
@@ -1,6 +1,6 @@
//! 使用记录 HTTP 处理器。
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use axum::{
extract::{Query, State},
@@ -17,7 +17,8 @@ use crate::{
usage_record::{UsageRecord, UsageRecordLevel},
usage_record_store::{
AdminUsageRecordError, AdminUsageRecordFilter, UsageRecordBreakdown,
UsageRecordInsights, UsageRecordSummary, UsageRecordTrendPoint,
UsageRecordEndpointSource, UsageRecordModelSource, UsageRecordSummary,
UsageRecordTrendPoint,
},
},
admin::{
@@ -56,6 +57,14 @@ pub(crate) struct UsageRecordsQuery {
end_time: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageRecordDistributionQuery {
#[serde(flatten)]
records: UsageRecordsQuery,
source: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageRecordDetailQuery {
@@ -142,19 +151,6 @@ struct UsageRecordSummaryData {
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 {
@@ -213,9 +209,8 @@ pub(crate) async fn usage_records(
{
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),
items: usage_record_items(page.items, &account_emails),
total: page.total,
page: page.page,
page_size: page.page_size,
@@ -237,9 +232,8 @@ pub(crate) async fn usage_records(
{
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),
items: usage_record_items(page.items, &account_emails),
next_cursor: page.next_cursor,
};
Ok(AdminResponse::new(
@@ -268,18 +262,101 @@ pub(crate) async fn usage_records_summary(
}
}
/// `GET /api/admin/usage/records/insights`
pub(crate) async fn usage_records_insights(
/// `GET /api/admin/usage/records/insights/models`
pub(crate) async fn usage_records_model_distribution(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordDistributionQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let source = model_source_from_query(query.source)?;
let filter = filter_from_query(query.records)?;
match state
.services
.usage_records
.model_distribution(filter, source)
.await
{
Ok(items) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
items
.into_iter()
.map(UsageRecordBreakdownData::from)
.collect::<Vec<_>>(),
),
)),
Err(error) => Err(log_error(&error)),
}
}
/// `GET /api/admin/usage/records/insights/endpoints`
pub(crate) async fn usage_records_endpoint_distribution(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<UsageRecordDistributionQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let source = endpoint_source_from_query(query.source)?;
let filter = filter_from_query(query.records)?;
match state
.services
.usage_records
.endpoint_distribution(filter, source)
.await
{
Ok(items) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
items
.into_iter()
.map(UsageRecordBreakdownData::from)
.collect::<Vec<_>>(),
),
)),
Err(error) => Err(log_error(&error)),
}
}
/// `GET /api/admin/usage/records/insights/token-trend`
pub(crate) async fn usage_records_token_trend(
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(
match state.services.usage_records.token_trend(filter).await {
Ok(points) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(UsageRecordInsightsData::from(insights)),
AdminEnvelope::ok(
points
.into_iter()
.map(UsageRecordTrendPointData::from)
.collect::<Vec<_>>(),
),
)),
Err(error) => Err(log_error(&error)),
}
}
/// `GET /api/admin/usage/records/insights/latency-trend`
pub(crate) async fn usage_records_latency_trend(
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.latency_trend(filter).await {
Ok(points) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
points
.into_iter()
.map(UsageRecordTrendPointData::from)
.collect::<Vec<_>>(),
),
)),
Err(error) => Err(log_error(&error)),
}
@@ -295,10 +372,9 @@ pub(crate) async fn usage_record_detail(
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)),
AdminEnvelope::ok(usage_record_data(log, &account_emails)),
))
}
Ok(None) => Err(AdminError::new(
@@ -349,6 +425,34 @@ fn filter_from_query(query: UsageRecordsQuery) -> Result<AdminUsageRecordFilter,
})
}
fn model_source_from_query(value: Option<String>) -> Result<UsageRecordModelSource, AdminError> {
match non_empty(value).as_deref().unwrap_or("requested") {
"requested" => Ok(UsageRecordModelSource::Requested),
"upstream" => Ok(UsageRecordModelSource::Upstream),
"mapping" => Ok(UsageRecordModelSource::Mapping),
_ => Err(AdminError::new(
StatusCode::BAD_REQUEST,
40003,
"Invalid model distribution source",
)),
}
}
fn endpoint_source_from_query(
value: Option<String>,
) -> Result<UsageRecordEndpointSource, AdminError> {
match non_empty(value).as_deref().unwrap_or("inbound") {
"inbound" => Ok(UsageRecordEndpointSource::Inbound),
"upstream" => Ok(UsageRecordEndpointSource::Upstream),
"path" => Ok(UsageRecordEndpointSource::Path),
_ => Err(AdminError::new(
StatusCode::BAD_REQUEST,
40004,
"Invalid endpoint distribution source",
)),
}
}
fn optional_datetime(
value: Option<String>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, AdminError> {
@@ -440,25 +544,24 @@ pub(crate) async fn account_email_map(
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))
.map(|record| usage_record_data(record, account_emails))
.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);
.cloned()
.or_else(|| metadata_string(&record.metadata, &["accountEmail", "account_email"]));
let (requested_model, upstream_model) = usage_record_models(&record);
let client_ip = metadata_string(&record.metadata, &["clientIp", "ipAddress", "ip_address"]);
let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]);
let reasoning_effort =
@@ -494,30 +597,17 @@ fn usage_record_data(
}
}
fn usage_record_models(
record: &UsageRecord,
model_aliases: &BTreeMap<String, String>,
) -> (Option<String>, Option<String>) {
fn usage_record_models(record: &UsageRecord) -> (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)));
.or_else(|| 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))
});
.or_else(|| requested_model.clone())
.or_else(|| stored_model.map(ToString::to_string));
(requested_model, upstream_model)
}
@@ -740,53 +830,6 @@ impl From<UsageRecordSummary> for UsageRecordSummaryData {
}
}
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 {
+17 -4
View File
@@ -18,8 +18,9 @@ use super::{
monitoring::{
dashboard::{dashboard_summary, dashboard_trend},
usage_records::{
clear_usage_records, usage_record_detail, usage_records, usage_records_insights,
usage_records_summary,
clear_usage_records, usage_record_detail, usage_records,
usage_records_endpoint_distribution, usage_records_latency_trend,
usage_records_model_distribution, usage_records_summary, usage_records_token_trend,
},
},
settings::routes::{
@@ -68,8 +69,20 @@ pub fn router() -> Router<AppState> {
get(usage_records_summary),
)
.route(
"/api/admin/usage/records/insights",
get(usage_records_insights),
"/api/admin/usage/records/insights/models",
get(usage_records_model_distribution),
)
.route(
"/api/admin/usage/records/insights/endpoints",
get(usage_records_endpoint_distribution),
)
.route(
"/api/admin/usage/records/insights/token-trend",
get(usage_records_token_trend),
)
.route(
"/api/admin/usage/records/insights/latency-trend",
get(usage_records_latency_trend),
)
.route("/api/admin/usage/records/delete", post(clear_usage_records))
.route("/api/admin/usage/records/detail", get(usage_record_detail))
+1 -1
View File
@@ -384,7 +384,7 @@ impl ChatDispatchService {
request_id,
account_id: &account_id,
route: "/v1/chat/completions",
model: &request.model,
model: &display_model,
requested_model: Some(requested_model),
client_ip: request.client_ip.as_deref(),
client_user_agent: request.client_user_agent.as_deref(),
+139 -19
View File
@@ -58,6 +58,7 @@ use crate::{
pool::{AccountAcquireRequest, RuntimeAccountPoolService},
},
upstream::{
models::ModelCatalog,
protocol::{
events::{extract_sse_usage, extract_usage, TokenUsage},
responses::{
@@ -386,6 +387,7 @@ impl ResponseDispatchService {
let started_at = Instant::now();
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
let display_model = ModelCatalog::build_display_model_name(&parsed_model);
apply_response_model_options(&mut request, &parsed_model);
let tuple_schema = request.tuple_schema.clone();
let image_generation_requested = request.expects_image_generation();
@@ -833,6 +835,7 @@ impl ResponseDispatchService {
usage_records: &self.usage_records,
request_id,
account_id: &release_account_id,
account_email: account.email.as_deref(),
route,
model: requested_model,
started_at,
@@ -876,7 +879,7 @@ impl ResponseDispatchService {
request_id,
account_id: &account.id,
route,
model: &request.model,
model: &display_model,
requested_model: Some(requested_model),
client_ip: request.client_ip.as_deref(),
client_user_agent: request.client_user_agent.as_deref(),
@@ -988,6 +991,7 @@ impl ResponseDispatchService {
let started_at = Instant::now();
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
let display_model = ModelCatalog::build_display_model_name(&parsed_model);
apply_response_model_options(&mut request, &parsed_model);
request.stream = true;
let tuple_schema = request.tuple_schema.clone();
@@ -1222,6 +1226,7 @@ impl ResponseDispatchService {
usage_records: &self.usage_records,
request_id,
account_id: &release_account_id,
account_email: account.email.as_deref(),
route,
model: requested_model,
started_at,
@@ -1334,7 +1339,7 @@ impl ResponseDispatchService {
request_id,
account_id: &release_account_id,
route,
model: &request.model,
model: &display_model,
requested_model,
started_at,
transport,
@@ -1358,6 +1363,7 @@ impl ResponseDispatchService {
request_id: request_id.to_string(),
route: route.to_string(),
model: request.model.clone(),
display_model: display_model.clone(),
requested_model: requested_model.to_string(),
client_ip: request.client_ip.clone(),
request,
@@ -1492,6 +1498,7 @@ impl ResponseDispatchService {
usage_records: &self.usage_records,
request_id,
account_id: &release_account_id,
account_email: account.email.as_deref(),
route,
model: requested_model,
started_at,
@@ -1516,6 +1523,7 @@ impl ResponseDispatchService {
let started_at = Instant::now();
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
let display_model = ModelCatalog::build_display_model_name(&parsed_model);
request.model = parsed_model.model_id;
let mut excluded_account_ids = Vec::new();
let mut rate_limited_count = 0usize;
@@ -1756,7 +1764,7 @@ impl ResponseDispatchService {
request_id,
account_id: &account.id,
route: "/v1/responses/compact",
model: &request.model,
model: &display_model,
requested_model: Some(requested_model),
client_ip: request.client_ip.as_deref(),
client_user_agent: request.client_user_agent.as_deref(),
@@ -2046,6 +2054,7 @@ struct LiveResponseStreamContext {
request_id: String,
route: String,
model: String,
display_model: String,
requested_model: String,
client_ip: Option<String>,
request: CodexResponsesRequest,
@@ -2169,7 +2178,7 @@ fn update_first_token_ms(
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));
*first_token_ms = Some(elapsed_millis_i64(context.started_at).max(1));
}
}
@@ -2180,23 +2189,78 @@ fn response_body_has_first_token(body_bytes: &[u8]) -> bool {
};
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())
})
serde_json::from_str::<Value>(&event.data)
.is_ok_and(|value| response_event_has_first_token(event.event.as_deref(), &value))
})
})
}
fn response_event_has_first_token(event_name: Option<&str>, value: &Value) -> bool {
match response_stream_event_type(event_name, value) {
Some(
"response.output_text.delta"
| "response.reasoning_summary_text.delta"
| "response.reasoning_text.delta"
| "response.function_call_arguments.delta"
| "response.custom_tool_call_input.delta",
) => non_empty_string_field(value, &["delta"]),
Some("response.output_text.done" | "response.reasoning_text.done") => {
non_empty_string_field(value, &["text"])
}
Some("response.output_item.added" | "response.output_item.done") => value
.get("item")
.is_some_and(output_item_has_generated_content),
Some("response.completed") => value
.get("response")
.is_some_and(completed_response_has_generated_content),
_ => false,
}
}
fn response_stream_event_type<'a>(
event_name: Option<&'a str>,
value: &'a Value,
) -> Option<&'a str> {
event_name.or_else(|| value.get("type").and_then(Value::as_str))
}
fn completed_response_has_generated_content(response: &Value) -> bool {
non_empty_string_field(response, &["output_text"])
|| response
.get("output")
.and_then(Value::as_array)
.is_some_and(|items| items.iter().any(output_item_has_generated_content))
|| response
.pointer("/usage/output_tokens")
.and_then(Value::as_i64)
.is_some_and(|tokens| tokens > 0)
}
fn output_item_has_generated_content(item: &Value) -> bool {
non_empty_string_field(item, &["content", "text", "arguments", "input"])
|| item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(output_content_part_has_text))
|| item
.get("summary")
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(output_content_part_has_text))
}
fn output_content_part_has_text(part: &Value) -> bool {
non_empty_string_field(part, &["text", "content"])
}
fn non_empty_string_field(value: &Value, fields: &[&str]) -> bool {
fields.iter().any(|field| {
value
.get(field)
.and_then(Value::as_str)
.is_some_and(|text| !text.trim().is_empty())
})
}
async fn send_live_response_stream_chunk(
sender: &mpsc::Sender<Result<Bytes, ResponseDispatchStreamError>>,
body_bytes: &mut Vec<u8>,
@@ -2534,6 +2598,7 @@ struct ResponseUpstreamErrorEventRecord<'a> {
usage_records: &'a AdminUsageRecordService,
request_id: &'a str,
account_id: &'a str,
account_email: Option<&'a str>,
route: &'a str,
model: &'a str,
started_at: Instant,
@@ -2626,6 +2691,18 @@ async fn record_response_upstream_error_event(record: ResponseUpstreamErrorEvent
event.model = Some(record.model.to_string());
event.status_code = Some(i64::from(upstream_error_http_status(record.error)));
event.latency_ms = Some(elapsed_millis_i64(record.started_at));
if let Some(object) = metadata.as_object_mut() {
if let Some(account_email) = record
.account_email
.map(str::trim)
.filter(|value| !value.is_empty())
{
object.insert(
"accountEmail".to_string(),
Value::String(account_email.to_string()),
);
}
}
event.metadata = metadata;
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");
@@ -2852,13 +2929,13 @@ async fn record_live_response_stream_event(
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.model = Some(context.display_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.display_model,
context.client_ip.as_deref(),
context.request.client_user_agent.as_deref(),
reasoning_effort_from_request(&context.request),
@@ -3269,3 +3346,46 @@ fn enrich_response_dispatch_error_metadata(metadata: &mut Value, error: &Respons
fn elapsed_millis_i64(started_at: Instant) -> i64 {
started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
}
#[cfg(test)]
mod tests {
use super::response_body_has_first_token;
#[test]
fn first_token_detection_should_match_text_delta() {
let body = br#"event: response.output_text.delta
data: {"delta":"hello"}
"#;
assert!(response_body_has_first_token(body));
}
#[test]
fn first_token_detection_should_match_output_item_done_text() {
let body = br#"event: response.output_item.done
data: {"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
"#;
assert!(response_body_has_first_token(body));
}
#[test]
fn first_token_detection_should_fallback_to_completed_usage_output_tokens() {
let body = br#"event: response.completed
data: {"response":{"id":"resp_1","status":"completed","usage":{"input_tokens":3,"output_tokens":4}}}
"#;
assert!(response_body_has_first_token(body));
}
#[test]
fn first_token_detection_should_ignore_incomplete_frames() {
let body = br#"event: response.output_text.delta
data: {"delta":"hello"}"#;
assert!(!response_body_has_first_token(body));
}
}
+273 -48
View File
@@ -116,15 +116,8 @@ async fn admin_usage_records_should_return_numbered_page_metadata() {
#[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 (app, store, pool, _dir) =
admin_usage_records_test_app_with_pool("admin-usage-records-display-fields.sqlite").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")
@@ -140,9 +133,11 @@ async fn admin_usage_records_should_return_table_display_fields() {
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.model = Some("gpt-5.5".to_string());
event.latency_ms = Some(12_345);
event.metadata = json!({
"requestedModel": "client-alias",
"upstreamModel": "gpt-5.5",
"clientIp": "203.0.113.8",
"userAgent": "codex-tui/0.142.2 (Ubuntu 24.4.0; aarch64) xterm-256color",
"reasoningEffort": "xhigh",
@@ -201,6 +196,38 @@ async fn admin_usage_records_should_return_table_display_fields() {
assert_eq!(row["latencyMsDisplay"], "12.3 s");
}
#[tokio::test]
async fn admin_usage_records_should_use_record_account_email_snapshot_when_account_is_missing() {
let (app, store, _dir) =
admin_usage_records_test_app("admin-usage-records-email-snapshot.sqlite").await;
let mut event = UsageRecord::new("v1.response", UsageRecordLevel::Error, "upstream failed");
event.id = "usage_email_snapshot".to_string();
event.account_id = Some("acct_deleted".to_string());
event.metadata = json!({
"accountEmail": "deleted@example.com",
"failed": true
});
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")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
assert_eq!(
body["data"]["items"][0]["accountEmail"],
"deleted@example.com"
);
}
#[tokio::test]
async fn admin_usage_records_summary_should_aggregate_filtered_usage() {
let (app, store, _dir) =
@@ -256,13 +283,13 @@ async fn admin_usage_records_summary_should_aggregate_filtered_usage() {
}
#[tokio::test]
async fn admin_usage_records_insights_should_aggregate_filtered_usage_dimensions() {
let (app, store, pool, _dir) =
async fn admin_usage_record_insight_cards_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.model = Some("gpt-5.5".to_string());
first.latency_ms = Some(240);
first.metadata = json!({
"stream": true,
@@ -277,14 +304,6 @@ async fn admin_usage_records_insights_should_aggregate_filtered_usage_dimensions
}
});
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();
@@ -305,40 +324,75 @@ async fn admin_usage_records_insights_should_aggregate_filtered_usage_dimensions
});
store.append(&second).await.unwrap();
let response = app
let requested_models = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights?search=insight")
.uri("/api/admin/usage/records/insights/models?source=requested&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;
let requested_body = response_json(requested_models).await;
assert_eq!(requested_body["code"], 200);
assert_eq!(requested_body["data"][0]["name"], "client-alias");
assert_eq!(requested_body["data"][0]["totalTokens"], 42);
assert_eq!(requested_body["data"][0]["actualCostDisplay"], "$0.000483");
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");
let upstream_models = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/models?source=upstream&search=insight")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
body["data"]["modelMappings"][0]["name"],
"client-alias -> gpt-5.5"
response_json(upstream_models).await["data"][0]["name"],
"gpt-5.5"
);
let mappings = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/models?source=mapping&search=insight")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let mapping_body = response_json(mappings).await;
assert_eq!(mapping_body["data"][0]["name"], "client-alias -> gpt-5.5");
assert_eq!(
body["data"]["modelMappings"][1]["name"],
mapping_body["data"][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"]
let endpoints = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/endpoints?source=path&search=insight")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let endpoint_body = response_json(endpoints).await;
assert_eq!(endpoint_body["data"].as_array().unwrap().len(), 2);
let endpoint_paths = endpoint_body["data"]
.as_array()
.unwrap()
.iter()
@@ -346,14 +400,185 @@ async fn admin_usage_records_insights_should_aggregate_filtered_usage_dimensions
.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);
let token_trend = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/token-trend?search=insight")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let token_body = response_json(token_trend).await;
assert_eq!(token_body["data"][0]["inputTokens"], 35);
assert_eq!(token_body["data"][0]["outputTokens"], 13);
assert_eq!(token_body["data"][0]["cacheCreationTokens"], 0);
assert_eq!(token_body["data"][0]["cachedTokens"], 6);
assert_eq!(token_body["data"][0]["actualCostDisplay"], "$0.000538");
assert_eq!(token_body["data"][0]["costDisplay"], "$0.000538");
let latency_trend = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/latency-trend?search=insight")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response_json(latency_trend).await["data"][0]["averageLatencyMs"],
180.0
);
}
#[tokio::test]
async fn admin_usage_records_insight_card_endpoints_should_return_source_specific_data() {
let (app, store, _pool, _dir) =
admin_usage_records_test_app_with_pool("admin-usage-records-insight-cards.sqlite").await;
let mut first = UsageRecord::new("request", UsageRecordLevel::Info, "card success");
first.id = "usage_card_success".to_string();
first.route = Some("/v1/responses".to_string());
first.model = Some("gpt-5.5".to_string());
first.latency_ms = Some(240);
first.metadata = json!({
"stream": true,
"requestedModel": "client-alias",
"upstreamModel": "gpt-5.5",
"upstreamEndpoint": "/backend/responses",
"usage": {
"inputTokens": 30,
"outputTokens": 12,
"cachedTokens": 6
}
});
store.append(&first).await.unwrap();
let mut second = UsageRecord::new("request", UsageRecordLevel::Info, "card mini");
second.id = "usage_card_mini".to_string();
second.route = Some("/v1/chat/completions".to_string());
second.model = Some("gpt-5.5-mini".to_string());
second.latency_ms = Some(120);
second.metadata = json!({
"stream": false,
"requestedModel": "gpt-5.5-mini",
"upstreamModel": "gpt-5.5-mini",
"upstreamEndpoint": "/backend/chat",
"usage": {
"inputTokens": 5,
"outputTokens": 1
}
});
store.append(&second).await.unwrap();
let upstream_models = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/models?source=upstream&search=card")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let upstream_body = response_json(upstream_models).await;
assert_eq!(upstream_body["code"], 200);
assert_eq!(upstream_body["data"][0]["name"], "gpt-5.5");
assert_eq!(upstream_body["data"][0]["totalTokens"], 42);
let mappings = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/models?source=mapping&search=card")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response_json(mappings).await["data"][0]["name"],
"client-alias -> gpt-5.5"
);
let endpoints = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/endpoints?source=path&search=card")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let endpoint_body = response_json(endpoints).await;
let endpoint_paths = endpoint_body["data"]
.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"));
let token_trend = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/token-trend?search=card")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let token_body = response_json(token_trend).await;
assert_eq!(token_body["data"][0]["inputTokens"], 35);
assert_eq!(token_body["data"][0]["cachedTokens"], 6);
let latency_trend = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/latency-trend?search=card")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response_json(latency_trend).await["data"][0]["averageLatencyMs"],
180.0
);
let invalid_source = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage/records/insights/models?source=actual")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid_source.status(), StatusCode::BAD_REQUEST);
assert_eq!(response_json(invalid_source).await["code"], 40003);
}
#[tokio::test]
@@ -132,6 +132,9 @@ async fn responses_usage_record_should_store_resolved_upstream_model_after_alias
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 metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert_eq!(metadata["requestedModel"], "client-alias");
assert_eq!(metadata["upstreamModel"], "gpt-5.5");
let received = server.received_requests().await.unwrap();
let upstream_body: Value = serde_json::from_slice(&received[0].body).unwrap();
@@ -537,6 +540,20 @@ async fn responses_stream_should_record_usage_record_after_completed_stream() {
assert_eq!(metadata["responseId"], "resp_stream_usage");
assert_eq!(metadata["usage"]["inputTokens"], 3);
assert_eq!(metadata["usage"]["outputTokens"], 5);
assert!(
metadata["firstTokenMs"]
.as_i64()
.is_some_and(|value| value > 0),
"stream usage metadata should include first token latency: {metadata:?}",
);
let first_token_bucket: (i64, i64) = sqlx::query_as(
"select first_token_latency_sum, first_token_latency_count from usage_time_buckets limit 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(first_token_bucket.0 > 0);
assert_eq!(first_token_bucket.1, 1);
assert_rate_limit_header(&metadata, "retry-after", "7");
assert_rate_limit_header(&metadata, "x-ratelimit-limit-requests", "99");
assert!(metadata.get("requestBody").is_none());
@@ -702,6 +719,10 @@ async fn responses_should_record_request_count_when_5xx_retries_are_exhausted()
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert_eq!(authorizations.len(), 3, "requests: {authorizations:?}");
assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1);
let event = latest_response_usage_record(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(metadata["accountEmail"], "user@example.com");
}
#[tokio::test]
+26 -2
View File
@@ -24,9 +24,33 @@ export function getUsageRecordSummary(params?: any) {
})
}
export function getUsageRecordInsights(params?: any) {
export function getUsageRecordModelDistribution(params?: any) {
return request({
url: '/api/admin/usage/records/insights',
url: '/api/admin/usage/records/insights/models',
method: 'GET',
params,
})
}
export function getUsageRecordEndpointDistribution(params?: any) {
return request({
url: '/api/admin/usage/records/insights/endpoints',
method: 'GET',
params,
})
}
export function getUsageRecordTokenTrend(params?: any) {
return request({
url: '/api/admin/usage/records/insights/token-trend',
method: 'GET',
params,
})
}
export function getUsageRecordLatencyTrend(params?: any) {
return request({
url: '/api/admin/usage/records/insights/latency-trend',
method: 'GET',
params,
})
+3 -7
View File
@@ -7,14 +7,12 @@ const props = withDefaults(
defineProps<{
viewClass?: string
maxHeight?: string
forceVisible?: boolean
horizontal?: boolean
vertical?: boolean
}>(),
{
viewClass: '',
maxHeight: undefined,
forceVisible: false,
horizontal: false,
vertical: true,
},
@@ -44,9 +42,7 @@ const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollba
const canScrollY = computed(() => thumbHeight.value > 0)
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
const scrollbarVisible = computed(
() => props.forceVisible || dragging.value || horizontalDragging.value || visible.value,
)
const scrollbarVisible = computed(() => dragging.value || horizontalDragging.value || visible.value)
const thumbStyle = computed(() => ({
height: `${thumbHeight.value}px`,
transform: `translateY(${thumbTop.value}px)`,
@@ -104,7 +100,7 @@ function clearHideTimer() {
function scheduleHideScrollbar() {
clearHideTimer()
if (props.forceVisible || dragging.value || horizontalDragging.value) {
if (dragging.value || horizontalDragging.value) {
return
}
@@ -113,7 +109,7 @@ function scheduleHideScrollbar() {
function hideScrollbar() {
clearHideTimer()
if (!props.forceVisible && !dragging.value && !horizontalDragging.value) {
if (!dragging.value && !horizontalDragging.value) {
visible.value = false
}
}
+24 -15
View File
@@ -2,7 +2,7 @@
import { useEventListener, useResizeObserver } from '@vueuse/core'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef } from 'vue'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
@@ -72,6 +72,7 @@ const totalPages = computed(() => getTotalPages(props.pagination))
const currentPage = computed(() => getCurrentPage(props.pagination, totalPages.value))
const pageSizeOptions = computed(() => getPageSizeOptions(props.pagination))
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
const hasRows = computed(() => props.rows.length > 0)
const pageSizeModel = computed({
get: () => String(props.pagination?.pageSize ?? ''),
@@ -193,19 +194,24 @@ function scrollWrap() {
return bodyScrollbarRef.value?.wrapRef ?? null
}
function resetHorizontalScrollbar() {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
}
function updateHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap) {
if (!wrap || !hasRows.value) {
resetHorizontalScrollbar()
return
}
const scrollRange = maxScrollLeft(wrap)
const trackWidth = horizontalTrackWidth(wrap)
if (scrollRange <= 0 || trackWidth <= 0) {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
resetHorizontalScrollbar()
return
}
@@ -298,6 +304,13 @@ useResizeObserver(
),
updateHorizontalScrollbar,
)
watch(
() => [props.rows.length, props.minWidth],
async () => {
await nextTick()
updateHorizontalScrollbar()
},
)
useEventListener(document, 'pointermove', handleHorizontalThumbPointerMove)
useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
@@ -388,9 +401,9 @@ function paginationPageClass(page: number) {
</div>
<BaseScrollbar
v-if="hasRows"
ref="bodyScrollbar"
class="min-h-0 flex-1"
:force-visible="horizontalHovering"
:max-height="maxHeight"
@scroll="handleTableScroll"
>
@@ -409,14 +422,6 @@ function paginationPageClass(page: number) {
</colgroup>
<tbody>
<tr v-if="!loading && rows.length === 0" role="row">
<td :colspan="computedColumns.length" class="h-72 p-0" role="cell">
<div class="grid h-full min-h-72 place-items-center">
<BaseEmpty :title="emptyText" plain class="w-full max-w-80" />
</div>
</td>
</tr>
<template v-for="(row, index) in rows" :key="getRowKey(row, index)">
<tr :class="rowClass(row, index)" role="row">
<td
@@ -457,6 +462,10 @@ function paginationPageClass(page: number) {
</tbody>
</table>
</BaseScrollbar>
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
<BaseEmpty v-if="!loading" :title="emptyText" plain class="w-full max-w-80" />
</div>
</div>
<div
@@ -16,10 +16,14 @@ const props = withDefaults(
defineProps<{
saving?: boolean
oauthLoading?: boolean
reauthorizing?: boolean
account?: any
}>(),
{
saving: false,
oauthLoading: false,
reauthorizing: false,
account: null,
},
)
@@ -49,6 +53,7 @@ const modeOptions = [
const mode = computed({
get: () => form.value.mode,
set: (value: string) => {
if (props.reauthorizing && value !== 'oauth') return
form.value = { ...form.value, mode: value }
fileError.value = ''
},
@@ -78,6 +83,23 @@ const oauthCallback = computed({
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
const accountName = computed(() => {
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
})
const modalTitle = computed(() => (props.reauthorizing ? '重新授权账号' : '添加账号'))
const oauthPanelTitle = computed(() =>
props.reauthorizing ? accountName.value : '使用 OpenAI OAuth 完成账号接入',
)
const oauthPanelDescription = computed(() => {
if (props.reauthorizing) {
return '生成新的授权链接,完成后粘贴回调地址更新账号凭据。'
}
return '复制授权链接到浏览器打开,完成后把回调地址粘贴到下方即可导入。'
})
const canSubmit = computed(() => {
if (props.saving || props.oauthLoading) return false
if (mode.value === 'oauth') {
@@ -90,6 +112,9 @@ const canSubmit = computed(() => {
})
const description = computed(() => {
if (props.reauthorizing) {
return '完成授权后粘贴回调地址,系统会更新账号凭据。'
}
if (mode.value === 'rt') {
return '一行一个 Refresh Token,导入时会自动换取访问令牌并补全账号信息。'
}
@@ -132,14 +157,14 @@ async function copyOAuthAuthUrl() {
<template>
<BaseModal
v-model="open"
title="添加账号"
:title="modalTitle"
:description="description"
variant="info"
width="620px"
:close-disabled="saving"
>
<div class="flex flex-col gap-4">
<BaseSegmented v-model="mode" :options="modeOptions" class="w-full" />
<BaseSegmented v-if="!reauthorizing" v-model="mode" :options="modeOptions" class="w-full" />
<BaseForm v-if="mode === 'rt'">
<BaseFormItem label="Refresh Token" required>
@@ -162,10 +187,10 @@ async function copyOAuthAuthUrl() {
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-[13px] font-[720] text-(--cp-text-primary)">
使用 OpenAI OAuth 完成账号接入
{{ oauthPanelTitle }}
</p>
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-(--cp-text-secondary)">
复制授权链接到浏览器打开,完成后把回调地址粘贴到下方即可导入。
{{ oauthPanelDescription }}
</p>
</div>
</div>
@@ -178,7 +203,7 @@ async function copyOAuthAuthUrl() {
:disabled="saving"
@click="emit('generateOauth')"
>
生成授权链接
{{ reauthorizing ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</div>
@@ -249,7 +274,7 @@ async function copyOAuthAuthUrl() {
:disabled="!canSubmit"
@click="emit('create')"
>
{{ mode === 'oauth' ? '完成授权导入' : '导入' }}
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : '导入' }}
</BaseButton>
</template>
</BaseModal>
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { LoaderCircle, MoreHorizontal, Pencil, Power, RefreshCw, Trash2, Wifi } from '@lucide/vue'
import {
KeyRound,
LoaderCircle,
MoreHorizontal,
Pencil,
Power,
RefreshCw,
Trash2,
Wifi,
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BasePopover from '@/components/base/BasePopover.vue'
@@ -18,6 +27,7 @@ const emit = defineEmits<{
delete: [account: any]
test: [account: any]
refresh: [accountId: string]
reauthorize: [account: any]
toggleSchedule: [account: any]
}>()
</script>
@@ -75,6 +85,14 @@ const emit = defineEmits<{
/>
刷新 token
</button>
<button
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover)"
@click.stop="(close(), emit('reauthorize', account))"
>
<KeyRound class="size-3.5 text-(--cp-text-muted)" />
重新授权
</button>
<button
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
@@ -33,9 +33,10 @@ export function useAccountMutations(options: {
highUsage: 0,
attention: 0,
})
const showCreateModal = ref(false)
const createModalOpen = ref(false)
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const reauthorizingAccount = ref<any>(null)
const editingAccount = ref<any>(null)
const editingScheduleStatus = ref('active')
const pendingDeleteAccount = ref<any>(null)
@@ -63,6 +64,16 @@ export function useAccountMutations(options: {
status: 'active',
})
const showCreateModal = computed({
get: () => createModalOpen.value,
set: (value: boolean) => {
createModalOpen.value = value
if (!value) {
reauthorizingAccount.value = null
}
},
})
const showEditModal = computed({
get: () => editingAccount.value !== null,
set: (value: boolean) => {
@@ -205,12 +216,15 @@ export function useAccountMutations(options: {
creatingAccount.value = true
const result = await exchangeAccountOAuth(payload)
const successText = oauthSuccessText(result)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(importSuccessText(result))
toast.success(successText)
} catch (error: any) {
toast.error(error.message || 'OAuth 授权导入失败')
toast.error(
error.message || (reauthorizingAccount.value ? '重新授权失败' : 'OAuth 授权导入失败'),
)
} finally {
creatingAccount.value = false
}
@@ -236,6 +250,26 @@ export function useAccountMutations(options: {
return `导入完成,写入 ${imported} 个`
}
function oauthSuccessText(result: any) {
if (reauthorizingAccount.value) {
return '账号重新授权成功'
}
return importSuccessText(result)
}
function openCreateAccount() {
reauthorizingAccount.value = null
resetCreateForm()
showCreateModal.value = true
}
function openReauthorizeAccount(account: any) {
reauthorizingAccount.value = account
resetCreateForm()
showCreateModal.value = true
void handleAuthorizeOAuth()
}
function openEditAccount(account: any) {
const scheduleStatus = account.status === 'disabled' ? 'disabled' : 'active'
editingAccount.value = account
@@ -410,6 +444,7 @@ export function useAccountMutations(options: {
showDeleteModal,
showSingleDeleteModal,
showEditModal,
reauthorizingAccount,
editingAccount,
pendingDeleteAccount,
refreshingAccountIds,
@@ -428,6 +463,8 @@ export function useAccountMutations(options: {
handleCreate,
handleAuthorizeOAuth,
handleExchangeOAuth,
openCreateAccount,
openReauthorizeAccount,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
+7 -1
View File
@@ -75,12 +75,15 @@ const {
savingAccount,
batchDeleting,
exportingAccounts,
reauthorizingAccount,
createForm,
editForm,
editStatusModel,
loadAccounts,
handleCreate,
handleAuthorizeOAuth,
openCreateAccount,
openReauthorizeAccount,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
@@ -186,7 +189,7 @@ function planTypeClass(planType?: string) {
<Download class="size-4" />
{{ selectedIds.size > 0 ? '导出选中' : '导出' }}
</BaseButton>
<BaseButton variant="primary" @click="showCreateModal = true">
<BaseButton variant="primary" @click="openCreateAccount">
<Plus class="size-4" />
添加账号
</BaseButton>
@@ -269,6 +272,7 @@ function planTypeClass(planType?: string) {
:updating-status="updatingStatusAccountIds.has(row.id)"
@delete="requestDeleteAccount"
@edit="openEditAccount"
@reauthorize="openReauthorizeAccount"
@refresh="handleRefresh"
@test="openConnectionTest"
@toggle-schedule="handleToggleSchedule"
@@ -309,7 +313,9 @@ function planTypeClass(planType?: string) {
<AccountCreateModal
v-model="showCreateModal"
v-model:form="createForm"
:account="reauthorizingAccount"
:oauth-loading="authorizingOAuth"
:reauthorizing="Boolean(reauthorizingAccount)"
:saving="creatingAccount"
@create="handleCreate"
@generate-oauth="handleAuthorizeOAuth"
@@ -233,14 +233,14 @@ function formatLatency(ms: number): string {
<div
class="mt-4.75 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
>
<div class="relative h-67 w-full overflow-hidden rounded-[10px] bg-(--cp-bg-subtle)">
<div class="relative h-67 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
<BaseEmpty
v-if="!hasSamples"
compact
title="暂无趋势数据"
description="最近 24 小时还没有可用于绘制趋势的请求日志。"
class="h-full place-content-center bg-(--cp-bg-subtle)"
class="h-full place-content-center bg-transparent"
/>
</div>
@@ -42,14 +42,14 @@ const filteredRows = computed(() => {
variant="dashboard"
title="使用记录"
description="最近 10 条网关请求"
class="h-87.5 w-full"
class="h-[468px] 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">
<div class="mt-4.25 flex h-[364px] w-full overflow-hidden">
<BaseTable
class="min-w-0 flex-1"
:columns="dashboardUsageRecordColumns"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -29,19 +30,14 @@ const totalMetric = computed(() =>
const tableGridClass = computed(() =>
props.showCostColumn === false
? 'grid-cols-[128px_64px_76px_76px_76px]'
: 'grid-cols-[148px_64px_76px_76px_76px_76px]',
? 'grid-cols-[minmax(128px,1fr)_repeat(4,minmax(66px,0.75fr))]'
: 'grid-cols-[minmax(128px,1fr)_repeat(5,minmax(64px,0.75fr))]',
)
const tableWidthClass = computed(() =>
props.showCostColumn === false ? 'min-w-[420px] w-full' : 'min-w-[516px] w-full',
props.showCostColumn === false ? 'min-w-[414px] w-full' : 'min-w-[492px] 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,
@@ -81,12 +77,6 @@ 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`
@@ -109,6 +99,16 @@ function formatCompactCost(value: number) {
function trimFixed(value: number, digits: number) {
return value.toFixed(digits).replace(/\.?0+$/, '')
}
function distributionNameParts(name: unknown) {
const text = typeof name === 'string' && name.trim() ? name.trim() : '未知'
const [primary, secondary] = text.split(' -> ', 2).map((part) => part.trim())
return {
primary: primary || text,
secondary: secondary || '',
full: text,
}
}
</script>
<template>
@@ -124,7 +124,7 @@ function trimFixed(value: number, digits: number) {
v-if="sourceOptions?.length"
v-model="source"
:options="sourceOptions"
class="w-42"
class="w-56"
/>
</template>
@@ -141,7 +141,7 @@ function trimFixed(value: number, digits: number) {
<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>
<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)"
@@ -153,59 +153,79 @@ function trimFixed(value: number, digits: number) {
</div>
<div class="min-h-0 min-w-0 overflow-hidden">
<BaseScrollbar horizontal :max-height="tableMaxHeight" view-class="pb-2 pr-3">
<BaseScrollbar horizontal view-class="pb-2">
<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>
<span class="text-center">请求</span>
<span class="text-center">Token</span>
<span class="text-center">实际</span>
<span v-if="showCostColumn !== false" class="text-center">成本</span>
<span class="text-center">标准</span>
</div>
<div class="grid gap-1.5">
<div
v-for="(item, index) in sortedItems"
v-for="item 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 class="flex min-w-0 items-start">
<div
class="inline-grid min-w-0 gap-1"
:title="distributionNameParts(item.name).full"
>
<code
class="block max-w-full truncate font-mono text-[12px] leading-none font-[760] text-(--cp-text-primary)"
>
{{ distributionNameParts(item.name).primary }}
</code>
<div
v-if="distributionNameParts(item.name).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]"
>
{{ distributionNameParts(item.name).secondary }}
</code>
</div>
</div>
</div>
</div>
<span class="text-right font-mono tabular-nums text-(--cp-text-primary)">
<span class="text-center font-mono tabular-nums text-(--cp-text-primary)">
{{ formatCompactUsage(item.requestCount) }}
</span>
<span
class="text-right font-mono tabular-nums text-(--cp-text-secondary)"
class="text-center 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)"
class="text-center 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)"
class="text-center 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)"
class="text-center font-mono tabular-nums text-(--cp-text-secondary)"
:title="item.costDisplay || formatCostMetric(item.cost)"
>
{{ formatCompactCost(item.cost) }}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { computed } from 'vue'
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
@@ -16,14 +16,15 @@ const props = defineProps<{
}>()
const chartHeight = 236
const chartSplitNumber = 3
const modelSource = shallowRef('requested')
const endpointSource = shallowRef('inbound')
const modelSource = defineModel<string>('modelSource', { default: 'requested' })
const endpointSource = defineModel<string>('endpointSource', { default: 'inbound' })
const { themeRevision } = storeToRefs(useUiStore())
const modelSourceOptions = [
{ label: '请求', value: 'requested' },
{ label: '上游', value: 'upstream' },
{ label: '请求模型', value: 'requested' },
{ label: '上游模型', value: 'upstream' },
{ label: '映射', value: 'mapping' },
]
@@ -33,19 +34,11 @@ const endpointSourceOptions = [
{ 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 modelItems = computed(() => props.insights?.modelDistribution ?? [])
const endpointItems = computed(() => props.insights?.endpointDistribution ?? [])
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 trendPoints = computed(() => props.insights?.tokenTrend ?? [])
const latencyPoints = computed(() => props.insights?.latencyTrend ?? [])
const hasTrend = computed(() =>
trendPoints.value.some(
(point: any) =>
@@ -55,6 +48,9 @@ const hasTrend = computed(() =>
point.averageLatencyMs > 0,
),
)
const hasLatencyTrend = computed(() =>
latencyPoints.value.some((point: any) => Number(point.averageLatencyMs || 0) > 0),
)
const trendOption = computed<EChartsOption>(() => ({
grid: { left: 8, right: 10, top: 36, bottom: 28 },
@@ -105,6 +101,7 @@ const trendOption = computed<EChartsOption>(() => ({
yAxis: [
{
type: 'value',
splitNumber: chartSplitNumber,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
@@ -146,7 +143,7 @@ const latencyOption = computed<EChartsOption>(() => ({
},
xAxis: {
type: 'category',
data: trendPoints.value.map((point: any) => point.date),
data: latencyPoints.value.map((point: any) => point.date),
axisLabel: {
color: themeColor('--cp-text-muted', '#94A3B8'),
fontSize: 10,
@@ -157,6 +154,7 @@ const latencyOption = computed<EChartsOption>(() => ({
},
yAxis: {
type: 'value',
splitNumber: chartSplitNumber,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
@@ -165,7 +163,7 @@ const latencyOption = computed<EChartsOption>(() => ({
series: [
lineSeries(
'平均延迟',
trendPoints.value.map((point: any) => Number(point.averageLatencyMs || 0)),
latencyPoints.value.map((point: any) => Number(point.averageLatencyMs || 0)),
themeColor('--cp-warning', '#D97706'),
true,
),
@@ -288,7 +286,7 @@ function formatCompactNumber(value: number) {
}
function chartGridLineColor() {
return themeColor('--cp-default-border', '#E7EDF5')
return themeColor('--cp-bg-muted', '#F1F5F9')
}
function chartPointerLineColor() {
@@ -332,7 +330,7 @@ function themeColor(name: string, fallback: string) {
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 class="h-64 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>
@@ -355,8 +353,8 @@ function themeColor(name: string, fallback: string) {
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" />
<div class="h-64 px-3 pt-3 pb-2">
<BaseChart v-if="hasLatencyTrend" :option="latencyOption" :height="chartHeight" />
<BaseEmpty
v-else
compact
@@ -38,16 +38,25 @@ 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 scrollViewClass = 'grid gap-3 pr-3'
const panelClass = 'rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5'
const panelTitleClass = 'm-0 text-[12px] leading-none font-[780] text-(--cp-text-secondary)'
const fieldLabelClass = 'text-[11px] leading-none font-bold text-(--cp-text-muted)'
const fieldValueBaseClass =
'mt-1.5 mb-0 min-w-0 truncate text-[12px] leading-none font-[700] text-(--cp-text-primary)'
const codeBlockViewClass = 'rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5'
const codeBlockClass =
'm-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)'
const metricItems = computed(() => [
const accountDisplay = computed(() => usageAccountText(props.record))
const overviewItems = computed(() => [
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ 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 },
{ label: '请求 ID', value: props.record?.requestId, mono: true, wide: true },
{ label: '消息', value: props.record?.message, wide: true },
])
const detailGroups = computed(() => [
@@ -82,6 +91,8 @@ const detailGroups = computed(() => [
],
},
])
const modelRouteGroup = computed(() => detailGroups.value[0])
const clientUpstreamGroup = computed(() => detailGroups.value[1])
const costItems = computed(() => {
const details = costDetails.value
@@ -189,6 +200,10 @@ function displayValue(value: unknown) {
return String(value)
}
function fieldValueClass(mono?: boolean) {
return [fieldValueBaseClass, mono ? 'font-mono tabular-nums' : undefined]
}
function themeColor(name: string, fallback: string) {
themeRevision.value
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
@@ -201,215 +216,159 @@ function themeColor(name: string, fallback: string) {
title="使用记录详情"
description="查看单次网关请求的模型、链路、Token、费用与上游响应。"
variant="info"
width="920px"
width="960px"
>
<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) }}
<BaseScrollbar :view-class="scrollViewClass" max-height="min(76vh,820px)">
<section :class="panelClass">
<div class="min-w-0">
<span :class="fieldLabelClass">账号</span>
<p
class="mt-1.5 mb-0 truncate font-mono text-[13px] leading-none font-[760] text-(--cp-text-primary)"
:title="displayValue(accountDisplay)"
>
{{ displayValue(accountDisplay) }}
</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"
<dl
class="mt-4 grid grid-cols-2 gap-x-5 gap-y-3 lg:grid-cols-[120px_120px_repeat(5,minmax(0,1fr))]"
>
<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>
<div class="min-w-0">
<dt :class="fieldLabelClass">结果</dt>
<dd class="mt-1.5 mb-0">
<UsageLevelBadge :level="record.level" />
</dd>
</div>
<div class="min-w-0">
<dt :class="fieldLabelClass">状态码</dt>
<dd class="mt-1.5 mb-0">
<UsageStatusCodeBadge :status-code="record.statusCode" />
</dd>
</div>
<div
v-for="item in overviewItems"
:key="item.label"
class="min-w-0"
:class="item.wide ? 'col-span-2 lg:col-span-2' : undefined"
>
<dt :class="fieldLabelClass">{{ item.label }}</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</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) }}
<div class="flex min-h-0 flex-col gap-3">
<section :class="panelClass">
<h3 :class="panelTitleClass">{{ modelRouteGroup.title }}</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div v-for="item in modelRouteGroup.items" :key="item.label" class="min-w-0">
<dt :class="fieldLabelClass">{{ item.label }}</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</div>
</section>
</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>
<section :class="[panelClass, 'flex min-h-0 flex-1 flex-col']">
<h3 :class="panelTitleClass">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-3">
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
<dt class="flex min-w-0 items-center gap-1.5" :class="fieldLabelClass">
<i
class="size-1.75 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</dt>
<dd
:class="[fieldValueBaseClass, 'font-mono tabular-nums']"
:title="displayValue(item.display)"
>
{{ displayValue(item.display) }}
</dd>
</div>
</dl>
</div>
</dl>
</section>
</section>
</div>
<div class="flex min-h-0 flex-col gap-3">
<section :class="[panelClass, 'flex-1']">
<h3 :class="panelTitleClass">{{ clientUpstreamGroup.title }}</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div v-for="item in clientUpstreamGroup.items" :key="item.label" class="min-w-0">
<dt :class="fieldLabelClass">{{ item.label }}</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
<section :class="panelClass">
<h3 :class="panelTitleClass">费用</h3>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div v-for="item in costItems" :key="item.label" class="min-w-0">
<dt :class="fieldLabelClass">{{ item.label }}</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
</section>
</div>
</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
>
<div v-if="requestText" :class="[panelClass, 'min-h-0']">
<h3 class="mb-3" :class="panelTitleClass">请求内容</h3>
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
<pre :class="codeBlockClass">{{ 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
>
<div v-if="responseText" :class="[panelClass, 'min-h-0']">
<h3 class="mb-3" :class="panelTitleClass">响应内容</h3>
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
<pre :class="codeBlockClass">{{ 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
>
<section v-if="record.metadata" :class="[panelClass, 'min-h-0']">
<h3 class="mb-3" :class="panelTitleClass">元数据</h3>
<BaseScrollbar max-height="min(32vh, 340px)" :view-class="codeBlockViewClass">
<pre :class="codeBlockClass">{{ JSON.stringify(record.metadata, null, 2) }}</pre>
</BaseScrollbar>
</section>
</BaseScrollbar>
@@ -62,26 +62,24 @@ function errorRateText(summary: any) {
<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)"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch 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="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">
<div class="flex min-w-0 flex-col justify-between py-0.5">
<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)"
class="block truncate text-[22px] leading-none font-extrabold text-(--cp-text-primary)"
>
{{ item.value() }}
</strong>
<span
class="mt-1.5 block truncate text-[12px] leading-none font-[650] text-(--cp-text-secondary)"
>
<span class="block truncate text-[12px] leading-none font-[650] text-(--cp-text-secondary)">
{{ item.detail() }}
</span>
</div>
@@ -1,7 +1,14 @@
import { clamp } from 'es-toolkit'
import { onMounted, ref, type Ref } from 'vue'
import { onMounted, ref, watch, type Ref } from 'vue'
import { getUsageRecordInsights, getUsageRecordSummary, getUsageRecords } from '@/api'
import {
getUsageRecordEndpointDistribution,
getUsageRecordLatencyTrend,
getUsageRecordModelDistribution,
getUsageRecordSummary,
getUsageRecordTokenTrend,
getUsageRecords,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
@@ -19,6 +26,10 @@ export function useUsageRecordsTable(options: {
const summary = ref(emptySummary())
const insights = ref(emptyInsights())
const refreshingList = ref(false)
const modelDistributionSource = ref('requested')
const endpointDistributionSource = ref('inbound')
let modelDistributionRequestId = 0
let endpointDistributionRequestId = 0
const filterParams = () => ({
level: options.filterStatus.value || undefined,
search: options.searchQuery.value || undefined,
@@ -41,22 +52,18 @@ export function useUsageRecordsTable(options: {
pageSize: options.pageSize.value,
...tableParams,
})
const [result, nextSummary, nextInsights] =
const analyticsPromise =
scope === 'all'
? await Promise.all([
resultPromise,
getUsageRecordSummary(globalParams),
getUsageRecordInsights(globalParams),
])
: await Promise.all([
resultPromise,
Promise.resolve(summary.value),
Promise.resolve(insights.value),
])
? loadUsageAnalytics(globalParams)
: Promise.resolve({
summary: summary.value,
insights: insights.value,
})
const [result, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
records.value = result.items
summary.value = nextSummary
insights.value = nextInsights
summary.value = nextAnalytics.summary
insights.value = nextAnalytics.insights
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
@@ -79,6 +86,68 @@ export function useUsageRecordsTable(options: {
}
}
async function loadUsageAnalytics(globalParams = options.timeRangeParams.value) {
const [nextSummary, modelDistribution, endpointDistribution, tokenTrend, latencyTrend] =
await Promise.all([
getUsageRecordSummary(globalParams),
getUsageRecordModelDistribution({
...globalParams,
source: modelDistributionSource.value,
}),
getUsageRecordEndpointDistribution({
...globalParams,
source: endpointDistributionSource.value,
}),
getUsageRecordTokenTrend(globalParams),
getUsageRecordLatencyTrend(globalParams),
])
return {
summary: nextSummary,
insights: {
...emptyInsights(),
modelDistribution,
endpointDistribution,
tokenTrend,
latencyTrend,
},
}
}
async function loadModelDistribution() {
const requestId = ++modelDistributionRequestId
try {
const modelDistribution = await getUsageRecordModelDistribution({
...options.timeRangeParams.value,
source: modelDistributionSource.value,
})
if (requestId !== modelDistributionRequestId) return
insights.value = {
...insights.value,
modelDistribution,
}
} catch (error: any) {
toast.error(error.message || '加载失败')
}
}
async function loadEndpointDistribution() {
const requestId = ++endpointDistributionRequestId
try {
const endpointDistribution = await getUsageRecordEndpointDistribution({
...options.timeRangeParams.value,
source: endpointDistributionSource.value,
})
if (requestId !== endpointDistributionRequestId) return
insights.value = {
...insights.value,
endpointDistribution,
}
} catch (error: any) {
toast.error(error.message || '加载失败')
}
}
async function refreshUsageRecords() {
if (refreshingList.value || loading.value) return
refreshingList.value = true
@@ -93,6 +162,14 @@ export function useUsageRecordsTable(options: {
loadUsageRecords()
})
watch(modelDistributionSource, () => {
void loadModelDistribution()
})
watch(endpointDistributionSource, () => {
void loadEndpointDistribution()
})
return {
loading,
analyticsLoading,
@@ -100,6 +177,8 @@ export function useUsageRecordsTable(options: {
summary,
insights,
refreshingList,
modelDistributionSource,
endpointDistributionSource,
loadUsageRecords,
refreshUsageRecords,
}
@@ -119,13 +198,9 @@ function emptySummary() {
function emptyInsights() {
return {
models: [],
upstreamModels: [],
modelMappings: [],
endpoints: [],
upstreamEndpoints: [],
endpointPaths: [],
types: [],
trend: [],
modelDistribution: [],
endpointDistribution: [],
tokenTrend: [],
latencyTrend: [],
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ export const usageRecordColumns = [
{
key: 'accountEmail',
label: '账号',
width: '220px',
width: '240px',
fixed: 'left' as const,
ellipsis: false,
},
@@ -22,7 +22,7 @@ export const usageRecordColumns = [
{
key: 'route',
label: '端点',
width: '136px',
width: '172px',
ellipsis: false,
cellClass: 'font-mono text-[12px] font-[650]',
},
@@ -36,7 +36,7 @@ export const usageRecordColumns = [
{
key: 'tokenDetails',
label: 'TOKEN',
width: '146px',
width: '184px',
align: 'right' as const,
ellipsis: false,
},
+10 -3
View File
@@ -75,6 +75,8 @@ const {
summary,
insights,
refreshingList,
modelDistributionSource,
endpointDistributionSource,
loadUsageRecords,
refreshUsageRecords,
} = useUsageRecordsTable({
@@ -114,13 +116,18 @@ watch(timeRange, () => {
</header>
<UsageSummaryCards :summary="summary" />
<UsageInsightsGrid :insights="insights" :loading="analyticsLoading" />
<UsageInsightsGrid
v-model:model-source="modelDistributionSource"
v-model:endpoint-source="endpointDistributionSource"
:insights="insights"
:loading="analyticsLoading"
/>
<BaseCard
:padded="false"
class="mt-5 flex min-h-155 flex-col"
class="mt-5 flex flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-[520px] flex-1 px-5 pt-3 pb-4"
body-class="flex h-[520px] min-h-0 px-5 pt-3 pb-4"
>
<template #header>
<div class="grid gap-3">