diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 11dfc49cc..3489bb012 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -564,6 +564,15 @@ impl fmt::Debug for RotateCredential { } } +/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuotaLocalUsageAttribution { + /// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。 + AccountWide, + /// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。 + Unavailable, +} + /// 一个 Provider quota 窗口的公共投影。 #[derive(Debug, Clone, PartialEq)] pub struct ProviderQuotaWindow { @@ -571,6 +580,7 @@ pub struct ProviderQuotaWindow { pub group: String, pub label: String, pub source: Option, + pub local_usage_attribution: QuotaLocalUsageAttribution, pub window_seconds: Option, pub used_percent: Option, pub reset_at: Option>, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 443811f9e..24eae9c09 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -25,7 +25,7 @@ use crate::{ provider_credentials::{ AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult, PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest, - ProviderQuotaWindow, + ProviderQuotaWindow, QuotaLocalUsageAttribution, }, }, ports::{ @@ -160,7 +160,9 @@ impl DefaultAccountsService { .collect::>(); for (account, quota) in accounts.iter().zip(quotas) { for window in &mut quota.windows { - if window.local_usage.is_none() { + if window.local_usage.is_none() + && window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide + { let key = (account.id.clone(), window.key.clone()); if let Some(usage) = usage_by_window.get(&key) { window.local_usage = Some(usage.clone()); @@ -568,7 +570,9 @@ fn quota_usage_window( account_id: &str, window: &ProviderQuotaWindow, ) -> Option { - if window.local_usage.is_some() { + if window.local_usage.is_some() + || window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide + { return None; } let reset_at = window.reset_at?; diff --git a/backend/crates/gateway-admin/tests/main.rs b/backend/crates/gateway-admin/tests/main.rs index 2e69d68a5..ee8b8d334 100644 --- a/backend/crates/gateway-admin/tests/main.rs +++ b/backend/crates/gateway-admin/tests/main.rs @@ -11,6 +11,7 @@ use gateway_admin::{ observability::{DecimalAmount, RequestOutcome, TimeRange}, provider_credentials::{ CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow, + QuotaLocalUsageAttribution, }, settings::AdminApiKey, }, @@ -145,6 +146,7 @@ fn quota_window( group: group.to_owned(), label: group.to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds, used_percent, reset_at: None, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 9d5141919..4f1e97377 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -33,7 +33,7 @@ use gateway_admin::{ PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota, - ProviderQuotaRequest, ProviderQuotaWindow, + ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution, }, settings::{ AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy, @@ -1007,6 +1007,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(97.0), reset_at: Some(Utc::now() + TimeDelta::hours(1)), @@ -1131,6 +1132,7 @@ async fn accounts_list_should_map_rate_limited_from_quota_limit_reached() { group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(100.0), reset_at: Some(Utc::now() + TimeDelta::hours(1)), @@ -1176,6 +1178,7 @@ async fn accounts_list_should_map_quota_exhausted_when_only_limit_reached() { group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(100.0), reset_at: Some(Utc::now() + TimeDelta::hours(1)), @@ -1221,6 +1224,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() { group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(97.0), reset_at: Some(reset_at), @@ -1260,6 +1264,96 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() { assert_eq!(usage.total_tokens, Some(4_330_000)); } +#[tokio::test] +async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_windows() { + let provider = FakeProviderAdmin::new("openai", events()); + let reset_at = Utc::now() + TimeDelta::days(7); + provider.set_quota(ProviderQuota { + observed_at: Some(Utc::now()), + refresh_token_expires_at: None, + windows: vec![ + ProviderQuotaWindow { + key: "core-primary".to_owned(), + group: "weekly".to_owned(), + label: "周额度".to_owned(), + source: Some("codex".to_owned()), + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(7 * 24 * 60 * 60), + used_percent: Some(1.0), + reset_at: Some(reset_at), + limit_reached: false, + local_usage: None, + provider_data: None, + }, + ProviderQuotaWindow { + key: "codex-bengalfox-primary".to_owned(), + group: "weekly".to_owned(), + label: "GPT-5.3-Codex-Spark · 周额度".to_owned(), + source: Some("codex_bengalfox".to_owned()), + local_usage_attribution: QuotaLocalUsageAttribution::Unavailable, + window_seconds: Some(7 * 24 * 60 * 60), + used_percent: Some(0.0), + reset_at: Some(reset_at), + limit_reached: false, + local_usage: None, + provider_data: None, + }, + ], + limit_reached: false, + rate_limited_until: None, + provider_data: None, + }); + let store = FakeAccountStore::new("openai", events()); + store.set_quota_window_usage(vec![ + AccountUsageWindowResult { + account_id: "acct_test".to_owned(), + key: "core-primary".to_owned(), + usage: quota_local_usage("acct_test", 12_818_806), + }, + AccountUsageWindowResult { + account_id: "acct_test".to_owned(), + key: "codex-bengalfox-primary".to_owned(), + usage: quota_local_usage("acct_test", 127_926), + }, + ]); + + let page = accounts_service(provider, store) + .await + .accounts() + .list(AccountListQuery { + page: 1, + page_size: gateway_admin::model::PageSize::new(20).expect("page size"), + provider_kind: None, + search: None, + status: None, + sort: None, + }) + .await + .expect("quota window usage"); + + let local_tokens = page.items[0] + .quota + .windows + .iter() + .map(|window| { + ( + window.key.as_str(), + window + .local_usage + .as_ref() + .and_then(|usage| usage.total_tokens), + ) + }) + .collect::>(); + assert_eq!( + local_tokens, + [ + ("core-primary", Some(12_818_806)), + ("codex-bengalfox-primary", None), + ], + ); +} + #[tokio::test] async fn accounts_refresh_provider_failure_should_not_call_store_commit() { let events = events(); diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 06d87bf9a..7d0fa5ac0 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -1438,6 +1438,7 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView { group, label, source: _, + local_usage_attribution: _, window_seconds, used_percent, reset_at, diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index fe1f1d148..a63906458 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -19,7 +19,7 @@ use gateway_admin::model::provider_credentials::{ PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota, - ProviderQuotaRequest, ProviderQuotaWindow, + ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution, }; use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}; @@ -744,6 +744,11 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota window.window_seconds(), ), source: Some(window.source().to_owned()), + local_usage_attribution: if window.is_account_wide() { + QuotaLocalUsageAttribution::AccountWide + } else { + QuotaLocalUsageAttribution::Unavailable + }, window_seconds: window.window_seconds(), used_percent: window.used_percent(), reset_at: window.reset_at(), @@ -802,6 +807,7 @@ fn force_confirmed_exhaustion_projection(quota: &mut ProviderQuota) { group: "other".to_owned(), label: "额度".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::Unavailable, window_seconds: None, used_percent: Some(100.0), reset_at: None, diff --git a/backend/crates/providers/openai/src/credential/quota/snapshot.rs b/backend/crates/providers/openai/src/credential/quota/snapshot.rs index 74d7a5d05..995640a70 100644 --- a/backend/crates/providers/openai/src/credential/quota/snapshot.rs +++ b/backend/crates/providers/openai/src/credential/quota/snapshot.rs @@ -41,6 +41,7 @@ pub enum CodexQuotaWindowRole { pub struct CodexQuotaWindow { key: String, source: String, + account_wide: bool, /// 上游提供的限流桶名称(`metered_feature`/`limit_name`/`limitId`);用于非核心桶的展示前缀。 limit_name: Option, kind: CodexQuotaWindowKind, @@ -62,6 +63,12 @@ impl CodexQuotaWindow { &self.source } + /// 该窗口是否覆盖账号全部 Codex 请求,可使用账号级本地用量聚合。 + #[must_use] + pub const fn is_account_wide(&self) -> bool { + self.account_wide + } + #[must_use] pub fn limit_name(&self) -> Option<&str> { self.limit_name.as_deref() @@ -329,6 +336,7 @@ pub(crate) fn parse_account_quota_snapshot( parse_rate_limit_windows( &limit.key, &limit.source, + limit.account_wide, limit.limit_name.as_deref(), limit.rate_limit, &mut windows, @@ -405,6 +413,7 @@ fn authoritative_core_primary_exhausted( struct CanonicalRateLimit<'a> { key: String, source: String, + account_wide: bool, limit_name: Option, rate_limit: &'a Value, } @@ -422,6 +431,7 @@ fn canonical_rate_limits( limits.push(CanonicalRateLimit { key: "core".to_owned(), source: "codex".to_owned(), + account_wide: true, limit_name: None, rate_limit, }); @@ -433,6 +443,7 @@ fn canonical_rate_limits( limits.push(CanonicalRateLimit { key: "code-review".to_owned(), source: "code_review".to_owned(), + account_wide: false, limit_name: Some("code_review".to_owned()), rate_limit, }); @@ -463,6 +474,7 @@ fn canonical_rate_limits( // 避免同名 additional 产生重复前端 key。 limits.push(CanonicalRateLimit { key: format!("additional-{index}-{source}"), + account_wide: is_codex_limit_id(&source), source, limit_name, rate_limit, @@ -503,6 +515,7 @@ fn canonical_rate_limit_source( fn parse_rate_limit_windows( key_source: &str, source: &str, + account_wide: bool, limit_name: Option<&str>, value: &Value, output: &mut Vec, @@ -561,6 +574,7 @@ fn parse_rate_limit_windows( output.push(CodexQuotaWindow { key: format!("{}-{}", quota_key(key_source), quota_role_name(role, kind)), source: source.to_owned(), + account_wide, limit_name: limit_name.map(str::to_owned), kind, role, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 069876770..a137dd7eb 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -15,7 +15,7 @@ use gateway_admin::model::provider_credentials::{ AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization, PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput, - ProviderQuotaRequest, + ProviderQuotaRequest, QuotaLocalUsageAttribution, }; use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::ProviderAdminErrorKind; @@ -503,6 +503,16 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc "limit_window_seconds": 604_800 } } + }, { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "primary_window": { + "used_percent": 0, + "reset_at": 1_900_000_000, + "limit_window_seconds": 604_800 + } + } }] }); store @@ -566,6 +576,23 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc .find(|window| window.source.as_deref() == Some("code_review")) .expect("code review quota"); assert_eq!(review.label, "代码审查 · 周额度"); + let spark = quota + .windows + .iter() + .find(|window| window.source.as_deref() == Some("codex_bengalfox")) + .expect("Spark quota"); + assert_eq!( + ( + monthly[0].local_usage_attribution, + review.local_usage_attribution, + spark.local_usage_attribution, + ), + ( + QuotaLocalUsageAttribution::AccountWide, + QuotaLocalUsageAttribution::Unavailable, + QuotaLocalUsageAttribution::Unavailable, + ), + ); } #[tokio::test] diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index f69fc54a7..f1b1db017 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -21,7 +21,7 @@ use gateway_admin::model::provider_credentials::{ PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota, - ProviderQuotaRequest, ProviderQuotaWindow, + ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution, }; use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}; @@ -1059,6 +1059,7 @@ fn project_quota( group: quota_group(period_kind).to_owned(), label: xai_quota_window_label(period_kind, window_seconds), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds, used_percent: billing.used_percent(), reset_at: billing.period_end().and_then(parse_utc), @@ -1074,6 +1075,7 @@ fn project_quota( group: "shortTerm".to_owned(), label: "日限额".to_owned(), source: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(crate::GROK_FREE_ROLLING_WINDOW_SECONDS), used_percent: None, reset_at: None,