From 90ecac978acd2f39daa294ebb18882068baf12ff Mon Sep 17 00:00:00 2001 From: Zyy <0x@ainz.cc> Date: Sat, 7 Nov 2026 00:20:43 +0800 Subject: [PATCH] feat(accounts): make quota credit use opt-in per account (#628) Co-authored-by: Zyy --- .../gateway-admin/src/model/accounts.rs | 3 + .../src/use_case/accounts/mod.rs | 36 +++- .../src/use_case/credential_mutation.rs | 20 ++- .../tests/use_case/accounts/mod.rs | 2 + .../src/admin/accounts/presenter.rs | 1 + .../gateway-api/src/admin/accounts/wire.rs | 8 +- .../tests/admin/accounts/handlers.rs | 1 + .../crates/gateway-api/tests/admin/errors.rs | 1 + .../gateway-api/tests/openai/peer_link/mod.rs | 1 + .../crates/gateway-core/src/account/model.rs | 67 ++++--- .../runtime/tests/generation/data.rs | 1 + .../src/postgres/account_groups.rs | 2 +- .../provider_accounts/admin_adapter.rs | 6 + .../provider_accounts/admin_queries.rs | 2 +- .../src/postgres/provider_accounts/mapping.rs | 1 + .../postgres/provider_accounts/repository.rs | 11 +- .../src/postgres/provider_accounts/rows.rs | 10 +- .../tests/postgres/provider_accounts/mod.rs | 86 +++++++++ .../gateway-store/tests/postgres/proxies.rs | 2 + .../crates/providers/openai/src/admin/mod.rs | 2 + .../openai/src/credential/quota/passive.rs | 63 ++++--- .../openai/src/credential/quota/recovery.rs | 38 ++-- .../openai/src/credential/quota/service.rs | 20 ++- .../openai/src/credential/quota/snapshot.rs | 50 +++--- .../openai/src/credential/quota/warmup.rs | 4 + .../providers/openai/src/provider/failure.rs | 5 +- .../providers/openai/tests/admin/mod.rs | 1 + .../openai/tests/credential/quota/credits.rs | 164 ++++++++++++------ .../openai/tests/credential/quota/mod.rs | 8 +- .../openai/tests/provider/contract/mod.rs | 145 +++++++++------- .../crates/providers/openai/tests/support.rs | 9 + backend/crates/providers/xai/tests/admin.rs | 1 + backend/migrations/.frozen-sha256 | 1 + .../migrations/0040_quota_credit_policy.sql | 26 +++ docs/api.md | 12 +- docs/architecture.md | 2 +- .../src/api/modules/accounts/management.ts | 1 + frontend/src/api/modules/accounts/types.ts | 2 + frontend/src/i18n/locales/en/accounts.json | 3 +- .../src/i18n/locales/zh-Hans/accounts.json | 3 +- .../src/i18n/locales/zh-Hant/accounts.json | 3 +- .../account-quota-panel/Credits.vue | 74 ++++++-- .../ResetCreditAutomation.vue | 6 +- .../account-quota-panel/ResetCredits.vue | 4 +- .../components/account-quota-panel/index.vue | 1 + 45 files changed, 641 insertions(+), 268 deletions(-) create mode 100644 backend/migrations/0040_quota_credit_policy.sql diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 0bd038f51..650454375 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -21,6 +21,7 @@ pub use gateway_core::account::{ /// 账号可用的管理操作 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ProviderAccountCapabilities { + pub quota_credits: bool, pub quota: bool, pub quota_refresh: bool, pub profile: bool, @@ -110,6 +111,7 @@ pub enum AccountGroupFilter { /// 账号公共存储投影;Provider 专属字段不进入此结构 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountRecord { + pub auto_use_quota_credits: bool, pub id: String, pub provider_kind: ProviderKind, pub groups: Vec, @@ -249,6 +251,7 @@ pub struct AccountUpdateResult { /// 仅修改显式字段的批量账号设置命令 #[derive(Debug, Clone, PartialEq, Eq)] pub struct BatchUpdateAccounts { + pub auto_use_quota_credits: Option, pub account_ids: Vec, pub enabled: Option, pub concurrency_limit: Option>, diff --git a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs index e2c6a9e88..1a28ae2bc 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs @@ -759,6 +759,7 @@ impl AccountsService for DefaultAccountsService { self.accounts .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, account_ids: vec![account_id.to_string()], enabled: Some(true), concurrency_limit: None, @@ -837,15 +838,28 @@ impl AccountsService for DefaultAccountsService { ( std::sync::Arc, Vec, + Vec, ), >::new(); for account_id in &account_ids { let (item, provider) = self.provider_for_account(account_id).await?; - providers + if command.auto_use_quota_credits.is_some() + && !provider + .account_capabilities(account_id, &item.account.authentication_kind) + .quota_credits + { + return Err(AdminError::invalid("该账号不支持使用额度点数")); + } + let (_, selected, exhausted) = providers .entry(item.account.provider_kind) - .or_insert_with(|| (provider, Vec::new())) - .1 - .push(account_id.clone()); + .or_insert_with(|| (provider, Vec::new(), Vec::new())); + selected.push(account_id.clone()); + if command.auto_use_quota_credits == Some(true) + && !item.account.auto_use_quota_credits + && item.account.quota.is_exhausted() + { + exhausted.push(account_id.clone()); + } } let enabled = command.enabled; let result = self @@ -853,7 +867,7 @@ impl AccountsService for DefaultAccountsService { .batch_update_accounts(command, context) .await .map_err(|error| map_store_error(error, "provider accounts"))?; - for (provider, provider_ids) in providers.values() { + for (provider, provider_ids, _) in providers.values() { if enabled == Some(false) { for account_id in provider_ids { super::notify_account_unavailable( @@ -872,6 +886,18 @@ impl AccountsService for DefaultAccountsService { .await; } publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + for (provider, _, exhausted) in providers.into_values() { + if exhausted.is_empty() { + continue; + } + super::credential_mutation::observe_account_quotas( + self.upstream_info.as_ref(), + &provider, + &self.accounts, + &exhausted, + context.request_id.as_str(), + ); + } Ok(result) } diff --git a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs index 8eb06f046..d4ca0709a 100644 --- a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs +++ b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs @@ -30,7 +30,18 @@ pub(super) async fn publish_credentials_and_observe_quota( notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await; publish_committed(snapshot, revision).await?; - // 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果 + observe_account_quotas(peer, provider, accounts, account_ids, request_id); + Ok(()) +} + +pub(super) fn observe_account_quotas( + peer: Option<&std::sync::Arc>, + provider: &std::sync::Arc, + accounts: &std::sync::Arc, + account_ids: &[ProviderAccountId], + request_id: &str, +) { + // 配置已提交,额度查询失败不回滚配置 let provider = provider.clone(); let peer = peer.cloned(); let accounts = accounts.clone(); @@ -48,14 +59,14 @@ pub(super) async fn publish_credentials_and_observe_quota( Ok(Some(account)) => account, Ok(None) => continue, Err(error) => { - tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed"); + tracing::warn!(request_id, account_id = %account_id, error = %error, "quota observation account lookup failed"); continue; } }; if !account.enabled || &account.provider_kind != provider.provider_kind() { continue; } - // 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释 + // 后台查询与手动刷新共用认证能力分派,互联协议由 Admin 解释 super::peer_link::upstream::read_quota( Some(peer), provider.as_ref(), @@ -72,12 +83,11 @@ pub(super) async fn publish_credentials_and_observe_quota( provider = %provider.provider_kind().as_str(), account_id = %account_id.as_str(), quota_error = ?error.kind(), - "initial quota observation failed" + "account quota observation failed" ); } } }); - Ok(()) } pub(super) async fn required_credential( diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index aeb538cfd..840537fcb 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -1908,6 +1908,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() { .batch_update( &context("batch-update-request"), BatchUpdateAccounts { + auto_use_quota_credits: None, model_access: Default::default(), outbound_proxy: None, account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()], @@ -3398,6 +3399,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage { pub(super) fn account_record(kind: &str) -> AccountRecord { let now = Utc::now(); AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, notes: None, diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index b89b1dfa4..d6b764877 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -116,6 +116,7 @@ pub(super) fn account_view( let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time); quota.protection_rules = protection_rules; AccountView { + auto_use_quota_credits: account.auto_use_quota_credits, upstream, capabilities: capabilities.into(), id: account.id.clone(), diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index cb092b831..e603c0427 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -61,6 +61,7 @@ pub struct ListQuery { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct BatchUpdateAccountsRequest { + pub auto_use_quota_credits: Option, pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_ids: Vec, @@ -117,7 +118,8 @@ impl BatchUpdateAccountsRequest { .map(parse_concurrency_limit) .transpose()?; self.weight.map(parse_account_weight).transpose()?; - if self.enabled.is_none() + if self.auto_use_quota_credits.is_none() + && self.enabled.is_none() && self.concurrency_limit.is_none() && self.weight.is_none() && self.group_ids.is_none() @@ -133,6 +135,7 @@ impl BatchUpdateAccountsRequest { pub(super) fn into_command(self) -> Result { self.validate()?; Ok(BatchUpdateAccounts { + auto_use_quota_credits: self.auto_use_quota_credits, outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?, account_ids: self.account_ids, enabled: self.enabled, @@ -266,6 +269,7 @@ pub struct AccountSummaryView { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountView { + pub auto_use_quota_credits: bool, pub upstream: Option, pub capabilities: AccountCapabilitiesView, pub outbound_proxy_id: Option, @@ -318,6 +322,7 @@ pub struct AccountCapacityView { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountCapabilitiesView { + pub quota_credits: bool, pub quota: bool, pub quota_refresh: bool, pub profile: bool, @@ -336,6 +341,7 @@ impl From for Accou subscription: value.subscription, avatar: value.avatar, reset_credits: value.reset_credits, + quota_credits: value.quota_credits, consume_reset_credit: value.consume_reset_credit, } } diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 3640b1d38..4afef432f 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -406,6 +406,7 @@ fn account_page_item( AccountPageItem { protection_rules: Vec::new(), account: AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, id: "acct_cooldown".to_owned(), diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs index d4181b8f4..e09b629f9 100644 --- a/backend/crates/gateway-api/tests/admin/errors.rs +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -578,6 +578,7 @@ mod provider { total_slots: None, }, account: AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, notes: None, diff --git a/backend/crates/gateway-api/tests/openai/peer_link/mod.rs b/backend/crates/gateway-api/tests/openai/peer_link/mod.rs index b41e768d6..acc02203d 100644 --- a/backend/crates/gateway-api/tests/openai/peer_link/mod.rs +++ b/backend/crates/gateway-api/tests/openai/peer_link/mod.rs @@ -97,6 +97,7 @@ async fn fixture(sharing: ClientPeerSharing, plaintext: &str) -> (AdminTestFixtu }, projection: resolve_account_status(&facts, now.into()), account: AccountRecord { + auto_use_quota_credits: false, id: "acct_private_sentinel".into(), provider_kind: provider, groups: vec![group.clone()], diff --git a/backend/crates/gateway-core/src/account/model.rs b/backend/crates/gateway-core/src/account/model.rs index a61366aaa..d91b31552 100644 --- a/backend/crates/gateway-core/src/account/model.rs +++ b/backend/crates/gateway-core/src/account/model.rs @@ -364,9 +364,11 @@ impl QuotaAccessState { } } -/// 权威额度耗尽证据 +/// 账号额度访问依据 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum QuotaEvidence { + /// 套餐已耗尽,但上游确认点数可用 + CreditsAvailable, ProviderDenied, AccountLimitReached, UsageLimitReached, @@ -377,6 +379,7 @@ impl QuotaEvidence { #[must_use] pub const fn as_str(self) -> &'static str { match self { + Self::CreditsAvailable => "credits_available", Self::ProviderDenied => "provider_denied", Self::AccountLimitReached => "account_limit_reached", Self::UsageLimitReached => "usage_limit_reached", @@ -387,6 +390,7 @@ impl QuotaEvidence { #[must_use] pub fn parse(value: &str) -> Option { match value { + "credits_available" => Some(Self::CreditsAvailable), "provider_denied" => Some(Self::ProviderDenied), "account_limit_reached" => Some(Self::AccountLimitReached), "usage_limit_reached" => Some(Self::UsageLimitReached), @@ -456,6 +460,17 @@ impl QuotaState { } } + /// 点数是唯一放行依据时保留套餐重置时间,开关变化不伪造新观测 + #[must_use] + pub const fn credits_only(observed_at: SystemTime, reset_at: Option) -> Self { + Self { + access: QuotaAccessState::Allowed, + evidence: Some(QuotaEvidence::CreditsAvailable), + observed_at: Some(observed_at), + reset_at, + } + } + /// 从持久化列恢复额度状态;非法组合返回 `None` #[must_use] pub const fn from_persisted( @@ -464,32 +479,25 @@ impl QuotaState { observed_at: Option, reset_at: Option, ) -> Option { - match access { - QuotaAccessState::Unknown if evidence.is_none() && reset_at.is_none() => Some(Self { + let valid = match (access, evidence) { + (QuotaAccessState::Unknown, None) => reset_at.is_none(), + (QuotaAccessState::Allowed, None) => observed_at.is_some() && reset_at.is_none(), + (QuotaAccessState::Allowed, Some(QuotaEvidence::CreditsAvailable)) => { + observed_at.is_some() + } + (QuotaAccessState::Exhausted, Some(QuotaEvidence::CreditsAvailable)) => false, + (QuotaAccessState::Exhausted, Some(_)) => observed_at.is_some(), + _ => false, + }; + if valid { + Some(Self { access, evidence, observed_at, reset_at, - }), - QuotaAccessState::Allowed - if evidence.is_none() && observed_at.is_some() && reset_at.is_none() => - { - Some(Self { - access, - evidence, - observed_at, - reset_at, - }) - } - QuotaAccessState::Exhausted if evidence.is_some() && observed_at.is_some() => { - Some(Self { - access, - evidence, - observed_at, - reset_at, - }) - } - _ => None, + }) + } else { + None } } @@ -813,6 +821,7 @@ pub struct ProviderAccount { enabled: bool, concurrency_limit: Option, weight: AccountWeight, + auto_use_quota_credits: bool, model_access: super::AccountModelAccess, credential_state: CredentialState, quota: QuotaState, @@ -851,6 +860,7 @@ impl ProviderAccount { enabled: true, concurrency_limit: None, weight: AccountWeight::DEFAULT, + auto_use_quota_credits: false, model_access: super::AccountModelAccess::all(), credential_state: CredentialState::Unknown, quota: QuotaState::unknown(), @@ -864,6 +874,17 @@ impl ProviderAccount { } } + #[must_use] + pub const fn with_auto_use_quota_credits(mut self, enabled: bool) -> Self { + self.auto_use_quota_credits = enabled; + self + } + + #[must_use] + pub const fn auto_use_quota_credits(&self) -> bool { + self.auto_use_quota_credits + } + #[must_use] pub fn with_notes(mut self, notes: Option) -> Self { self.notes = notes; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs index fda99e110..57f35cc63 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs @@ -32,6 +32,7 @@ struct Facts { fn account() -> AccountRecord { let at = "2026-01-01T00:00:00Z".parse().unwrap(); AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, id: "acct_facts".into(), diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 5e7d0b5ec..9be510c6a 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -242,7 +242,7 @@ impl AccountGroupStore for PgAccountGroupRepository { account.upstream_user_id, account.upstream_account_id, account.plan_type, account.authentication_kind, account.credential_revision, account.outbound_proxy_id, account.outbound_proxy_url, account.has_refresh_token, account.access_token_expires_at, - account.next_refresh_at, account.enabled, account.concurrency_limit, + account.next_refresh_at, account.enabled, account.auto_use_quota_credits, account.concurrency_limit, account.quota_observed_at, account.weight, account.model_access_json, account.credential_state, account.quota_access_state, account.quota_evidence, account.quota_access_observed_at, account.quota_reset_at, account.last_error_reason, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 4482305cc..908a92de2 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -664,6 +664,7 @@ impl AccountStore for PgAdminAccountStore { .accounts .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { account_ids: vec![command.account_id.clone()], + auto_use_quota_credits: None, name: command.name, notes: command.notes, enabled: Some(command.enabled), @@ -747,6 +748,10 @@ impl AccountStore for PgAdminAccountStore { }; let mut changed_fields = Vec::new(); for (changed, field) in [ + ( + command.auto_use_quota_credits.is_some(), + "auto_use_quota_credits", + ), (command.enabled.is_some(), "enabled"), (command.concurrency_limit.is_some(), "concurrency_limit"), (command.weight.is_some(), "weight"), @@ -766,6 +771,7 @@ impl AccountStore for PgAdminAccountStore { .accounts .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { account_ids: command.account_ids, + auto_use_quota_credits: command.auto_use_quota_credits, name: None, notes: None, enabled: command.enabled, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 78593b004..3f1a17e2e 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -106,7 +106,7 @@ pub(crate) async fn load_admin_account_page( select coalesce(p.enabled, true) as outbound_proxy_enabled, coalesce(p.max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, p.auto_location, p.detected_location_json, p.location_country, p.location_region, p.location_city, p.location_timezone, a.outbound_proxy_id, a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.notes, a.email, a.upstream_user_id, a.upstream_account_id, a.plan_type, a.authentication_kind, a.credential_revision, a.has_refresh_token, a.access_token_expires_at, - a.next_refresh_at, a.enabled, a.concurrency_limit, a.weight, a.model_access_json, + a.next_refresh_at, a.enabled, a.auto_use_quota_credits, a.concurrency_limit, a.weight, a.model_access_json, a.credential_state, a.quota_access_state, a.quota_evidence, a.quota_access_observed_at, a.quota_reset_at, a.last_error_reason, a.last_error_message, a.credential_observed_at, a.created_at, a.updated_at, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs index 912c6f0e9..932c0b389 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs @@ -10,6 +10,7 @@ pub(crate) fn admin_account_record( summary: ProviderAccountSummary, ) -> AdminStoreResult { Ok(AccountRecord { + auto_use_quota_credits: summary.auto_use_quota_credits, outbound_proxy_id: summary.outbound_proxy_id, outbound_proxy_enabled: summary.outbound_proxy_enabled, outbound_proxy: summary.outbound_proxy, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 19c3ab5f9..3acb8fca2 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -117,7 +117,7 @@ impl PgProviderAccountRepository { let mut query = sqlx::QueryBuilder::::new( "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, - access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, credential_observed_at, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, quota_observed_at, last_error_reason, last_error_message, created_at, updated_at @@ -198,7 +198,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository { let rows = sqlx::query( "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, - access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, credential_observed_at, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, quota_observed_at, last_error_reason, last_error_message, created_at, updated_at @@ -552,6 +552,11 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { command.outbound_proxy.as_ref(), ) .await?; + if let Some(enabled) = command.auto_use_quota_credits { + sqlx::query("update provider_accounts set auto_use_quota_credits = $2 where id = any($1) and auto_use_quota_credits <> $2") + .bind(&command.account_ids).bind(enabled).execute(&mut *transaction).await + .map_err(|source| postgres_unavailable("set account quota credits policy", source))?; + } if let Some(group_ids) = &command.group_ids { replace_account_group_assignments_in_transaction( &mut transaction, @@ -1236,7 +1241,7 @@ impl PgProviderAccountRepository { let rows = sqlx::query( "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, - access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, credential_observed_at, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, quota_observed_at, last_error_reason, last_error_message, created_at, updated_at diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index f36a1cc88..1fdfc5240 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -51,6 +51,7 @@ pub(crate) fn parse_error_reason(value: Option) -> StoreResult, pub outbound_proxy_enabled: bool, pub request_location: Option, @@ -249,6 +250,7 @@ impl fmt::Debug for RotateProviderAccount { #[derive(Debug, Clone)] pub struct BatchUpdateProviderAccountsAdmin { + pub auto_use_quota_credits: Option, pub outbound_proxy: Option, pub account_ids: Vec, pub name: Option, @@ -322,7 +324,7 @@ impl ProviderAccountStateUpdate { pub(crate) const ACCOUNT_SELECT: &str = "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, - has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + has_refresh_token, access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, last_error_reason, last_error_message, credential_observed_at, quota_observed_at, created_at, updated_at @@ -333,7 +335,7 @@ pub(crate) const ACCOUNT_SELECT: &str = "select coalesce(proxy_enabled, true) as pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, - has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + has_refresh_token, access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, last_error_reason, last_error_message, credential_observed_at, quota_observed_at, created_at, updated_at @@ -345,7 +347,7 @@ pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select coalesce(proxy_enabled, t pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, - has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + has_refresh_token, access_token_expires_at, next_refresh_at, enabled, auto_use_quota_credits, concurrency_limit, weight, model_access_json, credential_state, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, last_error_reason, last_error_message, credential_observed_at, quota_observed_at, created_at, updated_at @@ -415,6 +417,7 @@ pub(crate) fn core_account_from_summary( ) .with_scheduling(summary.concurrency_limit, summary.weight) .with_model_access(summary.model_access) + .with_auto_use_quota_credits(summary.auto_use_quota_credits) .with_outbound_proxy( summary .outbound_proxy @@ -470,6 +473,7 @@ pub(crate) fn account_summary_from_row( .and_then(AccountWeight::new) .ok_or_else(|| invalid("invalid weight"))?; Ok(ProviderAccountSummary { + auto_use_quota_credits: get(&row, "auto_use_quota_credits")?, outbound_proxy_id: get(&row, "outbound_proxy_id")?, outbound_proxy_enabled: get(&row, "outbound_proxy_enabled")?, request_location: super::super::proxies::effective_location_from_row(&row)?, diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 5e59d00e0..5fa05ca8e 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -1540,6 +1540,7 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() { store .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, account_ids: vec!["acct_notes".to_owned()], enabled: Some(false), concurrency_limit: Some(None), @@ -1778,6 +1779,7 @@ async fn account_enable_preserves_facts_and_explicit_recovery_clears_them() { let enabled = store .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, account_ids: vec!["acct_recovery".to_owned()], enabled: Some(true), concurrency_limit: None, @@ -1902,6 +1904,7 @@ async fn terminal_batch_update_replaces_state_and_groups_once_or_rolls_back_ever let result = store .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, model_access: Default::default(), outbound_proxy: None, account_ids: account_ids.clone(), @@ -1939,6 +1942,7 @@ async fn terminal_batch_update_replaces_state_and_groups_once_or_rolls_back_ever store .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, model_access: Default::default(), outbound_proxy: None, account_ids: account_ids.clone(), @@ -2678,6 +2682,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let revision = repository .batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin { + auto_use_quota_credits: None, name: None, notes: None, model_access: Default::default(), @@ -3693,6 +3698,7 @@ async fn model_access_only_batch_update_preserves_other_settings_and_survives_re .expect("policy"); let store = admin_account_store(&database.pool); let command = BatchUpdateAccounts { + auto_use_quota_credits: None, account_ids: vec![input.id.clone()], enabled: None, concurrency_limit: None, @@ -3813,3 +3819,83 @@ async fn connection_configuration_update_preserves_credential_health() { assert_eq!(after["credential_revision"], 2); database.close().await; } + +#[tokio::test] +async fn disabling_quota_credits_revokes_credit_only_access_even_with_a_late_observation() { + let Some(database) = TestDatabase::create("quota_credit_policy").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let input = account("acct_credit_policy", "user-credit-policy"); + repository + .insert_provider_account(input.clone()) + .await + .expect("insert"); + let id = ProviderAccountId::new(input.id.clone()).expect("id"); + let before = repository.get_account(&id).await.unwrap().unwrap(); + assert!(!before.auto_use_quota_credits()); + let admin = admin_account_store(&database.pool); + let context = MutationContext { + actor: MutationActor::System, + request_id: "quota_credits".into(), + }; + let set_policy = async |enabled| { + let command = BatchUpdateAccounts { + account_ids: vec![input.id.clone()], + auto_use_quota_credits: Some(enabled), + enabled: None, + concurrency_limit: None, + weight: None, + model_access: None, + group_ids: None, + outbound_proxy: None, + }; + admin + .batch_update_accounts(command, &context) + .await + .unwrap(); + }; + let observe = async |state: QuotaState| { + let observation = QuotaObservation { + expected_connection: None, + plan_type: None, + account_id: id.clone(), + expected_revision: before.revision(), + quota: OpaqueProviderData::new(Default::default()), + observed_at: state.observed_at().unwrap(), + state, + }; + assert_eq!( + repository + .compare_and_swap_quota(observation) + .await + .unwrap(), + QuotaWriteOutcome::Updated + ); + }; + let access = async || { + repository + .get_account(&id) + .await + .unwrap() + .unwrap() + .quota() + .access() + }; + + set_policy(true).await; + observe(QuotaState::credits_only(SystemTime::now(), None)).await; + assert_eq!(access().await, QuotaAccessState::Allowed); + + set_policy(false).await; + assert_eq!(access().await, QuotaAccessState::Exhausted); + observe(QuotaState::credits_only(SystemTime::now(), None)).await; + assert_eq!(access().await, QuotaAccessState::Exhausted); + + set_policy(true).await; + assert_eq!(access().await, QuotaAccessState::Exhausted); + observe(QuotaState::allowed(SystemTime::now())).await; + set_policy(false).await; + assert_eq!(access().await, QuotaAccessState::Allowed); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index a997fb86b..404895d7b 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -355,6 +355,7 @@ async fn disabled_proxy_projects_direct_while_preserving_bindings_and_credential let error = admin .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, account_ids: vec![ "acct_enabled_openai".to_owned(), "acct_enabled_xai".to_owned(), @@ -1180,6 +1181,7 @@ async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() { admin .batch_update_accounts( BatchUpdateAccounts { + auto_use_quota_credits: None, model_access: Default::default(), account_ids: vec!["acct_one".to_owned(), "acct_missing".to_owned()], enabled: Some(false), diff --git a/backend/crates/providers/openai/src/admin/mod.rs b/backend/crates/providers/openai/src/admin/mod.rs index e810f25b5..cdb9f43c0 100644 --- a/backend/crates/providers/openai/src/admin/mod.rs +++ b/backend/crates/providers/openai/src/admin/mod.rs @@ -205,6 +205,7 @@ impl ProviderAdmin for OpenAiAdminProvider { ) -> gateway_admin::model::accounts::ProviderAccountCapabilities { let oauth = authentication_kind == crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH; gateway_admin::model::accounts::ProviderAccountCapabilities { + quota_credits: oauth, quota: oauth, quota_refresh: oauth, profile: oauth, @@ -1151,6 +1152,7 @@ fn account_from_record(account: &AccountRecord) -> Result, observations: &[ParsedRateLimits], -) -> Map { +) -> (Map, bool) { + let mut updated_default = false; let mut snapshots = RateLimitSnapshotsByLimitId::take_from_document(&mut quota); for rate_limits in observations { let default_is_named_alias = default_limit_is_named_alias(rate_limits); @@ -127,6 +139,7 @@ fn merge_passive_quota( let Some(rate_limit) = passive_rate_limit_snapshot(details) else { continue; }; + updated_default |= limit_id == DEFAULT_CODEX_LIMIT_ID; snapshots.upsert(&limit_id, details, rate_limit); resolved_limit_ids.insert(wire_limit_id.as_str(), limit_id); } @@ -147,7 +160,7 @@ fn merge_passive_quota( merge_passive_metadata(&mut quota, rate_limits); } snapshots.write_to_document(&mut quota); - quota + (quota, updated_default) } fn merge_passive_metadata(quota: &mut Map, rate_limits: &ParsedRateLimits) { diff --git a/backend/crates/providers/openai/src/credential/quota/recovery.rs b/backend/crates/providers/openai/src/credential/quota/recovery.rs index 23490e2e8..8810eff5b 100644 --- a/backend/crates/providers/openai/src/credential/quota/recovery.rs +++ b/backend/crates/providers/openai/src/credential/quota/recovery.rs @@ -131,17 +131,21 @@ fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option, document: &mut Map, ) -> Result { document.remove(RECOVERY_FIELD); - if refreshed.credits_usable() { - // 点数独立于套餐窗口,恢复后不能继续携带已失效的窗口恢复进度 - refreshed.recovery = None; - return Ok(QuotaState::allowed(refreshed.observed_at())); - } - let state = if current.is_exhausted() { + // 点数已不再可用时,旧点数依据不能随下一次开关切换复活 + let state = if current.evidence() == Some(QuotaEvidence::CreditsAvailable) { + QuotaState::exhausted( + QuotaEvidence::AccountLimitReached, + current.observed_at().unwrap_or(refreshed.observed_at()), + current.reset_at(), + ) + .merge_observation(refreshed.quota()) + } else if current.is_exhausted() { current } else { current.merge_observation(refreshed.quota()) @@ -178,17 +182,25 @@ pub(super) fn reconcile_refresh( .min() .map(SystemTime::from) .or(state.reset_at()); + let state = refreshed.access_with_credits( + QuotaState::exhausted( + state + .evidence() + .unwrap_or(QuotaEvidence::AccountLimitReached), + state.observed_at().unwrap_or(refreshed.observed_at()), + reset_at, + ), + auto_use_credits, + ); + if !state.is_exhausted() { + refreshed.recovery = None; + return Ok(state); + } document.insert( RECOVERY_FIELD.to_owned(), serde_json::to_value(&recovery) .map_err(|_| CodexCredentialQuotaError::InvalidCredentialData)?, ); refreshed.recovery = Some(recovery); - Ok(QuotaState::exhausted( - state - .evidence() - .unwrap_or(QuotaEvidence::AccountLimitReached), - state.observed_at().unwrap_or(refreshed.observed_at()), - reset_at, - )) + Ok(state) } diff --git a/backend/crates/providers/openai/src/credential/quota/service.rs b/backend/crates/providers/openai/src/credential/quota/service.rs index be1945c32..0ef46f329 100644 --- a/backend/crates/providers/openai/src/credential/quota/service.rs +++ b/backend/crates/providers/openai/src/credential/quota/service.rs @@ -116,15 +116,15 @@ impl CodexCredentialQuotaService { Ok(()) } - /// 成功推理是额度可访问的权威证据,同时解除账号级 429 冷却 + /// 成功推理解除账号级 429 冷却;OAuth 额度由额度观测维护 pub async fn record_successful_inference( &self, account: &ProviderAccount, observed_at: SystemTime, ) -> Result<(), CodexCredentialQuotaError> { - // 已经发出的并发请求可能晚于耗尽事实成功返回;真实成功仍是独立的 - // Allowed 权威证据,但调度不会为了试探恢复而放行耗尽账号 - if account.quota().access() == QuotaAccessState::Exhausted { + if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + && account.quota().is_exhausted() + { let outcome = self .store .apply_quota_access(QuotaAccessChange { @@ -304,6 +304,7 @@ impl CodexCredentialQuotaService { }; let state = reconcile_refresh( account.quota(), + account.auto_use_quota_credits(), &mut snapshot, previous.as_ref(), &mut object, @@ -503,8 +504,8 @@ impl CodexCredentialQuotaService { None }; // 失败后回读只允许明确可用的点数恢复访问,套餐 allowed 仍可能滞后 - let observe_access = - matches!(authority, QuotaRefreshAuthority::ObserveAccess) || snapshot.credits_usable(); + let observe_access = matches!(authority, QuotaRefreshAuthority::ObserveAccess) + || (account.auto_use_quota_credits() && snapshot.credits_usable()); if account.quota().is_exhausted() && !observe_access { if self .store @@ -529,15 +530,16 @@ impl CodexCredentialQuotaService { .with_observed_at(observed_at), }); } - let state = if observe_access { + let state = if matches!(authority, QuotaRefreshAuthority::PreserveAccess) { + snapshot.access_with_credits(account.quota(), account.auto_use_quota_credits()) + } else { reconcile_refresh( account.quota(), + account.auto_use_quota_credits(), &mut snapshot, previous.as_ref(), &mut object, )? - } else { - account.quota() }; let snapshot = snapshot.with_quota_state(state); if self diff --git a/backend/crates/providers/openai/src/credential/quota/snapshot.rs b/backend/crates/providers/openai/src/credential/quota/snapshot.rs index 175381012..f4cb1ef4c 100644 --- a/backend/crates/providers/openai/src/credential/quota/snapshot.rs +++ b/backend/crates/providers/openai/src/credential/quota/snapshot.rs @@ -151,6 +151,18 @@ impl CodexAccountQuotaSnapshot { self.credits.as_ref() } + pub(super) fn access_with_credits( + &self, + subscription: QuotaState, + enabled: bool, + ) -> QuotaState { + if enabled && subscription.is_exhausted() && self.credits_usable { + QuotaState::credits_only(self.observed_at, subscription.reset_at()) + } else { + subscription + } + } + pub(super) const fn credits_usable(&self) -> bool { self.credits_usable } @@ -351,11 +363,6 @@ pub(crate) fn parse_account_quota_snapshot( }); let quota = authoritative_quota_state(&object, &windows, observed_at)?; let credits_usable = has_usable_credits(&object); - let quota = if credits_usable { - QuotaState::allowed(observed_at) - } else { - quota - }; let recovery = object .get(RECOVERY_FIELD) .map(|value| serde_json::from_value(value.clone())) @@ -400,10 +407,13 @@ fn has_usable_credits(usage: &Map) -> bool { == Some(true); let other_limit_reached = usage .get("rate_limit_reached_type") - .and_then(|reason| reason.get("type")) - .and_then(Value::as_str) + .and_then(|reason| reason.as_str().or_else(|| reason.get("type")?.as_str())) .is_some_and(|reason| reason != "rate_limit_reached"); - available && !spend_reached && !other_limit_reached + let overage_reached = credits + .get("overage_limit_reached") + .and_then(Value::as_bool) + == Some(true); + available && !spend_reached && !other_limit_reached && !overage_reached } /// 只有默认 `codex` 桶能提供账号级套餐访问结论,具名桶只保留展示事实 @@ -427,9 +437,14 @@ fn authoritative_quota_state( .ok_or(CodexCredentialQuotaError::InvalidCredentialData)?; let allowed = optional_bool(rate_limit, "allowed")?; let top_level_reached = optional_bool(rate_limit, "limit_reached")?.unwrap_or(false); - let window_reached = windows - .iter() - .any(|w| w.is_account_wide() && w.limit_reached); + let window_reached = windows.iter().any(|window| { + window.is_account_wide() + && (window.limit_reached + || (window.limit_reached() + && window + .reset_at() + .is_none_or(|reset| SystemTime::from(reset) > observed_at))) + }); let reset_at = windows .iter() .filter(|w| w.is_account_wide() && w.limit_reached()) @@ -444,16 +459,6 @@ fn authoritative_quota_state( }) .map(SystemTime::from); - if allowed == Some(true) { - if top_level_reached || window_reached { - tracing::warn!( - top_level_reached, - window_reached, - "OpenAI quota returned contradictory access facts; explicit allowed=true wins" - ); - } - return Ok(QuotaState::allowed(observed_at)); - } if allowed == Some(false) { return Ok(QuotaState::exhausted( QuotaEvidence::ProviderDenied, @@ -468,6 +473,9 @@ fn authoritative_quota_state( reset_at, )); } + if allowed == Some(true) { + return Ok(QuotaState::allowed(observed_at)); + } Ok(QuotaState::observed_unknown(observed_at)) } diff --git a/backend/crates/providers/openai/src/credential/quota/warmup.rs b/backend/crates/providers/openai/src/credential/quota/warmup.rs index 2a5583df8..1cebc4bad 100644 --- a/backend/crates/providers/openai/src/credential/quota/warmup.rs +++ b/backend/crates/providers/openai/src/credential/quota/warmup.rs @@ -110,6 +110,10 @@ impl CodexCredentialQuotaService { let now_utc = chrono::Utc::now(); for account in accounts { + if account.quota().is_exhausted() { + summary.skipped_exhausted += 1; + continue; + } if let Some(snapshot) = observed_snapshots.get(account.id()) { // 1. 周线已触顶或耗尽跳过 let weekly_exhausted = snapshot diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index e89f08949..783458039 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -391,8 +391,11 @@ pub(super) fn schedule_authoritative_quota_refresh_after_failure( quota: &Arc, account: &ProviderAccount, ) { + if !account.auto_use_quota_credits() { + return; + } // 限额错误可以确认账号状态,但 Responses 事件中的 used_percent 可能仍停在上一结算点 - // usage 快照在后台补齐展示基线,不得撤销同一轮真实失败,也不能阻塞原始响应 + // 后台查询可为后续请求确认点数,但不阻塞或重放原始请求 // 随机等待 1–3 秒让上游结算再查询,避免多账号同时受限时补查完全同步; // 仍保持单次语义(一次 sleep + 一次查询),5 秒仅限制实际查询自身 let quota = Arc::clone(quota); diff --git a/backend/crates/providers/openai/tests/admin/mod.rs b/backend/crates/providers/openai/tests/admin/mod.rs index a7f36e360..517d2d3bf 100644 --- a/backend/crates/providers/openai/tests/admin/mod.rs +++ b/backend/crates/providers/openai/tests/admin/mod.rs @@ -2178,6 +2178,7 @@ fn provider_ports_with_catalog( fn account_record(account: &ProviderAccount) -> AccountRecord { let now = Utc::now(); AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, notes: None, diff --git a/backend/crates/providers/openai/tests/credential/quota/credits.rs b/backend/crates/providers/openai/tests/credential/quota/credits.rs index 5bf24afff..9ca7770f2 100644 --- a/backend/crates/providers/openai/tests/credential/quota/credits.rs +++ b/backend/crates/providers/openai/tests/credential/quota/credits.rs @@ -22,62 +22,72 @@ async fn mount_usage(server: &MockServer, usage: serde_json::Value) { #[tokio::test] async fn credits_allow_exhausted_subscription_without_bypassing_spend_restrictions() { - for (metadata, allowed) in [ - ( - json!({"credits": {"has_credits": true, "unlimited": false}}), - true, - ), - ( - json!({"credits": {"has_credits": false, "unlimited": true}}), - true, - ), - (json!({"credits": {"has_credits": true}}), true), - (json!({"credits": {"unlimited": true}}), true), - ( - json!({"credits": {"has_credits": false, "unlimited": false, "balance": "500"}}), - false, - ), - (json!({"credits": null}), false), - ( - json!({"credits": {"has_credits": true}, "spend_control": {"reached": true}}), - false, - ), - ( - json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "spend_limit_reached"}}), - false, - ), - ( - json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "rate_limit_reached"}}), - true, - ), - ] { - let store = Arc::new(MemoryAccountStore::default()); - create_account_with_enabled(&store, "acct_credits", false).await; - let account = store.account("acct_credits").expect("account"); - persist_credential_state(&store, &account, CredentialState::Expired).await; - let server = MockServer::start().await; - let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri()); - let mut usage = exhausted_usage(); - usage - .as_object_mut() - .expect("usage") - .extend(metadata.as_object().expect("metadata").clone()); - mount_usage(&server, usage).await; + for auto_use in [false, true] { + for (metadata, usable) in [ + ( + json!({"credits": {"has_credits": true, "overage_limit_reached": true}}), + false, + ), + ( + json!({"credits": {"has_credits": true, "unlimited": false}}), + true, + ), + ( + json!({"credits": {"has_credits": false, "unlimited": true}}), + true, + ), + (json!({"credits": {"has_credits": true}}), true), + (json!({"credits": {"unlimited": true}}), true), + ( + json!({"credits": {"has_credits": false, "unlimited": false, "balance": "500"}}), + false, + ), + (json!({"credits": null}), false), + ( + json!({"credits": {"has_credits": true}, "spend_control": {"reached": true}}), + false, + ), + ( + json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "spend_limit_reached"}}), + false, + ), + ( + json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "rate_limit_reached"}}), + true, + ), + ] { + let store = Arc::new(MemoryAccountStore::default()); + create_account_with_enabled(&store, "acct_credits", false).await; + if auto_use { + store.enable_quota_credits("acct_credits"); + } + let allowed = auto_use && usable; + let account = store.account("acct_credits").expect("account"); + persist_credential_state(&store, &account, CredentialState::Expired).await; + let server = MockServer::start().await; + let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri()); + let mut usage = exhausted_usage(); + usage + .as_object_mut() + .expect("usage") + .extend(metadata.as_object().expect("metadata").clone()); + mount_usage(&server, usage).await; - let snapshot = service - .refresh_account(account.id()) - .await - .expect("refresh credits"); - assert_eq!( - snapshot.quota().access() == QuotaAccessState::Allowed, - allowed, - "{metadata}" - ); - assert_eq!(snapshot.fact().remaining_percent(), Some(0)); - let current = store.account("acct_credits").expect("current account"); - assert_eq!(current.quota().is_exhausted(), !allowed, "{metadata}"); - assert!(!current.enabled()); - assert_eq!(current.credential_state(), CredentialState::Expired); + let snapshot = service + .refresh_account(account.id()) + .await + .expect("refresh credits"); + assert_eq!( + snapshot.quota().access() == QuotaAccessState::Allowed, + allowed, + "{metadata}" + ); + assert_eq!(snapshot.fact().remaining_percent(), Some(0)); + let current = store.account("acct_credits").expect("current account"); + assert_eq!(current.quota().is_exhausted(), !allowed, "{metadata}"); + assert!(!current.enabled()); + assert_eq!(current.credential_state(), CredentialState::Expired); + } } } @@ -86,6 +96,7 @@ async fn credits_refresh_clears_old_recovery_and_locks_again_only_when_spent() { for periodic in [false, true] { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_credits_recovery").await; + store.enable_quota_credits("acct_credits_recovery"); let account = store.account("acct_credits_recovery").expect("account"); let server = MockServer::start().await; let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri()); @@ -161,6 +172,7 @@ async fn credits_refresh_clears_old_recovery_and_locks_again_only_when_spent() { async fn credits_query_started_before_new_exhaustion_cannot_restore_access() { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_stale_credits").await; + store.enable_quota_credits("acct_stale_credits"); let account = store.account("acct_stale_credits").expect("account"); let server = MockServer::start().await; let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri()); @@ -213,3 +225,45 @@ async fn credits_query_started_before_new_exhaustion_cannot_restore_access() { .is_exhausted() ); } + +#[tokio::test] +async fn passive_exhaustion_requires_fresh_credits_and_revokes_access_when_spent() { + let store = Arc::new(MemoryAccountStore::default()); + let id = "acct_passive_credit_policy"; + create_account(&store, id).await; + store.enable_quota_credits(id); + let service = quota_service(&store); + let window = |used| json!({"primary": {"used_percent": used, "window_minutes": 300}}); + let credits = |available| json!({"has_credits": available, "unlimited": false}); + for (facts, expected) in [ + ( + json!({"rate_limits": window(20), "credits": credits(true)}), + QuotaAccessState::Allowed, + ), + ( + json!({"rate_limits": window(100)}), + QuotaAccessState::Exhausted, + ), + ( + json!({"rate_limits": window(100), "credits": credits(true)}), + QuotaAccessState::Allowed, + ), + ( + json!({"credits": credits(false)}), + QuotaAccessState::Exhausted, + ), + ] { + let mut wire = facts; + wire["type"] = json!("codex.rate_limits"); + let update = parse_rate_limits_event(&wire).expect("rate limit event"); + service + .synchronize_passive_rate_limits(&store.account(id).unwrap(), &[update]) + .await + .unwrap(); + assert_eq!( + store.account(id).unwrap().quota().access(), + expected, + "{wire}" + ); + } +} diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 3b3728e7d..372a00f74 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -155,7 +155,7 @@ fn exhausted_quota(reset_at: Option) -> QuotaState { } #[tokio::test] -async fn successful_inference_headers_are_authoritative_even_at_full_display_usage() { +async fn successful_inference_at_the_subscription_limit_stops_further_scheduling() { let store = Arc::new(MemoryAccountStore::default()); let account_id = "acct_passive_allowed"; create_account(&store, account_id).await; @@ -182,11 +182,11 @@ async fn successful_inference_headers_are_authoritative_even_at_full_display_usa .expect("quota snapshot"); assert_eq!(snapshot.fact().remaining_percent(), Some(0)); - assert_eq!(snapshot.quota().access(), QuotaAccessState::Allowed); - assert_eq!(current.quota().access(), QuotaAccessState::Allowed); + assert_eq!(snapshot.quota().access(), QuotaAccessState::Exhausted); + assert_eq!(current.quota().access(), QuotaAccessState::Exhausted); assert_eq!( current.status_projection(SystemTime::now(), None).status, - AccountStatus::Normal + AccountStatus::QuotaExhausted ); } diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 374fc2e8f..906b8d1d9 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -8326,6 +8326,7 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage() let account_id = "acct_usage_limit_request_path"; let reset_at = 1_900_000_000; create_account(&store, account_id).await; + store.enable_quota_credits(account_id); let account = store.account(account_id).expect("created account"); let observed_at = SystemTime::now(); store @@ -8511,24 +8512,28 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage() #[tokio::test] async fn usage_limit_failure_recovers_with_credits_without_replaying_the_failed_request() { - let store = Arc::new(MemoryAccountStore::default()); - let account_id = "acct_provider_contract"; - create_account(&store, account_id).await; - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "rate_limit": { - "allowed": false, - "limit_reached": true, - "primary_window": {"used_percent": 100, "reset_at": 1_900_000_000} - }, - "credits": {"has_credits": true, "unlimited": false, "balance": "20"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) + for enabled in [false, true] { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_provider_contract"; + create_account(&store, account_id).await; + if enabled { + store.enable_quota_credits(account_id); + } + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rate_limit": { + "allowed": false, + "limit_reached": true, + "primary_window": {"used_percent": 100, "reset_at": 1_900_000_000} + }, + "credits": {"has_credits": true, "unlimited": false, "balance": "20"} + }))) + .expect(if enabled { 1 } else { 0 }) + .mount(&server) + .await; + Mock::given(method("POST")) .and(path("/codex/responses")) .respond_with(ResponseTemplate::new(429).set_body_json(json!({ "error": {"type": "usage_limit_reached", "message": "usage limit reached", "resets_at": 1_900_000_000} @@ -8536,53 +8541,61 @@ async fn usage_limit_failure_recovers_with_credits_without_replaying_the_failed_ .expect(1) .mount(&server) .await; - let mut stream = provider_with_base_url(&store, server.uri()) - .execute( - planned_request("openai", http_generate_operation()), - context("req_usage_limit_credits", CancellationToken::new()), - ) - .await - .expect("provider stream"); - let failure = loop { - match stream.next().await { - Some(Ok(_)) => {} - Some(Err(error)) => break error, - None => panic!("quota failure must reach the caller"), - } - }; - assert_eq!(failure.kind(), ProviderErrorKind::QuotaExhausted); - assert!( - store - .account(account_id) - .expect("account") - .quota() - .is_exhausted() - ); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", http_generate_operation()), + context("req_usage_limit_credits", CancellationToken::new()), + ) + .await + .expect("provider stream"); + let failure = loop { + match stream.next().await { + Some(Ok(_)) => {} + Some(Err(error)) => break error, + None => panic!("quota failure must reach the caller"), + } + }; + assert_eq!(failure.kind(), ProviderErrorKind::QuotaExhausted); + assert!( + store + .account(account_id) + .expect("account") + .quota() + .is_exhausted() + ); - timeout(Duration::from_secs(5), async { - while store - .account(account_id) - .expect("account") - .quota() - .is_exhausted() - { - tokio::time::sleep(Duration::from_millis(10)).await; + if !enabled { + tokio::time::sleep(Duration::from_millis(3100)).await; + assert!(store.account(account_id).unwrap().quota().is_exhausted()); + assert!(stream.next().await.is_none()); + server.verify().await; + continue; } - }) - .await - .expect("credits restore access after background refresh"); - assert!(stream.next().await.is_none()); - let account = store.account(account_id).expect("recovered account"); - assert_eq!(account.quota().access(), QuotaAccessState::Allowed); - assert_eq!(account.credential_state(), CredentialState::Ready); - let quota = store.quota_json(account_id).expect("persisted credits"); - assert_eq!(quota.pointer("/credits/has_credits"), Some(&json!(true))); - assert_eq!( - quota.pointer("/rate_limits_by_limit_id/codex/primary_window/used_percent"), - Some(&json!(100)) - ); - assert!(quota.get("_quota_recovery").is_none()); - server.verify().await; + timeout(Duration::from_secs(5), async { + while store + .account(account_id) + .expect("account") + .quota() + .is_exhausted() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("credits restore access after background refresh"); + assert!(stream.next().await.is_none()); + let account = store.account(account_id).expect("recovered account"); + assert_eq!(account.quota().access(), QuotaAccessState::Allowed); + assert_eq!(account.credential_state(), CredentialState::Ready); + let quota = store.quota_json(account_id).expect("persisted credits"); + assert_eq!(quota.pointer("/credits/has_credits"), Some(&json!(true))); + assert_eq!( + quota.pointer("/rate_limits_by_limit_id/codex/primary_window/used_percent"), + Some(&json!(100)) + ); + assert!(quota.get("_quota_recovery").is_none()); + server.verify().await; + } } #[tokio::test] @@ -9989,7 +10002,7 @@ async fn quota_limited_account_diagnostic_uses_upstream() { } #[tokio::test] -async fn successful_response_treats_inference_success_as_authoritative_allowance() { +async fn successful_response_with_full_subscription_blocks_subsequent_requests() { let store = Arc::new(MemoryAccountStore::default()); let account_id = "acct_success_exhausted"; create_account(&store, account_id).await; @@ -10037,7 +10050,7 @@ async fn successful_response_treats_inference_success_as_authoritative_allowance .expect("account after successful response") .quota() .access(), - QuotaAccessState::Allowed + QuotaAccessState::Exhausted ); assert!(store.has_quota(account_id)); } @@ -10099,7 +10112,7 @@ async fn successful_http_sse_rate_limit_event_persists_structured_exhaustion() { .account(account_id) .expect("account after HTTP SSE response"); assert_eq!(account.credential_state(), CredentialState::Ready); - assert_eq!(account.quota().access(), QuotaAccessState::Allowed); + assert_eq!(account.quota().access(), QuotaAccessState::Exhausted); assert!(store.has_quota(account_id)); } diff --git a/backend/crates/providers/openai/tests/support.rs b/backend/crates/providers/openai/tests/support.rs index 92cc87593..df9f0f6e4 100644 --- a/backend/crates/providers/openai/tests/support.rs +++ b/backend/crates/providers/openai/tests/support.rs @@ -150,6 +150,14 @@ impl MemoryAccountStore { .map(|stored| stored.account.clone()) } + pub(crate) fn enable_quota_credits(&self, id: &str) { + let mut accounts = self.accounts.lock().unwrap(); + let stored = accounts + .get_mut(&ProviderAccountId::new(id).unwrap()) + .unwrap(); + stored.account = stored.account.clone().with_auto_use_quota_credits(true); + } + pub(crate) fn set_model_access( &self, id: &str, @@ -764,6 +772,7 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid rebuild.last_error_message, ) .with_scheduling(current.concurrency_limit(), current.weight()) + .with_auto_use_quota_credits(current.auto_use_quota_credits()) .with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at) .with_outbound_proxy(current.outbound_proxy().cloned()) .with_request_location(current.request_location().cloned()) diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 34973d15d..903897eca 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -768,6 +768,7 @@ fn provider_ports_with_catalog( fn account_record(account: &ProviderAccount) -> AccountRecord { let now = Utc::now(); AccountRecord { + auto_use_quota_credits: false, outbound_proxy_id: None, outbound_proxy_enabled: true, notes: None, diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 6df68a2ef..d3c699204 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -37,3 +37,4 @@ d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_d b215ea90d5c8753aeb8192b17ffe65f7a95c1d620aaeaf9fc4337d8cf6f2634e 0037_retry_after_wait_limit.sql 321f4a09367717eb13c3cf5dbfd1f4b014e31c615a74bf6e3b09b8e810d87198 0038_preserve_client_key_ledger.sql 80ba91cad23667ace80a43daf905ca78fd0f6de5fb57f444c9ebbfa2fab90c34 0039_queue_timeouts.sql +2438e107835cd69effdb37e4d008b3b631a287da9e41185b04dd239449cb6733 0040_quota_credit_policy.sql diff --git a/backend/migrations/0040_quota_credit_policy.sql b/backend/migrations/0040_quota_credit_policy.sql new file mode 100644 index 000000000..ae4525ad4 --- /dev/null +++ b/backend/migrations/0040_quota_credit_policy.sql @@ -0,0 +1,26 @@ +alter table provider_accounts + add column auto_use_quota_credits boolean not null default false, + drop constraint provider_accounts_quota_evidence_ck, + drop constraint provider_accounts_quota_fact_ck, + add constraint provider_accounts_quota_evidence_ck check ( + quota_evidence in ('provider_denied', 'account_limit_reached', 'usage_limit_reached', 'payment_required', 'credits_available') + ), + add constraint provider_accounts_quota_fact_ck check ( + (quota_access_state = 'unknown' and quota_evidence is null and quota_reset_at is null) + or (quota_access_state = 'allowed' and quota_access_observed_at is not null + and ((quota_evidence is null and quota_reset_at is null) or quota_evidence is not distinct from 'credits_available')) + or (quota_access_state = 'exhausted' and quota_evidence is not null and quota_evidence <> 'credits_available' and quota_access_observed_at is not null) + ); + +-- 在行锁内按当前配置投影仅点数可用的观测,晚返回的查询不能覆盖关闭开关的决定 +create function apply_quota_credit_policy() returns trigger language plpgsql as $$ +begin + new.quota_access_state := 'exhausted'; + new.quota_evidence := 'account_limit_reached'; + return new; +end +$$; + +create trigger provider_accounts_quota_credit_policy +before insert or update of auto_use_quota_credits, quota_evidence, quota_access_state on provider_accounts +for each row when (not new.auto_use_quota_credits and new.quota_evidence = 'credits_available') execute function apply_quota_credit_policy(); diff --git a/docs/api.md b/docs/api.md index aefdc1b3d..bead183df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1133,11 +1133,13 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 `quota.credits` 返回上游点数余额,未提供点数信息时为 `null`。对象包含 `hasCredits`、`unlimited` 和 `balance`,余额为保留上游精度的十进制字符串,未提供余额时为 `null`,明确的零余额为 `"0"`。 -OpenAI 点数随现有额度刷新和正常请求的额度信息同步,不与主动重置卡库存或百分比限额合并, -不根据 `balance` 单独推导可用性。主动查询明确 `hasCredits=true` 或 `unlimited=true`,且无阻止 -点数支出的上限或工作区限制时,即使套餐窗口耗尽也可使用点数,并解除已有额度耗尽状态。 -真实额度拒绝后的后台回读同样支持此恢复路径,但不会自动重发本次失败请求;点数不能清除 -凭据错误、停用或冷却状态。上游点数信息也可能延迟,后续真实拒绝仍会记录额度耗尽 +点数余额与重置卡库存独立。支持点数的账号返回 `capabilities.quotaCredits=true`,通过 +`POST /api/admin/accounts/batch-update` 提交 `{ "accountIds": ["acct_..."], "autoUseQuotaCredits": true }` +设置,账号字段 `autoUseQuotaCredits` 默认 `false`。关闭时,订阅额度耗尽即停止调度,即使还有点数。 +开启后,只有上游确认 `hasCredits=true` 或 `unlimited=true` 且没有支出限制,才允许靠点数继续请求; +余额数字本身不能证明点数可用。对已耗尽的账号开启时会复查额度,关闭时立即停止仅靠点数的放行 + +点数不能清除凭据错误、停用或冷却状态。实际请求被上游拒绝后不会为了使用点数重发同一次请求 账号名称是本地显示信息,与账号 ID、上游邮箱和凭据独立。编辑接口的可选 `name` 省略或为 `null` 时保留原值; 提供字符串时去除首尾空白,空字符串清除名称并回退显示邮箱,原始输入最多 512 个 UTF-8 字节且不能包含控制字符。 diff --git a/docs/architecture.md b/docs/architecture.md index d85fcf666..14b5185a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1026,7 +1026,7 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota 空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料 OpenAI Provider 的额度快照统一解释点数可用性,主动查询、耗尽恢复与失败后回读复用同一规则。 -可用点数独立于套餐窗口;通过点数恢复时原子保存新快照、访问事实并清理旧窗口恢复进度。 +套餐耗尽后,仅对开启自动使用点数的账号以可用点数放行。点数放行依据与快照原子保存,关闭开关在同一账号事务内撤销该依据,迟到的点数观测不能绕过已关闭的开关。 无可用点数的失败回读保留已确认的拒绝;常规刷新在到期前仍按窗口证据恢复,本次耗尽之后的 已记录窗口 reset 全部到期后可依据新的明确放行观察恢复。查询沿用 credential revision 与 观察时间隔离,不允许旧查询撤销较新的失败;恢复条件和后台复核节奏见 [账号 API](api.md#账号能力导入与-oauth) diff --git a/frontend/src/api/modules/accounts/management.ts b/frontend/src/api/modules/accounts/management.ts index cefbfc64d..0199fa2d4 100644 --- a/frontend/src/api/modules/accounts/management.ts +++ b/frontend/src/api/modules/accounts/management.ts @@ -66,6 +66,7 @@ interface AccountUpdateParam { } interface AccountBatchUpdateParam { + autoUseQuotaCredits?: boolean outboundProxyUrl?: string outboundProxyId?: string accountIds: string[] diff --git a/frontend/src/api/modules/accounts/types.ts b/frontend/src/api/modules/accounts/types.ts index 9ab43e112..bd91fc6d4 100644 --- a/frontend/src/api/modules/accounts/types.ts +++ b/frontend/src/api/modules/accounts/types.ts @@ -138,6 +138,7 @@ export interface AccountModelAccess { } export interface Account { + autoUseQuotaCredits: boolean upstream: AccountUpstream | null capabilities: AccountCapabilities outboundProxyId: string | null @@ -199,6 +200,7 @@ export interface AccountUpstream { } export interface AccountCapabilities { + quotaCredits: boolean quota: boolean quotaRefresh: boolean profile: boolean diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 5f3fff301..55cb966f6 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -13,6 +13,8 @@ "s": "{duration} s", "unlimited": "Unlimited", "noPointsAvailableYet": "No credits available", + "autoUsePoints": "Automatically use credits", + "autoUsePointsHelp": "Use available credits when the subscription limit is reached", "quotaPoints": "Credits", "point": "credits", "requestsInThisWindow": "Requests in this window: {requestDisplay}", @@ -515,7 +517,6 @@ "label": "Automatically use expiring reset credits" }, "automaticallyUseResetCard": "Automatically use resets", - "automaticallyUsed": "Auto use", "automaticUsageRules": "Automatic usage rules", "setGlobalLeadTime": "Set global lead time", "minutesInAdvance": "{leadMinutes} min in advance", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index 61878745e..697a192b7 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -13,6 +13,8 @@ "s": "{duration} 秒", "unlimited": "无限", "noPointsAvailableYet": "暂无可用点数", + "autoUsePoints": "自动使用额度点数", + "autoUsePointsHelp": "订阅额度耗尽后,使用可用点数继续请求", "quotaPoints": "额度点数", "point": "点", "requestsInThisWindow": "窗口请求:{requestDisplay} 次", @@ -515,7 +517,6 @@ "label": "自动使用临期重置卡" }, "automaticallyUseResetCard": "自动使用重置卡", - "automaticallyUsed": "自动使用", "automaticUsageRules": "自动使用规则", "setGlobalLeadTime": "设置全局提前时间", "minutesInAdvance": "提前 {leadMinutes} 分钟", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 808744d7e..3c99893ba 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -13,6 +13,8 @@ "s": "{duration} 秒", "unlimited": "無限", "noPointsAvailableYet": "暫無可用點數", + "autoUsePoints": "自動使用額度點數", + "autoUsePointsHelp": "訂閱額度耗盡後,使用可用點數繼續請求", "quotaPoints": "額度點數", "point": "點", "requestsInThisWindow": "窗口請求:{requestDisplay} 次", @@ -515,7 +517,6 @@ "label": "自動使用臨期重置卡" }, "automaticallyUseResetCard": "自動使用重置卡", - "automaticallyUsed": "自動使用", "automaticUsageRules": "自動使用規則", "setGlobalLeadTime": "設置全局提前時間", "minutesInAdvance": "提前 {leadMinutes} 分鐘", diff --git a/frontend/src/views/accounts/components/account-quota-panel/Credits.vue b/frontend/src/views/accounts/components/account-quota-panel/Credits.vue index 1f0bc0b98..8df7b68d1 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/Credits.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/Credits.vue @@ -1,18 +1,40 @@ diff --git a/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue b/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue index 0da82750e..6a6713531 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue @@ -69,11 +69,11 @@ watch(() => props.accountId, () => {

- {{ t('accounts.quotaReset.automaticallyUsed') }} + {{ t('accounts.quotaReset.automaticallyUseResetCard') }}

- + diff --git a/frontend/src/views/accounts/components/account-quota-panel/ResetCredits.vue b/frontend/src/views/accounts/components/account-quota-panel/ResetCredits.vue index 66fa676d4..42d0300dc 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/ResetCredits.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/ResetCredits.vue @@ -16,6 +16,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ + accountUpdated: [account: Account] consumed: [accountId: string] }>() @@ -164,9 +165,10 @@ function handleRequestConsume(creditId: string) {
-
+ +
diff --git a/frontend/src/views/accounts/components/account-quota-panel/index.vue b/frontend/src/views/accounts/components/account-quota-panel/index.vue index 75fc19a4b..1670a04d9 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/index.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/index.vue @@ -93,6 +93,7 @@ watch(hasPersonalInfo, (available) => {