From cae2c8656afcc2e410906b305d7863bdba748154 Mon Sep 17 00:00:00 2001 From: zyycn Date: Thu, 30 Jul 2026 03:10:35 +0800 Subject: [PATCH] fix(xai): preserve quota exhaustion state Co-authored-by: Codex --- .../gateway-admin/src/model/observability.rs | 4 ++- .../gateway-api/src/admin/observability.rs | 11 ++----- .../gateway-api/tests/admin/observability.rs | 8 ++--- .../src/postgres/observability.rs | 3 +- .../tests/postgres/observability.rs | 8 +++-- .../providers/xai/src/credential/catalog.rs | 29 +++++++++++++------ .../providers/xai/tests/credential/catalog.rs | 13 +++++++-- .../components/AccountOverviewCards.vue | 2 +- 8 files changed, 46 insertions(+), 32 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index e630d5b8d..a7df85d18 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -461,7 +461,9 @@ pub struct RequestMetricPoint { /// 账号池容量统计。 /// -/// 配额耗尽单列为可恢复状态;`unavailable` 只包含过期、禁用和封禁账号。 +/// 首页概览的 `unavailable` 表示不可参与调度,包含配额耗尽及过期、禁用和封禁账号。 +/// +/// `quota_exhausted` 仍单列,以便状态详情展示和诊断。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AccountPoolMetrics { pub total: u64, diff --git a/backend/crates/gateway-api/src/admin/observability.rs b/backend/crates/gateway-api/src/admin/observability.rs index 2c50d9a40..4d7cab556 100644 --- a/backend/crates/gateway-api/src/admin/observability.rs +++ b/backend/crates/gateway-api/src/admin/observability.rs @@ -2234,9 +2234,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard account_usage, recent_requests, } = observation; - // 概览卡只有可用和不可用两桶:额度耗尽可恢复,保留在可用;不可用只含终态账号。 - let available_accounts = - dashboard_available_account_count(provider_accounts.total, provider_accounts.unavailable); let mut account_usage_views = Vec::with_capacity(account_usage.len()); let mut credential_usage_views = Vec::with_capacity(account_usage.len()); for credential in account_usage { @@ -2285,8 +2282,8 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard credentials: DashboardCredentialsCardView { total: format_compact_number(provider_accounts.total), total_value: provider_accounts.total, - available: format_compact_number(available_accounts), - available_value: available_accounts, + available: format_compact_number(provider_accounts.active), + available_value: provider_accounts.active, unavailable: format_compact_number(provider_accounts.unavailable), unavailable_value: provider_accounts.unavailable, }, @@ -2342,10 +2339,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard } } -fn dashboard_available_account_count(total: u64, unavailable: u64) -> u64 { - total.saturating_sub(unavailable) -} - fn usage_summary_view(summary: domain::UsageSummary) -> UsageSummaryView { let overview = summary.overview; UsageSummaryView { diff --git a/backend/crates/gateway-api/tests/admin/observability.rs b/backend/crates/gateway-api/tests/admin/observability.rs index c4dd179f0..259730b1e 100644 --- a/backend/crates/gateway-api/tests/admin/observability.rs +++ b/backend/crates/gateway-api/tests/admin/observability.rs @@ -261,7 +261,7 @@ mod response { } #[tokio::test] -async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in_headline_card() { +async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headline_count() { use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, @@ -288,7 +288,7 @@ async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in provider_accounts: AccountPoolMetrics { total: 807, enabled: 804, - unavailable: 7, + unavailable: 792, active: 15, expired: 2, quota_exhausted: 785, @@ -328,8 +328,8 @@ async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in ), ( &serde_json::json!(807), - &serde_json::json!(800), - &serde_json::json!(7) + &serde_json::json!(15), + &serde_json::json!(792) ), ); } diff --git a/backend/crates/gateway-store/src/postgres/observability.rs b/backend/crates/gateway-store/src/postgres/observability.rs index 99ff4ea68..4c59154b0 100644 --- a/backend/crates/gateway-store/src/postgres/observability.rs +++ b/backend/crates/gateway-store/src/postgres/observability.rs @@ -2013,8 +2013,7 @@ async fn provider_account_metrics( ) select count(*)::bigint as total, count(*) filter (where enabled)::bigint as enabled, - count(*) filter (where status in ('expired', 'disabled', 'banned'))::bigint - as unavailable, + count(*) filter (where status <> 'active')::bigint as unavailable, count(*) filter (where status = 'active')::bigint as active, count(*) filter (where status = 'expired')::bigint as expired, count(*) filter (where status = 'quota_exhausted')::bigint as quota_exhausted, diff --git a/backend/crates/gateway-store/tests/postgres/observability.rs b/backend/crates/gateway-store/tests/postgres/observability.rs index 14cad81ea..4ebc4c318 100644 --- a/backend/crates/gateway-store/tests/postgres/observability.rs +++ b/backend/crates/gateway-store/tests/postgres/observability.rs @@ -169,12 +169,14 @@ async fn dashboard_account_metrics_should_partition_account_statuses() { metrics.disabled, metrics.banned, ), - (6, 5, 2, 3, 1, 1, 1, 1), + (6, 5, 2, 4, 1, 1, 1, 1), ); + // 首页按调度可用性聚合:配额耗尽也属于不可用。 assert_eq!( - metrics.total, - metrics.active + metrics.quota_exhausted + metrics.unavailable + metrics.unavailable, + metrics.quota_exhausted + metrics.expired + metrics.disabled + metrics.banned ); + assert_eq!(metrics.total, metrics.active + metrics.unavailable); database.close().await; } diff --git a/backend/crates/providers/xai/src/credential/catalog.rs b/backend/crates/providers/xai/src/credential/catalog.rs index d24df203d..ade2731a1 100644 --- a/backend/crates/providers/xai/src/credential/catalog.rs +++ b/backend/crates/providers/xai/src/credential/catalog.rs @@ -469,7 +469,7 @@ impl GrokCredentialQuotaService { if quota_refresh_may_update_state(&loaded.account, ¤t.account) && let Some(availability) = quota_refresh_availability( current.account.availability(), - quota_is_exhausted(snapshot.billing()), + authoritative_quota_exhaustion(snapshot.billing()), ) { self.repository @@ -1329,6 +1329,13 @@ fn quota_is_exhausted(billing: &GrokBillingPresentation) -> bool { ) } +/// `None` means the billing document cannot prove either quota state. +fn authoritative_quota_exhaustion(billing: &GrokBillingPresentation) -> Option { + billing + .has_authoritative_quota() + .then(|| quota_is_exhausted(billing)) +} + fn quota_refresh_may_update_state(before: &ProviderAccount, current: &ProviderAccount) -> bool { current.enabled() && current @@ -1341,22 +1348,26 @@ fn quota_refresh_may_update_state(before: &ProviderAccount, current: &ProviderAc fn quota_refresh_availability( current: AccountAvailability, - exhausted: bool, + exhausted: Option, ) -> Option { match current { AccountAvailability::Invalid | AccountAvailability::Expired | AccountAvailability::Banned => None, AccountAvailability::QuotaExhausted => { - (!exhausted).then_some(GrokCredentialAvailability::Ready) + matches!(exhausted, Some(false)).then_some(GrokCredentialAvailability::Ready) + } + AccountAvailability::Cooldown | AccountAvailability::Unknown => { + exhausted.map(|exhausted| { + if exhausted { + GrokCredentialAvailability::QuotaExhausted + } else { + GrokCredentialAvailability::Ready + } + }) } - AccountAvailability::Cooldown | AccountAvailability::Unknown => Some(if exhausted { - GrokCredentialAvailability::QuotaExhausted - } else { - GrokCredentialAvailability::Ready - }), AccountAvailability::Ready => { - exhausted.then_some(GrokCredentialAvailability::QuotaExhausted) + matches!(exhausted, Some(true)).then_some(GrokCredentialAvailability::QuotaExhausted) } } } diff --git a/backend/crates/providers/xai/tests/credential/catalog.rs b/backend/crates/providers/xai/tests/credential/catalog.rs index 9a76cc147..43ea468e3 100644 --- a/backend/crates/providers/xai/tests/credential/catalog.rs +++ b/backend/crates/providers/xai/tests/credential/catalog.rs @@ -874,17 +874,24 @@ async fn quota_projection_preserves_unknown_period_for_dynamic_duration_fallback } #[tokio::test] -async fn weekly_period_without_reported_allowance_is_not_authoritative_quota() { - let (_, repository) = repository_with_accounts(&[("free-quota", "subject-free-quota")]).await; +async fn non_authoritative_quota_refresh_does_not_clear_existing_quota_exhaustion() { + let (store, repository) = + repository_with_accounts(&[("free-quota", "subject-free-quota")]).await; + let id = account_id("free-quota"); + set_account_state(&store, &id, AccountAvailability::QuotaExhausted, None).await; let transport = QueueBillingTransport::success( br#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-15T00:00:00Z","end":"2026-07-22T00:00:00Z"},"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"prepaidBalance":{"val":0}}}"#, ); let snapshot = crate::support::grok_quota_service(repository, transport) - .refresh_account(&account_id("free-quota")) + .refresh_account(&id) .await .expect("refresh Free quota"); assert!(!snapshot.billing().has_authoritative_quota()); + assert_eq!( + store.account(&id).expect("account").availability(), + AccountAvailability::QuotaExhausted + ); } #[tokio::test] diff --git a/frontend/src/views/accounts/components/AccountOverviewCards.vue b/frontend/src/views/accounts/components/AccountOverviewCards.vue index 2edc7bc6c..98b3003ec 100644 --- a/frontend/src/views/accounts/components/AccountOverviewCards.vue +++ b/frontend/src/views/accounts/components/AccountOverviewCards.vue @@ -33,7 +33,7 @@ const overviewItems = computed(() => [ icon: Gauge, }, { - label: '不可用账号', + label: '异常账号', value: formatCount(props.summary.unavailable), caption: '过期 / 禁用 / 封禁', tone: 'danger',