From d388fa344a2a5ad2b71f2783405f402ae555a0d6 Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 4 Aug 2026 22:21:34 +0800 Subject: [PATCH] fix(openai): drop placeholder secondary quota windows Co-authored-by: Codex --- .../openai/src/credential/quota/mod.rs | 59 +++++- .../openai/tests/credential/quota/mod.rs | 178 ++++++++++++++++++ .../openai/tests/provider/contract.rs | 26 ++- 3 files changed, 247 insertions(+), 16 deletions(-) diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs index b8b77f1e3..76567a20b 100644 --- a/backend/crates/providers/openai/src/credential/quota/mod.rs +++ b/backend/crates/providers/openai/src/credential/quota/mod.rs @@ -430,7 +430,10 @@ impl CodexCredentialQuotaService { .and_then(|observation| observation.quota) .map(OpaqueProviderData::into_inner) .unwrap_or_default(); - let quota = confirmed_exhaustion_projection(existing, reset_at); + let quota = confirmed_exhaustion_projection( + normalize_quota_window_placeholders(existing), + reset_at, + ); let observed_at = SystemTime::now(); let outcome = self .store @@ -594,10 +597,12 @@ impl CodexCredentialQuotaService { let refreshed_fact = refreshed_snapshot.fact(); let previous_reset_at = self.previous_confirmed_reset(account).await; let now = SystemTime::now(); - let object = value - .as_object() - .cloned() - .ok_or(CodexCredentialQuotaError::InvalidCredentialData)?; + let object = normalize_quota_window_placeholders( + value + .as_object() + .cloned() + .ok_or(CodexCredentialQuotaError::InvalidCredentialData)?, + ); let object = if account.availability() == AccountAvailability::QuotaExhausted && quota_success_state( account.availability(), @@ -927,10 +932,12 @@ impl CodexCredentialQuotaService { let refreshed_fact = refreshed_snapshot.fact(); let previous_reset_at = self.previous_confirmed_reset(&account).await; let now = SystemTime::now(); - let object = value - .as_object() - .cloned() - .ok_or(CodexCredentialQuotaError::InvalidCredentialData)?; + let object = normalize_quota_window_placeholders( + value + .as_object() + .cloned() + .ok_or(CodexCredentialQuotaError::InvalidCredentialData)?, + ); let object = if account.availability() == AccountAvailability::QuotaExhausted && quota_success_state( account.availability(), @@ -1181,6 +1188,40 @@ fn merge_passive_quota( quota } +/// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。 +/// +/// 生产响应头可能携带 `used_percent=0`、零时长和空 reset 的占位。正常被动同步 +/// 保留该响应事实,Admin 展示会将其隐藏;但主动 `/usage` 刷新或 402 确认投影若 +/// 遇到这个对象,会把它写成 100%,从而显示并不存在的“次级额度”。因此这些 +/// 非被动写入口在落库前移除无事实对象。带 reset、时长、正用量、触顶、未知或 +/// 非法字段的次级窗口均完全按原有额度逻辑保留。 +fn normalize_quota_window_placeholders(mut quota: Map) -> Map { + if let Some(rate_limit) = quota.get_mut("rate_limit").and_then(Value::as_object_mut) { + drop_secondary_window_placeholder(rate_limit); + } + quota +} + +fn drop_secondary_window_placeholder(rate_limit: &mut Map) { + let placeholder = rate_limit + .get("secondary_window") + .and_then(Value::as_object) + .is_some_and(secondary_window_is_placeholder); + if placeholder { + rate_limit.remove("secondary_window"); + } +} + +fn secondary_window_is_placeholder(window: &Map) -> bool { + window.iter().all(|(field, value)| match field.as_str() { + "used_percent" => value + .as_f64() + .is_some_and(|used_percent| used_percent.is_finite() && used_percent == 0.0), + "limit_reached" => value.as_bool() == Some(false), + _ => false, + }) +} + /// 用已确认的上游额度拒绝覆盖 core 限额投影。 /// /// 上游 usage 快照可能在结算期间短暂滞后为 98/99% 或 `allowed=true`;保留其他 diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index baf786083..a12cda8c4 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -424,14 +424,71 @@ async fn passive_rate_limit_headers_replace_stale_secondary_window() { .await .expect("read quota") .expect("quota snapshot"); + let raw = store + .quota_json("acct_passive_quota_snapshot") + .expect("stored passive quota"); let monthly = snapshot .windows() .iter() .filter(|window| window.kind() == CodexQuotaWindowKind::Monthly) .collect::>(); + assert_eq!(snapshot.windows().len(), 1); assert_eq!(monthly.len(), 1); assert_eq!(monthly[0].used_percent(), Some(92.0)); + assert!( + raw.pointer("/rate_limit/secondary_window").is_none(), + "a normal response without a secondary window must delete stale raw state" + ); +} + +#[tokio::test] +async fn manual_quota_refresh_drops_empty_secondary_window() { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_manual_secondary_placeholder"; + create_account(&store, account_id).await; + let account = store.account(account_id).expect("created account"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header( + "authorization", + format!("Bearer token-{account_id}"), + )) + .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, + "limit_window_seconds": 2_592_000 + }, + "secondary_window": { + "used_percent": 0, + "limit_reached": false + } + } + }))) + .expect(1) + .mount(&server) + .await; + let service = quota_service_with_base_url( + &store, + reqwest::Client::builder() + .no_proxy() + .build() + .expect("client"), + server.uri(), + ); + + let snapshot = service + .refresh_account(account.id()) + .await + .expect("manual quota refresh"); + + assert_eq!(snapshot.windows().len(), 1); + assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0)); } #[tokio::test] @@ -719,6 +776,127 @@ async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_li assert_eq!(account.availability(), AccountAvailability::QuotaExhausted); } +#[tokio::test] +async fn periodic_quota_refresh_drops_empty_secondary_window() { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_periodic_secondary_placeholder"; + create_account(&store, account_id).await; + let account = store.account(account_id).expect("created account"); + store + .apply_state_change(AccountStateChange { + message: None, + account_id: account.id().clone(), + expected_revision: account.revision(), + availability: AccountAvailability::QuotaExhausted, + observed_at: SystemTime::now(), + }) + .await + .expect("mark account exhausted"); + 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": true, + "limit_reached": false, + "primary_window": { + "used_percent": 99, + "reset_at": 1_900_000_000, + "limit_window_seconds": 2_592_000 + }, + "secondary_window": {} + } + }))) + .expect(1) + .mount(&server) + .await; + let service = quota_service_with_base_url( + &store, + reqwest::Client::builder() + .no_proxy() + .build() + .expect("client"), + server.uri(), + ); + + let summary = service.synchronize().await.expect("periodic quota refresh"); + let snapshot = service + .read_account(account.id()) + .await + .expect("read quota") + .expect("quota snapshot"); + + assert_eq!(summary.exhausted, 1); + assert_eq!(snapshot.windows().len(), 1); + assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0)); +} + +#[tokio::test] +async fn periodic_quota_refresh_keeps_nonempty_secondary_window_during_confirmed_exhaustion() { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_periodic_real_secondary"; + create_account(&store, account_id).await; + let account = store.account(account_id).expect("created account"); + store + .apply_state_change(AccountStateChange { + message: None, + account_id: account.id().clone(), + expected_revision: account.revision(), + availability: AccountAvailability::QuotaExhausted, + observed_at: SystemTime::now(), + }) + .await + .expect("mark account exhausted"); + let secondary_reset_at = 1_900_604_800; + 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": true, + "limit_reached": false, + "primary_window": { + "used_percent": 99, + "reset_at": 1_900_000_000, + "limit_window_seconds": 2_592_000 + }, + "secondary_window": { + "used_percent": 0, + "reset_at": secondary_reset_at, + "limit_window_seconds": 604_800 + } + } + }))) + .expect(1) + .mount(&server) + .await; + let service = quota_service_with_base_url( + &store, + reqwest::Client::builder() + .no_proxy() + .build() + .expect("client"), + server.uri(), + ); + + let summary = service.synchronize().await.expect("periodic quota refresh"); + let snapshot = service + .read_account(account.id()) + .await + .expect("read quota") + .expect("quota snapshot"); + + assert_eq!(summary.exhausted, 1); + assert_eq!(snapshot.windows().len(), 2); + assert!(snapshot.windows().iter().any(|window| { + window.window_seconds() == Some(604_800) + && window.used_percent() == Some(100.0) + && window + .reset_at() + .is_some_and(|reset_at| reset_at.timestamp() == secondary_reset_at) + })); +} + #[tokio::test] async fn periodic_quota_synchronization_respects_retry_after_on_service_unavailable() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index c36d066a9..608bac059 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -1437,7 +1437,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset "rate_limit": { "allowed": true, "limit_reached": false, - "primary_window": {"used_percent": 99, "reset_at": reset_at} + "primary_window": {"used_percent": 99, "reset_at": reset_at}, + "secondary_window": {"used_percent": 0} } }) .as_object() @@ -1463,7 +1464,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset "rate_limit": { "allowed": true, "limit_reached": false, - "primary_window": {"used_percent": 99, "reset_at": reset_at} + "primary_window": {"used_percent": 99, "reset_at": reset_at}, + "secondary_window": {"used_percent": 0} } })), ) @@ -1512,16 +1514,22 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset .next() .expect("confirmed quota observation"); let projected_observed_at = projected.observed_at.expect("projection observed at"); - let projected = projected - .quota - .map(OpaqueProviderData::into_inner) - .expect("confirmed quota projection"); + let projected = Value::Object( + projected + .quota + .map(OpaqueProviderData::into_inner) + .expect("confirmed quota projection"), + ); assert_eq!( - Value::Object(projected) + projected .pointer("/rate_limit/primary_window/used_percent") .and_then(Value::as_u64), Some(100) ); + assert!( + projected.pointer("/rate_limit/secondary_window").is_none(), + "request failure must not manufacture a secondary quota" + ); tokio::time::sleep(Duration::from_millis(200)).await; let requests = server.received_requests().await.expect("received requests"); assert_eq!( @@ -1572,6 +1580,10 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset .and_then(Value::as_i64), Some(reset_at) ); + assert!( + quota.pointer("/rate_limit/secondary_window").is_none(), + "background quota refresh must retain the same window projection" + ); let requests = server.received_requests().await.expect("received requests"); assert_eq!( requests