mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: refine usage analytics workflow
feat: refine usage analytics workflow Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user