mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine admin usage reporting
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
//! 管理端账号 quota 展示视图。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::infra::{
|
||||
format::format_percent,
|
||||
format::{format_compact_percent, format_tokens},
|
||||
time::{china_datetime, china_relative_time},
|
||||
};
|
||||
|
||||
@@ -29,8 +31,33 @@ struct AdminAccountQuotaWindowData {
|
||||
label_display: String,
|
||||
used_percent: Option<f64>,
|
||||
used_percent_display: String,
|
||||
token_usage_display: String,
|
||||
reset_at_display: String,
|
||||
window_used_display: String,
|
||||
#[serde(skip)]
|
||||
reset_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AdminAccountQuotaUsageWindow {
|
||||
pub(crate) key: String,
|
||||
pub(crate) start: DateTime<Utc>,
|
||||
pub(crate) end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct AdminAccountQuotaWindowTokenUsage {
|
||||
total_tokens: u64,
|
||||
}
|
||||
|
||||
impl AdminAccountQuotaWindowTokenUsage {
|
||||
pub(crate) fn add_tokens(&mut self, tokens: u64) {
|
||||
self.total_tokens = self.total_tokens.saturating_add(tokens);
|
||||
}
|
||||
|
||||
fn display(self) -> String {
|
||||
format_tokens(self.total_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdminAccountQuotaData {
|
||||
@@ -48,6 +75,40 @@ impl AdminAccountQuotaData {
|
||||
.iter()
|
||||
.any(|window| window.used_percent.is_some_and(|percent| percent >= 80.0))
|
||||
}
|
||||
|
||||
pub(crate) fn usage_windows(&self) -> Vec<AdminAccountQuotaUsageWindow> {
|
||||
self.windows
|
||||
.iter()
|
||||
.filter_map(AdminAccountQuotaWindowData::usage_window)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_token_usage(
|
||||
&mut self,
|
||||
usage_by_window: &HashMap<String, AdminAccountQuotaWindowTokenUsage>,
|
||||
) {
|
||||
for window in &mut self.windows {
|
||||
window.token_usage_display =
|
||||
match (window.usage_window(), usage_by_window.get(&window.key)) {
|
||||
(Some(_), Some(usage)) => usage.display(),
|
||||
(Some(_), None) => AdminAccountQuotaWindowTokenUsage::default().display(),
|
||||
(None, _) => "-".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminAccountQuotaWindowData {
|
||||
fn usage_window(&self) -> Option<AdminAccountQuotaUsageWindow> {
|
||||
let end = self.reset_at?;
|
||||
let seconds = i64::try_from(self.window_seconds?).ok()?;
|
||||
let start = end.checked_sub_signed(Duration::seconds(seconds))?;
|
||||
(start <= end).then(|| AdminAccountQuotaUsageWindow {
|
||||
key: self.key.clone(),
|
||||
start,
|
||||
end,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn quota_data(
|
||||
@@ -111,11 +172,13 @@ fn push_monthly_quota_window(
|
||||
window_seconds,
|
||||
label_display: "月限额".to_string(),
|
||||
used_percent,
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_compact_percent),
|
||||
token_usage_display: "-".to_string(),
|
||||
reset_at_display: reset_at
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_string(), china_datetime),
|
||||
window_used_display: quota_window_used_display(reset_at, window_seconds),
|
||||
reset_at,
|
||||
});
|
||||
true
|
||||
}
|
||||
@@ -198,11 +261,13 @@ fn push_quota_window(
|
||||
window_seconds,
|
||||
label_display,
|
||||
used_percent,
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_compact_percent),
|
||||
token_usage_display: "-".to_string(),
|
||||
reset_at_display: reset_at
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_string(), china_datetime),
|
||||
window_used_display: quota_window_used_display(reset_at, window_seconds),
|
||||
reset_at,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,7 +25,10 @@ use crate::{
|
||||
},
|
||||
admin::update_payload::{parse_editable_update, EditableUpdateMessages},
|
||||
admin::{
|
||||
accounts::quota_view::{quota_data, AdminAccountQuotaData},
|
||||
accounts::quota_view::{
|
||||
quota_data, AdminAccountQuotaData, AdminAccountQuotaUsageWindow,
|
||||
AdminAccountQuotaWindowTokenUsage,
|
||||
},
|
||||
accounts::service::{
|
||||
AdminAccountError, AdminAccountHealthCheck, AdminAccountMetadata,
|
||||
AdminAccountRefreshOutcome, AdminAccountRefreshResult, AdminAccountUpdate,
|
||||
@@ -38,7 +41,10 @@ use crate::{
|
||||
format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64,
|
||||
},
|
||||
json::{clamp_limit, clamp_page, total_pages, Page},
|
||||
time::{china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339},
|
||||
time::{
|
||||
china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339,
|
||||
parse_rfc3339_utc,
|
||||
},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::model::AccountStatus,
|
||||
@@ -297,6 +303,12 @@ struct AccountModelUsageRecord {
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AccountQuotaUsageWindow {
|
||||
account_id: String,
|
||||
window: AdminAccountQuotaUsageWindow,
|
||||
}
|
||||
|
||||
impl AccountListStats {
|
||||
fn data_for(&self, account: AdminAccountMetadata) -> AdminAccountData {
|
||||
let account_id = account.id.clone();
|
||||
@@ -642,7 +654,8 @@ pub(crate) async fn account_quota(
|
||||
let quota = data.get("quota").cloned().unwrap_or(Value::Null);
|
||||
let raw = data.get("raw").cloned().unwrap_or(Value::Null);
|
||||
let quota_json = quota.to_string();
|
||||
let quota_data = quota_data("a_json, Some(Utc::now()));
|
||||
let mut quota_data = quota_data("a_json, Some(Utc::now()));
|
||||
apply_account_quota_window_token_usage(&state, &account_id, &mut quota_data).await;
|
||||
let plan_type = quota
|
||||
.get("plan_type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -858,6 +871,8 @@ async fn account_list_stats(
|
||||
.list_by_account_ids(&account_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut quota_by_account = quota_by_account.clone();
|
||||
apply_quota_window_token_usage(state, accounts, &mut quota_by_account).await;
|
||||
let models_by_account = list_current_window_model_usage(state, &usage_records).await;
|
||||
|
||||
AccountListStats {
|
||||
@@ -865,11 +880,138 @@ async fn account_list_stats(
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect(),
|
||||
quota_by_account: quota_by_account.clone(),
|
||||
quota_by_account,
|
||||
models_by_account,
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_account_quota_window_token_usage(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
quota: &mut AdminAccountQuotaData,
|
||||
) {
|
||||
let windows = quota
|
||||
.usage_windows()
|
||||
.into_iter()
|
||||
.map(|window| AccountQuotaUsageWindow {
|
||||
account_id: account_id.to_string(),
|
||||
window,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let usage_by_account = quota_window_token_usage_by_account(state, &windows).await;
|
||||
let empty_usage = HashMap::new();
|
||||
quota.apply_token_usage(usage_by_account.get(account_id).unwrap_or(&empty_usage));
|
||||
}
|
||||
|
||||
async fn apply_quota_window_token_usage(
|
||||
state: &AppState,
|
||||
accounts: &[AdminAccountMetadata],
|
||||
quota_by_account: &mut HashMap<String, AdminAccountQuotaData>,
|
||||
) {
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.filter_map(|account| {
|
||||
quota_by_account
|
||||
.get(&account.id)
|
||||
.map(|quota| (account.id.as_str(), quota))
|
||||
})
|
||||
.flat_map(|(account_id, quota)| {
|
||||
quota
|
||||
.usage_windows()
|
||||
.into_iter()
|
||||
.map(move |window| AccountQuotaUsageWindow {
|
||||
account_id: account_id.to_string(),
|
||||
window,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let usage_by_account = quota_window_token_usage_by_account(state, &windows).await;
|
||||
|
||||
for account in accounts {
|
||||
if let Some(quota) = quota_by_account.get_mut(&account.id) {
|
||||
let empty_usage = HashMap::new();
|
||||
quota.apply_token_usage(usage_by_account.get(&account.id).unwrap_or(&empty_usage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn quota_window_token_usage_by_account(
|
||||
state: &AppState,
|
||||
windows: &[AccountQuotaUsageWindow],
|
||||
) -> HashMap<String, HashMap<String, AdminAccountQuotaWindowTokenUsage>> {
|
||||
let Some(min_start) = windows.iter().map(|window| window.window.start).min() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Some(max_end) = windows.iter().map(|window| window.window.end).max() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let account_ids = windows
|
||||
.iter()
|
||||
.map(|window| window.account_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut windows_by_account = HashMap::<&str, Vec<&AdminAccountQuotaUsageWindow>>::new();
|
||||
for window in windows {
|
||||
windows_by_account
|
||||
.entry(window.account_id.as_str())
|
||||
.or_default()
|
||||
.push(&window.window);
|
||||
}
|
||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||
"select
|
||||
account_id,
|
||||
bucket_start,
|
||||
coalesce(sum(input_tokens), 0) as input_tokens,
|
||||
coalesce(sum(output_tokens), 0) as output_tokens
|
||||
from usage_time_buckets
|
||||
where account_id in (",
|
||||
);
|
||||
let mut separated = builder.separated(", ");
|
||||
for account_id in &account_ids {
|
||||
separated.push_bind(*account_id);
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
builder.push(" and bucket_start >= ");
|
||||
builder.push_bind(min_start.to_rfc3339());
|
||||
builder.push(" and bucket_start <= ");
|
||||
builder.push_bind(max_end.to_rfc3339());
|
||||
builder.push(" group by account_id, bucket_start");
|
||||
|
||||
let rows = builder
|
||||
.build()
|
||||
.fetch_all(state.services.background_tasks.accounts.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut usage_by_account =
|
||||
HashMap::<String, HashMap<String, AdminAccountQuotaWindowTokenUsage>>::new();
|
||||
|
||||
for row in rows {
|
||||
let account_id: String = row.get("account_id");
|
||||
let Ok(bucket_start) = parse_rfc3339_utc(row.get::<&str, _>("bucket_start")) else {
|
||||
continue;
|
||||
};
|
||||
let total_tokens = nonnegative_i64_to_u64(row.get::<i64, _>("input_tokens"))
|
||||
.saturating_add(nonnegative_i64_to_u64(row.get::<i64, _>("output_tokens")));
|
||||
let Some(account_windows) = windows_by_account.get(account_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for window in account_windows
|
||||
.iter()
|
||||
.filter(|window| bucket_start >= window.start && bucket_start <= window.end)
|
||||
{
|
||||
usage_by_account
|
||||
.entry(account_id.clone())
|
||||
.or_default()
|
||||
.entry(window.key.clone())
|
||||
.or_default()
|
||||
.add_tokens(total_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
usage_by_account
|
||||
}
|
||||
|
||||
async fn account_summary_data(
|
||||
state: &AppState,
|
||||
quota_by_account: &HashMap<String, AdminAccountQuotaData>,
|
||||
|
||||
@@ -219,10 +219,10 @@ struct UsageWindow {
|
||||
errors: u64,
|
||||
first_token_latency_sum: u64,
|
||||
first_token_latency_count: u64,
|
||||
max_first_token_bucket_latency: u64,
|
||||
min_first_token_bucket_latency: u64,
|
||||
latency_sum: u64,
|
||||
latency_count: u64,
|
||||
max_latency: u64,
|
||||
min_latency: u64,
|
||||
}
|
||||
|
||||
impl UsageWindow {
|
||||
@@ -243,6 +243,14 @@ impl UsageWindow {
|
||||
.checked_div(self.first_token_latency_count)
|
||||
}
|
||||
|
||||
fn max_first_token_bucket_latency(self) -> Option<u64> {
|
||||
(self.max_first_token_bucket_latency > 0).then_some(self.max_first_token_bucket_latency)
|
||||
}
|
||||
|
||||
fn min_first_token_bucket_latency(self) -> Option<u64> {
|
||||
(self.min_first_token_bucket_latency > 0).then_some(self.min_first_token_bucket_latency)
|
||||
}
|
||||
|
||||
fn avg_latency(self) -> Option<u64> {
|
||||
self.latency_sum.checked_div(self.latency_count)
|
||||
}
|
||||
@@ -252,6 +260,7 @@ impl UsageWindow {
|
||||
pub(crate) async fn dashboard_summary(
|
||||
State(state): State<AppState>,
|
||||
_auth: AdminAuth,
|
||||
Query(query): Query<DashboardTrendQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let accounts = state
|
||||
.services
|
||||
@@ -288,7 +297,7 @@ pub(crate) async fn dashboard_summary(
|
||||
let now = Utc::now();
|
||||
let time_buckets = dashboard_time_buckets(&state, now).await;
|
||||
let pool_summary = account_pool_summary(&accounts);
|
||||
let trend = dashboard_trend_data(&time_buckets, DashboardTrendKind::Usage);
|
||||
let trend = dashboard_trend_data(&time_buckets, query.kind.unwrap_or_default());
|
||||
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
|
||||
let settings = state.services.settings.current();
|
||||
let recent_usage_records = recent_events.into_iter().take(10).collect::<Vec<_>>();
|
||||
@@ -483,7 +492,9 @@ fn dashboard_trend_data_at(
|
||||
let points = buckets
|
||||
.iter()
|
||||
.map(|(bucket_start, bucket)| {
|
||||
let latency = bucket.avg_latency().unwrap_or(0);
|
||||
let latency = bucket.avg_first_token_latency().unwrap_or(0);
|
||||
let max_latency = bucket.max_first_token_bucket_latency().unwrap_or(0);
|
||||
let min_latency = bucket.min_first_token_bucket_latency().unwrap_or(0);
|
||||
let tokens = bucket.tokens();
|
||||
let success_rate = if bucket.requests > 0 {
|
||||
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0).round()
|
||||
@@ -507,10 +518,10 @@ fn dashboard_trend_data_at(
|
||||
errors_value: bucket.errors,
|
||||
latency: format_optional_duration_ms(Some(latency)),
|
||||
latency_value: latency,
|
||||
max_latency: format_optional_duration_ms(Some(bucket.max_latency)),
|
||||
max_latency_value: bucket.max_latency,
|
||||
min_latency: format_optional_duration_ms(Some(bucket.min_latency)),
|
||||
min_latency_value: bucket.min_latency,
|
||||
max_latency: format_optional_duration_ms(Some(max_latency)),
|
||||
max_latency_value: max_latency,
|
||||
min_latency: format_optional_duration_ms(Some(min_latency)),
|
||||
min_latency_value: min_latency,
|
||||
success_rate: format_percent(success_rate),
|
||||
success_rate_value: success_rate,
|
||||
}
|
||||
@@ -772,20 +783,23 @@ fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) {
|
||||
window.errors += nonnegative_i64_to_u64(record.error_count);
|
||||
window.first_token_latency_sum += nonnegative_i64_to_u64(record.first_token_latency_sum);
|
||||
window.first_token_latency_count += nonnegative_i64_to_u64(record.first_token_latency_count);
|
||||
window.latency_sum += nonnegative_i64_to_u64(record.latency_sum);
|
||||
window.latency_count += nonnegative_i64_to_u64(record.latency_count);
|
||||
let max_latency = nonnegative_i64_to_u64(record.max_latency_ms);
|
||||
if max_latency > 0 {
|
||||
window.max_latency = window.max_latency.max(max_latency);
|
||||
}
|
||||
let min_latency = nonnegative_i64_to_u64(record.min_latency_ms);
|
||||
if min_latency > 0 {
|
||||
window.min_latency = if window.min_latency == 0 {
|
||||
min_latency
|
||||
let bucket_first_token_latency = first_token_bucket_latency(record);
|
||||
if let Some(latency) = bucket_first_token_latency {
|
||||
window.max_first_token_bucket_latency = window.max_first_token_bucket_latency.max(latency);
|
||||
window.min_first_token_bucket_latency = if window.min_first_token_bucket_latency == 0 {
|
||||
latency
|
||||
} else {
|
||||
window.min_latency.min(min_latency)
|
||||
window.min_first_token_bucket_latency.min(latency)
|
||||
};
|
||||
}
|
||||
window.latency_sum += nonnegative_i64_to_u64(record.latency_sum);
|
||||
window.latency_count += nonnegative_i64_to_u64(record.latency_count);
|
||||
}
|
||||
|
||||
fn first_token_bucket_latency(record: &AdminUsageTimeBucketRecord) -> Option<u64> {
|
||||
let sum = nonnegative_i64_to_u64(record.first_token_latency_sum);
|
||||
let count = nonnegative_i64_to_u64(record.first_token_latency_count);
|
||||
sum.checked_div(count).filter(|latency| *latency > 0)
|
||||
}
|
||||
|
||||
fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics {
|
||||
|
||||
@@ -29,8 +29,8 @@ use crate::{
|
||||
},
|
||||
infra::{
|
||||
format::{
|
||||
format_compact_number, format_duration_ms, format_duration_ms_f64, format_multiplier,
|
||||
format_number, format_percent, format_precise_cost, format_rate, format_token_price,
|
||||
format_compact_number, format_cost, format_duration_ms, format_duration_ms_f64,
|
||||
format_multiplier, format_number, format_percent, format_rate, format_token_price,
|
||||
format_tokens,
|
||||
},
|
||||
json::{clamp_limit, clamp_page, NumberedPage, Page},
|
||||
@@ -607,11 +607,11 @@ fn usage_cost_details(
|
||||
service_tier: cost.service_tier,
|
||||
service_tier_display: cost.service_tier_display,
|
||||
multiplier: cost.multiplier,
|
||||
input_cost_display: format_precise_cost(cost.input_cost),
|
||||
output_cost_display: format_precise_cost(cost.output_cost),
|
||||
cache_read_cost_display: format_precise_cost(cost.cache_read_cost),
|
||||
total_cost_display: format_precise_cost(cost.total_cost),
|
||||
original_cost_display: format_precise_cost(cost.original_cost),
|
||||
input_cost_display: format_cost(cost.input_cost),
|
||||
output_cost_display: format_cost(cost.output_cost),
|
||||
cache_read_cost_display: format_cost(cost.cache_read_cost),
|
||||
total_cost_display: format_cost(cost.total_cost),
|
||||
original_cost_display: format_cost(cost.original_cost),
|
||||
input_price_display: format_token_price(cost.input_price_per_mtoken),
|
||||
output_price_display: format_token_price(cost.output_price_per_mtoken),
|
||||
cache_read_price_display: format_token_price(cost.cache_read_price_per_mtoken),
|
||||
@@ -673,9 +673,9 @@ impl UsageRecordBreakdownData {
|
||||
total_tokens_value: item.total_tokens,
|
||||
total_tokens_total: format_tokens(total_tokens_total),
|
||||
total_tokens_total_value: total_tokens_total,
|
||||
cost: format_precise_cost(item.cost),
|
||||
actual_cost: format_precise_cost(item.actual_cost),
|
||||
account_cost: format_precise_cost(item.account_cost),
|
||||
cost: format_cost(item.cost),
|
||||
actual_cost: format_cost(item.actual_cost),
|
||||
account_cost: format_cost(item.account_cost),
|
||||
average_latency_ms: format_duration_ms_f64(item.average_latency_ms),
|
||||
}
|
||||
}
|
||||
@@ -717,8 +717,8 @@ impl From<UsageRecordTrendPoint> for UsageRecordTrendPointData {
|
||||
total_tokens_value: point.total_tokens,
|
||||
cache_hit_rate: format_percent(cache_hit_rate_value),
|
||||
cache_hit_rate_value,
|
||||
cost: format_precise_cost(point.cost),
|
||||
actual_cost: format_precise_cost(point.actual_cost),
|
||||
cost: format_cost(point.cost),
|
||||
actual_cost: format_cost(point.actual_cost),
|
||||
average_latency_ms: format_duration_ms_f64(point.average_latency_ms),
|
||||
average_latency_ms_value: point.average_latency_ms,
|
||||
}
|
||||
|
||||
+11
-10
@@ -63,6 +63,15 @@ pub fn format_percent(value: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示百分比,并去掉无意义的尾随 0。
|
||||
pub fn format_compact_percent(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
format!("{}%", trim_trailing_zeroes(&format!("{value:.1}")))
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 0 到 1 的比例展示为百分比。
|
||||
pub fn format_rate(value: Option<f64>) -> String {
|
||||
value.map_or_else(|| "—".to_string(), |value| format_percent(value * 100.0))
|
||||
@@ -86,15 +95,6 @@ pub fn format_cost(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
|
||||
/// 使用六位小数展示美元成本。
|
||||
pub fn format_precise_cost(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
format!("${value:.6}")
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示每百万 token 的美元单价。
|
||||
pub fn format_token_price(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
@@ -160,7 +160,8 @@ fn format_compact_scaled(value: f64, unit: &str) -> String {
|
||||
format!("{}{unit}", trim_trailing_zeroes(&rounded))
|
||||
}
|
||||
|
||||
fn trim_trailing_zeroes(value: &str) -> String {
|
||||
/// 去掉小数字符串中无意义的尾随 0 和小数点。
|
||||
pub fn trim_trailing_zeroes(value: &str) -> String {
|
||||
value
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
|
||||
@@ -132,6 +132,8 @@ fn window_from_rate_limit(window: Option<&Value>) -> Value {
|
||||
"reset_at": window.get("reset_at").and_then(positive_i64),
|
||||
"window_minutes": window_minutes,
|
||||
"limit_reached": used_percent >= 100.0,
|
||||
"used": first_present(window, &["used_tokens", "used", "used_credits", "usage", "consumed_tokens", "consumed"]),
|
||||
"limit": first_present(window, &["limit_tokens", "limit", "limit_credits", "quota", "total_tokens", "total"]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,8 +203,10 @@ fn monthly_limit_from_snapshots(snapshots: &[Value]) -> Option<Value> {
|
||||
"reset_at": bucket.get("reset_at").cloned().unwrap_or(Value::Null),
|
||||
"window_minutes": bucket.get("window_minutes").cloned().unwrap_or(Value::Null),
|
||||
"limit_reached": bucket.get("limit_reached").cloned().unwrap_or(Value::Bool(false)),
|
||||
"used_credits": Value::Null,
|
||||
"limit_credits": Value::Null,
|
||||
"used": bucket.get("used").cloned().unwrap_or(Value::Null),
|
||||
"limit": bucket.get("limit").cloned().unwrap_or(Value::Null),
|
||||
"used_credits": bucket.get("used_credits").cloned().unwrap_or(Value::Null),
|
||||
"limit_credits": bucket.get("limit_credits").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -214,6 +218,12 @@ fn remaining_percent(used_percent: f64) -> i64 {
|
||||
(100.0 - used_percent.clamp(0.0, 100.0)).round() as i64
|
||||
}
|
||||
|
||||
fn first_present(value: &Value, keys: &[&str]) -> Value {
|
||||
keys.iter()
|
||||
.find_map(|key| value.get(*key).filter(|value| !value.is_null()).cloned())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn rate_limit_blocked(rate_limit: &Value) -> bool {
|
||||
rate_limit
|
||||
.get("limit_reached")
|
||||
|
||||
@@ -10,8 +10,9 @@ use std::{
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use tokio_tungstenite::{connect_async_tls_with_config, Connector};
|
||||
use tungstenite::{
|
||||
@@ -876,8 +877,12 @@ fn stream_websocket_response(
|
||||
.await;
|
||||
});
|
||||
|
||||
let body = futures::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv().await.map(|item| (item, rx))
|
||||
});
|
||||
|
||||
CodexWebSocketStreamingExchange {
|
||||
body: Box::pin(rx),
|
||||
body: Box::pin(body),
|
||||
turn_state: response_metadata.turn_state,
|
||||
set_cookie_headers: response_metadata.set_cookie_headers,
|
||||
rate_limit_headers: response_metadata.rate_limit_headers,
|
||||
@@ -894,7 +899,7 @@ async fn forward_websocket_response_stream(
|
||||
prefetched_text: Option<String>,
|
||||
rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
|
||||
turn_state_update: CodexWebSocketTurnStateUpdate,
|
||||
mut tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
|
||||
tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
|
||||
) {
|
||||
let mut pool_return = pool_return;
|
||||
let mut prefetched_text = prefetched_text;
|
||||
@@ -903,12 +908,20 @@ async fn forward_websocket_response_stream(
|
||||
let raw = if let Some(text) = prefetched_text.take() {
|
||||
text
|
||||
} else {
|
||||
let message = match next_websocket_message(
|
||||
&mut websocket,
|
||||
receive_idle_timeout(saw_response_frame),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let message = tokio::select! {
|
||||
biased;
|
||||
// 客户端断开下游 SSE 流:立即丢弃连接并释放池 slot,
|
||||
// 不再傻等上游 idle 超时(否则同会话后续请求会一直 bypass/busy)。
|
||||
() = tx.closed() => {
|
||||
discard_stream_websocket(websocket, pool_return).await;
|
||||
return;
|
||||
}
|
||||
message = next_websocket_message(
|
||||
&mut websocket,
|
||||
receive_idle_timeout(saw_response_frame),
|
||||
) => message,
|
||||
};
|
||||
let message = match message {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
discard_stream_websocket(websocket, pool_return).await;
|
||||
|
||||
@@ -17,7 +17,6 @@ const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
|
||||
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
|
||||
const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(25);
|
||||
const DEFAULT_PING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_LIVENESS_TIMEOUT: Duration = Duration::from_millis(62_500);
|
||||
|
||||
/// WebSocket 连接池 key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -99,7 +98,9 @@ impl Default for CodexWebSocketPoolConfig {
|
||||
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
|
||||
ping_interval: Some(DEFAULT_PING_INTERVAL),
|
||||
ping_timeout: DEFAULT_PING_TIMEOUT,
|
||||
liveness_timeout: Some(DEFAULT_LIVENESS_TIMEOUT),
|
||||
// idle 连接不设失活截断:靠 ping/pong 保活,只在 max_age(55 分钟)
|
||||
// 或 ping 失败时关闭,最大化跨轮复用(对齐 Codex CLI 的长连接策略)。
|
||||
liveness_timeout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
window_input_tokens: 120_000,
|
||||
window_output_tokens: 4_000,
|
||||
window_cached_tokens: 40_000,
|
||||
window_started_at: "2026-06-20T00:00:00Z",
|
||||
window_reset_at: "2026-06-27T00:00:00Z",
|
||||
window_started_at: "2026-06-22T12:51:00Z",
|
||||
window_reset_at: "2026-06-29T12:51:00Z",
|
||||
limit_window_seconds: 604_800,
|
||||
last_used_at: "2026-06-23T08:50:13Z",
|
||||
},
|
||||
@@ -217,6 +217,20 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-22T10:00:00+00:00")
|
||||
.bind("acct_stats")
|
||||
.bind("gpt-five-hour-window")
|
||||
.bind(1)
|
||||
.bind(2_000)
|
||||
.bind(500)
|
||||
.bind(100)
|
||||
.bind("2026-06-22T10:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-19T23:45:00+00:00")
|
||||
.bind("acct_stats")
|
||||
.bind("gpt-outside-window")
|
||||
@@ -253,10 +267,13 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
assert_eq!(item["quota"]["windows"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(item["quota"]["windows"][0]["labelDisplay"], "月限额");
|
||||
assert_eq!(item["quota"]["windows"][0]["group"], "monthly");
|
||||
assert_eq!(item["quota"]["windows"][0]["tokenUsageDisplay"], "1.12M");
|
||||
assert_eq!(item["quota"]["windows"][1]["labelDisplay"], "5小时限额");
|
||||
assert_eq!(item["quota"]["windows"][1]["group"], "shortTerm");
|
||||
assert_eq!(item["quota"]["windows"][1]["tokenUsageDisplay"], "2.5K");
|
||||
assert_eq!(item["quota"]["windows"][2]["labelDisplay"], "周限额");
|
||||
assert_eq!(item["quota"]["windows"][2]["group"], "shortTerm");
|
||||
assert_eq!(item["quota"]["windows"][2]["tokenUsageDisplay"], "124K");
|
||||
assert!(item["quota"]["windows"][2]["windowUsedDisplay"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains(" / 7.0d")));
|
||||
|
||||
@@ -18,6 +18,11 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
"used_percent": 12,
|
||||
"reset_at": 1_800_000_000,
|
||||
"limit_window_seconds": 18_000
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 34,
|
||||
"reset_at": 1_800_604_800,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
})))
|
||||
@@ -46,6 +51,29 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (
|
||||
bucket_start,
|
||||
account_id,
|
||||
model,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cached_tokens,
|
||||
updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2027-01-15T04:00:00+00:00")
|
||||
.bind("acct_quota_cookie")
|
||||
.bind("gpt-5")
|
||||
.bind(1)
|
||||
.bind(1_500)
|
||||
.bind(500)
|
||||
.bind(300)
|
||||
.bind("2027-01-15T04:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteCookieStore::new(pool.clone())
|
||||
.capture_set_cookie(
|
||||
"acct_quota_cookie",
|
||||
@@ -87,4 +115,16 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
body["data"]["quotaData"]["windows"][0]["group"],
|
||||
"shortTerm"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][0]["tokenUsageDisplay"],
|
||||
"2K"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][1]["labelDisplay"],
|
||||
"周限额"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][1]["tokenUsageDisplay"],
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use codex_proxy_rs::{
|
||||
},
|
||||
infra::{
|
||||
database::connect_sqlite,
|
||||
time::{china_day_start, china_hour},
|
||||
time::{china_day_start, china_hour, china_hour_start},
|
||||
},
|
||||
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
|
||||
runtime::{
|
||||
@@ -155,7 +155,7 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_trend(app).await;
|
||||
let body = dashboard_trend(app, "usage").await;
|
||||
let point = body["data"]["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -167,6 +167,78 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() {
|
||||
assert_eq!(point["tokensValue"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_latency_trend_should_use_first_token_latency() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-trend-first-token-latency.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let current_hour_start = china_hour_start(now);
|
||||
let sample_hour_start = if now - current_hour_start > Duration::minutes(31) {
|
||||
current_hour_start
|
||||
} else {
|
||||
current_hour_start - Duration::hours(1)
|
||||
};
|
||||
let mut slow_completion =
|
||||
usage_record_with_tokens(sample_hour_start + Duration::minutes(1), 10);
|
||||
slow_completion.latency_ms = Some(10_000);
|
||||
slow_completion.metadata["firstTokenMs"] = json!(100_i64);
|
||||
let mut fast_completion =
|
||||
usage_record_with_tokens(sample_hour_start + Duration::minutes(16), 10);
|
||||
fast_completion.latency_ms = Some(1_000);
|
||||
fast_completion.metadata["firstTokenMs"] = json!(500_i64);
|
||||
store.append(&slow_completion).await.unwrap();
|
||||
store.append(&fast_completion).await.unwrap();
|
||||
|
||||
let body = dashboard_trend(app, "latency").await;
|
||||
let point = body["data"]["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|point| point["requestsValue"] == 2)
|
||||
.expect("latency trend should include the inserted usage bucket");
|
||||
let summary = body["data"]["summary"].as_array().unwrap();
|
||||
|
||||
assert_eq!(body["data"]["kind"], "latency");
|
||||
assert_eq!(point["latencyValue"], 300);
|
||||
assert_eq!(point["latency"], "300 ms");
|
||||
assert_eq!(point["maxLatencyValue"], 500);
|
||||
assert_eq!(point["maxLatency"], "500 ms");
|
||||
assert_eq!(point["minLatencyValue"], 100);
|
||||
assert_eq!(point["minLatency"], "100 ms");
|
||||
assert_eq!(summary[0]["value"], "300 ms");
|
||||
assert_eq!(summary[1]["value"], "500 ms");
|
||||
assert_eq!(summary[2]["value"], "100 ms");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_return_requested_trend_kind() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-summary-requested-trend-kind.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let mut record = usage_record_with_tokens(now - Duration::seconds(1), 10);
|
||||
record.latency_ms = Some(10_000);
|
||||
record.metadata["firstTokenMs"] = json!(250_i64);
|
||||
store.append(&record).await.unwrap();
|
||||
|
||||
let body = dashboard_summary_with_kind(app, "latency").await;
|
||||
|
||||
assert_eq!(body["data"]["trend"]["kind"], "latency");
|
||||
assert!(
|
||||
body["data"]["trend"]["summary"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["value"] == "250 ms"),
|
||||
"summary should include first-token latency trend values: {body:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tier() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
@@ -408,11 +480,19 @@ async fn dashboard_test_app(
|
||||
}
|
||||
|
||||
async fn dashboard_summary(app: axum::Router) -> Value {
|
||||
dashboard_summary_uri(app, "/api/admin/dashboard/summary").await
|
||||
}
|
||||
|
||||
async fn dashboard_summary_with_kind(app: axum::Router, kind: &str) -> Value {
|
||||
dashboard_summary_uri(app, &format!("/api/admin/dashboard/summary?kind={kind}")).await
|
||||
}
|
||||
|
||||
async fn dashboard_summary_uri(app: axum::Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.uri(uri)
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_dashboard_summary")
|
||||
.body(Body::empty())
|
||||
@@ -424,12 +504,12 @@ async fn dashboard_summary(app: axum::Router) -> Value {
|
||||
response_json(response).await
|
||||
}
|
||||
|
||||
async fn dashboard_trend(app: axum::Router) -> Value {
|
||||
async fn dashboard_trend(app: axum::Router, kind: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/dashboard/trend?kind=usage")
|
||||
.uri(format!("/api/admin/dashboard/trend?kind={kind}"))
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_dashboard_trend")
|
||||
.body(Body::empty())
|
||||
|
||||
@@ -183,10 +183,10 @@ async fn admin_usage_records_should_return_table_display_fields() {
|
||||
assert_eq!(row["tokenDetails"]["reasoningTokens"], 12);
|
||||
assert_eq!(row["tokenDetails"]["totalTokens"], 1200);
|
||||
assert_eq!(row["tokenDetails"]["inputTokensDisplay"], "1,000");
|
||||
assert_eq!(row["costDetails"]["inputCostDisplay"], "$0.004500");
|
||||
assert_eq!(row["costDetails"]["outputCostDisplay"], "$0.006000");
|
||||
assert_eq!(row["costDetails"]["cacheReadCostDisplay"], "$0.000050");
|
||||
assert_eq!(row["costDetails"]["totalCostDisplay"], "$0.010550");
|
||||
assert_eq!(row["costDetails"]["inputCostDisplay"], "$0.00");
|
||||
assert_eq!(row["costDetails"]["outputCostDisplay"], "$0.01");
|
||||
assert_eq!(row["costDetails"]["cacheReadCostDisplay"], "$0.00");
|
||||
assert_eq!(row["costDetails"]["totalCostDisplay"], "$0.01");
|
||||
assert_eq!(
|
||||
row["costDetails"]["inputPriceDisplay"],
|
||||
"$5.0000 / 1M Token"
|
||||
@@ -343,7 +343,7 @@ async fn admin_usage_record_insight_cards_should_aggregate_filtered_usage_dimens
|
||||
assert_eq!(requested_body["data"][0]["totalTokensValue"], 42);
|
||||
assert_eq!(requested_body["data"][0]["totalTokensTotal"], "48");
|
||||
assert_eq!(requested_body["data"][0]["totalTokensTotalValue"], 48);
|
||||
assert_eq!(requested_body["data"][0]["actualCost"], "$0.000483");
|
||||
assert_eq!(requested_body["data"][0]["actualCost"], "$0.00");
|
||||
|
||||
let upstream_models = app
|
||||
.clone()
|
||||
@@ -422,8 +422,8 @@ async fn admin_usage_record_insight_cards_should_aggregate_filtered_usage_dimens
|
||||
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]["actualCost"], "$0.000538");
|
||||
assert_eq!(token_body["data"][0]["cost"], "$0.000538");
|
||||
assert_eq!(token_body["data"][0]["actualCost"], "$0.00");
|
||||
assert_eq!(token_body["data"][0]["cost"], "$0.00");
|
||||
|
||||
let latency_trend = app
|
||||
.oneshot(
|
||||
|
||||
@@ -246,6 +246,92 @@ async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_release_slot_when_client_drops_stream() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
// 第一个连接:发一帧后保持沉默(模拟上游不再发帧、也不发 terminal)。
|
||||
// slot 只能靠客户端断开来释放,隔离验证 tx.closed() 机制。
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
|
||||
let _first_message = first_websocket.next().await.unwrap().unwrap();
|
||||
first_websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "streaming has begun"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 第二个连接:客户端断开释放 slot 后,同 key 请求应新建连接。
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
let mut second_websocket = accept_codex_test_websocket(second_stream).await;
|
||||
let _second_message = second_websocket.next().await.unwrap().unwrap();
|
||||
second_websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_released_second", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
drop(first_websocket);
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.with_websocket_pool(pool);
|
||||
let request = pooled_websocket_request("conversation-drop");
|
||||
|
||||
// 起流式请求:slot 变 Busy。
|
||||
let mut stream = backend
|
||||
.create_response_stream(
|
||||
&request,
|
||||
request_context("req_drop_first", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("first pooled websocket stream should start")
|
||||
.body;
|
||||
let first_chunk = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("stream should yield an initial chunk")
|
||||
.expect("stream chunk should be valid");
|
||||
assert!(std::str::from_utf8(&first_chunk)
|
||||
.unwrap()
|
||||
.contains("streaming has begun"));
|
||||
|
||||
// 客户端断开:drop stream → rx 被 drop → tx.closed() 完成 →
|
||||
// 代理丢弃上游连接并释放 slot(不再等 idle 超时)。
|
||||
drop(stream);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// 同 key 的后续请求:slot 已释放 → 新建连接(new),而非 bypass(busy)。
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_drop_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("second request should succeed after slot release");
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(second.body.contains("resp_released_second"));
|
||||
let decision = second.websocket_pool_decision.unwrap();
|
||||
assert_eq!(
|
||||
decision.kind(),
|
||||
"new",
|
||||
"client-drop must release the pool slot so the next same-key request builds a fresh connection instead of bypassing as busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_bypass_new_keys_after_account_cap() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import request from '../request'
|
||||
|
||||
export function getDashboardSummary() {
|
||||
export function getDashboardSummary(params?: any) {
|
||||
return request({
|
||||
url: '/api/admin/dashboard/summary',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ watch(
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto mb-8" :class="collapsed ? 'w-11' : 'w-full'">
|
||||
<div class="mt-auto mb-6" :class="collapsed ? 'w-11' : 'w-full'">
|
||||
<div
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
:class="
|
||||
|
||||
@@ -6,12 +6,23 @@ const props = defineProps<{
|
||||
account: any
|
||||
}>()
|
||||
|
||||
const FIVE_HOUR_WINDOW_SECONDS = 18_000
|
||||
const WEEK_WINDOW_SECONDS = 604_800
|
||||
const MONTH_WINDOW_SECONDS = 2_592_000
|
||||
|
||||
const quotaWindows = computed(() => props.account.quota.windows as any[])
|
||||
const visibleQuotaWindows = computed(() => quotaWindows.value.slice(0, 2))
|
||||
const summaryClass = computed(() =>
|
||||
visibleQuotaWindows.value.length === 1
|
||||
? 'flex h-13 w-full max-w-31 min-w-0 flex-col justify-center whitespace-normal py-0.5'
|
||||
: 'grid h-13 w-full max-w-31 min-w-0 grid-rows-2 gap-1.5 whitespace-normal py-0.5',
|
||||
const visibleQuotaWindows = computed(() => {
|
||||
const knownWindows = [...quotaWindows.value]
|
||||
.filter((window) => window.group === 'shortTerm' || window.group === 'monthly')
|
||||
.sort((a, b) => quotaWindowOrder(a) - quotaWindowOrder(b))
|
||||
|
||||
return knownWindows.length > 0 ? knownWindows : quotaWindows.value
|
||||
})
|
||||
const summaryClass = computed(
|
||||
() =>
|
||||
`grid w-full max-w-40 min-w-0 gap-2 whitespace-normal py-0.5 ${
|
||||
visibleQuotaWindows.value.length === 1 ? 'min-h-13 content-center' : ''
|
||||
}`,
|
||||
)
|
||||
|
||||
function quotaWindowPercent(window?: any) {
|
||||
@@ -38,17 +49,48 @@ function quotaWindowBarClass(window?: any) {
|
||||
}
|
||||
return 'bg-(--cp-success)'
|
||||
}
|
||||
|
||||
function quotaWindowUsageText(window?: any) {
|
||||
return `${windowPercentDisplay(window)}/${windowUsageDisplay(window)}`
|
||||
}
|
||||
|
||||
function windowUsageDisplay(window?: any) {
|
||||
const display = window?.tokenUsageDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '-'
|
||||
}
|
||||
|
||||
function windowPercentDisplay(window?: any) {
|
||||
const display = window?.usedPercentDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '-'
|
||||
}
|
||||
|
||||
function quotaWindowOrder(window?: any) {
|
||||
const seconds = window?.windowSeconds
|
||||
if (quotaWindowMatches(seconds, FIVE_HOUR_WINDOW_SECONDS)) return 0
|
||||
if (quotaWindowMatches(seconds, WEEK_WINDOW_SECONDS)) return 1
|
||||
if (quotaWindowMatches(seconds, MONTH_WINDOW_SECONDS)) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function quotaWindowMatches(actual: unknown, expected: number) {
|
||||
return (
|
||||
typeof actual === 'number' &&
|
||||
Number.isFinite(actual) &&
|
||||
actual > 0 &&
|
||||
Math.abs(actual - expected) <= expected / 20
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="quotaWindows.length > 0" :class="summaryClass">
|
||||
<div v-for="window in visibleQuotaWindows" :key="window.key" class="min-w-0">
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]">
|
||||
<span class="truncate text-(--cp-text-secondary)">
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-[10px] leading-none font-[760]">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-(--cp-text-primary)">
|
||||
{{ window.usedPercentDisplay }}
|
||||
<span class="shrink-0 text-(--cp-text-secondary)">
|
||||
{{ quotaWindowUsageText(window) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-(--cp-default-border)">
|
||||
|
||||
@@ -20,8 +20,7 @@ export const accountColumns = [
|
||||
{ key: 'identity', label: '邮箱', flex: 2.6, minWidth: '260px', cellClass: relaxedCellClass },
|
||||
{ key: 'status', label: '状态', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'planType', label: '套餐', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.4, minWidth: '210px', cellClass: relaxedCellClass },
|
||||
{ key: 'quota', label: '额度', flex: 0.8, minWidth: '112px', cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.8, minWidth: '250px', cellClass: relaxedCellClass },
|
||||
{
|
||||
key: 'updatedAtDisplay',
|
||||
label: '最后使用',
|
||||
|
||||
@@ -107,11 +107,6 @@ const {
|
||||
} = useAccountsTable(accounts, selectedIds)
|
||||
|
||||
bindAccountLoader(loadAccounts)
|
||||
|
||||
function quotaTokenDisplay(account: any) {
|
||||
const display = account?.usage?.totalTokensDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '0'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -237,12 +232,6 @@ function quotaTokenDisplay(account: any) {
|
||||
<AccountQuotaSummaryCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #quota="{ row }">
|
||||
<span class="font-mono text-[12px] font-[760] text-(--cp-text-primary)">
|
||||
{{ quotaTokenDisplay(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<AccountTableActions
|
||||
:account="row"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import AccountPlanBadge from '../../accounts/components/AccountPlanBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: any[]
|
||||
@@ -216,16 +217,13 @@ const quotaToneClasses: Record<string, string> = {
|
||||
{{ account.name[0] }}
|
||||
</span>
|
||||
<span class="flex min-w-0 max-w-full flex-col gap-1.5 xl:gap-4.25">
|
||||
<span class="flex min-w-0 max-w-full items-start gap-1.5">
|
||||
<span class="flex min-w-0 max-w-full items-center gap-2">
|
||||
<strong
|
||||
class="min-w-0 truncate text-sm leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
:title="account.name"
|
||||
>{{ account.name }}</strong
|
||||
>
|
||||
<small
|
||||
class="mt-0.75 shrink-0 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)"
|
||||
>{{ account.plan }}</small
|
||||
>
|
||||
<AccountPlanBadge class="shrink-0" :plan-type="account.planType" />
|
||||
</span>
|
||||
<span
|
||||
class="block min-w-0 max-w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
@@ -8,6 +8,8 @@ import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
|
||||
import { useUiStore } from '../../../stores/modules/ui'
|
||||
|
||||
type TrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
const props = defineProps<{
|
||||
points: any[]
|
||||
summary: any[]
|
||||
@@ -15,15 +17,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
trendChange: [tab: string]
|
||||
trendChange: [kind: TrendKind]
|
||||
}>()
|
||||
|
||||
const tabs = [
|
||||
{ label: '用量', value: '用量' },
|
||||
{ label: '延迟', value: '延迟' },
|
||||
{ label: '错误', value: '错误' },
|
||||
{ label: '用量', value: 'usage' },
|
||||
{ label: '延迟', value: 'latency' },
|
||||
{ label: '错误', value: 'errors' },
|
||||
]
|
||||
const activeTab = ref('用量')
|
||||
const activeKind = defineModel<TrendKind>('kind', { required: true })
|
||||
const { themeRevision } = storeToRefs(useUiStore())
|
||||
|
||||
const hasSamples = computed(() =>
|
||||
@@ -104,12 +106,12 @@ function formatTooltip(params: unknown) {
|
||||
}
|
||||
|
||||
function tooltipDisplayValue(point: any, name: string, value: string): string {
|
||||
if (activeTab.value === '用量') {
|
||||
if (activeKind.value === 'usage') {
|
||||
if (name === '输入') return point?.inputTokens ?? value
|
||||
if (name === '输出') return point?.outputTokens ?? value
|
||||
if (name === '缓存') return point?.cachedTokens ?? value
|
||||
}
|
||||
if (activeTab.value === '延迟') {
|
||||
if (activeKind.value === 'latency') {
|
||||
if (name === '平均') return point?.latency ?? value
|
||||
if (name === '最高') return point?.maxLatency ?? value
|
||||
if (name === '最低') return point?.minLatency ?? value
|
||||
@@ -133,7 +135,7 @@ function tooltipIndex(source: unknown) {
|
||||
}
|
||||
|
||||
function getSeries() {
|
||||
if (activeTab.value === '用量') {
|
||||
if (activeKind.value === 'usage') {
|
||||
return [
|
||||
lineSeries(
|
||||
'输入',
|
||||
@@ -153,7 +155,7 @@ function getSeries() {
|
||||
),
|
||||
]
|
||||
}
|
||||
if (activeTab.value === '延迟') {
|
||||
if (activeKind.value === 'latency') {
|
||||
return [
|
||||
lineSeries(
|
||||
'平均',
|
||||
@@ -226,16 +228,20 @@ function lineSeries(name: string, data: number[], color: string, area = false, y
|
||||
function summaryMarkerStyle(item: any) {
|
||||
return item.colorVar ? { backgroundColor: `var(${item.colorVar})` } : undefined
|
||||
}
|
||||
|
||||
function handleTrendChange(value: string) {
|
||||
emit('trendChange', value as TrendKind)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-model="activeTab"
|
||||
v-model="activeKind"
|
||||
:options="tabs"
|
||||
class="w-full max-w-61.5 sm:w-61.5"
|
||||
@update:model-value="emit('trendChange', $event)"
|
||||
@update:model-value="handleTrendChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
type DashboardTrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
export function useDashboard(): any {
|
||||
const activeTrendKind = ref<DashboardTrendKind>('usage')
|
||||
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
|
||||
const trendPoints = ref<any[]>([])
|
||||
const trendSummary = ref<any[]>([])
|
||||
@@ -32,6 +35,7 @@ export function useDashboard(): any {
|
||||
const refreshing = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const lastRefreshedAt = ref('')
|
||||
let trendRequestId = 0
|
||||
const { resume: startAutoRefresh } = useIntervalFn(
|
||||
() => {
|
||||
void loadDashboardData()
|
||||
@@ -41,10 +45,10 @@ export function useDashboard(): any {
|
||||
)
|
||||
|
||||
async function loadDashboardData() {
|
||||
if (loading.value || refreshing.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const summary = await getDashboardSummary()
|
||||
applySummary(summary)
|
||||
await loadDashboardSnapshot()
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
@@ -57,8 +61,7 @@ export function useDashboard(): any {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
const summary = await getDashboardSummary()
|
||||
applySummary(summary)
|
||||
await loadDashboardSnapshot()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh dashboard data:', error)
|
||||
@@ -67,21 +70,43 @@ export function useDashboard(): any {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrend(tab: string) {
|
||||
const kind = trendKindFromTab(tab)
|
||||
async function loadTrend(kind: string) {
|
||||
const trendKind = normalizeTrendKind(kind)
|
||||
activeTrendKind.value = trendKind
|
||||
const requestId = nextTrendRequestId()
|
||||
try {
|
||||
trendLoading.value = true
|
||||
applyTrend(await getDashboardTrend({ kind }))
|
||||
const trend = await getDashboardTrend({ kind: trendKind })
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
applyTrend(trend)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard trend:', error)
|
||||
} finally {
|
||||
trendLoading.value = false
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboardSnapshot() {
|
||||
const trendKind = activeTrendKind.value
|
||||
const requestId = nextTrendRequestId()
|
||||
try {
|
||||
const summary = await getDashboardSummary({ kind: trendKind })
|
||||
applySummary(summary)
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
applyTrend(summary.trend)
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySummary(summary: any) {
|
||||
metrics.value = metricCards(summary)
|
||||
applyTrend(summary.trend)
|
||||
healthTimeline.value = summary.healthTimeline
|
||||
accountUsage.value = summary.accountUsage.map(accountUsageItem)
|
||||
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
|
||||
@@ -294,7 +319,7 @@ export function useDashboard(): any {
|
||||
return {
|
||||
name: item.name,
|
||||
email: item.email || '-',
|
||||
plan: item.plan || 'free',
|
||||
planType: item.plan || 'free',
|
||||
requests: item.requests,
|
||||
tokens: item.tokens,
|
||||
lastUsed: item.lastUsed || '-',
|
||||
@@ -333,12 +358,20 @@ export function useDashboard(): any {
|
||||
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
|
||||
}
|
||||
|
||||
function trendKindFromTab(tab: string) {
|
||||
if (tab === '延迟') return 'latency'
|
||||
if (tab === '错误') return 'errors'
|
||||
function normalizeTrendKind(kind: string): DashboardTrendKind {
|
||||
if (kind === 'latency' || kind === 'errors') return kind
|
||||
return 'usage'
|
||||
}
|
||||
|
||||
function nextTrendRequestId() {
|
||||
trendRequestId += 1
|
||||
return trendRequestId
|
||||
}
|
||||
|
||||
function isCurrentTrendRequest(requestId: number, kind: DashboardTrendKind) {
|
||||
return requestId === trendRequestId && activeTrendKind.value === kind
|
||||
}
|
||||
|
||||
function trendSummaryTone(label: string) {
|
||||
if (label.includes('错误')) return 'danger'
|
||||
if (label.includes('最高')) return 'warning'
|
||||
@@ -380,6 +413,7 @@ export function useDashboard(): any {
|
||||
loading,
|
||||
refreshing,
|
||||
trendLoading,
|
||||
activeTrendKind,
|
||||
lastRefreshedAt,
|
||||
metrics,
|
||||
trendPoints,
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
loading,
|
||||
refreshing,
|
||||
trendLoading,
|
||||
activeTrendKind,
|
||||
metrics,
|
||||
trendPoints,
|
||||
trendSummary,
|
||||
@@ -65,6 +66,7 @@ const {
|
||||
class="mt-6 grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,948fr)_minmax(0,608fr)] 2xl:gap-7"
|
||||
>
|
||||
<RequestTrendCard
|
||||
v-model:kind="activeTrendKind"
|
||||
:points="trendPoints"
|
||||
:summary="trendSummary"
|
||||
:loading="trendLoading"
|
||||
|
||||
@@ -36,8 +36,8 @@ const billingItems = computed(() => {
|
||||
|
||||
function itemValueClass(tone?: string, accent?: boolean) {
|
||||
if (tone === 'success') return 'text-(--cp-success-text)'
|
||||
if (tone === 'info' || accent) return 'text-[#93c5fd]'
|
||||
return 'text-white'
|
||||
if (tone === 'info' || accent) return 'text-(--cp-info-text)'
|
||||
return 'text-(--cp-text-primary)'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,7 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
trigger="hover"
|
||||
placement="right"
|
||||
width="248px"
|
||||
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
|
||||
panel-class="!p-3 text-(--cp-text-primary)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
@@ -64,9 +64,11 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-2 text-[12px] leading-none [&_span:first-child]:whitespace-nowrap">
|
||||
<p class="m-0 font-[760] text-white">成本明细</p>
|
||||
<div class="grid gap-1.5 text-[#cbd5e1]">
|
||||
<div
|
||||
class="grid gap-2 text-[12px] leading-none [&_span:first-child]:whitespace-nowrap [&_span:last-child]:whitespace-nowrap"
|
||||
>
|
||||
<p class="m-0 font-[760] text-(--cp-text-primary)">成本明细</p>
|
||||
<div class="grid gap-1.5 text-(--cp-text-secondary)">
|
||||
<div v-for="item in mainCostItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760]" :class="itemValueClass(undefined, item.accent)">
|
||||
@@ -74,7 +76,9 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1.5 border-t border-white/12 pt-2 text-[#cbd5e1]">
|
||||
<div
|
||||
class="mt-1 grid gap-1.5 border-t border-(--cp-divider-subtle) pt-2 text-(--cp-text-secondary)"
|
||||
>
|
||||
<div v-for="item in billingItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760]" :class="itemValueClass(item.tone)">
|
||||
|
||||
@@ -40,8 +40,8 @@ const tokenItems = computed(() => [
|
||||
<BasePopover
|
||||
trigger="hover"
|
||||
placement="right"
|
||||
width="168px"
|
||||
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
|
||||
width="196px"
|
||||
panel-class="!p-3 text-(--cp-text-primary)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
@@ -54,18 +54,18 @@ const tokenItems = computed(() => [
|
||||
</template>
|
||||
|
||||
<div class="grid gap-2 text-[12px] leading-none">
|
||||
<p class="m-0 font-[760] text-white">Token 明细</p>
|
||||
<div class="grid gap-1.5 text-[#cbd5e1]">
|
||||
<p class="m-0 font-[760] text-(--cp-text-primary)">Token 明细</p>
|
||||
<div class="grid gap-1.5 text-(--cp-text-secondary)">
|
||||
<div v-for="item in tokenItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760] text-white">
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
<span class="whitespace-nowrap font-mono font-[760] text-(--cp-text-primary)">
|
||||
{{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between border-t border-white/12 pt-2">
|
||||
<span class="text-[#cbd5e1]">总 Token</span>
|
||||
<span class="font-mono font-[760] text-[#93c5fd]">
|
||||
<div class="mt-1 flex justify-between border-t border-(--cp-divider-subtle) pt-2">
|
||||
<span class="whitespace-nowrap text-(--cp-text-secondary)">总 Token</span>
|
||||
<span class="whitespace-nowrap font-mono font-[760] text-(--cp-info-text)">
|
||||
{{ tokenDetails.totalTokensDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function useUsageRecordsTable(options: {
|
||||
if (refreshingList.value || loading.value) return
|
||||
refreshingList.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadUsageRecords)
|
||||
await withMinimumDuration(() => loadUsageRecords('table'))
|
||||
} finally {
|
||||
refreshingList.value = false
|
||||
}
|
||||
|
||||
@@ -170,14 +170,6 @@ export function formatLatencyAverage(value: number | null) {
|
||||
return `${Math.round(value)} ms`
|
||||
}
|
||||
|
||||
export function formatCostMetric(value: number) {
|
||||
if (!value) {
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
return `$${Number(value || 0).toFixed(6)}`
|
||||
}
|
||||
|
||||
export function usageRecordType(record: any) {
|
||||
if (record?.transport === 'websocket') {
|
||||
return 'WS'
|
||||
|
||||
Reference in New Issue
Block a user