Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex e30aad4755 chore(release): v3.18.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:41:55 +08:00
ZyyandCodex 945f7912ac chore(deps): sync UI v0.3.1 and plugin references
Update the admin UI dependency and lockfile, and align the UI and plugin submodules with the released modal fix.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:38:10 +08:00
ZyyandCodex 19038f264a fix(admin): correct usage diagnostics and refine management views
Use all matching requests for diagnostic shares and count retried requests once. Show current account plans in diagnostics and request records with compact, aligned columns.

Simplify quota forecasts and remove unused remaining-capacity response fields. Open system updates immediately with the existing loading skeleton and clip masked key prefixes without an ellipsis.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:28:58 +08:00
ZyyandCodex 71c3b03878 fix(openai): add built-in GPT-6.1 Sol pricing
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:27:01 +08:00
46 changed files with 598 additions and 213 deletions
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
/// 账号当前套餐,不属于请求历史快照。
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -757,11 +760,20 @@ pub struct UsageSummary {
pub average_latency_ms: Option<u64>,
}
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
/// 单个诊断维度值的聚合结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -1142,6 +1155,8 @@ pub struct UsageInsights {
pub struct DiagnosticsItem {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
pub incomplete_tokens: bool,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
pub remaining_tokens: Option<u64>,
pub remaining_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
incomplete_tokens: false,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
remaining_usd: None,
};
if let Some((window, source_period)) = selected {
forecast.project(
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
self.remaining_usd = Some(&sample.usage)
let remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(self.remaining_usd)
.zip(remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
@@ -27,7 +27,7 @@ use crate::model::{
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
@@ -444,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
) -> AdminStoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_billing(&mut page.items);
self.enrich_list_records(&mut page.items);
Ok(page)
}
@@ -334,29 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> Result<DiagnosticsResult, AdminError> {
let items = self
let observation = self
.store
.usage_diagnostics(range, filter, dimension)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
let items = items
let items = observation
.items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
key: item.key,
name: item.name,
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|provider| {
self.providers
.plan_type_display(provider, item.account_plan_type.as_deref())
},
),
account_plan_type: item.account_plan_type,
request_count: item.request_count,
success_count: item.success_count,
error_count: item.failure_count,
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
request_share: rate_or_zero(
item.request_count,
observation.total_request_count,
),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
@@ -772,10 +780,15 @@ impl DefaultObservabilityService {
}
}
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
for record in records {
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
});
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
};
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
}
#[test]
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
let [week, month] = forecast(&quota(vec![window("week", 7)]));
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert!(!week.extrapolated);
assert!(month.extrapolated);
assert_eq!(month.estimated_tokens, Some(21_429));
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
source.windows[0].used_percent = Some(100.0);
let [week, _] = forecast(&source);
assert!(!week.low_sample);
assert_eq!(week.remaining_tokens, Some(0));
assert_eq!(week.remaining_usd, Some(0.0));
assert_eq!(week.estimated_tokens, Some(1_000));
assert_eq!(week.estimated_usd, Some(2.0));
}
#[test]
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
assert_eq!(week.estimated_tokens, Some(9_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert_eq!(month.estimated_tokens, Some(38_571));
assert!(week.unavailable_reason.is_none());
assert!(!week.low_sample);
}
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
assert!(week.incomplete_tokens);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
assert!(
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
);
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.estimated_tokens, Some(1_000));
assert_eq!(cycle.estimated_usd, Some(2.0));
assert_eq!(cycle.remaining_tokens, Some(0));
assert_eq!(cycle.remaining_usd, Some(0.0));
}
@@ -2430,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.unwrap();
assert!(result.forecasts[0].unavailable_reason.is_none());
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
assert_eq!(store.quota_window_queries()[0].range.start, added);
store
.quota_forecast_history
@@ -2471,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let cycle = &result.forecasts[0];
assert!(cycle.unavailable_reason.is_none());
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
assert_eq!(cycle.remaining_tokens, Some(429));
assert_eq!(cycle.estimated_tokens, Some(679));
}
@@ -44,7 +44,7 @@ use gateway_admin::{
observability::{
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
@@ -770,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Err(unavailable("usage diagnostics"))
}
@@ -16,11 +16,11 @@ use gateway_admin::{
MutationContext, PageSize, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery, china_day_start,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.expect("calculated total"),
},
}]);
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 4,
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
});
let services = observability_services_with_calculated_billing(store).await;
let insights = services
@@ -513,7 +516,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let mut failure = diagnostic("single_failure", 1);
failure.success_count = 0;
failure.failure_count = 1;
store.replace_diagnostics(vec![success, failure]);
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 101,
items: vec![success, failure],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
@@ -529,6 +535,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
assert_eq!(result.items[1].error_rate, 1.0);
}
#[tokio::test]
async fn diagnostics_should_use_the_total_before_truncating_groups() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 200,
items: (0..100)
.map(|index| diagnostic(&format!("model-{index}"), 1))
.collect(),
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("limited diagnostics");
assert!(result.items.iter().all(|item| item.request_share == 0.005));
}
#[tokio::test]
async fn diagnostics_should_count_each_retried_request_once() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let mut item = diagnostic("model", 8);
item.attempt_count = 14;
item.retry_count = 6;
item.retried_request_count = 2;
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 8,
items: vec![item],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("retry diagnostics");
assert_eq!(
(result.items[0].retry_count, result.items[0].retry_rate),
(6, 0.25)
);
}
#[tokio::test]
async fn diagnostics_should_preserve_empty_results() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
.await
.expect("empty diagnostics");
assert!(result.items.is_empty());
}
#[tokio::test]
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
let now = Utc::now();
@@ -632,7 +700,7 @@ struct FixtureObservabilityStore {
overview: Mutex<UsageOverview>,
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
billing_stream_fails: AtomicBool,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
diagnostics: Mutex<DiagnosticsObservation>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
@@ -653,7 +721,7 @@ impl FixtureObservabilityStore {
}),
calculated_billing_facts: Mutex::new(Vec::new()),
billing_stream_fails: AtomicBool::new(false),
diagnostics: Mutex::new(Vec::new()),
diagnostics: Mutex::new(DiagnosticsObservation::default()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
slots_observed_at: Mutex::new(None),
@@ -686,7 +754,7 @@ impl FixtureObservabilityStore {
.expect("calculated billing facts") = facts;
}
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
}
@@ -798,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -948,6 +1016,8 @@ fn total_record(
provider_account_name: None,
provider_account_email: None,
provider_account_notes: None,
provider_account_plan_type: None,
provider_account_plan_type_display: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
@@ -1010,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
retried_request_count: 0,
account_provider_kind: None,
account_plan_type: None,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -180,10 +180,6 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
estimated_usd: forecast.estimated_usd,
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
remaining_tokens: forecast.remaining_tokens,
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
remaining_usd: forecast.remaining_usd,
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
}
}
@@ -362,10 +362,6 @@ pub struct AccountQuotaForecastView {
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
pub estimated_usd_display: String,
pub remaining_tokens: Option<u64>,
pub remaining_tokens_display: String,
pub remaining_usd: Option<f64>,
pub remaining_usd_display: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -287,6 +287,8 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
account_email: record.provider_account_email,
account_name: record.provider_account_name,
account_notes: record.provider_account_notes,
account_plan_type: record.provider_account_plan_type,
account_plan_type_display: record.provider_account_plan_type_display,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -1283,6 +1285,8 @@ pub(crate) fn diagnostics_view(
item.name
},
request_count: item.request_count,
account_plan_type: item.account_plan_type,
account_plan_type_display: item.account_plan_type_display,
success_count: item.success_count,
error_count: item.error_count,
error_rate: item.error_rate,
@@ -94,6 +94,8 @@ pub struct UsageListRecordView {
pub account_email: Option<String>,
pub account_name: Option<String>,
pub account_notes: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -746,6 +748,8 @@ pub struct UsageInsightsOverviewView {
pub struct DiagnosticItemView {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -29,13 +29,13 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
incomplete_tokens: false,
estimated_tokens: Some(1_000_000),
estimated_usd: None,
remaining_tokens: Some(0),
remaining_usd: None,
};
let mut monthly = forecast.clone();
monthly.period = AccountUsagePeriod::Monthly;
monthly.extrapolated = true;
monthly.target_seconds = 30 * 86_400;
monthly.estimated_tokens = Some(0);
monthly.estimated_usd = Some(0.0);
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
account_id: "acct_forecast".to_owned(),
generated_at: now,
@@ -49,7 +49,16 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert_eq!(week["estimatedTokensDisplay"], "1M");
assert!(week["estimatedUsd"].is_null());
assert_eq!(week["estimatedUsdDisplay"], "—");
assert_eq!(week["remainingTokensDisplay"], "0");
for forecast in value["forecasts"].as_array().unwrap() {
for field in [
"remainingTokens",
"remainingTokensDisplay",
"remainingUsd",
"remainingUsdDisplay",
] {
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
}
}
assert_eq!(
week["source"],
serde_json::json!({
@@ -66,6 +75,10 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert!(week.get("method").is_none());
assert!(week.get("methodDisplay").is_none());
assert!(value.get("generatedAtDisplay").is_none());
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
@@ -35,7 +35,7 @@ use gateway_admin::{
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
},
@@ -100,7 +100,7 @@ pub(super) struct AdminTestFixture {
pub settings: Arc<MemorySettingsStore>,
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
pub diagnostics: Arc<Mutex<DiagnosticsObservation>>,
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
@@ -137,7 +137,7 @@ impl AdminTestFixture {
let account_groups = Arc::new(MemoryAccountGroupStore::new());
let usage_records = Arc::new(Mutex::new(Vec::new()));
let usage_detail = Arc::new(Mutex::new(None));
let diagnostics = Arc::new(Mutex::new(Vec::new()));
let diagnostics = Arc::new(Mutex::new(DiagnosticsObservation::default()));
let ops_errors = Arc::new(Mutex::new(Vec::new()));
let dashboard_observation = Arc::new(Mutex::new(None));
let dashboard_summary_range = Arc::new(Mutex::new(None));
@@ -1000,7 +1000,7 @@ struct UnusedStore {
observations: Arc<Mutex<MemoryObservations>>,
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
diagnostics: Arc<Mutex<DiagnosticsObservation>>,
ops_errors: Arc<Mutex<Vec<OpsError>>>,
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
@@ -1312,7 +1312,7 @@ impl ObservabilityStore for UnusedStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -550,7 +550,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
use gateway_admin::model::observability::{
CostCoverage, DiagnosticObservation, DiagnosticsObservation,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -558,26 +560,28 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
fixture
.diagnostics
.lock()
.expect("diagnostics")
.push(DiagnosticObservation {
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
total_request_count: 4,
items: vec![DiagnosticObservation {
key: "acct_diag".to_owned(),
account_provider_kind: Some("openai".to_owned()),
account_plan_type: Some("pro".to_owned()),
name: "diag@example.invalid".to_owned(),
request_count: 2,
success_count: 2,
failure_count: 0,
attempt_count: 2,
attempt_count: 5,
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
retry_count: 0,
retry_count: 3,
retried_request_count: 1,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
});
}],
};
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
@@ -596,6 +600,11 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert!(value["data"]["items"][0].get("impactScore").is_none());
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
assert_eq!(
(
&value["data"]["items"][0]["key"],
@@ -766,6 +775,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_name: Some("Snapshot Alpha".to_owned()),
provider_account_email: Some("alpha@example.invalid".to_owned()),
provider_account_notes: Some("Team workspace".to_owned()),
provider_account_plan_type: Some("supergrok_plus".to_owned()),
provider_account_plan_type_display: None,
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -858,6 +869,14 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
value["data"]["items"][0]["accountPlanType"],
"supergrok_plus"
);
assert_eq!(
value["data"]["items"][0]["accountPlanTypeDisplay"],
"SupergrokPlus"
);
assert_eq!(
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
true
@@ -100,6 +100,8 @@ fn usage_record() -> UsageListRecord {
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_notes: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
@@ -261,7 +261,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
) -> StoreResult<DiagnosticsObservation> {
self.query_budget
.run(
"load usage diagnostics",
@@ -441,18 +441,17 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
dimension: admin_observability::DiagnosticDimension,
) -> AdminStoreResult<Vec<admin_observability::DiagnosticObservation>> {
self.repository
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
let observation = self
.repository
.usage_diagnostics(
store_range(range)?,
store_usage_filter(filter),
store_diagnostic_dimension(dimension),
)
.await
.map_err(observability_error)?
.into_iter()
.map(admin_diagnostic_observation)
.collect()
.map_err(observability_error)?;
admin_diagnostics_observation(observation)
}
async fn list_ops_errors(
@@ -459,6 +459,8 @@ pub(crate) fn admin_usage_list_record(
provider_account_name: record.provider_account_name,
provider_account_email: record.provider_account_email,
provider_account_notes: record.provider_account_notes,
provider_account_plan_type: record.provider_account_plan_type,
provider_account_plan_type_display: None,
provider_account_authentication_kind: record.provider_account_authentication_kind,
upstream_model_id: record.upstream_model_id,
upstream_transport: record.upstream_transport,
@@ -688,12 +690,27 @@ pub(crate) fn admin_provider_observation(
}
}
pub(crate) fn admin_diagnostic_observation(
pub(crate) fn admin_diagnostics_observation(
observation: DiagnosticsObservation,
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
Ok(admin_observability::DiagnosticsObservation {
total_request_count: observation.total_request_count,
items: observation
.items
.into_iter()
.map(admin_diagnostic_observation)
.collect::<AdminStoreResult<_>>()?,
})
}
fn admin_diagnostic_observation(
observation: DiagnosticObservation,
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
Ok(admin_observability::DiagnosticObservation {
key: observation.key,
name: observation.name,
account_provider_kind: observation.account_provider_kind,
account_plan_type: observation.account_plan_type,
request_count: observation.request_count,
success_count: observation.success_count,
failure_count: observation.failure_count,
@@ -704,6 +721,7 @@ pub(crate) fn admin_diagnostic_observation(
first_token_p95_ms: observation.first_token_p95_ms,
non_completion_count: observation.non_completion_count,
retry_count: observation.retry_count,
retried_request_count: observation.retried_request_count,
cost_coverage: admin_cost_coverage(observation.cost_coverage),
costs: admin_currency_costs(observation.costs)?,
})
@@ -791,6 +809,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -491,6 +491,7 @@ pub struct UsageListRecord {
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_notes: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -675,10 +676,18 @@ pub struct UsageOverview {
pub providers: Vec<ProviderObservation>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -689,6 +698,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
@@ -793,6 +803,6 @@ pub trait ObservabilityRepository: Send + Sync {
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>>;
) -> StoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage>;
}
@@ -129,6 +129,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.notes as provider_account_notes,
account.plan_type as provider_account_plan_type,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
@@ -384,7 +385,7 @@ pub(crate) async fn usage_diagnostics(
range: ObservabilityRange,
filter: &UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
) -> StoreResult<DiagnosticsObservation> {
filter.validate()?;
let dimension_sql = diagnostic_dimension_sql(dimension);
let completed_usage = completed_usage_fact_predicate("mr");
@@ -424,6 +425,7 @@ pub(crate) async fn usage_diagnostics(
count(*) filter (where outcome in ('cancelled', 'incomplete'))::bigint
as non_completion_count,
coalesce(sum(greatest(attempt_count - 1, 0)), 0)::bigint as retry_count,
count(*) filter (where attempt_count > 1)::bigint as retried_request_count,
count(*) filter (
where is_completed_usage and cost_source = 'provider_reported'
)::bigint
@@ -443,6 +445,7 @@ pub(crate) async fn usage_diagnostics(
group by dimension_name, grouping sets ((), (cost_currency))
), selected_dimensions as (
select dimension_name,
sum(request_count) over ()::bigint as total_request_count,
row_number() over (order by request_count desc, dimension_name)
as sort_position
from aggregated
@@ -452,16 +455,25 @@ pub(crate) async fn usage_diagnostics(
statement.push_bind(DIAGNOSTIC_LIMIT);
statement.push(
")
select aggregated.*
select aggregated.*, selected.total_request_count,
account.provider_kind as account_provider_kind, account.plan_type as account_plan_type
from aggregated
join selected_dimensions selected using (dimension_name)
order by selected.sort_position, currency_grouping desc, cost_currency nulls last",
left join provider_accounts account on account.id = aggregated.dimension_name and ",
);
statement.push_bind(dimension == DiagnosticDimension::Account);
statement
.push(" order by selected.sort_position, currency_grouping desc, cost_currency nulls last");
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load usage diagnostics"))?;
let total_request_count = rows
.first()
.map(|row| unsigned(row, "total_request_count"))
.transpose()?
.unwrap_or_default();
let mut observations = Vec::with_capacity(DIAGNOSTIC_LIMIT as usize);
let mut costs = HashMap::<String, Vec<CurrencyCostTotal>>::new();
for row in &rows {
@@ -469,6 +481,8 @@ pub(crate) async fn usage_diagnostics(
1 => observations.push(DiagnosticObservation {
key: get(row, "dimension_name")?,
name: get(row, "dimension_name")?,
account_provider_kind: get(row, "account_provider_kind")?,
account_plan_type: get(row, "account_plan_type")?,
request_count: unsigned(row, "request_count")?,
success_count: unsigned(row, "success_count")?,
failure_count: unsigned(row, "failure_count")?,
@@ -479,6 +493,7 @@ pub(crate) async fn usage_diagnostics(
first_token_p95_ms: optional_unsigned(row, "first_token_p95_ms")?,
non_completion_count: unsigned(row, "non_completion_count")?,
retry_count: unsigned(row, "retry_count")?,
retried_request_count: unsigned(row, "retried_request_count")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
}),
@@ -523,7 +538,10 @@ pub(crate) async fn usage_diagnostics(
}
observation.costs = costs.remove(&observation.key).unwrap_or_default();
}
Ok(observations)
Ok(DiagnosticsObservation {
total_request_count,
items: observations,
})
}
pub(crate) async fn diagnostic_account_display_names(
@@ -160,7 +160,7 @@ async fn usage_page_should_always_return_total() {
}
#[tokio::test]
async fn usage_list_should_resolve_current_notes_by_account_id() {
async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_id() {
let Some(database) = TestDatabase::create("usage_account_notes").await else {
return;
};
@@ -197,12 +197,20 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
.expect("admin observability range");
let store = admin_observability_store(&database.pool);
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
.bind(notes)
.execute(&database.pool)
.await
.expect("update current account notes");
for (notes, plan) in [
(None, Some("pro")),
(Some("个人主号"), Some("plus")),
(Some("个人备用号"), Some("pro")),
(None, None),
] {
sqlx::query(
"update provider_accounts set notes = $1, plan_type = $2 where id = 'acct_observe'",
)
.bind(notes)
.bind(plan)
.execute(&database.pool)
.await
.expect("update current account notes");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
@@ -230,6 +238,36 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
);
assert_eq!(personal.provider_account_email, team.provider_account_email);
assert_eq!(personal.provider_account_notes.as_deref(), notes);
assert_eq!(personal.provider_account_plan_type.as_deref(), plan);
assert_eq!(team.provider_account_plan_type.as_deref(), Some("team"));
let diagnostics = store
.usage_diagnostics(
range,
admin_observability::UsageFilter::default(),
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("account plans");
assert_eq!(
diagnostics
.items
.iter()
.find(|item| item.key == "acct_observe")
.expect("personal diagnostics")
.account_plan_type
.as_deref(),
plan
);
assert_eq!(
diagnostics
.items
.iter()
.find(|item| item.key == "acct_team")
.expect("team diagnostics")
.account_plan_type
.as_deref(),
Some("team")
);
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
}
@@ -274,6 +312,19 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
Some("account@example.invalid")
);
assert_eq!(page.items[0].provider_account_notes, None);
assert_eq!(page.items[0].provider_account_plan_type, None);
let diagnostics = store
.usage_diagnostics(
range,
admin_observability::UsageFilter {
provider_account_ref: Some("acct_team".to_owned()),
..Default::default()
},
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("deleted account diagnostics");
assert_eq!(diagnostics.items[0].account_plan_type, None);
database.close().await;
}
@@ -765,7 +816,8 @@ async fn recovered_continuation_failure_should_be_visible_in_ops_but_hidden_from
DiagnosticDimension::Account,
)
.await
.expect("diagnostics without recovered intermediates");
.expect("diagnostics without recovered intermediates")
.items;
assert_eq!(diagnostics[0].request_count, 2);
assert_eq!(diagnostics[0].failure_count, 0);
@@ -1358,7 +1410,8 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("admin diagnostics");
.expect("admin diagnostics")
.items;
assert_eq!(diagnostics[0].key, "acct_observe");
assert_eq!(diagnostics[0].name, "account@example.invalid");
assert_eq!(diagnostics[0].cost_coverage.provider_reported_count, 1);
@@ -1775,7 +1828,8 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
DiagnosticDimension::Account,
)
.await
.expect("usage diagnostics");
.expect("usage diagnostics")
.items;
assert_eq!(diagnostics[0].key, "acct_observe");
assert_eq!(diagnostics[0].name, "account@example.invalid");
assert_eq!(diagnostics[0].request_count, 3);
@@ -1,4 +1,5 @@
use chrono::{DateTime, TimeDelta, Utc};
use gateway_admin::ports::store::ObservabilityStore as _;
use gateway_store::postgres::{
DiagnosticDimension, ModelRequestAttemptStart, ModelRequestRepository, NewModelRequest,
ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, OpsErrorFilter,
@@ -111,7 +112,8 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
DiagnosticDimension::Account,
)
.await
.expect("load statusless usage diagnostics");
.expect("load statusless usage diagnostics")
.items;
assert_eq!(
(
diagnostics[0].request_count,
@@ -515,7 +517,8 @@ async fn diagnostics_should_group_same_email_accounts_by_stable_ref() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics
@@ -587,7 +590,8 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
let by_key = diagnostics
.iter()
.map(|item| (item.key.as_str(), item.name.as_str()))
@@ -652,7 +656,8 @@ async fn diagnostics_should_prefer_the_latest_non_null_email_snapshot() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "acct_snapshot_history");
@@ -713,7 +718,8 @@ async fn failure_diagnostics_should_only_include_errored_requests() {
DiagnosticDimension::Failure,
)
.await
.expect("failure diagnostics");
.expect("failure diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "rate_limited");
assert_eq!(diagnostics[0].request_count, 1);
@@ -777,7 +783,8 @@ async fn model_diagnostics_should_exclude_provider_endpoints_without_a_model() {
DiagnosticDimension::Model,
)
.await
.expect("model diagnostics");
.expect("model diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "upstream-model");
@@ -838,6 +845,137 @@ async fn tokenless_provider_endpoints_should_remain_in_health_but_not_usage_reco
database.close().await;
}
#[tokio::test]
async fn diagnostics_should_keep_filtered_totals_before_limiting_groups() {
let Some(database) = TestDatabase::create("diagnostic_totals").await else {
return;
};
let now = Utc::now();
seed_account(&database.pool, "acct_totals", "Totals", None, "oauth", now).await;
let store = PgExecutionStore::new(database.pool.clone());
for index in 0..201 {
let id = format!("req_total_{index:03}");
let mut first_attempt = attempt(&id, 1, "acct_totals");
first_attempt.upstream_model_id = Some(format!("model_{:03}", index % 200));
store
.insert_model_request_with_first_attempt(new_request(&id, now), first_attempt)
.await
.expect("insert model group");
finalize_request(&database.pool, &id, now).await;
}
sqlx::query(
"update model_requests set cost_source = 'provider_reported', cost_amount = 1,
cost_currency = case when id = 'req_total_200' then 'EUR' else 'USD' end",
)
.execute(&database.pool)
.await
.expect("set currency groups");
let repository = observability_repository(&database.pool);
let result = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter::default(),
DiagnosticDimension::Model,
)
.await
.expect("limited groups");
assert_eq!(
(
result.total_request_count,
result.items.len(),
result
.items
.iter()
.map(|item| item.request_count)
.sum::<u64>()
),
(201, 100, 101)
);
assert_eq!(
(result.items[0].key.as_str(), result.items[0].costs.len()),
("model_000", 2)
);
let filtered = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter {
model: Some("model_000".to_owned()),
..UsageRecordFilter::default()
},
DiagnosticDimension::Model,
)
.await
.expect("filtered denominator");
assert_eq!((filtered.total_request_count, filtered.items.len()), (2, 1));
let empty = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter::default(),
DiagnosticDimension::Failure,
)
.await
.expect("empty error dimension");
assert_eq!((empty.total_request_count, empty.items.len()), (0, 0));
database.close().await;
}
#[tokio::test]
async fn diagnostics_should_distinguish_retry_attempts_from_retried_requests() {
let Some(database) = TestDatabase::create("diagnostic_retries").await else {
return;
};
let now = Utc::now();
seed_account(
&database.pool,
"acct_retries",
"Retries",
None,
"oauth",
now,
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
for (index, attempts) in [0, 1, 1, 1, 1, 1, 3, 5].into_iter().enumerate() {
let id = format!("req_retry_{index}");
store
.insert_model_request_with_first_attempt(
new_request(&id, now),
attempt(&id, 1, "acct_retries"),
)
.await
.expect("insert retry request");
finalize_request(&database.pool, &id, now).await;
sqlx::query("update model_requests set attempt_count = $2 where id = $1")
.bind(id)
.bind(attempts)
.execute(&database.pool)
.await
.expect("set attempts");
}
let result = super::admin_observability_store(&database.pool)
.usage_diagnostics(
gateway_admin::model::observability::TimeRange::new(
now - TimeDelta::hours(1),
now + TimeDelta::hours(1),
)
.expect("range"),
gateway_admin::model::observability::UsageFilter::default(),
gateway_admin::model::observability::DiagnosticDimension::Model,
)
.await
.expect("retry metrics through admin adapter");
assert_eq!(
(
result.total_request_count,
result.items[0].retry_count,
result.items[0].retried_request_count
),
(8, 6, 2)
);
database.close().await;
}
#[tokio::test]
async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
let Some(database) = TestDatabase::create("snapshot_api_key_dimension").await else {
@@ -886,7 +1024,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
DiagnosticDimension::ApiKey,
)
.await
.expect("api key diagnostics");
.expect("api key diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "My Key");
@@ -904,7 +1043,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
DiagnosticDimension::ApiKey,
)
.await
.expect("api key diagnostics after deletion");
.expect("api key diagnostics after deletion")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "key_diag");
@@ -228,7 +228,7 @@ struct PricingRule {
// 价格来源:https://developers.openai.com/api/docs/pricing。
// 只登记已核验的常规价格及服务档位;临时优惠不写入内置价目。
// 原有规则于 2026-09-09 核验,新增型号的核验日期见对应条目。
// 原有规则于 2026-09-09 核验,GPT-6.1 Sol 于 2026-09-29 核验,其余新增型号见对应条目。
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
const PRICING_RULES: &[PricingRule] = &[
@@ -244,6 +244,16 @@ const PRICING_RULES: &[PricingRule] = &[
.with_long_flex(100_000, 375_000, 10_000)
.with_long_fast(400_000, 1_500_000, 40_000),
},
PricingRule {
model: "gpt-6.1-sol",
pricing: ModelPricing::new(20_000, 100_000, 1_000)
.with_cache_write(125)
.with_flex(10_000, 50_000, 500)
.with_fast(40_000, 200_000, 2_000)
.with_long(40_000, 150_000, 2_000)
.with_long_flex(20_000, 75_000, 1_000)
.with_long_fast(80_000, 300_000, 4_000),
},
// https://developers.openai.com/api/docs/models/gpt-6-sol,核验日期 2026-09-24。
PricingRule {
model: "gpt-6-sol",
@@ -864,7 +874,10 @@ fn reasoning_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
let model = pricing_model_name(&normalized);
model.starts_with("gpt-5")
|| matches!(model, "gpt-6-astra" | "gpt-6-sol" | "gpt-6-luna")
|| matches!(
model,
"gpt-6-astra" | "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna"
)
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
@@ -352,7 +352,14 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() {
.await
.unwrap();
let prices = bundle.admin_provider().pricing_catalog();
for model in ["gpt-4", "gpt-4o", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
for model in [
"gpt-4",
"gpt-4o",
"gpt-6-astra",
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
] {
let usage = OpenAiBillingUsage::new(100, 10, 20, 15);
let inherited = openai_billing_breakdown(model, usage, None).unwrap();
let copied =
@@ -399,7 +399,7 @@ fn decoder_should_use_non_reasoning_preview_web_search_price() {
#[test]
fn gpt_6_decoders_should_use_reasoning_preview_web_search_price() {
let tools = vec![json!({ "type": "web_search_preview" })];
for model in ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
for model in ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"] {
let created = json!({
"type":"response.created",
"response":{"id":"resp_preview_search_cost","model":model}
@@ -80,6 +80,17 @@ fn astra_billing_should_preserve_components_across_tiers_and_context_boundary()
fn new_gpt_6_models_should_use_published_prices_across_tiers_and_context_boundary() {
// 每档依次核对每百万 Token 的输入、缓存读取、缓存写入和输出价格。
for (model, cases) in [
(
"gpt-6.1-sol",
[
(None, 272_000, ["2", "0.1", "2.5", "10"]),
(None, 272_001, ["4", "0.2", "5", "15"]),
(Some("flex"), 272_000, ["1", "0.05", "1.25", "5"]),
(Some("flex"), 272_001, ["2", "0.1", "2.5", "7.5"]),
(Some("fast"), 272_000, ["4", "0.2", "5", "20"]),
(Some("priority"), 272_001, ["8", "0.4", "10", "30"]),
],
),
(
"gpt-6-sol",
[
+6 -2
View File
@@ -911,8 +911,6 @@ OAuth start 使用:
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量,
公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。
真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
@@ -1561,6 +1559,12 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列
`requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比
`retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次
账号维度诊断项和使用记录列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前套餐及展示名称,
按内部账号 ID 关联;套餐未知或账号已删除时为 `null`,不作为请求发生时的套餐快照
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.47.0",
+6 -6
View File
@@ -170,8 +170,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.3.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.3.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -384,9 +384,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/e1d4a164e7cfe72272301ebcb1b1d9c54b786522':
resolution: {gitHosted: true, integrity: sha512-I2h13qjmqABk4IviUXdiZXPU9o//qO6WGbBBvav9rXKXyEjfFXvwvz0ofa/4UWOmTRBhmcRFmEIE0qUTzHZThQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522}
version: 0.3.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01':
resolution: {gitHosted: true, integrity: sha512-TcVbsvwVd+19Ex+o0z3Q6XYx+k2p/lsAWFpCN704zujhz2qqBh9hEp8+aCsA/1kUhLASQki3sXFa5cmpYpTO2g==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01}
version: 0.3.1
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2783,7 +2783,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/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
-4
View File
@@ -190,10 +190,6 @@ export interface AccountQuotaForecast {
estimatedTokensDisplay: string
estimatedUsd: number | null
estimatedUsdDisplay: string
remainingTokens: number | null
remainingTokensDisplay: string
remainingUsd: number | null
remainingUsdDisplay: string
}
export interface AccountQuotaForecastResponse {
+4
View File
@@ -83,6 +83,8 @@ export interface UsageListRecord {
accountEmail: string | null
accountName: string | null
accountNotes: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
route: string
model: string | null
requestedModel: string | null
@@ -436,6 +438,8 @@ export interface UsageInsightsOverviewResponse {
export interface UsageDiagnosticItem {
key: string
name: string
accountPlanType: string | null
accountPlanTypeDisplay: string | null
requestCount: number
successCount: number
errorCount: number
+3 -22
View File
@@ -15,14 +15,13 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
const { sidebarCollapsed } = storeToRefs(uiStore)
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
const { version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
const mobileSidebarOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const systemUpdateOpen = shallowRef(false)
const systemUpdateOpening = shallowRef(false)
function openMobileSidebar() {
mobileSidebarOpen.value = true
@@ -32,24 +31,6 @@ function closeMobileSidebar() {
mobileSidebarOpen.value = false
}
async function openSystemUpdate() {
if (systemUpdateOpen.value || systemUpdateOpening.value)
return
systemUpdateOpening.value = true
try {
if (!loadedOnce.value)
await systemUpdateStore.loadSystem(false)
}
catch {
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
}
finally {
systemUpdateOpening.value = false
systemUpdateOpen.value = true
}
}
onMounted(() => {
void systemUpdateStore.loadVersion().catch(() => undefined)
})
@@ -75,7 +56,7 @@ watch(
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
@open-system-update="systemUpdateOpen = true"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
@@ -103,7 +84,7 @@ watch(
@close="closeMobileSidebar"
@navigate="closeMobileSidebar"
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
@open-system-update="systemUpdateOpen = true"
/>
</div>
</div>
@@ -60,7 +60,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const updateError = shallowRef('')
const statusAvailable = shallowRef(false)
const policy = shallowRef<SystemUpdatePolicy | null>(null)
const loadedOnce = shallowRef(false)
const updateLogs = ref<SystemUpdateEvent[]>([])
const updateStreaming = shallowRef(false)
const updateStreamError = shallowRef('')
@@ -282,7 +281,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
return
updateInfo.value = detail
policy.value = detail.policy
loadedOnce.value = true
if (!version.value)
await loadVersion()
if (generation !== detailGeneration)
@@ -501,7 +499,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
updateError,
lastFailedOperation,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
@@ -18,6 +18,7 @@ const props = withDefaults(
const planPalettes: Record<string, string> = {
free: 'bg-cp-cyan-container text-cp-cyan-on-container',
plus: 'bg-cp-blue-container-strong text-cp-blue-on-container',
pro: 'bg-cp-purple-container-strong text-cp-purple-on-container',
prolite: 'bg-cp-purple-container text-cp-purple-on-container',
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { ArrowRight, Info } from '@lucide/vue'
import { ArrowRight } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
@@ -26,8 +26,7 @@ const metrics = computed(() => [
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-cp-xs text-cp-text-secondary">本周期已用</span>
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
<Info class="size-3" aria-hidden="true" />
<span class="inline-flex items-center rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
</span>
</div>
@@ -70,14 +69,5 @@ const metrics = computed(() => [
</div>
</div>
</div>
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
<span class="text-cp-text-secondary">更新时的剩余额度</span>
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
{{ forecast.remainingTokensDisplay }} Tokens
<span class="mx-1 text-cp-text-tertiary">/</span>
{{ forecast.remainingUsdDisplay }}
</span>
</div>
</section>
</template>
@@ -6,7 +6,7 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<div role="status" aria-busy="true">
<span class="sr-only">正在加载额度预测</span>
<div aria-hidden="true">
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-16" />
@@ -33,11 +33,6 @@ import { BaseSkeleton } from '@codex-proxy/ui'
</div>
</div>
</div>
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-28" />
<BaseSkeleton shape="text" class="w-32" />
</div>
</div>
</div>
</div>
@@ -27,7 +27,7 @@ const { now, pause, resume } = useNow({
scheduler: callback => useIntervalFn(callback, 30_000),
})
const options = computed(() => report.value?.forecasts.map(item => ({
label: item.extrapolated ? `${item.targetDays}天折算` : item.source?.label ?? (item.period === 'weekly' ? '周额度' : '月额度'),
label: item.period === 'weekly' ? '周额度' : '月额度',
value: item.period,
})) ?? [
{ label: '周额度', value: 'weekly' },
@@ -75,9 +75,9 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<AccountIdentityCell :account="account" show-plan title-mode="email" class="max-w-full">
<AccountIdentityCell :account="account" show-plan title-mode="email" meta-position="secondary" meta-size="xs" class="max-w-full">
<template #meta>
<ProviderIconGroup :provider="account.provider" size="sm" />
<ProviderIconGroup :provider="account.provider" size="xs" />
</template>
</AccountIdentityCell>
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
@@ -104,7 +104,10 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</div>
<template #footer>
<div class="mr-auto flex min-w-0 items-center gap-2">
<div class="mr-auto flex min-w-0 items-center gap-1">
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
@@ -132,16 +135,13 @@ function handleExplanationKeydown(event: KeyboardEvent) {
目前数据还较少,结果可能有较大波动
</p>
<p v-if="forecast?.extrapolated" class="m-0">
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
本页预测由{{ forecast.source?.label ?? '当前周期' }}按 {{ forecast.targetDays }} 天折算
</p>
<p class="m-0">
剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
预测以数据更新时间为准,等价费用不是实际账单或账户余额
</p>
</section>
</BasePopover>
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
</div>
<BaseButton variant="secondary" @click="open = false">
关闭
@@ -15,7 +15,7 @@ const emit = defineEmits<{
<template>
<div class="flex items-center gap-2">
<code class="block min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text">
<code class="block min-w-0 overflow-hidden whitespace-nowrap font-mono text-cp-sm font-emphasis text-cp-text">
{{ prefix }}••••••••••••••••
</code>
<BaseIconButton
@@ -118,6 +118,8 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
accountEmail: options.accountEmail,
accountName: options.accountEmail.split('@')[0] ?? null,
accountNotes: null,
accountPlanType: null,
accountPlanTypeDisplay: null,
route: stream ? '/v1/responses' : '/v1/chat/completions',
model: options.model,
requestedModel: options.model,
@@ -5,6 +5,7 @@ import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } fro
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
@@ -31,38 +32,46 @@ const dimensionOptions = [
{ label: '错误', value: 'failureClass' },
]
const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayItem>([
{
key: 'nameDisplay',
label: '维度',
kind: 'custom',
size: 'xl',
},
...(resultDimension.value === 'account'
? defineTableColumns<DiagnosticDisplayItem>([
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
])
: []),
{
key: 'requestCount',
label: '请求',
kind: 'numeric',
size: 'sm',
size: 'xs',
fixedWidth: true,
},
{
key: 'errorCount',
label: '失败',
kind: 'numeric',
size: 'sm',
size: 'xs',
fixedWidth: true,
},
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg', fixedWidth: true },
{
key: 'estimatedCost',
label: '费用',
kind: 'numeric',
size: 'sm',
fixedWidth: true,
},
])
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
]))
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
@@ -98,7 +107,7 @@ function diagnosticNameDisplay(name: string) {
as="article"
title="热点诊断"
:description="`按${resultDimensionLabel}聚合`"
class="h-105 min-h-105 max-h-105 min-w-0 w-full lg:h-full lg:min-h-90 lg:max-h-105"
class="max-h-105 min-w-0 w-full xl:h-full"
>
<template #actions>
<BaseSegmented
@@ -114,7 +123,7 @@ function diagnosticNameDisplay(name: string) {
<BaseTable
v-if="hasData"
:key="resultDimension"
class="min-h-0 w-full xl:contain-[size]"
class="min-h-0 max-h-80 w-full xl:max-h-none xl:contain-[size]"
:columns="diagnosticColumns"
:rows="displayItems"
density="compact"
@@ -148,6 +157,16 @@ function diagnosticNameDisplay(name: string) {
</div>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #requestCount="{ row }">
<span
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
@@ -205,7 +224,7 @@ function diagnosticNameDisplay(name: string) {
surface="none"
:title="loading ? '正在加载热点诊断数据' : '暂无诊断数据'"
description="当前范围没有可诊断的请求记录"
class="h-full place-content-center"
class="h-full min-h-40 place-content-center"
/>
</template>
</BaseCard>
@@ -4,6 +4,7 @@ import type { UsageDisplayRecord } from '../utils/records'
import { BaseTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import {
usageAccountText,
usageAuthenticationKind,
@@ -72,6 +73,16 @@ withDefaults(
</span>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
+4 -2
View File
@@ -14,14 +14,16 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
kind: 'identity',
size: '3xl',
},
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{
key: 'provider',
label: '平台/类型',
kind: 'status',
size: 'sm',
size: 'md',
fixedWidth: true,
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'lg' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
+13 -9
View File
@@ -1,15 +1,19 @@
# v3.18.1
# v3.18.2
## 问题修复
- 在线更新不再因插件不兼容而阻止安装,点击重启时检查已安装目标版本,列出不兼容插件的名称与原因,确认后停用这些插件并重启
- 确认绑定目标发行和插件配置,检查后发生变更时要求重新确认;取消不修改插件状态,批量停用保留配置、密钥与私有数据
- 插件管理使用黄色警告三角展示不兼容原因,禁止启动不兼容插件,安装兼容版本后可重新启用
- 兼容更新文件已安装、旧进程尚未重启时的管理端操作,保留旧版本的重启入口
- 修复热点诊断在分组超过 100 项时请求占比偏高的问题,分母使用筛选范围内全部匹配请求;重试率按发生重试的请求数计算,同一请求多次重试只计一次
- 系统更新弹窗点击后立即打开,加载期间显示骨架屏;修复关闭时发布说明提前消失、面板高度跳变的问题
## 计费与管理界面
- 补充 GPT-6.1 Sol 内置计费规则,覆盖长上下文、服务档位、缓存与搜索费用
- 热点诊断的账号维度和使用记录增加当前套餐列,调整列宽、对齐及套餐徽标对比度,减少诊断卡片空白
- 精简额度预测弹窗,移除剩余额度行,周期标签统一为“周额度”和“月额度”;平台与套餐放到邮箱下方,说明图标移到更新时间后
- API 密钥前缀超出列宽时直接裁切,保留脱敏显示与复制按钮
## 升级说明
- 从 v3.17.0 升级:插件已卸载或全部停用时,可直接在线更新到本版;仍启用旧合同插件时,需先停用一次,旧进程不能提前使用新版确认流程
- 本版发行清单保留 v3.17.0 可解析的传输格式,实际支持范围仍为清单 2、进程协议 2、中间件能力 3,不宣称兼容旧插件
- v3.18.0 的清单解析规则与 v3.17.0 互斥,无法读取此过渡格式;已运行 v3.18.0 的实例需用完整平台发行包或容器镜像升级一次。二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
- 进入本版后的后续在线更新使用重启前检查与确认流程,Workbench 可安装 [v0.2.0](https://github.com/zyycn/codex-proxy-plugins/releases/tag/v0.2.0)
- 额度预测接口不再返回 `remainingTokens`、`remainingUsd` 及对应的 `*Display` 字段;直接使用该接口的客户端需同步调整,预计总量的计算规则保持不变
- 套餐列展示账号当前套餐,不是请求发生时的套餐快照;月额度缺少对应上游窗口时仍按 30 天折算,并保留折算提示
- v3.18.0 的更新清单解析限制仍适用,需使用完整平台发行包或容器镜像升级;二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
+1 -1
View File
@@ -1 +1 @@
version: "3.18.1"
version: "3.18.2"