mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76291f448c | ||
|
|
c10efea12c | ||
|
|
806f7ad9ee | ||
|
|
632edee7dc | ||
|
|
22518d064c | ||
|
|
64c1ec2afd | ||
|
|
a3f0c1b028 |
Generated
+4
-2
@@ -2253,8 +2253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -2262,6 +2263,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
|
||||
@@ -57,7 +57,5 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
|
||||
@@ -1190,10 +1190,10 @@ pub struct DashboardWireProfile {
|
||||
pub release: Option<DashboardDesktopRelease>,
|
||||
}
|
||||
|
||||
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
|
||||
/// Provider 画像的差异字段,以稳定标识传递语义,名称由前端本地化
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireAttribute {
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
|
||||
},
|
||||
user_agent: "gateway-admin-test".to_owned(),
|
||||
attributes: vec![DashboardWireAttribute {
|
||||
label: "Core".to_owned(),
|
||||
key: "core".to_owned(),
|
||||
value: "test".to_owned(),
|
||||
}],
|
||||
verified_at: Some(chrono::Utc::now()),
|
||||
|
||||
@@ -241,6 +241,10 @@ pub(super) fn account_quota_view(
|
||||
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
|
||||
(
|
||||
AccountQuotaView {
|
||||
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
|
||||
refreshed_at_relative: quota
|
||||
.observed_at
|
||||
.map(|value| time.relative_view(value, now)),
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
|
||||
@@ -385,6 +385,8 @@ pub use crate::admin::account_groups::AccountGroupRefView;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub refreshed_at: Option<String>,
|
||||
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub refreshed_at_display: String,
|
||||
pub limit_reached: bool,
|
||||
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
|
||||
|
||||
@@ -686,20 +686,10 @@ pub(crate) fn trend_point_view(
|
||||
tokens_value: point.metrics.total_tokens,
|
||||
errors: format_compact_number(point.service_failure_count),
|
||||
errors_value: point.service_failure_count,
|
||||
latency: display_duration(point.average_latency_ms),
|
||||
latency_value: point.average_latency_ms,
|
||||
max_latency: display_duration(point.metrics.max_latency_ms),
|
||||
max_latency_value: point.metrics.max_latency_ms,
|
||||
min_latency: display_duration(point.metrics.min_latency_ms),
|
||||
min_latency_value: point.metrics.min_latency_ms,
|
||||
success_rate: success_rate_value
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
success_rate_value,
|
||||
first_token_p50_ms: point
|
||||
.metrics
|
||||
.first_token_latency_percentiles
|
||||
.p50_ms
|
||||
.map(|value| value.as_f64()),
|
||||
first_token_p95_ms: point
|
||||
.metrics
|
||||
.first_token_latency_percentiles
|
||||
@@ -711,20 +701,6 @@ pub(crate) fn trend_point_view(
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
output_throughput_p50: point.metrics.output_throughput_p50,
|
||||
admission_decision_p95_ms: point
|
||||
.metrics
|
||||
.admission_decision_percentiles
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
account_selection_wait_p95_ms: point
|
||||
.metrics
|
||||
.account_selection_wait_percentiles
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
capacity_utilization: point
|
||||
.metrics
|
||||
.capacity_utilization_p95_basis_points
|
||||
.map(basis_points_rate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,7 +851,7 @@ pub(crate) fn wire_profile_view(
|
||||
.attributes
|
||||
.into_iter()
|
||||
.map(|attribute| DashboardWireAttributeView {
|
||||
label: attribute.label,
|
||||
key: attribute.key,
|
||||
value: attribute.value,
|
||||
})
|
||||
.collect(),
|
||||
@@ -928,8 +904,10 @@ pub(crate) fn dashboard_view(
|
||||
.filter(|window| window.used_percent.is_none())
|
||||
.and_then(|window| window.local_usage.as_ref())
|
||||
.map(|usage| usage.request_count);
|
||||
let (metric_label, metric_value) = match local_requests {
|
||||
let (metric, metric_value_raw, metric_label, metric_value) = match local_requests {
|
||||
Some(count) => (
|
||||
DashboardAccountMetric::Requests,
|
||||
Some(count),
|
||||
"次数".to_owned(),
|
||||
if count > 0 {
|
||||
format_compact_number(count)
|
||||
@@ -938,6 +916,8 @@ pub(crate) fn dashboard_view(
|
||||
},
|
||||
),
|
||||
None => (
|
||||
DashboardAccountMetric::Tokens,
|
||||
credential.total_tokens,
|
||||
"今日 Token".to_owned(),
|
||||
credential
|
||||
.total_tokens
|
||||
@@ -971,8 +951,14 @@ pub(crate) fn dashboard_view(
|
||||
usage_window: credential
|
||||
.quota_window
|
||||
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
|
||||
metric,
|
||||
metric_value_raw,
|
||||
metric_label,
|
||||
metric_value,
|
||||
last_used_at: credential.last_used_at,
|
||||
last_used_relative: credential
|
||||
.last_used_at
|
||||
.map(|value| time.relative_view(value, range.end)),
|
||||
last_used: time.relative_optional(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
@@ -1060,6 +1046,7 @@ pub(crate) fn usage_summary_view(
|
||||
cache_write_input_tokens: format_compact_number(overview.requests.cache_write_input_tokens),
|
||||
total_tokens: format_compact_number(overview.requests.total_tokens),
|
||||
average_latency_ms: display_duration(summary.average_latency_ms),
|
||||
average_latency_ms_value: summary.average_latency_ms,
|
||||
logical_requests: request_summary_metrics_view(&overview.requests),
|
||||
attempts: attempt_metrics_view(&overview.attempts),
|
||||
}
|
||||
|
||||
@@ -306,21 +306,12 @@ pub struct TrendPointView {
|
||||
pub tokens_value: u64,
|
||||
pub errors: String,
|
||||
pub errors_value: u64,
|
||||
pub latency: String,
|
||||
pub latency_value: Option<u64>,
|
||||
pub max_latency: String,
|
||||
pub max_latency_value: Option<u64>,
|
||||
pub min_latency: String,
|
||||
pub min_latency_value: Option<u64>,
|
||||
pub success_rate: String,
|
||||
pub success_rate_value: Option<f64>,
|
||||
pub first_token_p50_ms: Option<f64>,
|
||||
pub first_token_p95_ms: Option<f64>,
|
||||
pub latency_p95_ms: Option<f64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub admission_decision_p95_ms: Option<f64>,
|
||||
pub account_selection_wait_p95_ms: Option<f64>,
|
||||
pub capacity_utilization: Option<f64>,
|
||||
}
|
||||
|
||||
/// 趋势指标的稳定身份,展示名称由管理端本地化
|
||||
@@ -401,7 +392,15 @@ pub struct DashboardCacheCardView {
|
||||
pub average_first_token_latency_ms: String,
|
||||
}
|
||||
|
||||
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影
|
||||
/// Dashboard 账号概览指标的稳定标识
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DashboardAccountMetric {
|
||||
Requests,
|
||||
Tokens,
|
||||
}
|
||||
|
||||
/// Dashboard 账号概览卡片所需的 Provider 安全投影
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardAccountUsageView {
|
||||
@@ -416,8 +415,12 @@ pub struct DashboardAccountUsageView {
|
||||
pub request_buckets: Vec<crate::time::RequestBucketView>,
|
||||
pub quota_used_percent: Option<f64>,
|
||||
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
|
||||
pub metric: DashboardAccountMetric,
|
||||
pub metric_value_raw: Option<u64>,
|
||||
pub metric_label: String,
|
||||
pub metric_value: String,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub last_used_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
@@ -530,7 +533,7 @@ pub struct DashboardWireProfileView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardWireAttributeView {
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
@@ -610,6 +613,7 @@ pub struct UsageSummaryView {
|
||||
pub cache_write_input_tokens: String,
|
||||
pub total_tokens: String,
|
||||
pub average_latency_ms: String,
|
||||
pub average_latency_ms_value: Option<u64>,
|
||||
pub logical_requests: RequestMetricsView,
|
||||
pub attempts: AttemptMetricsView,
|
||||
}
|
||||
|
||||
@@ -101,21 +101,51 @@ impl TimePresenter {
|
||||
self.timezone.local(*value).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
pub(crate) fn relative_view(
|
||||
self,
|
||||
value: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> RelativeTimeView {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return self.datetime(&value);
|
||||
return RelativeTimeView::Absolute {
|
||||
value: self.datetime(&value),
|
||||
};
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
return RelativeTimeView::JustNow;
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_minutes(),
|
||||
unit: RelativeTimeUnit::Minute,
|
||||
};
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_hours(),
|
||||
unit: RelativeTimeUnit::Hour,
|
||||
};
|
||||
}
|
||||
RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_days(),
|
||||
unit: RelativeTimeUnit::Day,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
match self.relative_view(value, now) {
|
||||
RelativeTimeView::JustNow => "刚刚".to_owned(),
|
||||
RelativeTimeView::Elapsed { value, unit } => {
|
||||
let unit = match unit {
|
||||
RelativeTimeUnit::Minute => "分钟",
|
||||
RelativeTimeUnit::Hour => "小时",
|
||||
RelativeTimeUnit::Day => "天",
|
||||
};
|
||||
format!("{value} {unit}前")
|
||||
}
|
||||
RelativeTimeView::Absolute { value } => value,
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(crate) fn relative_optional(
|
||||
@@ -127,6 +157,24 @@ impl TimePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端按查询锚点与部署时区决定展示方式,前端只本地化分类和单位
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RelativeTimeView {
|
||||
JustNow,
|
||||
Elapsed { value: i64, unit: RelativeTimeUnit },
|
||||
Absolute { value: String },
|
||||
}
|
||||
|
||||
/// 相对时间保持分钟、小时、天的固定精度,不引入月或年的日历推算
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelativeTimeUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestBucketView {
|
||||
|
||||
@@ -160,6 +160,90 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::provider_credentials::ProviderQuota;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
|
||||
use serde_json::json;
|
||||
|
||||
for zone in ["UTC", "Asia/Shanghai", "Asia/Kathmandu", "America/New_York"] {
|
||||
let zone = zone
|
||||
.parse::<gateway_core::time::DeploymentTimeZone>()
|
||||
.unwrap();
|
||||
let fixture = AdminTestFixture::with_timezone(zone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
now,
|
||||
"oauth",
|
||||
));
|
||||
let future = now + Duration::days(1);
|
||||
let future_display = zone.local(future).format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
for (observed_at, expected, legacy) in [
|
||||
(None, json!(null), "—"),
|
||||
(Some(now), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(
|
||||
Some(now - Duration::seconds(90)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(now - Duration::hours(25)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
|
||||
"1 天前",
|
||||
),
|
||||
(
|
||||
Some(future),
|
||||
json!({ "kind": "absolute", "value": future_display }),
|
||||
future_display.as_str(),
|
||||
),
|
||||
] {
|
||||
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
credits: None,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
});
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_quota_relative_time")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::ACCEPT_LANGUAGE, "en")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let quota = &value["data"]["items"][0]["quota"];
|
||||
assert_eq!(quota["refreshedAtRelative"], expected);
|
||||
assert_eq!(quota["refreshedAtDisplay"], legacy);
|
||||
let refreshed_at = quota["refreshedAt"]
|
||||
.as_str()
|
||||
.map(|value| DateTime::parse_from_rfc3339(value).unwrap().to_utc());
|
||||
assert_eq!(refreshed_at, observed_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_page_item(
|
||||
facts: gateway_core::account::AccountStatusFacts,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
pub account: Arc<Mutex<Option<AccountPageItem>>>,
|
||||
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
|
||||
}
|
||||
@@ -187,9 +188,18 @@ impl AdminTestFixture {
|
||||
plugin_ports.clone(),
|
||||
plugin_ports.clone(),
|
||||
);
|
||||
let provider_quota = Arc::new(Mutex::new(None));
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
|
||||
Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))),
|
||||
Arc::new(UnusedProvider::new(
|
||||
"openai",
|
||||
Arc::clone(&provider_error),
|
||||
Arc::clone(&provider_quota),
|
||||
)),
|
||||
Arc::new(UnusedProvider::new(
|
||||
"xai",
|
||||
Arc::clone(&provider_error),
|
||||
Arc::clone(&provider_quota),
|
||||
)),
|
||||
];
|
||||
let bundle = gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
@@ -236,6 +246,7 @@ impl AdminTestFixture {
|
||||
dashboard_observation,
|
||||
dashboard_summary_range,
|
||||
provider_error,
|
||||
provider_quota,
|
||||
account,
|
||||
account_usage,
|
||||
}
|
||||
@@ -1590,13 +1601,19 @@ impl ObservabilityStore for UnusedStore {
|
||||
struct UnusedProvider {
|
||||
kind: ProviderKind,
|
||||
error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
}
|
||||
|
||||
impl UnusedProvider {
|
||||
fn new(kind: &str, error: Arc<Mutex<Option<ProviderAdminError>>>) -> Self {
|
||||
fn new(
|
||||
kind: &str,
|
||||
error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
quota: Arc<Mutex<Option<ProviderQuota>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
error,
|
||||
quota,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1691,6 +1708,9 @@ impl ProviderAdmin for UnusedProvider {
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
if let Some(quota) = self.quota.lock().expect("provider quota").clone() {
|
||||
return Ok(quota);
|
||||
}
|
||||
Err(self
|
||||
.error
|
||||
.lock()
|
||||
|
||||
@@ -17,7 +17,11 @@ async fn usage_summary_keeps_request_attempt_cost_and_average_latency_wire_field
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
for (sum, count, display) in [(404, 3, "134 ms"), (0, 1, "0 ms"), (0, 0, "—")] {
|
||||
for (sum, count, display, raw) in [
|
||||
(404, 3, "134 ms", Some(134)),
|
||||
(0, 1, "0 ms", Some(0)),
|
||||
(0, 0, "—", None),
|
||||
] {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
@@ -84,6 +88,7 @@ async fn usage_summary_keeps_request_attempt_cost_and_average_latency_wire_field
|
||||
"totalRequests": "5", "inputTokens": "400", "outputTokens": "80",
|
||||
"cachedInputTokens": "30", "cacheWriteInputTokens": "7", "totalTokens": "480",
|
||||
"averageLatencyMs": display,
|
||||
"averageLatencyMsValue": raw,
|
||||
"logicalRequests": {"requestCount":5,"successCount":4,"failureCount":0,"cancelledCount":1,"incompleteCount":0,"callerErrorCount":1,"inputTokens":400,"outputTokens":80,"cachedInputTokens":30,"cacheWriteInputTokens":7,"reasoningOutputTokens":9,"totalTokens":480},
|
||||
"attempts": {"attemptCount":6,"successCount":4,"failureCount":1,"cancelledCount":1,"incompleteCount":0,"rateLimitedCount":1,"authFailureCount":0,"provider5xxCount":0,"costCoverage":{"known":3,"partial":0,"unknown":1,"notBillable":0},"costs":[{"currency":"USD","estimatedAmount":"1.25"}]}
|
||||
})
|
||||
@@ -1472,7 +1477,11 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Duration;
|
||||
use gateway_admin::model::observability::{DashboardObservation, TimeRange};
|
||||
use gateway_admin::model::{
|
||||
accounts::AccountUsage,
|
||||
observability::{DashboardAccountUsage, DashboardObservation, TimeRange},
|
||||
provider_credentials::{ProviderQuotaWindow, QuotaLocalUsageAttribution},
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -1489,7 +1498,80 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
|
||||
totals: Default::default(),
|
||||
provider_accounts: Default::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
account_usage: [
|
||||
(None, Some(2_000), Some(now)),
|
||||
(None, Some(0), None),
|
||||
(None, None, None),
|
||||
(Some(9), Some(2_000), Some(now)),
|
||||
(Some(0), Some(2_000), None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (requests, tokens, last_used_at))| {
|
||||
let account_id = format!("acct_metric_{index}");
|
||||
DashboardAccountUsage {
|
||||
account_id: account_id.clone(),
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
name: account_id.clone(),
|
||||
email: None,
|
||||
plan_type: None,
|
||||
plan_type_display: None,
|
||||
request_count: 10,
|
||||
success_count: 10,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
cache_write_input_tokens: None,
|
||||
reasoning_output_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: tokens,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at,
|
||||
request_buckets: Vec::new(),
|
||||
quota_used_percent: None,
|
||||
quota_window: requests.map(|request_count| ProviderQuotaWindow {
|
||||
key: "rolling".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "滚动窗口".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: Some(AccountUsage {
|
||||
account_id,
|
||||
request_count,
|
||||
success_count: request_count,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
cache_write_input_tokens: None,
|
||||
reasoning_output_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at,
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}),
|
||||
provider_data: None,
|
||||
}),
|
||||
models: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
|
||||
@@ -1521,6 +1603,20 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{kind}: {value}");
|
||||
let accounts = value["data"]["accountUsage"].as_array().unwrap();
|
||||
assert_eq!(accounts.len(), 5);
|
||||
for (account, (metric, raw, display, last_used_at)) in accounts.iter().zip([
|
||||
("tokens", Some(2_000), "2K", Some(now)),
|
||||
("tokens", Some(0), "0", None),
|
||||
("tokens", None, "—", None),
|
||||
("requests", Some(9), "9", Some(now)),
|
||||
("requests", Some(0), "—", None),
|
||||
]) {
|
||||
assert_eq!(account["metric"], metric);
|
||||
assert_eq!(account["metricValueRaw"], json!(raw));
|
||||
assert_eq!(account["metricValue"], display);
|
||||
assert_eq!(account["lastUsedAt"], json!(last_used_at));
|
||||
}
|
||||
let summary = value["data"]["trend"]["summary"].as_array().unwrap();
|
||||
assert_eq!(summary.len(), metrics.len());
|
||||
for (item, metric) in summary.iter().zip(metrics) {
|
||||
@@ -1530,3 +1626,144 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_relative_time_preserves_server_anchor_boundaries_and_deployment_timezone() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{DateTime, Duration};
|
||||
use gateway_admin::model::observability::{
|
||||
DashboardAccountUsage, DashboardObservation, TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let now = "2026-06-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
|
||||
for (zone, future_display) in [
|
||||
("UTC", "2026-06-15 00:00:01"),
|
||||
("Asia/Shanghai", "2026-06-15 08:00:01"),
|
||||
("Asia/Kathmandu", "2026-06-15 05:45:01"),
|
||||
("America/New_York", "2026-06-14 20:00:01"),
|
||||
] {
|
||||
for (elapsed_ms, expected, legacy) in [
|
||||
(None, json!(null), "—"),
|
||||
(
|
||||
Some(-1_000),
|
||||
json!({ "kind": "absolute", "value": future_display }),
|
||||
future_display,
|
||||
),
|
||||
(Some(-1), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(Some(0), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(Some(59_999), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(
|
||||
Some(60_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(119_999),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(3_599_999),
|
||||
json!({ "kind": "elapsed", "value": 59, "unit": "minute" }),
|
||||
"59 分钟前",
|
||||
),
|
||||
(
|
||||
Some(3_600_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "hour" }),
|
||||
"1 小时前",
|
||||
),
|
||||
(
|
||||
Some(86_399_999),
|
||||
json!({ "kind": "elapsed", "value": 23, "unit": "hour" }),
|
||||
"23 小时前",
|
||||
),
|
||||
(
|
||||
Some(86_400_000),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
|
||||
"1 天前",
|
||||
),
|
||||
(
|
||||
Some(34_560_000_000),
|
||||
json!({ "kind": "elapsed", "value": 400, "unit": "day" }),
|
||||
"400 天前",
|
||||
),
|
||||
] {
|
||||
// 每个边界使用独立缓存,避免相同锚点复用上一轮观测数据
|
||||
let fixture = AdminTestFixture::with_timezone(zone.parse().unwrap()).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let last_used_at = elapsed_ms.map(|value| now - Duration::milliseconds(value));
|
||||
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
|
||||
yesterday: Default::default(),
|
||||
analytics: Default::default(),
|
||||
runtime_slots: None,
|
||||
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: Default::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: vec![DashboardAccountUsage {
|
||||
account_id: "acct_relative_time".to_owned(),
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
name: "Relative time fixture".to_owned(),
|
||||
email: None,
|
||||
plan_type: None,
|
||||
plan_type_display: None,
|
||||
request_count: 1,
|
||||
success_count: 1,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
cache_write_input_tokens: None,
|
||||
reasoning_output_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at,
|
||||
request_buckets: Vec::new(),
|
||||
quota_used_percent: None,
|
||||
quota_window: None,
|
||||
models: Vec::new(),
|
||||
}],
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/admin/dashboard/summary?asOf={}",
|
||||
now.timestamp_millis()
|
||||
))
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::ACCEPT_LANGUAGE, "en")
|
||||
.header("x-request-id", "req_relative_time")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{zone}: {value}");
|
||||
let account = &value["data"]["accountUsage"][0];
|
||||
assert_eq!(
|
||||
account["lastUsedRelative"], expected,
|
||||
"{zone}: {elapsed_ms:?}"
|
||||
);
|
||||
assert_eq!(account["lastUsed"], legacy, "{zone}: {elapsed_ms:?}");
|
||||
assert_eq!(account["lastUsedAt"], json!(last_used_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ http = "1.4.0"
|
||||
http-body-util = "0.1.3"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = { version = "=0.27.7", default-features = false, features = ["http1", "http2", "tls12"] }
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] }
|
||||
hyper-util = { version = "=0.1.21", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] }
|
||||
ipnet = "2.11.0"
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
|
||||
|
||||
@@ -89,8 +89,10 @@ pub(super) fn push_fact_aggregation_selected(
|
||||
}
|
||||
query.push(") sample(metric,value) where value is not null), sample_stats as (select output_bucket,name,metric,count(*) as count,sum(value) as sum,min(value) as minimum,max(value) as maximum,count(*) filter(where value=0) as zero");
|
||||
query.push(" from samples group by output_bucket,name,metric)");
|
||||
// 对数桶定义与 Histogram::add 相同,零值单独保留,不对零取对数
|
||||
query.push(", bin_counts as (select output_bucket,name,metric,floor(log(2::numeric,value::numeric)*16)::integer as bin,count(*) as count from samples where value>0 group by output_bucket,name,metric,bin), merged_bins as (select output_bucket,name,metric,jsonb_object_agg(bin,count) as bins from bin_counts group by output_bucket,name,metric)");
|
||||
// 先合并同值样本,避免逐条重复计算 numeric 对数,保持原有桶边界和样本权重
|
||||
// 零值仍由 sample_stats 单独保留,不参与对数计算
|
||||
query.push(", sample_values as materialized (select output_bucket,name,metric,value,count(*) as count from samples where value>0 group by output_bucket,name,metric,value)");
|
||||
query.push(", bin_counts as (select output_bucket,name,metric,floor(log(2::numeric,value::numeric)*16)::integer as bin,sum(count)::bigint as count from sample_values group by output_bucket,name,metric,bin), merged_bins as (select output_bucket,name,metric,jsonb_object_agg(bin,count) as bins from bin_counts group by output_bucket,name,metric)");
|
||||
query.push(", merged_distributions as (select s.output_bucket,s.name,jsonb_object_agg(s.metric,jsonb_build_object('count',s.count,'sum',s.sum,'minimum',s.minimum,'maximum',s.maximum,'zero',s.zero,'bins',");
|
||||
query.push("coalesce(b.bins,'{}'::jsonb)");
|
||||
query.push(")) as distributions");
|
||||
|
||||
@@ -1692,16 +1692,18 @@ async fn daily_rank_counts_follow_rebuilds_rollbacks_deletions_and_dirty_days()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_edge_histograms_match_persisted_power_of_two_boundaries() {
|
||||
async fn sql_edge_histograms_preserve_repeated_samples_and_power_of_two_boundaries() {
|
||||
let Some(database) = TestDatabase::create("analytics_sql_histogram_boundaries").await else {
|
||||
return;
|
||||
};
|
||||
let start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
for (index, value) in [0_i64, 1, 2, 4, 8, 16, 31, 32, 33, 64, 128, 1024, 1_048_576]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
for (index, value) in [
|
||||
0_i64, 0, 1, 1, 1, 2, 2, 4, 8, 16, 31, 32, 32, 33, 64, 128, 1024, 1_048_576,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("sql_hist_{index}");
|
||||
seed(
|
||||
|
||||
@@ -25,13 +25,13 @@ getrandom = "0.4.3"
|
||||
hex = "0.4.3"
|
||||
# TLS 依赖以官方 Core 锁文件为基线,安全补丁经握手特征核验后精确固定
|
||||
# 包含用于约束传递依赖的直接声明
|
||||
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
|
||||
# hyper-util 固定到包含 SOCKS IPv6 修复的正式版
|
||||
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
|
||||
aws-lc-rs = "=1.18.1"
|
||||
h2 = "=0.4.20"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = "=0.27.7"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
|
||||
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
|
||||
# 与官方 Codex Desktop 一致:HTTP 默认走 native-tls,rustls 保留给自定义 CA 回退;
|
||||
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
|
||||
native-tls = "=0.2.14"
|
||||
|
||||
@@ -298,7 +298,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
},
|
||||
user_agent,
|
||||
attributes: vec![DashboardWireAttribute {
|
||||
label: "客户端标识".to_owned(),
|
||||
key: "client_identifier".to_owned(),
|
||||
value: client_identity,
|
||||
}],
|
||||
verified_at: Some(profile.verified_at),
|
||||
@@ -366,7 +366,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
},
|
||||
attributes: vec![
|
||||
DashboardWireAttribute {
|
||||
label: "客户端标识".to_owned(),
|
||||
key: "client_identifier".to_owned(),
|
||||
value: if profile.client_kind == ClientKind::Desktop {
|
||||
format!("{}; {}", profile.originator, profile.desktop_version)
|
||||
} else {
|
||||
@@ -374,13 +374,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
},
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "版本策略".to_owned(),
|
||||
value: if custom {
|
||||
"固定身份"
|
||||
} else {
|
||||
"自动最新"
|
||||
}
|
||||
.to_owned(),
|
||||
key: "version_policy".to_owned(),
|
||||
value: if custom { "fixed" } else { "latest" }.to_owned(),
|
||||
},
|
||||
],
|
||||
verified_at: (!custom && profile.verified_at != chrono::DateTime::UNIX_EPOCH)
|
||||
|
||||
@@ -472,10 +472,18 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
profile
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|attribute| attribute.label == "客户端标识")
|
||||
.find(|attribute| attribute.key == "client_identifier")
|
||||
.map(|attribute| attribute.value.as_str()),
|
||||
Some("Codex Desktop; 1.2026.190")
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|attribute| attribute.key == "version_policy")
|
||||
.map(|attribute| attribute.value.as_str()),
|
||||
Some("fixed")
|
||||
);
|
||||
assert!(profile.release.is_none());
|
||||
let billing = admin
|
||||
.calculated_billing(&ProviderBillingInput {
|
||||
|
||||
@@ -369,15 +369,15 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
view.target.terminal = profile.client_mode.clone();
|
||||
view.attributes = vec![
|
||||
DashboardWireAttribute {
|
||||
label: "客户端标识".to_owned(),
|
||||
key: "client_identifier".to_owned(),
|
||||
value: profile.client_identifier,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "运行模式".to_owned(),
|
||||
key: "client_mode".to_owned(),
|
||||
value: profile.client_mode,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "Token 认证".to_owned(),
|
||||
key: "token_auth".to_owned(),
|
||||
value: "xai-grok-cli".to_owned(),
|
||||
},
|
||||
];
|
||||
@@ -429,15 +429,15 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
user_agent: profile.user_agent(),
|
||||
attributes: vec![
|
||||
DashboardWireAttribute {
|
||||
label: "客户端标识".to_owned(),
|
||||
key: "client_identifier".to_owned(),
|
||||
value: profile.client_identifier,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "运行模式".to_owned(),
|
||||
key: "client_mode".to_owned(),
|
||||
value: profile.client_mode,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "Token 认证".to_owned(),
|
||||
key: "token_auth".to_owned(),
|
||||
value: "xai-grok-cli".to_owned(),
|
||||
},
|
||||
],
|
||||
|
||||
+32
-1
@@ -160,6 +160,16 @@ Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`5020
|
||||
|
||||
账号、模型用量和 Key 列表的最近使用时间由前端根据原始 RFC3339 时间点,通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新;悬停的完整日期仍使用后端 `lastUsedAtFullDisplay`,不使用浏览器本地时区重算
|
||||
|
||||
Dashboard 的 `lastUsedRelative` 和账号额度的 `refreshedAtRelative` 由后端确定时间语义,
|
||||
分别以查询锚点和服务端当前时间计算,随接口刷新更新。前端只翻译分类与单位,不使用浏览器时钟或时区重算
|
||||
|
||||
- 缺失时间返回 `null`,前端显示占位文本
|
||||
- `kind=just_now` 表示不足 60 秒
|
||||
- `kind=elapsed` 包含整数 `value` 和 `unit=minute|hour|day`,分钟不足 60、小时不足 24,之后始终用天;数值向下取整
|
||||
- `kind=absolute` 的 `value` 是后端按部署 `host.timezone` 生成的完整日期文本,用于按整秒判断的未来时间点
|
||||
|
||||
原始 RFC3339 `lastUsedAt`、`quota.refreshedAt` 与兼容展示字段保持可用,不用于前端重新决定上述展示方式
|
||||
|
||||
账号冷却返回 RFC3339 `rateLimitedUntil` 和 `rateLimitRecoveryDisplay`,后者包含剩余时长、
|
||||
到期时间或等待恢复探测的提示,随账号数据刷新;前端不解析展示文本或自行计算恢复提示
|
||||
|
||||
@@ -1997,6 +2007,9 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
|
||||
| `GET` | `/api/admin/operations/errors` | 运维错误分页列表 |
|
||||
| `GET` | `/api/admin/diagnostics/export` | 按 `requestId` 或 `errorId` 下载诊断 ZIP 附件 |
|
||||
|
||||
用量汇总的 `averageLatencyMsValue` 为可空毫秒数,前端按持续时长格式化;无延迟样本时为 `null`,
|
||||
`averageLatencyMs` 保留为兼容展示字段
|
||||
|
||||
### Dashboard 趋势指标
|
||||
|
||||
趋势摘要 `summary[]` 返回稳定的 `metric`、展示数值 `value` 和可空的 `ratio`,指标名称由前端本地化
|
||||
@@ -2009,13 +2022,31 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
|
||||
|
||||
图表颜色、系列身份和选中状态使用 `metric`,不根据译文或数组位置推断指标
|
||||
|
||||
`points[]` 提供趋势图与首页卡片使用的请求数、Tokens、缓存比例、错误数、成功率、
|
||||
`latencyValue`、首 Token P95、总延迟 P95 和输出吞吐 P50,以及时间标签与展示值
|
||||
内部调度分位数和容量分布通过用量洞察查询
|
||||
|
||||
### Dashboard 客户端身份
|
||||
|
||||
`wireProfiles[].attributes[]` 返回稳定的 `key` 和 `value`,字段名称由前端本地化
|
||||
|
||||
- `client_identifier`:上游客户端标识
|
||||
- `client_mode`:客户端运行模式
|
||||
- `token_auth`:认证协议
|
||||
- `version_policy`:版本选择策略,值为 `fixed` 或 `latest`
|
||||
|
||||
页面按 `key` 取值;状态判断使用原始值,不依赖界面译文
|
||||
|
||||
### Dashboard 容量与账号用量
|
||||
|
||||
Dashboard 的 `capacityInfo` 统计普通容量,不含独立审批池;`maxConcurrentPerAccount` 为默认账号并发上限,`0` 表示不限制。
|
||||
`capacityInfo.totalSlots` 为 `number | null`;可用账号池含无限并发账号时为 `null`,此时 `availableSlots` 也为 `null`。
|
||||
`usedSlots` 仍表示实际在途数,Redis 不可用时为 `null`;没有可用账号时 `totalSlots` 为 `0`
|
||||
|
||||
Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`metricValue`
|
||||
Dashboard 的 `accountUsage[]` 返回稳定指标 `metric=requests|tokens`、可空原始数值 `metricValueRaw` 和
|
||||
可空 RFC3339 `lastUsedAt`;前端负责指标名称和数字本地化,相对时间按[页面时间合同](#页面时间合同)翻译 `lastUsedRelative`。
|
||||
`requests` 使用代表性额度窗口的本地请求数,`tokens` 使用今日 Token;未知数值为 `null`,零值保留 `0`。
|
||||
`metricLabel`、`metricValue` 和 `lastUsed` 保留给兼容调用方,当前管理端不解析或显示这些字段
|
||||
|
||||
`usageWindow` 复用账号额度窗口合同,缺失额度事实时为 `null`;窗口标签、百分比、触顶状态、重置时间
|
||||
和本地用量由 Provider/Admin 投影。套餐缺失不表示免费套餐,显示时舍入的百分比不用于判断触顶
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#acd8578f82a91dc5d6da70b68c3bd354a1dde061",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#8177ba741376564f229aef50da8e76d29e4a5282",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.59.0",
|
||||
|
||||
Generated
+6
-6
@@ -195,8 +195,8 @@ importers:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
|
||||
'@codex-proxy/ui':
|
||||
specifier: github:zyycn/codex-proxy-ui#acd8578f82a91dc5d6da70b68c3bd354a1dde061
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
|
||||
specifier: github:zyycn/codex-proxy-ui#8177ba741376564f229aef50da8e76d29e4a5282
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -443,9 +443,9 @@ packages:
|
||||
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
|
||||
engines: {node: '>= 20.12.0'}
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061':
|
||||
resolution: {gitHosted: true, integrity: sha512-Zme/PI94AL8qt3mQ2YsYMlYWAtqQhqZDxDq7s/R1uE/lEt9ZCcpLkG+o14UmMbal9fLTUMMjAjudOCUUhevk3A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061}
|
||||
version: 0.5.0
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282':
|
||||
resolution: {gitHosted: true, integrity: sha512-ZXS77U0WYM25AEmqleT3rQmenv3GcIvmOJsxxvzJWZ7KQvwMXvSwwIqYrQw/y0Ch37214IVeqYTf68yDjx4mvA==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282}
|
||||
version: 0.6.0
|
||||
engines: {node: '>=24'}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -3422,7 +3422,7 @@ snapshots:
|
||||
fast-wrap-ansi: 0.2.2
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@ant-design/colors': 8.0.1
|
||||
'@ant-design/fast-color': 3.0.1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestOptions } from '../request'
|
||||
import type { RelativeTimeView } from '../time'
|
||||
import type { AccountGroupRef } from './groups'
|
||||
import request from '../request'
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface AccountQuotaCredits {
|
||||
}
|
||||
|
||||
export interface AccountQuota {
|
||||
refreshedAt: string | null
|
||||
refreshedAtRelative: RelativeTimeView | null
|
||||
refreshedAtDisplay: string
|
||||
limitReached: boolean
|
||||
// 429 临时限流(Redis 冷却)到期时间;非限流中为 null。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestOptions } from '../request'
|
||||
import type { RelativeTimeView } from '../time'
|
||||
import type { AccountQuotaWindow } from './accounts'
|
||||
import type { AnalyticsMetadata } from './analytics'
|
||||
import type { UsageListRecord } from './usage'
|
||||
@@ -25,21 +26,12 @@ export interface DashboardTrendPoint {
|
||||
tokensValue: number
|
||||
errors: string
|
||||
errorsValue: number
|
||||
latency: string
|
||||
latencyValue: number | null
|
||||
maxLatency: string
|
||||
maxLatencyValue: number | null
|
||||
minLatency: string
|
||||
minLatencyValue: number | null
|
||||
successRate: string
|
||||
successRateValue: number | null
|
||||
firstTokenP50Ms: number | null
|
||||
firstTokenP95Ms: number | null
|
||||
latencyP95Ms: number | null
|
||||
outputThroughputP50: number | null
|
||||
admissionDecisionP95Ms: number | null
|
||||
accountSelectionWaitP95Ms: number | null
|
||||
capacityUtilization: number | null
|
||||
}
|
||||
|
||||
export type DashboardTrendMetric
|
||||
@@ -129,7 +121,7 @@ export interface DashboardWireProfile {
|
||||
terminal: string
|
||||
}
|
||||
userAgent: string
|
||||
attributes: Array<{ label: string, value: string }>
|
||||
attributes: Array<{ key: string, value: string }>
|
||||
verifiedAt?: string
|
||||
verifiedAtDisplay?: string
|
||||
release?: {
|
||||
@@ -160,8 +152,12 @@ export interface DashboardAccountUsage {
|
||||
requestBuckets: DashboardAccountRequestBucket[]
|
||||
quotaUsedPercent: number | null
|
||||
usageWindow: AccountQuotaWindow | null
|
||||
metric: 'requests' | 'tokens'
|
||||
metricValueRaw: number | null
|
||||
metricLabel: string
|
||||
metricValue: string
|
||||
lastUsedAt: string | null
|
||||
lastUsedRelative: RelativeTimeView | null
|
||||
lastUsed: string
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface UsageSummaryResponse {
|
||||
cacheWriteInputTokens: string
|
||||
totalTokens: string
|
||||
averageLatencyMs: string
|
||||
averageLatencyMsValue: number | null
|
||||
}
|
||||
|
||||
export interface UsageOverviewHealthPoint {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type RelativeTimeView
|
||||
= { kind: 'just_now' }
|
||||
| { kind: 'elapsed', value: number, unit: 'minute' | 'hour' | 'day' }
|
||||
| { kind: 'absolute', value: string }
|
||||
@@ -257,18 +257,29 @@ export function quotaWindowLabel(window: AccountQuotaWindow, includeLimitName =
|
||||
seconds >= period.seconds * 0.95 && seconds <= period.seconds * 1.05,
|
||||
)
|
||||
let label: string
|
||||
if (period)
|
||||
if (period) {
|
||||
label = period.label()
|
||||
else if (seconds !== null && seconds > 0)
|
||||
label = t('accounts.quota.windows.duration', { duration: rollingWindowDurationDisplay(seconds) })
|
||||
else if (window.role === 'monthly')
|
||||
}
|
||||
else if (seconds !== null && seconds > 0) {
|
||||
if (seconds % 86_400 === 0)
|
||||
label = t('accounts.quota.windows.days', { duration: formatInteger(seconds / 86_400) })
|
||||
else if (seconds % 3_600 === 0)
|
||||
label = t('accounts.quota.windows.hours', { duration: formatInteger(seconds / 3_600) })
|
||||
else
|
||||
label = t('accounts.quota.windows.minutes', { duration: formatInteger(Math.ceil(seconds / 60)) })
|
||||
}
|
||||
else if (window.role === 'monthly') {
|
||||
label = t('accounts.quota.windows.monthly')
|
||||
else if (window.role === 'primary')
|
||||
label = t('accounts.quota.windows.usage')
|
||||
else if (window.role === 'secondary')
|
||||
}
|
||||
else if (window.role === 'primary') {
|
||||
label = t('accounts.quota.windows.primary')
|
||||
}
|
||||
else if (window.role === 'secondary') {
|
||||
label = t('accounts.quota.windows.secondary')
|
||||
else
|
||||
}
|
||||
else {
|
||||
label = window.windowLabelDisplay
|
||||
}
|
||||
|
||||
return includeLimitName && window.limitName && window.limitId !== 'codex'
|
||||
? `${window.limitName} · ${label}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"accounts": {
|
||||
"quota": {
|
||||
"general": "General quota",
|
||||
"usageInThisWindow": "Usage in this window: {localUsageDisplay}",
|
||||
"quotaUsed": "Quota used: {rate}",
|
||||
"resets": "Resets: {resetAtDisplay}",
|
||||
@@ -48,9 +49,11 @@
|
||||
"weekly": "Weekly limit",
|
||||
"monthly": "Monthly limit",
|
||||
"annual": "Annual limit",
|
||||
"usage": "Usage limit",
|
||||
"secondary": "Secondary usage limit",
|
||||
"duration": "{duration} limit"
|
||||
"primary": "Primary limit",
|
||||
"secondary": "Secondary limit",
|
||||
"days": "{duration}-day limit",
|
||||
"hours": "{duration}-hour limit",
|
||||
"minutes": "{duration}-minute limit"
|
||||
}
|
||||
},
|
||||
"account": "Account",
|
||||
@@ -138,7 +141,6 @@
|
||||
"batchEdit": {
|
||||
"title": "Edit accounts",
|
||||
"selectionTitle": "Edit {count} account | Edit {count} accounts",
|
||||
"saveChanges": "Save changes",
|
||||
"proxyRequired": "Select a proxy that passed the connection test",
|
||||
"saved": "Updated {count} account | Updated {count} accounts",
|
||||
"staleAccount": "Account {accountId} is out of date, select it again"
|
||||
|
||||
@@ -114,8 +114,6 @@
|
||||
"demoKey": "Demo Key",
|
||||
"requestHealthTimeline": "Request a health timeline",
|
||||
"validRequestAvailability": "Valid request availability",
|
||||
"clientId": "Client ID",
|
||||
"tokenAuthentication": "Token authentication",
|
||||
"todaySToken": "Today's tokens",
|
||||
"hours": "5 hours",
|
||||
"requests": "08-23 {value}:00–{value2}:00 · Requests: {count}",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"auth": {
|
||||
"signOut": "Sign out",
|
||||
"adminSignIn": "Admin sign-in",
|
||||
"apiKeySignIn": "API key sign-in",
|
||||
"signingIn": "Signing in…",
|
||||
"signIn": "Sign in",
|
||||
"method": "Sign-in method",
|
||||
"title": "Sign in",
|
||||
"adminSignIn": "Admin login",
|
||||
"apiKeySignIn": "API key login",
|
||||
"signingIn": "Logging in…",
|
||||
"signIn": "Log in",
|
||||
"method": "Login method",
|
||||
"title": "Console login",
|
||||
"welcome": "Welcome back",
|
||||
"username": "Admin account",
|
||||
"usernamePlaceholder": "Enter your admin account",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"apiKeyPlaceholder": "Enter your API key",
|
||||
"pageLabel": "Sign in to Codex Proxy RS",
|
||||
"pageLabel": "Log in to Codex Proxy RS",
|
||||
"password": "Password",
|
||||
"apiKey": "API key",
|
||||
"adminRealm": "Admin",
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "Copied"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} minute ago | {count} minutes ago"
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{count} minute ago | {count} minutes ago",
|
||||
"hoursAgo": "{count} hour ago | {count} hours ago",
|
||||
"daysAgo": "{count} day ago | {count} days ago"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Tokens"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"unavailableCount": "Unavailable: {value}"
|
||||
},
|
||||
"activeAccounts": {
|
||||
"todayTokens": "Today’s tokens",
|
||||
"title": "Active account usage",
|
||||
"sortByRecent": "Sorted by last used",
|
||||
"empty": "No recent account requests",
|
||||
@@ -78,8 +79,9 @@
|
||||
"empty": "No successful requests"
|
||||
},
|
||||
"clientIdentity": {
|
||||
"notChecked": "Not checked yet",
|
||||
"clientMode": "Client mode",
|
||||
"versionPolicy": "Version policy",
|
||||
"notChecked": "Not checked yet",
|
||||
"customVersion": "Custom version",
|
||||
"active": "Active",
|
||||
"activeDescription": "New requests using the global settings use this identity",
|
||||
@@ -94,7 +96,6 @@
|
||||
"verifiedAt": "Profile verified: {verifiedAt}",
|
||||
"checkedAt": "Release checked: {checkedAt}",
|
||||
"clientId": "Client ID",
|
||||
"tokenAuth": "Token authentication",
|
||||
"title": "Default upstream identity",
|
||||
"provider": "Upstream platform",
|
||||
"empty": "No request identity available",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"weeklyLimitUsd": "Weekly limit (USD)",
|
||||
"maximumConcurrency": "Maximum concurrency",
|
||||
"requestsPerMinuteRpm": "Requests per minute (RPM)",
|
||||
"saveChanges": "Save changes",
|
||||
"updated": "API key updated",
|
||||
"created": "API key created",
|
||||
"validation": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"accounts": {
|
||||
"quota": {
|
||||
"general": "通用额度",
|
||||
"usageInThisWindow": "窗口消耗:{localUsageDisplay}",
|
||||
"quotaUsed": "额度已使用:{rate}",
|
||||
"resets": "重置时间: {resetAtDisplay}",
|
||||
@@ -48,9 +49,11 @@
|
||||
"weekly": "周额度",
|
||||
"monthly": "月额度",
|
||||
"annual": "年额度",
|
||||
"usage": "用量上限",
|
||||
"secondary": "次级用量上限",
|
||||
"duration": "{duration}额度"
|
||||
"primary": "主额度",
|
||||
"secondary": "次级额度",
|
||||
"days": "{duration}日额度",
|
||||
"hours": "{duration}小时额度",
|
||||
"minutes": "{duration}分钟额度"
|
||||
}
|
||||
},
|
||||
"account": "账号",
|
||||
@@ -138,7 +141,6 @@
|
||||
"batchEdit": {
|
||||
"title": "批量编辑账号",
|
||||
"selectionTitle": "编辑 {count} 个账号",
|
||||
"saveChanges": "保存更改",
|
||||
"proxyRequired": "请选择已通过测试的代理",
|
||||
"saved": "已更新 {count} 个账号",
|
||||
"staleAccount": "账号 {accountId} 的页面数据已失效,请重新选择"
|
||||
|
||||
@@ -114,8 +114,6 @@
|
||||
"demoKey": "演示 Key",
|
||||
"requestHealthTimeline": "请求健康时间线",
|
||||
"validRequestAvailability": "有效请求可用性",
|
||||
"clientId": "客户端 ID",
|
||||
"tokenAuthentication": "Token 认证",
|
||||
"todaySToken": "今日 Token",
|
||||
"hours": "5 小时",
|
||||
"requests": "08-23 {value}:00–{value2}:00 · {count} 次请求",
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "已复制"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} 分钟前"
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{count} 分钟前",
|
||||
"hoursAgo": "{count} 小时前",
|
||||
"daysAgo": "{count} 天前"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Token"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"unavailableCount": "不可用 {value}"
|
||||
},
|
||||
"activeAccounts": {
|
||||
"todayTokens": "今日 Token",
|
||||
"title": "活跃账号用量",
|
||||
"sortByRecent": "最近使用排序",
|
||||
"empty": "暂无账号请求记录",
|
||||
@@ -78,8 +79,9 @@
|
||||
"empty": "暂无成功记录"
|
||||
},
|
||||
"clientIdentity": {
|
||||
"notChecked": "尚未检查",
|
||||
"clientMode": "运行模式",
|
||||
"versionPolicy": "版本策略",
|
||||
"notChecked": "尚未检查",
|
||||
"customVersion": "固定自定义",
|
||||
"active": "当前生效",
|
||||
"activeDescription": "跟随通用设置的新请求使用此身份",
|
||||
@@ -94,7 +96,6 @@
|
||||
"verifiedAt": "画像核验 {verifiedAt}",
|
||||
"checkedAt": "发布检查 {checkedAt}",
|
||||
"clientId": "客户端 ID",
|
||||
"tokenAuth": "Token 认证",
|
||||
"title": "通用上游身份",
|
||||
"provider": "上游平台",
|
||||
"empty": "暂无请求身份",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"weeklyLimitUsd": "周限额(USD)",
|
||||
"maximumConcurrency": "最大并发",
|
||||
"requestsPerMinuteRpm": "每分钟请求数(RPM)",
|
||||
"saveChanges": "保存更改",
|
||||
"updated": "API Key 已更新",
|
||||
"created": "API Key 已创建",
|
||||
"validation": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"accounts": {
|
||||
"quota": {
|
||||
"general": "通用額度",
|
||||
"usageInThisWindow": "窗口消耗:{localUsageDisplay}",
|
||||
"quotaUsed": "額度已使用:{rate}",
|
||||
"resets": "重置時間: {resetAtDisplay}",
|
||||
@@ -48,9 +49,11 @@
|
||||
"weekly": "周額度",
|
||||
"monthly": "月額度",
|
||||
"annual": "年額度",
|
||||
"usage": "用量上限",
|
||||
"secondary": "次級用量上限",
|
||||
"duration": "{duration}額度"
|
||||
"primary": "主額度",
|
||||
"secondary": "次級額度",
|
||||
"days": "{duration}日額度",
|
||||
"hours": "{duration}小時額度",
|
||||
"minutes": "{duration}分鐘額度"
|
||||
}
|
||||
},
|
||||
"account": "賬號",
|
||||
@@ -138,7 +141,6 @@
|
||||
"batchEdit": {
|
||||
"title": "批量編輯賬號",
|
||||
"selectionTitle": "編輯 {count} 個賬號",
|
||||
"saveChanges": "保存更改",
|
||||
"proxyRequired": "請選擇已通過測試的代理",
|
||||
"saved": "已更新 {count} 個賬號",
|
||||
"staleAccount": "賬號 {accountId} 的頁面數據已失效,請重新選擇"
|
||||
|
||||
@@ -114,8 +114,6 @@
|
||||
"demoKey": "演示 Key",
|
||||
"requestHealthTimeline": "請求健康時間線",
|
||||
"validRequestAvailability": "有效請求可用性",
|
||||
"clientId": "客戶端 ID",
|
||||
"tokenAuthentication": "Token 認證",
|
||||
"todaySToken": "今日 Token",
|
||||
"hours": "5 小時",
|
||||
"requests": "08-23 {value}:00–{value2}:00 · {count} 次請求",
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
"copied": "已複製"
|
||||
},
|
||||
"time": {
|
||||
"minutesAgo": "{count} 分鐘前"
|
||||
"justNow": "剛剛",
|
||||
"minutesAgo": "{count} 分鐘前",
|
||||
"hoursAgo": "{count} 小時前",
|
||||
"daysAgo": "{count} 天前"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Token"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"unavailableCount": "不可用 {value}"
|
||||
},
|
||||
"activeAccounts": {
|
||||
"todayTokens": "今日 Token",
|
||||
"title": "活躍賬號用量",
|
||||
"sortByRecent": "最近使用排序",
|
||||
"empty": "暫無賬號請求記錄",
|
||||
@@ -78,8 +79,9 @@
|
||||
"empty": "暫無成功記錄"
|
||||
},
|
||||
"clientIdentity": {
|
||||
"notChecked": "尚未檢查",
|
||||
"clientMode": "運行模式",
|
||||
"versionPolicy": "版本策略",
|
||||
"notChecked": "尚未檢查",
|
||||
"customVersion": "固定自定義",
|
||||
"active": "當前生效",
|
||||
"activeDescription": "跟隨通用設置的新請求使用此身份",
|
||||
@@ -94,7 +96,6 @@
|
||||
"verifiedAt": "畫像覈驗 {verifiedAt}",
|
||||
"checkedAt": "發佈檢查 {checkedAt}",
|
||||
"clientId": "客戶端 ID",
|
||||
"tokenAuth": "Token 認證",
|
||||
"title": "通用上游身份",
|
||||
"provider": "上游平臺",
|
||||
"empty": "暫無請求身份",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"weeklyLimitUsd": "周限額(USD)",
|
||||
"maximumConcurrency": "最大併發",
|
||||
"requestsPerMinuteRpm": "每分鐘請求數(RPM)",
|
||||
"saveChanges": "保存更改",
|
||||
"updated": "API Key 已更新",
|
||||
"created": "API Key 已創建",
|
||||
"validation": {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function formatUsd(value?: string | number | null, precise = false) {
|
||||
if (parsed == null)
|
||||
return '—'
|
||||
const fractionDigits = precise || (Math.abs(parsed) > 0 && Math.abs(parsed) < 0.01) ? 4 : 2
|
||||
return new Intl.NumberFormat(numberLocale(), {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: fractionDigits,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RelativeTimeView } from '@/api/time'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function formatRelativeTime(value: RelativeTimeView | null | undefined) {
|
||||
if (value == null)
|
||||
return '—'
|
||||
if (value.kind === 'absolute')
|
||||
return value.value
|
||||
if (value.kind === 'just_now')
|
||||
return t('common.time.justNow')
|
||||
|
||||
switch (value.unit) {
|
||||
case 'minute': return t('common.time.minutesAgo', { count: value.value }, value.value)
|
||||
case 'hour': return t('common.time.hoursAgo', { count: value.value }, value.value)
|
||||
case 'day': return t('common.time.daysAgo', { count: value.value }, value.value)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
:disabled="selectedCount === 0 || groupsLoading"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('accounts.batchEdit.saveChanges') }}
|
||||
{{ t('common.actions.save') }}
|
||||
</ZButton>
|
||||
</template>
|
||||
</ZDialog>
|
||||
|
||||
@@ -118,7 +118,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
:disabled="!account || groupsLoading || (isOpenAiApiKeyAccount(account) && !configurationReady)"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('accounts.batchEdit.saveChanges') }}
|
||||
{{ t('common.actions.save') }}
|
||||
</ZButton>
|
||||
</template>
|
||||
</ZDialog>
|
||||
|
||||
@@ -35,7 +35,7 @@ const modelUsageColumns = computed(() => (defineTableColumns<AccountModelUsage>(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4 rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary xl:min-h-0 xl:grid-cols-[minmax(10rem,1fr)_minmax(0,4fr)]">
|
||||
<section class="grid gap-4 rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary xl:min-h-0 xl:grid-cols-[0.52fr_1.48fr]">
|
||||
<div class="xl:flex xl:min-h-0 xl:flex-col">
|
||||
<div class="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
import { formatRelativeTime } from '@/utils/relative-time'
|
||||
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
|
||||
import AccountProfileModal from '../account-profile-modal/index.vue'
|
||||
import AccountQuotaForecastModal from '../account-quota-forecast-modal/index.vue'
|
||||
@@ -59,7 +60,7 @@ watch(hasPersonalInfo, (available) => {
|
||||
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
|
||||
</template>
|
||||
<span>·</span>
|
||||
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: account.quota.refreshedAtDisplay }) }}</span>
|
||||
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: formatRelativeTime(account.quota.refreshedAtRelative) }) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
|
||||
|
||||
@@ -194,7 +194,7 @@ function windowRoleOrder(role: AccountQuotaWindow['role']) {
|
||||
|
||||
function quotaLimitLabel(window: AccountQuotaWindow) {
|
||||
if (window.limitId === 'codex')
|
||||
return 'Codex'
|
||||
return t('accounts.quota.general')
|
||||
return window.limitName
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,6 @@ const {
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
|
||||
<ZPageHeader
|
||||
class="h-17"
|
||||
:title="t('navigation.accounts')"
|
||||
:description="t('accounts.page.description')"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { formatRelativeTime } from '@/utils/relative-time'
|
||||
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
|
||||
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
|
||||
|
||||
@@ -251,12 +252,12 @@ const statusBars = computed(() => {
|
||||
>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
|
||||
{{ account.metricLabel }}
|
||||
{{ account.metric === 'requests' ? t('dashboard.metrics.requestCount') : t('dashboard.activeAccounts.todayTokens') }}
|
||||
</span>
|
||||
<strong
|
||||
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-text"
|
||||
>
|
||||
{{ account.metricValue }}
|
||||
{{ account.metricValueRaw == null || (account.metric === 'requests' && account.metricValueRaw === 0) ? '—' : formatCompactNumber(account.metricValueRaw) }}
|
||||
</strong>
|
||||
</span>
|
||||
|
||||
@@ -265,7 +266,7 @@ const statusBars = computed(() => {
|
||||
<span
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-text-secondary xl:whitespace-nowrap"
|
||||
>
|
||||
{{ account.lastUsed }}
|
||||
{{ formatRelativeTime(account.lastUsedRelative) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { DashboardWireProfile } from '@/api/modules/dashboard'
|
||||
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
|
||||
|
||||
import { Box, CheckCircle2, Monitor, RefreshCw, ShieldCheck, Terminal, TriangleAlert } from '@lucide/vue'
|
||||
@@ -8,36 +9,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import { formatProviderLabel, isSupportedProvider, providerIcon } from '@/utils/providers'
|
||||
|
||||
const props = defineProps<{
|
||||
profiles: WireProfile[]
|
||||
profiles: DashboardWireProfile[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
interface WireProfile {
|
||||
provider: string
|
||||
product: string
|
||||
version: string
|
||||
build?: string | null
|
||||
target: {
|
||||
osType: string
|
||||
osVersion: string
|
||||
arch: string
|
||||
terminal: string
|
||||
}
|
||||
userAgent: string
|
||||
attributes: Array<{ label: string, value: string }>
|
||||
verifiedAt?: string | null
|
||||
verifiedAtDisplay?: string | null
|
||||
release?: {
|
||||
status?: 'unchecked' | 'aligned' | 'review_required' | 'check_failed'
|
||||
checkedAt?: string | null
|
||||
checkedAtDisplay?: string | null
|
||||
latestVersion?: string | null
|
||||
latestBuild?: string | null
|
||||
error?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const activeProvider = shallowRef('')
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
@@ -75,7 +51,7 @@ const releaseStatus = computed(() => {
|
||||
const current = profile.value
|
||||
if (!current?.release) {
|
||||
return {
|
||||
label: current?.attributes.some(item => item.label === t('dashboard.clientIdentity.versionPolicy') && item.value === t('dashboard.clientIdentity.customVersion')) ? t('dashboard.clientIdentity.customVersion') : t('dashboard.clientIdentity.active'),
|
||||
label: current?.attributes.some(item => item.key === 'version_policy' && item.value === 'fixed') ? t('dashboard.clientIdentity.customVersion') : t('dashboard.clientIdentity.active'),
|
||||
title: t('dashboard.clientIdentity.activeDescription'),
|
||||
tone: 'bg-cp-info-container text-cp-info-on-container',
|
||||
icon: ShieldCheck,
|
||||
@@ -137,21 +113,33 @@ const clientIdentity = computed(() => {
|
||||
const current = profile.value
|
||||
if (!current)
|
||||
return '—'
|
||||
const value = current.attributes.find(attribute => attribute.label === t('dashboard.clientIdentity.clientId'))?.value ?? '—'
|
||||
const value = current.attributes.find(attribute => attribute.key === 'client_identifier')?.value
|
||||
if (!value)
|
||||
return '—'
|
||||
return current.provider === 'xai' ? toPascalCase(value) : value
|
||||
})
|
||||
|
||||
const attributeLabels = computed<Record<string, string>>(() => ({
|
||||
client_identifier: t('dashboard.clientIdentity.clientId'),
|
||||
token_auth: t('dashboard.clientIdentity.authProtocol'),
|
||||
client_mode: t('dashboard.clientIdentity.clientMode'),
|
||||
version_policy: t('dashboard.clientIdentity.versionPolicy'),
|
||||
}))
|
||||
|
||||
const identityAttributes = computed(() => {
|
||||
const current = profile.value
|
||||
if (!current)
|
||||
return []
|
||||
return isSupportedProvider(current.provider)
|
||||
? [{ label: t('dashboard.clientIdentity.clientId'), value: clientIdentity.value }]
|
||||
: current.attributes
|
||||
: current.attributes.map(attribute => ({
|
||||
label: attributeLabels.value[attribute.key] ?? attribute.key,
|
||||
value: attribute.value,
|
||||
}))
|
||||
})
|
||||
|
||||
const authProtocol = computed(() => {
|
||||
const value = profile.value?.attributes.find(attribute => attribute.label === t('dashboard.clientIdentity.tokenAuth'))?.value
|
||||
const value = profile.value?.attributes.find(attribute => attribute.key === 'token_auth')?.value
|
||||
if (!value)
|
||||
return '—'
|
||||
return toPascalCase(value)
|
||||
|
||||
@@ -54,7 +54,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<ZPageHeader
|
||||
class="h-17"
|
||||
:title="t('navigation.groups')"
|
||||
:description="t('groups.page.description')"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { t } from '@/i18n'
|
||||
import { numberLocale } from '@/utils/format'
|
||||
|
||||
export function money(value: string | number | null, currency = 'USD') {
|
||||
export function money(value: string | number | null) {
|
||||
if (value === null)
|
||||
return t('usage.metrics.notPriced')
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount))
|
||||
return '—'
|
||||
return new Intl.NumberFormat(numberLocale(), {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 6,
|
||||
}).format(amount)
|
||||
|
||||
@@ -170,7 +170,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
|
||||
:disabled="!form.name.trim()"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ editing ? t('keys.editor.saveChanges') : t('common.actions.create') }}
|
||||
{{ editing ? t('common.actions.save') : t('common.actions.create') }}
|
||||
</ZButton>
|
||||
</template>
|
||||
</ZDialog>
|
||||
|
||||
@@ -114,7 +114,6 @@ watch(
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<ZPageHeader
|
||||
class="h-17"
|
||||
:title="t('navigation.apiKeys')"
|
||||
:description="t('keys.page.description')"
|
||||
/>
|
||||
|
||||
@@ -83,7 +83,7 @@ const submitLabel = computed(() => {
|
||||
>
|
||||
{{ t('auth.title') }}
|
||||
</h1>
|
||||
<p class="m-0 -ml-2 text-sm leading-[1.45] font-normal text-(--cp-login-description-color)">
|
||||
<p class="m-0 text-sm leading-[1.45] font-normal text-(--cp-login-description-color) [&:lang(zh)]:-ml-2">
|
||||
{{ t('auth.welcome') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ZSelect } from '@codex-proxy/ui'
|
||||
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useLocale } from '@/composables/useLocale'
|
||||
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
|
||||
import LoginBackground from './components/LoginBackground.vue'
|
||||
@@ -19,7 +17,6 @@ type LoginRealm = 'admin' | 'key'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { locale, localeOptions, setLocale } = useLocale()
|
||||
|
||||
const realm = shallowRef<LoginRealm>(normalizeRealm(window.history.state?.loginMode))
|
||||
const username = shallowRef('')
|
||||
@@ -81,13 +78,7 @@ function resolveDestination(): string {
|
||||
<LoginBackground />
|
||||
|
||||
<div class="absolute top-5 right-5 z-20 flex items-center gap-2 max-[560px]:top-4 max-[560px]:right-4">
|
||||
<ZSelect
|
||||
:model-value="locale"
|
||||
:options="localeOptions"
|
||||
:aria-label="t('common.language')"
|
||||
class="w-36"
|
||||
@update:model-value="setLocale"
|
||||
/>
|
||||
<AppLanguageMenu size="default" class="[&>button:hover]:bg-cp-bg-text-hover" />
|
||||
<LoginThemeToggle />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ watch(search, () => {
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<ZPageHeader
|
||||
class="h-17"
|
||||
:title="t('navigation.proxies')"
|
||||
:description="t('proxies.page.description')"
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@ function saveTheme(event: MouseEvent) {
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-col">
|
||||
<ZPageHeader class="xl:h-17" :title="t('navigation.appearance')">
|
||||
<ZPageHeader :title="t('navigation.appearance')">
|
||||
<template #description>
|
||||
<span class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{{ t('appearance.settings.description') }}</span>
|
||||
|
||||
@@ -81,21 +81,12 @@ function previewTrendPoint(requestsValue: number, index: number): DashboardTrend
|
||||
tokensValue: inputTokensValue + outputTokensValue,
|
||||
errors: errorsValue.toLocaleString('zh-CN'),
|
||||
errorsValue,
|
||||
latency: `${410 + index * 7} ms`,
|
||||
latencyValue: 410 + index * 7,
|
||||
maxLatency: `${760 + index * 12} ms`,
|
||||
maxLatencyValue: 760 + index * 12,
|
||||
minLatency: `${180 + index * 3} ms`,
|
||||
minLatencyValue: 180 + index * 3,
|
||||
successRate: `${successRateValue.toFixed(1)}%`,
|
||||
successRateValue,
|
||||
firstTokenP50Ms: 210 + index * 3,
|
||||
firstTokenP95Ms: 420 + index * 5,
|
||||
latencyP95Ms: 760 + index * 12,
|
||||
outputThroughputP50: 52 + (index % 5),
|
||||
admissionDecisionP95Ms: 2 + (index % 3),
|
||||
accountSelectionWaitP95Ms: 8 + (index % 4),
|
||||
capacityUtilization: 0.42 + (index % 6) * 0.04,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +277,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
},
|
||||
userAgent: 'codex_cli_rs/0.163.0-alpha.10.2 (Mac OS 27.0.1; arm64) Codex Desktop; 26.1013.40934',
|
||||
attributes: [
|
||||
{ label: t('appearance.preview.dashboard.clientId'), value: 'Codex Desktop; 26.1013.40934' },
|
||||
{ label: t('appearance.preview.dashboard.tokenAuthentication'), value: 'bearer' },
|
||||
{ key: 'client_identifier', value: 'Codex Desktop; 26.1013.40934' },
|
||||
{ key: 'token_auth', value: 'bearer' },
|
||||
],
|
||||
verifiedAt: '2026-10-17T20:15:28+08:00',
|
||||
verifiedAtDisplay: '2026-10-17 20:15:28',
|
||||
@@ -310,8 +301,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
},
|
||||
userAgent: 'grok_cli/0.7.3',
|
||||
attributes: [
|
||||
{ label: t('appearance.preview.dashboard.clientId'), value: 'grok_cli' },
|
||||
{ label: t('appearance.preview.dashboard.tokenAuthentication'), value: 'bearer' },
|
||||
{ key: 'client_identifier', value: 'grok_cli' },
|
||||
{ key: 'token_auth', value: 'bearer' },
|
||||
],
|
||||
verifiedAt: '2026-08-23T10:25:00+08:00',
|
||||
verifiedAtDisplay: '2026-08-23 10:25:00',
|
||||
@@ -337,6 +328,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
quotaUsedPercent: 32,
|
||||
metricLabel: t('appearance.preview.dashboard.todaySToken'),
|
||||
metricValue: '926K',
|
||||
metric: 'tokens',
|
||||
metricValueRaw: 926_000,
|
||||
usageWindow: {
|
||||
key: 'short',
|
||||
group: 'shortTerm',
|
||||
@@ -352,6 +345,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
resetAtDisplay: '—',
|
||||
},
|
||||
lastUsed: t('appearance.preview.components.justNow'),
|
||||
lastUsedAt: '2026-08-23T10:30:00+08:00',
|
||||
lastUsedRelative: { kind: 'just_now' },
|
||||
},
|
||||
{
|
||||
id: 'preview-xai-1',
|
||||
@@ -370,6 +365,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
quotaUsedPercent: null,
|
||||
metricLabel: t('appearance.preview.dashboard.times'),
|
||||
metricValue: '894',
|
||||
metric: 'requests',
|
||||
metricValueRaw: 894,
|
||||
usageWindow: {
|
||||
key: 'free-rolling-24h',
|
||||
group: 'shortTerm',
|
||||
@@ -394,6 +391,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
},
|
||||
},
|
||||
lastUsed: t('common.time.minutesAgo', { count: 5 }),
|
||||
lastUsedAt: '2026-08-23T10:25:00+08:00',
|
||||
lastUsedRelative: { kind: 'elapsed', value: 5, unit: 'minute' },
|
||||
},
|
||||
{
|
||||
id: 'preview-openai-2',
|
||||
@@ -408,6 +407,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
quotaUsedPercent: 68,
|
||||
metricLabel: t('appearance.preview.dashboard.todaySToken'),
|
||||
metricValue: '512K',
|
||||
metric: 'tokens',
|
||||
metricValueRaw: 512_000,
|
||||
usageWindow: {
|
||||
key: 'short',
|
||||
group: 'shortTerm',
|
||||
@@ -423,6 +424,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
|
||||
resetAtDisplay: '—',
|
||||
},
|
||||
lastUsed: t('common.time.minutesAgo', { count: 8 }),
|
||||
lastUsedAt: '2026-08-23T10:22:00+08:00',
|
||||
lastUsedRelative: { kind: 'elapsed', value: 8, unit: 'minute' },
|
||||
},
|
||||
],
|
||||
usageRecords: [
|
||||
|
||||
@@ -13,9 +13,17 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
function averageLatencyDisplay(value: string) {
|
||||
return !value || value === '—' || value === '-' ? '0 ms' : value
|
||||
}
|
||||
const averageLatency = computed(() => {
|
||||
const raw = props.summary.averageLatencyMsValue
|
||||
const value = raw == null || !Number.isFinite(raw) || raw < 0 ? 0 : raw
|
||||
if (value < 1_000)
|
||||
return { value: String(Math.round(value)), unit: 'ms' }
|
||||
if (value < 60_000)
|
||||
return { value: (value / 1_000).toFixed(2), unit: 's' }
|
||||
if (value < 3_600_000)
|
||||
return { value: (value / 60_000).toFixed(1), unit: 'min' }
|
||||
return { value: (value / 3_600_000).toFixed(1), unit: 'h' }
|
||||
})
|
||||
|
||||
const items = computed(() => [
|
||||
{
|
||||
@@ -46,7 +54,8 @@ const items = computed(() => [
|
||||
key: 'latency',
|
||||
label: t('usage.averageTimeTaken'),
|
||||
icon: Timer,
|
||||
value: averageLatencyDisplay(props.summary.averageLatencyMs),
|
||||
value: averageLatency.value.value,
|
||||
unit: averageLatency.value.unit,
|
||||
detail: t('usage.successfulRequestAverage'),
|
||||
tone: 'bg-cp-cyan-container text-cp-cyan-on-container',
|
||||
},
|
||||
@@ -70,7 +79,7 @@ const items = computed(() => [
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<strong class="my-2 block truncate font-mono text-[24px] leading-none font-extrabold text-cp-text">
|
||||
{{ item.value }}
|
||||
{{ item.value }}<span v-if="item.unit" class="ml-0.5 font-sans text-cp font-semibold text-cp-text-secondary">{{ item.unit }}</span>
|
||||
</strong>
|
||||
<span class="block truncate text-cp-sm leading-none font-emphasis text-cp-text-quaternary">
|
||||
{{ item.detail }}
|
||||
|
||||
@@ -267,6 +267,7 @@ function emptySummary() {
|
||||
cacheWriteInputTokens: '0',
|
||||
totalTokens: '0',
|
||||
averageLatencyMs: '0 ms',
|
||||
averageLatencyMsValue: null,
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 757a276fd2...74c9281071
+1
-1
Submodule modules/ui updated: acd8578f82...8177ba7413
+11
-19
@@ -1,25 +1,17 @@
|
||||
# v3.30.0
|
||||
# v3.30.2
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 前后端需同步更新。管理 API 的账号额度窗口由 `windowLabelDisplay` 改为 `windowKind`,趋势摘要由 `label` 改为 `metric`,展示文案由前端按语言生成;自行对接这些字段的客户端需同步适配
|
||||
- 用量请求明细改用 `clientApiKeyName`、`accountName` 和 `model` 按字面包含筛选,移除旧的通用 `search` 及其他未保留的筛选参数;汇总与洞察仅按时间和 Provider 筛选。自定义管理 API 调用方请按本版 API 文档调整参数
|
||||
- 诊断导出接口改为返回 ZIP,包内包含 `diagnostics.json`、`summary.txt` 和 `README.txt`;直接消费原 JSON 下载响应的工具需改为解包读取
|
||||
|
||||
## 新功能
|
||||
|
||||
- 管理端与 Key 用量页支持简体中文、繁體中文和 English,切换后同步更新表格、图表、通知、共享组件与主题预览,并保存语言选择
|
||||
- 诊断包补充请求输入与最终准备发送正文的指纹、上游终态用量和网关归集值,为关键事件保留采集预算,并明确标记缺失或截断的证据,便于排查缓存、用量与重试问题
|
||||
- API 密钥创建、配置和列表操作统一使用客户端导入弹窗,提供 CC Switch 导入入口及项目链接
|
||||
- 前后端需同步更新,管理端使用新增的账号指标原始值、相对时间结构和平均延迟数值生成本地化显示
|
||||
- Dashboard 趋势 `points[]` 移除页面未使用的 `latency`、`maxLatency`、`maxLatencyValue`、`minLatency`、`minLatencyValue`、`firstTokenP50Ms`、`admissionDecisionP95Ms`、`accountSelectionWaitP95Ms` 和 `capacityUtilization`;自行对接这些字段的客户端需同步适配,平均延迟仍使用 `latencyValue`,内部调度分位数和容量分布通过用量洞察查询
|
||||
|
||||
## 修复与调整
|
||||
|
||||
- OpenAI 出站会话、窗口和缓存关联标识按选中账号稳定隔离,剥离客户端 trace;轮次 ID、父子轮次引用和续接引用保留客户端原值
|
||||
- Live 语音引导、sideband 与挂断转发业务扩展头,保留 `X-Session-Id`、`Voice-Session-Id` 及通话归属;追踪标识按账号隔离,HTTP 业务头保留重复值和原始字节
|
||||
- 修复位置覆盖在跨日、续接或重放时刷新历史上下文日期的问题,保留原日期,仅调整时区和搜索位置
|
||||
- 调整新建的工作区隐私预设,仅改写附加元数据中的用户名和 Git 远端命名空间,保留项目名与工具执行路径;已有自定义规则保持原配置
|
||||
- 修复 SSE / WebSocket 流内 404 请求错误被误判为路径封禁、进而重试换号或将账号标为凭据无效的问题
|
||||
- 完善取消期间的请求证据与插件中间件处理边界;备份创建、删除请求与审计记录原子写入,账号分组变更保留已提交结果,重启受理后阻止并发更新、回滚和重复重启
|
||||
- 统一管理端流式请求和插件页面的会话恢复,身份切换后取消迟到响应,及时清理失效的 API 密钥明文
|
||||
- 优化用量汇总、请求计数、账号额度和冷却状态查询,减少重复数据库读取;统一管理端与 Key 用量页的字段筛选,并调整表格间距、菜单、统计卡片和窄屏布局
|
||||
- 更新 DOMPurify、`ctutils`、`chacha20` 和 `wnaf` 等依赖,修复已知安全问题并移出已撤回的依赖版本
|
||||
- 修复 Dashboard 活跃账号的指标名称、数字和最近使用时间,以及账号额度刷新时间未随界面语言切换的问题
|
||||
- 相对时间继续由服务端按查询锚点、当前时间和部署时区确定,前端只翻译分类与单位,保留分钟、小时、天的边界及未来时间的完整日期显示
|
||||
- 调整分析统计页平均延迟的数值与单位间距,统一账号和 API Key 编辑弹窗的保存文案,并让账号、分组、密钥、代理及主题设置页复用页面标题的响应式高度
|
||||
|
||||
## 性能优化
|
||||
|
||||
- 统计直方图先合并相同的正值样本再计算对数分桶,减少重复计算,保留原有桶边界、样本权重和统计结果;实际收益取决于样本重复度
|
||||
- 精简 Dashboard 趋势响应中未使用的字段,减少传输体积
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.30.0"
|
||||
version: "3.30.2"
|
||||
|
||||
Reference in New Issue
Block a user