From 0fffd2c550789a4b2b4f699df9f7f600f6cce534 Mon Sep 17 00:00:00 2001 From: zyycn Date: Thu, 30 Jul 2026 01:23:42 +0800 Subject: [PATCH] fix(accounts): converge account state and imports Co-authored-by: Codex --- .../gateway-admin/tests/use_case/accounts.rs | 71 +++++++++- .../gateway-api/src/admin/observability.rs | 7 +- .../gateway-api/tests/admin/accounts.rs | 4 +- .../gateway-api/tests/admin/observability.rs | 41 +++++- .../src/postgres/observability.rs | 2 +- .../src/postgres/provider_accounts.rs | 9 +- .../tests/postgres/observability.rs | 5 +- .../tests/postgres/provider_accounts.rs | 102 ++++++++++++++ .../openai/src/credential/affinity.rs | 9 +- .../providers/openai/src/transport/request.rs | 14 +- .../openai/tests/provider/contract.rs | 88 ++++++++++++ .../providers/xai/src/credential/selector.rs | 15 ++- backend/crates/providers/xai/src/provider.rs | 125 ++++++++++++------ .../providers/xai/src/transport/canonical.rs | 27 ++-- .../crates/providers/xai/src/transport/mod.rs | 51 +++++++ .../providers/xai/src/transport/network.rs | 33 ++--- .../providers/xai/src/transport/session.rs | 2 +- .../providers/xai/src/transport/transport.rs | 2 +- .../xai/tests/credential/selector.rs | 49 ++++--- .../providers/xai/tests/provider/contract.rs | 63 +++++++++ .../xai/tests/transport/canonical.rs | 22 +++ .../components/AccountConnectionTestModal.vue | 13 -- .../composables/useAccountConnectionTest.ts | 12 +- .../composables/useAccountOnboarding.ts | 44 ++++-- .../usage/components/UsageBillingCell.vue | 12 +- 25 files changed, 654 insertions(+), 168 deletions(-) diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 92c1038a7..8bbdf9bd7 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -374,6 +374,7 @@ impl ProviderAdmin for FakeProviderAdmin { pub(super) struct FakeAccountStore { events: EventLog, account: AccountRecord, + account_after_probe: Mutex>, fail_commit: Mutex, audit_requests: Mutex>, quota_window_usage: Mutex>, @@ -388,6 +389,7 @@ impl FakeAccountStore { Arc::new(Self { events, account, + account_after_probe: Mutex::new(None), fail_commit: Mutex::new(false), audit_requests: Mutex::new(Vec::new()), quota_window_usage: Mutex::new(Vec::new()), @@ -406,6 +408,13 @@ impl FakeAccountStore { *self.quota_window_usage.lock().expect("quota window usage") = usage; } + fn set_account_after_probe(&self, account: AccountRecord) { + *self + .account_after_probe + .lock() + .expect("account after probe") = Some(account); + } + fn record(&self, event: &'static str) { self.events.lock().expect("store events").push(event); } @@ -445,7 +454,13 @@ impl AccountStore for FakeAccountStore { async fn load_account(&self, account_id: &str) -> AdminStoreResult> { self.record("store.load_account"); - Ok((account_id == self.account.id).then(|| self.account.clone())) + let account = self + .account_after_probe + .lock() + .expect("account after probe") + .clone() + .unwrap_or_else(|| self.account.clone()); + Ok((account_id == account.id.as_str()).then_some(account)) } async fn load_account_usage( @@ -802,6 +817,36 @@ async fn connection_test_should_probe_unavailable_account() { )); } +#[tokio::test] +async fn connection_test_reloads_resettable_usage_limit_status_after_probe() { + let events = events(); + let provider = FakeProviderAdmin::new("xai", events.clone()); + let store = FakeAccountStore::new("xai", events); + let probe = Arc::new(FreeModelQuotaProbe { + store: Arc::clone(&store), + }); + let services = accounts_service_with_probe(provider, store, probe).await; + + let events = services + .accounts() + .test_connection( + ProviderAccountId::new("acct_test").expect("account ID"), + gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"), + ) + .await + .expect("connection test stream") + .collect::>() + .await; + + assert!(matches!( + events.last(), + Some(AccountConnectionTestEvent::Failed { + account_status: gateway_admin::model::accounts::AccountStatus::QuotaExhausted, + .. + }) + )); +} + #[tokio::test] async fn connection_test_should_preserve_disabled_account_status() { let provider = FakeProviderAdmin::new("xai", events()); @@ -1234,6 +1279,30 @@ impl AccountProbe for FailingAccountProbe { } } +struct FreeModelQuotaProbe { + store: Arc, +} + +impl AccountProbe for FreeModelQuotaProbe { + fn probe( + &self, + _: AccountProbeRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let mut account = account_record("xai"); + account.availability = AccountAvailability::Cooldown; + account.availability_reason = Some("usage_limit_exhausted".to_owned()); + account.cooldown_until = Some(Utc::now() + TimeDelta::hours(24)); + self.store.set_account_after_probe(account); + Err(GatewayError::new( + GatewayErrorKind::RateLimited, + "xAI free model quota is exhausted", + ) + .into()) + }) + } +} + fn store_unavailable() -> AdminStoreError { AdminStoreError::new( AdminStoreErrorKind::Unavailable, diff --git a/backend/crates/gateway-api/src/admin/observability.rs b/backend/crates/gateway-api/src/admin/observability.rs index a96ed713e..4d7cab556 100644 --- a/backend/crates/gateway-api/src/admin/observability.rs +++ b/backend/crates/gateway-api/src/admin/observability.rs @@ -1653,12 +1653,7 @@ fn format_token_price(cost: &domain::CurrencyCost) -> String { if cost.currency != "USD" { return format!("{} {} / 1M Token", cost.currency, cost.amount.as_str()); } - let value = cost - .amount - .as_str() - .parse::() - .map_or(0.0, |value| value); - format!("${value:.4} / 1M Token") + format!("${} / 1M Token", cost.amount.as_str()) } fn format_service_tier(service_tier: Option<&str>) -> String { diff --git a/backend/crates/gateway-api/tests/admin/accounts.rs b/backend/crates/gateway-api/tests/admin/accounts.rs index a74f68e2a..144577ceb 100644 --- a/backend/crates/gateway-api/tests/admin/accounts.rs +++ b/backend/crates/gateway-api/tests/admin/accounts.rs @@ -340,7 +340,7 @@ mod actions { upstream_status: Some(429), upstream_content_type: Some("application/json".to_owned()), upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()), - account_status: AccountStatus::Active, + account_status: AccountStatus::QuotaExhausted, }, ] .map(|event| AccountConnectionTestEvent::from(event).data); @@ -374,7 +374,7 @@ mod actions { "upstreamStatus": 429, "upstreamContentType": "application/json", "upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#, - "accountStatus": "active" + "accountStatus": "quota_exhausted" }), ] ); diff --git a/backend/crates/gateway-api/tests/admin/observability.rs b/backend/crates/gateway-api/tests/admin/observability.rs index 01dc4e950..3d4f7d05d 100644 --- a/backend/crates/gateway-api/tests/admin/observability.rs +++ b/backend/crates/gateway-api/tests/admin/observability.rs @@ -291,12 +291,17 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() { #[tokio::test] async fn usage_route_should_expose_image_and_websocket_facts() { + use std::str::FromStr as _; + use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; use chrono::Utc; - use gateway_admin::model::observability::{RequestOutcome, UsageRecord}; + use gateway_admin::model::observability::{ + CalculatedBillingBreakdown, CurrencyCost, DecimalAmount, RequestOutcome, UsageBilling, + UsageRecord, + }; use gateway_api::admin::observability; use tower::ServiceExt as _; @@ -304,6 +309,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() { let fixture = AdminTestFixture::new().await; fixture.auth.insert_session("valid-session"); + let usd = |amount: &str| CurrencyCost { + currency: "USD".to_owned(), + amount: DecimalAmount::from_str(amount).expect("USD amount"), + }; fixture .usage_records .lock() @@ -359,7 +368,22 @@ async fn usage_route_should_expose_image_and_websocket_facts() { cost_source: "unavailable".to_owned(), cost_amount: None, cost_currency: None, - billing: None, + billing: Some(UsageBilling::Calculated(Box::new( + CalculatedBillingBreakdown { + input_amount: usd("0.03"), + output_amount: usd("0.07"), + cache_read_amount: usd("0.00"), + cache_write_amount: usd("0.00"), + standard_amount: usd("0.10"), + total_amount: usd("0.10"), + input_price_per_million: usd("10.0000"), + output_price_per_million: usd("60.0000"), + cache_read_price_per_million: usd("1.0000"), + cache_write_price_per_million: usd("12.5000"), + service_tier: Some("priority".to_owned()), + multiplier_percent: 100, + }, + ))), transport_decision_wait_ms: Some(7), connect_ms: Some(11), headers_ms: Some(13), @@ -400,6 +424,19 @@ async fn usage_route_should_expose_image_and_websocket_facts() { .expect("usage response body"); let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON"); + assert_eq!( + value["data"]["items"][0]["billing"]["inputPriceDisplay"], + "$10 / 1M Token" + ); + assert_eq!( + value["data"]["items"][0]["billing"]["outputPriceDisplay"], + "$60 / 1M Token" + ); + assert_eq!( + value["data"]["items"][0]["billing"]["cacheWritePriceDisplay"], + "$12.5 / 1M Token" + ); + assert_eq!( serde_json::json!({ "route": value["data"]["items"][0]["route"], diff --git a/backend/crates/gateway-store/src/postgres/observability.rs b/backend/crates/gateway-store/src/postgres/observability.rs index 99ff4ea68..520ff860a 100644 --- a/backend/crates/gateway-store/src/postgres/observability.rs +++ b/backend/crates/gateway-store/src/postgres/observability.rs @@ -2013,7 +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 + count(*) filter (where status <> 'active')::bigint as unavailable, count(*) filter (where status = 'active')::bigint as active, count(*) filter (where status = 'expired')::bigint as expired, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts.rs b/backend/crates/gateway-store/src/postgres/provider_accounts.rs index b4c6563d6..dad7bb6ba 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts.rs @@ -1630,14 +1630,7 @@ fn admin_account_summary( let unavailable = u64::try_from( accounts .iter() - .filter(|account| { - matches!( - admin_account_status(account, now), - AdminAccountStatus::Expired - | AdminAccountStatus::Disabled - | AdminAccountStatus::Banned - ) - }) + .filter(|account| admin_account_status(account, now) != AdminAccountStatus::Active) .count(), ) .unwrap_or(u64::MAX); diff --git a/backend/crates/gateway-store/tests/postgres/observability.rs b/backend/crates/gateway-store/tests/postgres/observability.rs index 56d417cfb..9253d64b6 100644 --- a/backend/crates/gateway-store/tests/postgres/observability.rs +++ b/backend/crates/gateway-store/tests/postgres/observability.rs @@ -134,7 +134,7 @@ async fn dashboard_account_metrics_should_partition_available_and_unavailable_ac ('acct_rate_cooldown', 'openai', 'rate-cooldown', 'user-rate-cooldown', 'oauth', '{}'::jsonb, 1, false, $1 + interval '1 day', true, 'cooldown', 'rate_limited', $1 + interval '2 hours', $1, $1, $1), - ('acct_usage_limit', 'openai', 'usage-limit', 'user-usage-limit', 'oauth', + ('acct_usage_limit', 'xai', 'usage-limit', 'user-usage-limit', 'oauth', '{}'::jsonb, 1, false, $1 + interval '1 day', true, 'cooldown', 'usage_limit_exhausted', $1 + interval '2 hours', $1, $1, $1), ('acct_banned', 'xai', 'banned', 'user-banned', 'oauth', @@ -169,8 +169,9 @@ async fn dashboard_account_metrics_should_partition_available_and_unavailable_ac 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.unavailable); database.close().await; } diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs index 09be8db55..339da19d7 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs @@ -217,6 +217,11 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u assert_eq!(usage_page.summary.total, 5); assert_eq!(usage_page.summary.active, 2); assert_eq!(usage_page.summary.quota_exhausted, 1); + assert_eq!(usage_page.summary.unavailable, 3); + assert_eq!( + usage_page.summary.total, + usage_page.summary.active + usage_page.summary.unavailable + ); assert_eq!( usage_page .items @@ -1104,6 +1109,103 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() { database.close().await; } +#[tokio::test] +async fn xai_free_quota_exhaustion_state_is_persisted() { + let Some(database) = TestDatabase::create("provider_account_xai_free_quota").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let account_id = ProviderAccountId::new("acct_xai_free_quota").expect("account ID"); + let mut imported = account(account_id.as_str(), "user-xai-free-quota"); + imported.provider_kind = "xai".to_owned(); + repository + .insert_provider_account(imported) + .await + .expect("insert xAI account"); + + repository + .apply_state_change(AccountStateChange { + account_id: account_id.clone(), + expected_revision: CredentialRevision::new(1).expect("credential revision"), + availability: AccountAvailability::QuotaExhausted, + reason: Some("upstream_free_quota_exhausted".to_owned()), + cooldown_until: None, + observed_at: SystemTime::now(), + }) + .await + .expect("persist xAI free quota exhaustion"); + + let current: (String, String, Option) = sqlx::query_as( + "select provider_kind, availability, availability_reason + from provider_accounts where id = $1", + ) + .bind(account_id.as_str()) + .fetch_one(&database.pool) + .await + .expect("load persisted xAI quota state"); + assert_eq!( + current, + ( + "xai".to_owned(), + "quota_exhausted".to_owned(), + Some("upstream_free_quota_exhausted".to_owned()), + ), + ); + + database.close().await; +} + +#[tokio::test] +async fn xai_resettable_usage_limit_state_is_persisted() { + let Some(database) = TestDatabase::create("provider_account_xai_usage_limit").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let account_id = ProviderAccountId::new("acct_xai_usage_limit").expect("account ID"); + let mut imported = account(account_id.as_str(), "user-xai-usage-limit"); + imported.provider_kind = "xai".to_owned(); + repository + .insert_provider_account(imported) + .await + .expect("insert xAI account"); + + let observed_at = SystemTime::now(); + let cooldown_until = observed_at + .checked_add(Duration::from_secs(24 * 60 * 60)) + .expect("cooldown deadline"); + repository + .apply_state_change(AccountStateChange { + account_id: account_id.clone(), + expected_revision: CredentialRevision::new(1).expect("credential revision"), + availability: AccountAvailability::Cooldown, + reason: Some("usage_limit_exhausted".to_owned()), + cooldown_until: Some(cooldown_until), + observed_at, + }) + .await + .expect("persist xAI resettable usage limit"); + + let current: (String, String, Option, bool) = sqlx::query_as( + "select provider_kind, availability, availability_reason, cooldown_until is not null + from provider_accounts where id = $1", + ) + .bind(account_id.as_str()) + .fetch_one(&database.pool) + .await + .expect("load persisted xAI usage limit state"); + assert_eq!( + current, + ( + "xai".to_owned(), + "cooldown".to_owned(), + Some("usage_limit_exhausted".to_owned()), + true, + ), + ); + + database.close().await; +} + #[tokio::test] async fn disabled_account_preserves_user_state_during_refresh_writes() { let Some(database) = TestDatabase::create("provider_account_disabled_refresh").await else { diff --git a/backend/crates/providers/openai/src/credential/affinity.rs b/backend/crates/providers/openai/src/credential/affinity.rs index d94b89bf1..97cfc5fdb 100644 --- a/backend/crates/providers/openai/src/credential/affinity.rs +++ b/backend/crates/providers/openai/src/credential/affinity.rs @@ -16,9 +16,16 @@ pub(crate) fn derive_codex_session_affinity_key( .map(str::trim) .filter(|value| !value.is_empty()) { - return ProviderSessionAffinityKey::try_new(conversation_id.to_owned()).ok(); + if let Ok(key) = ProviderSessionAffinityKey::try_new(conversation_id.to_owned()) { + return Some(key); + } + return opaque_affinity_key("local-conversation", conversation_id); } let (domain, value) = derive_conversation_anchor(request)?; + opaque_affinity_key(domain, &value) +} + +fn opaque_affinity_key(domain: &str, value: &str) -> Option { let mut hasher = Sha256::new(); hasher.update(b"codex-session-affinity-v1\0"); hasher.update(domain.as_bytes()); diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index abf28107d..99a82daef 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -161,13 +161,14 @@ pub(crate) fn derive_conversation_anchor( request: &CodexResponsesRequest, ) -> Option<(&'static str, String)> { request - .prompt_cache_key() - .map(|value| ("prompt-cache", value.to_owned())) + .client_session_id + .as_deref() + .map(|value| ("session", value.to_owned())) .or_else(|| { request - .client_session_id + .client_conversation_id .as_deref() - .map(|value| ("session", value.to_owned())) + .map(|value| ("conversation", value.to_owned())) }) .or_else(|| { request @@ -177,9 +178,8 @@ pub(crate) fn derive_conversation_anchor( }) .or_else(|| { request - .client_conversation_id - .as_deref() - .map(|value| ("conversation", value.to_owned())) + .prompt_cache_key() + .map(|value| ("prompt-cache", value.to_owned())) }) .or_else(|| derive_stable_conversation_key(request).map(|value| ("request", value))) } diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 48676994b..4beaaa635 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -784,6 +784,94 @@ async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key() assert!(keys[0].bytes().all(|byte| byte.is_ascii_hexdigit())); } +#[tokio::test] +async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for_affinity() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_session_affinity").await; + let affinity = Arc::new(MemorySessionAffinity::default()); + let provider = provider_with_affinity(&store, Arc::clone(&affinity)); + + for (request_id, prompt_cache_key) in [ + ("req_session_affinity_first", "turn-cache-first"), + ("req_session_affinity_second", "turn-cache-second"), + ] { + let generation = GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), json!("hello")), + ("prompt_cache_key".to_owned(), json!(prompt_cache_key)), + ]), + ) + .expect("OpenAI payload") + .with_context(Map::from_iter([( + "session_id".to_owned(), + json!("stable-client-session"), + )])), + ); + + let stream = provider + .execute( + planned_request("openai", Operation::Generate(generation)), + context(request_id, CancellationToken::new()), + ) + .await + .expect("prepare provider stream"); + drop(stream); + } + + let keys = affinity.lookup_keys(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], keys[1]); +} + +#[tokio::test] +async fn invalid_local_conversation_id_should_fall_back_to_an_opaque_affinity_key() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_local_affinity").await; + let affinity = Arc::new(MemorySessionAffinity::default()); + let generation = GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), json!("hello")), + ]), + ) + .expect("OpenAI payload"), + ) + .with_provider_session_state( + ProviderSessionState::new( + "openai", + Map::from_iter([ + ("account_id".to_owned(), json!("acct_local_affinity")), + ( + "conversation_id".to_owned(), + json!("lc_UppercaseBase64ConversationId"), + ), + ("continuation_scope".to_owned(), json!("replay_required")), + ("transcript".to_owned(), json!([])), + ]), + ) + .expect("provider session state"), + ); + + let stream = provider_with_affinity(&store, Arc::clone(&affinity)) + .execute( + planned_request("openai", Operation::Generate(generation)), + context("req_local_affinity", CancellationToken::new()), + ) + .await + .expect("prepare provider stream"); + drop(stream); + + let keys = affinity.lookup_keys(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].len(), 64); + assert!(keys[0].bytes().all(|byte| byte.is_ascii_hexdigit())); +} + #[tokio::test] async fn completed_response_persists_session_affinity_before_stream_consumer_stops_polling() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/xai/src/credential/selector.rs b/backend/crates/providers/xai/src/credential/selector.rs index d255229aa..63f9304d0 100644 --- a/backend/crates/providers/xai/src/credential/selector.rs +++ b/backend/crates/providers/xai/src/credential/selector.rs @@ -479,15 +479,16 @@ impl GrokSessionSelector for GrokAccountSessionSelector { "upstream_payment_quota_exhausted", None, ), - GrokCredentialFailure::ModelQuotaExhausted { - upstream_model, - retry_after, - } => { + GrokCredentialFailure::ModelQuotaExhausted { retry_after, .. } => { let retry_after = bounded_cooldown(retry_after, MODEL_QUOTA_COOLDOWN, MAX_MODEL_COOLDOWN); - self.record_model_runtime_cooldown(session, upstream_model, retry_after) - .await; - return; + // xAI 免费模型额度按账号滚动窗口恢复;持久化使页面和各调度节点收敛。 + self.record_runtime_cooldown(session, retry_after).await; + ( + GrokCredentialAvailability::Cooldown, + "usage_limit_exhausted", + persistent_cooldown_until(observed_at, retry_after), + ) } GrokCredentialFailure::ModelAccessDenied { upstream_model, diff --git a/backend/crates/providers/xai/src/provider.rs b/backend/crates/providers/xai/src/provider.rs index 5b757bef4..401344d49 100644 --- a/backend/crates/providers/xai/src/provider.rs +++ b/backend/crates/providers/xai/src/provider.rs @@ -60,8 +60,9 @@ use crate::transport::{ GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokInferenceChunkStream, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportMetrics, - GrokProviderConfigError, GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey, - GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, SelectedGrokSession, + GrokProviderConfigError, GrokQuotaFailureKind, GrokRequestEncodeError, GrokResponsesRequest, + GrokSessionAffinityKey, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, + SelectedGrokSession, classify_grok_quota_failure, }; use crate::{GrokCatalogCapabilityEvidence, GrokCatalogModel}; @@ -1118,9 +1119,21 @@ fn cold_compaction_http_sse_stream( .await .map_err(mark_transient_compaction_failure)? { - let events = canonical.push(&chunk).map_err(|error| { - mark_transient_compaction_failure(map_continuation_failure(&context, error)) - })?; + let events = match canonical.push(&chunk) { + Ok(events) => events, + Err(error) => { + let error = map_continuation_failure(&context, error); + let error = record_stream_failure( + selector.as_ref(), + &session, + error, + &upstream_model, + ) + .await; + Err(mark_transient_compaction_failure(error))?; + return; + } + }; for event in events { summary.observe(&event).map_err(map_compaction_decode_error)?; facts.observe(&event); @@ -1131,9 +1144,22 @@ fn cold_compaction_http_sse_stream( } if facts.completed.is_none() { - for event in canonical.finish_without_terminal().map_err(|error| { - mark_transient_compaction_failure(map_continuation_failure(&context, error)) - })? { + let events = match canonical.finish_without_terminal() { + Ok(events) => events, + Err(error) => { + let error = map_continuation_failure(&context, error); + let error = record_stream_failure( + selector.as_ref(), + &session, + error, + &upstream_model, + ) + .await; + Err(mark_transient_compaction_failure(error))?; + return; + } + }; + for event in events { summary.observe(&event).map_err(map_compaction_decode_error)?; facts.observe(&event); if facts.completed.is_some() { @@ -1371,7 +1397,13 @@ fn cold_http_sse_stream( Ok(events) => events, Err(error) => { let error = map_continuation_failure(&context, error); - let error = record_stream_failure(selector.as_ref(), &session, error).await; + let error = record_stream_failure( + selector.as_ref(), + &session, + error, + &upstream_model, + ) + .await; Err(error)?; return; } @@ -1399,7 +1431,13 @@ fn cold_http_sse_stream( Ok(events) => events, Err(error) => { let error = map_continuation_failure(&context, error); - let error = record_stream_failure(selector.as_ref(), &session, error).await; + let error = record_stream_failure( + selector.as_ref(), + &session, + error, + &upstream_model, + ) + .await; Err(error)?; return; } @@ -1508,28 +1546,6 @@ fn xai_transport_metadata( ProviderResponseMetadata::new(serde_json::to_string(&Value::Object(metadata)).ok()?) } -async fn record_failure( - selector: &dyn GrokSessionSelector, - session: &SelectedGrokSession, - error: ProviderError, -) -> ProviderError { - if !session.allows_account_state_mutation() { - return error; - } - let failure = match error.kind() { - ProviderErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized), - ProviderErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited { - retry_after: error.retry_after(), - }), - ProviderErrorKind::QuotaExhausted => Some(GrokCredentialFailure::QuotaExhausted), - _ => None, - }; - if let Some(failure) = failure { - selector.record_failure(session, failure).await; - } - error -} - async fn record_credential_failure( selector: &dyn GrokSessionSelector, session: &SelectedGrokSession, @@ -1546,17 +1562,13 @@ async fn record_stream_failure( selector: &dyn GrokSessionSelector, session: &SelectedGrokSession, error: ProviderError, + upstream_model: &UpstreamModelId, ) -> ProviderError { if !session.allows_account_state_mutation() { return error; } - if matches!( - error.kind(), - ProviderErrorKind::Unauthorized - | ProviderErrorKind::RateLimited - | ProviderErrorKind::QuotaExhausted - ) { - return record_failure(selector, session, error).await; + if let Some(failure) = stream_credential_failure(&error, upstream_model) { + return record_credential_failure(selector, session, error, failure).await; } if error.kind() != ProviderErrorKind::Cancelled { selector @@ -1566,6 +1578,39 @@ async fn record_stream_failure( error } +fn stream_credential_failure( + error: &ProviderError, + upstream_model: &UpstreamModelId, +) -> Option { + match error.kind() { + ProviderErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized), + ProviderErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited { + retry_after: error.retry_after(), + }), + ProviderErrorKind::QuotaExhausted => { + let visible = error.client_visible_upstream_error(); + let quota_kind = classify_grok_quota_failure( + visible + .and_then(ClientVisibleUpstreamError::code) + .or_else(|| error.upstream_code().map(|code| code.as_str())), + visible.and_then(ClientVisibleUpstreamError::error_type), + visible.map(ClientVisibleUpstreamError::message), + ); + Some(match quota_kind { + Some(GrokQuotaFailureKind::FreeAccount) => { + GrokCredentialFailure::FreeQuotaExhausted + } + Some(GrokQuotaFailureKind::Model) => GrokCredentialFailure::ModelQuotaExhausted { + upstream_model: upstream_model.clone(), + retry_after: error.retry_after(), + }, + Some(GrokQuotaFailureKind::Account) | None => GrokCredentialFailure::QuotaExhausted, + }) + } + _ => None, + } +} + async fn recover_or_record_failure( selector: &dyn GrokSessionSelector, recovery: &dyn GrokCredentialRecovery, @@ -1644,7 +1689,7 @@ async fn map_and_record_stream_transport_failure( match credential_failure { Some(failure) => record_credential_failure(selector, session, error, failure).await, None if request_scoped => error, - None => record_stream_failure(selector, session, error).await, + None => record_stream_failure(selector, session, error, upstream_model).await, } } diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index feaa67f37..e4545ddb3 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -19,7 +19,7 @@ use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder}; use serde_json::Value; use super::request::{GrokResponseTransform, GrokResponsesRequest}; -use super::scrub_account_fingerprints; +use super::{classify_grok_quota_failure, scrub_account_fingerprints}; const CONTENTS_PER_OUTPUT: u32 = 1_024; const LONG_CONTEXT_THRESHOLD: u64 = 200_000; @@ -785,14 +785,19 @@ fn incomplete_finish_reason(response: &Value) -> FinishReason { fn upstream_event_error(value: &Value) -> ProviderError { let code = upstream_error_field(value, "code"); - let kind = match code { - Some("invalid_request" | "invalid_prompt") => ProviderErrorKind::InvalidRequest, - Some("unsupported" | "unsupported_feature") => ProviderErrorKind::Unsupported, - Some("unauthorized" | "invalid_token") => ProviderErrorKind::Unauthorized, - Some("permission_denied") => ProviderErrorKind::PermissionDenied, - Some("rate_limit_exceeded") => ProviderErrorKind::RateLimited, - Some("quota_exceeded" | "insufficient_quota") => ProviderErrorKind::QuotaExhausted, - _ => ProviderErrorKind::Unavailable, + let error_type = upstream_error_field(value, "type"); + let message = upstream_error_field(value, "message"); + let kind = if classify_grok_quota_failure(code, error_type, message).is_some() { + ProviderErrorKind::QuotaExhausted + } else { + match code { + Some("invalid_request" | "invalid_prompt") => ProviderErrorKind::InvalidRequest, + Some("unsupported" | "unsupported_feature") => ProviderErrorKind::Unsupported, + Some("unauthorized" | "invalid_token") => ProviderErrorKind::Unauthorized, + Some("permission_denied") => ProviderErrorKind::PermissionDenied, + Some("rate_limit_exceeded") => ProviderErrorKind::RateLimited, + _ => ProviderErrorKind::Unavailable, + } }; let mut error = ProviderError::new(kind, UpstreamSendState::Sent) .redact_sensitive_context("upstream event"); @@ -801,11 +806,11 @@ fn upstream_event_error(value: &Value) -> ProviderError { } // 结构化 message/code/type 供原客户端展示与重试分类;message 先脱去 // 账号指纹(上游限流文案内嵌 team UUID),非结构化正文不透出。 - if let Some(message) = upstream_error_field(value, "message") { + if let Some(message) = message { error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new( scrub_account_fingerprints(message), code.map(str::to_owned), - upstream_error_field(value, "type").map(str::to_owned), + error_type.map(str::to_owned), )); } error diff --git a/backend/crates/providers/xai/src/transport/mod.rs b/backend/crates/providers/xai/src/transport/mod.rs index 9a06f5d7d..8722340da 100644 --- a/backend/crates/providers/xai/src/transport/mod.rs +++ b/backend/crates/providers/xai/src/transport/mod.rs @@ -57,6 +57,57 @@ pub use transport::{ const UUID_TEXT_LEN: usize = 36; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrokQuotaFailureKind { + Account, + FreeAccount, + Model, +} + +pub(crate) fn classify_grok_quota_failure( + code: Option<&str>, + error_type: Option<&str>, + message: Option<&str>, +) -> Option { + let text = [ + code.unwrap_or_default(), + error_type.unwrap_or_default(), + message.unwrap_or_default(), + ] + .join(" ") + .to_ascii_lowercase(); + let free_usage = contains_any( + &text, + &[ + "subscription:free-usage-exhausted", + "subscription_free_usage_exhausted", + "free-usage-exhausted", + "free_usage_exhausted", + "used all the included free usage", + "used all your free usage", + ], + ); + if free_usage && text.contains("for model") { + return Some(GrokQuotaFailureKind::Model); + } + if contains_any( + &text, + &[ + "personal-team-blocked:spending-limit", + "personal_team_blocked_spending_limit", + "quota_exceeded", + "insufficient_quota", + ], + ) { + return Some(GrokQuotaFailureKind::Account); + } + free_usage.then_some(GrokQuotaFailureKind::FreeAccount) +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + /// 把 message 中的 UUID 替换为占位符,控制字符归一为空格, /// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。 fn scrub_account_fingerprints(message: &str) -> String { diff --git a/backend/crates/providers/xai/src/transport/network.rs b/backend/crates/providers/xai/src/transport/network.rs index 80b59a322..74020742e 100644 --- a/backend/crates/providers/xai/src/transport/network.rs +++ b/backend/crates/providers/xai/src/transport/network.rs @@ -33,7 +33,7 @@ use gateway_core::engine::UpstreamSendState; use gateway_core::error::{ClientVisibleUpstreamError, OpaqueUpstreamValue}; use gateway_core::event::UpstreamHttpVersion; -use super::scrub_account_fingerprints; +use super::{GrokQuotaFailureKind, classify_grok_quota_failure, scrub_account_fingerprints}; pub(crate) const OFFICIAL_OAUTH_HOST: &str = "auth.x.ai"; const OFFICIAL_INFERENCE_HOST: &str = "cli-chat-proxy.grok.com"; @@ -1138,31 +1138,26 @@ fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool { } fn paid_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool { - contains_any( - &inference_metadata_text(metadata), - &[ - "personal-team-blocked:spending-limit", - "personal_team_blocked_spending_limit", - ], - ) + matches!(quota_failure(metadata), Some(GrokQuotaFailureKind::Account)) } fn free_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool { - contains_any( - &inference_metadata_text(metadata), - &[ - "subscription:free-usage-exhausted", - "subscription_free_usage_exhausted", - "free-usage-exhausted", - "free_usage_exhausted", - "used all the included free usage", - "used all your free usage", - ], + matches!( + quota_failure(metadata), + Some(GrokQuotaFailureKind::FreeAccount) ) } fn model_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool { - inference_metadata_text(metadata).contains("used all the included free usage for model") + matches!(quota_failure(metadata), Some(GrokQuotaFailureKind::Model)) +} + +fn quota_failure(metadata: &InferenceErrorMetadata) -> Option { + classify_grok_quota_failure( + metadata.code.as_deref(), + metadata.error_type.as_deref(), + metadata.message.as_deref(), + ) } fn model_access_denied(metadata: &InferenceErrorMetadata) -> bool { diff --git a/backend/crates/providers/xai/src/transport/session.rs b/backend/crates/providers/xai/src/transport/session.rs index 37a0ce934..97e0bebdf 100644 --- a/backend/crates/providers/xai/src/transport/session.rs +++ b/backend/crates/providers/xai/src/transport/session.rs @@ -319,7 +319,7 @@ pub enum GrokCredentialFailure { /// 已解析并脱敏的延迟;缺失时由 selector 使用短暂默认值。 retry_after: Option, }, - /// 选中账号只耗尽了指定模型的额度。 + /// 上游声明指定模型的免费用量耗尽;selector 会收敛为账号级可恢复额度状态。 ModelQuotaExhausted { upstream_model: UpstreamModelId, retry_after: Option, diff --git a/backend/crates/providers/xai/src/transport/transport.rs b/backend/crates/providers/xai/src/transport/transport.rs index f540ee066..ba74ee346 100644 --- a/backend/crates/providers/xai/src/transport/transport.rs +++ b/backend/crates/providers/xai/src/transport/transport.rs @@ -282,7 +282,7 @@ pub enum GrokInferenceTransportErrorKind { QuotaExhausted, /// 账号订阅级免费额度耗尽。 FreeQuotaExhausted, - /// 当前账号只耗尽了指定模型的免费额度。 + /// 上游声明指定模型的免费用量耗尽;selector 会收敛为账号级可恢复额度状态。 ModelQuotaExhausted, /// 当前账号只缺少指定模型的访问权限。 ModelAccessDenied, diff --git a/backend/crates/providers/xai/tests/credential/selector.rs b/backend/crates/providers/xai/tests/credential/selector.rs index 40e837713..7cf52e602 100644 --- a/backend/crates/providers/xai/tests/credential/selector.rs +++ b/backend/crates/providers/xai/tests/credential/selector.rs @@ -490,7 +490,7 @@ async fn payment_required_feedback_persists_account_quota_exhaustion() { } #[tokio::test] -async fn model_quota_feedback_blocks_only_the_failed_account_and_model() { +async fn free_model_quota_feedback_persists_resettable_account_exhaustion() { let fixture = SelectorFixture::new(&["model-quota", "available"]).await; let failed_model = UpstreamModelId::new("grok-4.5").expect("failed model"); let session = fixture @@ -505,38 +505,47 @@ async fn model_quota_feedback_blocks_only_the_failed_account_and_model() { &session, GrokCredentialFailure::ModelQuotaExhausted { upstream_model: failed_model.clone(), - retry_after: Some(Duration::from_secs(5)), + retry_after: None, }, ) .await; - assert_eq!( - fixture - .store - .account(&selected) - .expect("selected account") - .availability(), - AccountAvailability::Ready + let account = fixture.store.account(&selected).expect("selected account"); + assert_eq!(account.availability(), AccountAvailability::Cooldown); + let minimum_until = SystemTime::now() + Duration::from_secs(23 * 60 * 60); + assert!( + account + .cooldown_until() + .is_some_and(|until| until > minimum_until) ); let scope = ProviderCooldownScope::upstream_model(failed_model); assert!( fixture .cooldowns .scoped_cooldown(&selected, &scope) - .is_some() + .is_none() ); - let unrelated_model = fixture + assert!( + fixture + .cooldowns + .cooldown(&selected) + .is_some_and(|cooldown| cooldown.until() > minimum_until) + ); + assert!(matches!( + fixture + .selector + .select(fixture.request_for_model("grok-4.6", Some(selected.clone()))) + .await, + Err(GrokSessionSelectorError::AccountCoolingDown { + retry_after: Some(_) + }) + )); + let next = fixture .selector - .select(fixture.request_for_model("grok-4.6", Some(selected.clone()))) + .select(fixture.request_for_model("grok-4.6", None)) .await - .expect("same account remains valid for another model"); - assert_eq!(unrelated_model.account_id(), &selected); - let failed_model_retry = fixture - .selector - .select(fixture.request_for_model("grok-4.5", None)) - .await - .expect("another account serves the failed model"); - assert_ne!(failed_model_retry.account_id(), &selected); + .expect("another account remains schedulable"); + assert_ne!(next.account_id(), &selected); } #[tokio::test] diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index ceaa39805..690a600a3 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -1518,6 +1518,69 @@ async fn model_quota_transport_failure_preserves_model_scoped_feedback() { ); } +#[tokio::test] +async fn free_quota_sse_failure_marks_the_account_exhausted() { + let selector = StubSelector::success(); + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_free_quota\",\"model\":\"grok-4.5\"}}\n\n", + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"error\":{\"code\":\"subscription:free-usage-exhausted\",\"message\":\"You have used all your free usage\"}}\n\n", + ) + .as_bytes() + .to_vec(); + let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(body)]); + let provider = provider(selector.clone(), transport).await; + let mut stream = provider + .execute( + provider_request("xai"), + context(CancellationToken::new(), None), + ) + .await + .expect("stream"); + + let error = next_provider_error(&mut stream).await; + + assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted); + assert_eq!( + selector.feedback.lock().expect("feedback").as_slice(), + &[GrokCredentialFailure::FreeQuotaExhausted] + ); +} + +#[tokio::test] +async fn free_model_quota_sse_failure_is_classified_for_resettable_account_cooldown() { + let selector = StubSelector::success(); + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_model_quota\",\"model\":\"grok-4.5\"}}\n\n", + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"error\":{\"code\":\"subscription:free-usage-exhausted\",\"message\":\"You have used all the included free usage for model grok-4.5-0722 for now. Usage resets over a rolling 24-hour window \u{2014} tokens (actual/limit): 500505/500000. Upgrade to a Grok subscription for higher limits: https://grok.com/supergrok\"}}\n\n", + ) + .as_bytes() + .to_vec(); + let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(body)]); + let provider = provider(selector.clone(), transport).await; + let mut stream = provider + .execute( + provider_request("xai"), + context(CancellationToken::new(), None), + ) + .await + .expect("stream"); + + let error = next_provider_error(&mut stream).await; + + assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted); + assert_eq!( + selector.feedback.lock().expect("feedback").as_slice(), + &[GrokCredentialFailure::ModelQuotaExhausted { + upstream_model: UpstreamModelId::new("grok-4.5").expect("model"), + retry_after: None, + }] + ); +} + #[tokio::test] async fn model_access_denial_is_retryable_without_credential_recovery() { let selector = StubSelector::success(); diff --git a/backend/crates/providers/xai/tests/transport/canonical.rs b/backend/crates/providers/xai/tests/transport/canonical.rs index 7554354a7..1376d6f28 100644 --- a/backend/crates/providers/xai/tests/transport/canonical.rs +++ b/backend/crates/providers/xai/tests/transport/canonical.rs @@ -802,6 +802,28 @@ fn decoder_should_classify_failed_event_without_retaining_body() { assert!(!format!("{error:?}").contains("secret")); } +#[test] +fn decoder_should_classify_free_quota_failed_event() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_quota\"}}\n\n", + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"error\":{\"code\":\"subscription:free-usage-exhausted\",\"type\":\"billing_error\",\"message\":\"You have used all your free usage\"}}\n\n", + ); + let error = GrokCanonicalDecoder::new("fallback") + .push(body.as_bytes()) + .expect_err("free quota failure must surface"); + + assert_eq!( + error.kind(), + gateway_core::error::ProviderErrorKind::QuotaExhausted + ); + assert_eq!( + error.upstream_code().map(|code| code.as_str()), + Some("subscription:free-usage-exhausted") + ); +} + #[test] fn decoder_should_scrub_account_fingerprints_from_failure_messages() { let body = concat!( diff --git a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue index 8a5ae042a..4ced4bb5b 100644 --- a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue +++ b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue @@ -193,19 +193,6 @@ function connectionLogClass(tone: string) { - -
-

- 错误信息 -

- -

- {{ error }} -

-
-
diff --git a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts index f2c3debcb..83c7ffda6 100644 --- a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts +++ b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts @@ -280,17 +280,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions) if (event.type === 'error') { applyAccountStatus(event.accountStatus) connectionTestError.value = connectionTestErrorText(event) - const detail = event.upstreamStatus || event.upstreamContentType || event.upstreamBody - || event.providerErrorCode || event.providerErrorType - ? { - status: event.upstreamStatus, - contentType: event.upstreamContentType, - body: event.upstreamBody, - code: event.providerErrorCode, - type: event.providerErrorType, - } - : undefined - appendConnectionTestLog(connectionTestError.value, 'danger', detail) + appendConnectionTestLog('上游返回错误', 'danger', connectionTestError.value) finishConnectionTest('error') clearConnectionTestRun() } diff --git a/frontend/src/views/accounts/composables/useAccountOnboarding.ts b/frontend/src/views/accounts/composables/useAccountOnboarding.ts index f6db6f5e3..759fcade2 100644 --- a/frontend/src/views/accounts/composables/useAccountOnboarding.ts +++ b/frontend/src/views/accounts/composables/useAccountOnboarding.ts @@ -148,15 +148,20 @@ export function useAccountOnboarding(options: { } async function importAccountDocument() { - const data = parseImportJson(createForm.value.importText) - if (Array.isArray(data) || typeof data !== 'object' || data === null) - throw new Error('导入文件必须是 JSON object') - const result = await importAccounts({ - provider: createForm.value.provider, - data, - }) - const provider = createForm.value.provider === 'xai' ? 'xAI' : 'OpenAI' - return `${provider} 账号已导入 ${result.importedCount} 个` + const provider = requireImportProvider(createForm.value.provider) + const documents = providerImportDocuments( + parseImportJson(createForm.value.importText), + provider, + ) + let importedCount = 0 + for (const entry of documents) { + const result = await importAccounts({ + provider, + data: entry.document, + }) + importedCount += result.importedCount + } + return `${providerDisplayName(provider) ?? provider} 账号已导入 ${importedCount} 个` } async function importMixedAccountDocument() { @@ -259,6 +264,27 @@ function parseImportJson(value: string) { } } +function requireImportProvider(value: string): ImportProvider { + if (value === 'openai' || value === 'xai') + return value + throw new Error('请选择要导入的账号平台') +} + +function providerImportDocuments(value: unknown, provider: ImportProvider): MixedImportDocument[] { + if (isRecord(value) && Array.isArray(value.documents)) { + const documents = parseMixedImportDocuments(value) + .filter(entry => entry.provider === provider) + if (documents.length === 0) { + const label = providerDisplayName(provider) ?? provider + throw new Error(`批量导入文件不包含 ${label} 账号文档`) + } + return documents + } + if (!isRecord(value)) + throw new Error('导入文件必须是 JSON object') + return [{ provider, document: value }] +} + function parseMixedImportDocuments(value: unknown): MixedImportDocument[] { if (!isRecord(value) || !Array.isArray(value.documents)) throw new Error('批量导入文件必须是 CPR 多平台导出文件') diff --git a/frontend/src/views/usage/components/UsageBillingCell.vue b/frontend/src/views/usage/components/UsageBillingCell.vue index ba987ee62..19b44bd9f 100644 --- a/frontend/src/views/usage/components/UsageBillingCell.vue +++ b/frontend/src/views/usage/components/UsageBillingCell.vue @@ -56,17 +56,17 @@ function itemValueClass(tone?: string, accent?: boolean) {
-
- {{ item.label }} - +
+ {{ item.label }} + {{ item.value }}
-
- {{ item.label }} - +
+ {{ item.label }} + {{ item.value }}