From f6e5d8f673b35c4774ba87357e6a0484875008a7 Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 5 Aug 2026 00:03:11 +0800 Subject: [PATCH] fix(openai): drop null secondary quota windows Co-authored-by: Codex --- .../openai/src/credential/quota/mod.rs | 21 ++- .../openai/tests/credential/quota/mod.rs | 169 +++++++++++++++++- 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs index 76567a20b..4aced80c5 100644 --- a/backend/crates/providers/openai/src/credential/quota/mod.rs +++ b/backend/crates/providers/openai/src/credential/quota/mod.rs @@ -1190,11 +1190,12 @@ fn merge_passive_quota( /// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。 /// -/// 生产响应头可能携带 `used_percent=0`、零时长和空 reset 的占位。正常被动同步 -/// 保留该响应事实,Admin 展示会将其隐藏;但主动 `/usage` 刷新或 402 确认投影若 -/// 遇到这个对象,会把它写成 100%,从而显示并不存在的“次级额度”。因此这些 -/// 非被动写入口在落库前移除无事实对象。带 reset、时长、正用量、触顶、未知或 -/// 非法字段的次级窗口均完全按原有额度逻辑保留。 +/// 生产响应头可能携带 `used_percent=0`、零时长和空 reset 的占位,`/usage` 也会 +/// 返回 `secondary_window: null`。正常被动同步保留该响应事实,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); @@ -1203,10 +1204,12 @@ fn normalize_quota_window_placeholders(mut quota: Map) -> Map) { - let placeholder = rate_limit - .get("secondary_window") - .and_then(Value::as_object) - .is_some_and(secondary_window_is_placeholder); + let placeholder = rate_limit.get("secondary_window").is_some_and(|window| { + window.is_null() + || window + .as_object() + .is_some_and(secondary_window_is_placeholder) + }); if placeholder { rate_limit.remove("secondary_window"); } diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index a12cda8c4..9e78f119c 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -20,7 +20,7 @@ use provider_openai::credential::{ CodexQuotaWindowKind, ImportCodexOAuthCredential, }; use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState}; -use serde_json::json; +use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -491,6 +491,117 @@ async fn manual_quota_refresh_drops_empty_secondary_window() { assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0)); } +#[tokio::test] +async fn manual_quota_refresh_drops_null_secondary_window_during_confirmed_exhaustion() { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_manual_null_secondary_placeholder"; + create_account(&store, account_id).await; + let account = store.account(account_id).expect("created account"); + let reset_at = 1_900_000_000; + store + .compare_and_swap_quota(QuotaObservation { + account_id: account.id().clone(), + expected_revision: account.revision(), + quota: Some(OpaqueProviderData::new( + json!({ + "rate_limit": { + "allowed": false, + "limit_reached": true, + "primary_window": { + "used_percent": 100, + "reset_at": reset_at, + "limit_window_seconds": 2_592_000 + } + } + }) + .as_object() + .expect("quota object") + .clone(), + )), + observed_at: Some(SystemTime::now()), + limit_reached: None, + }) + .await + .expect("seed confirmed exhaustion"); + 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")) + .and(header( + "authorization", + format!("Bearer token-{account_id}"), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 86, + "reset_at": reset_at, + "reset_after_seconds": 2_133_000, + "limit_window_seconds": 2_592_000 + }, + "secondary_window": null + } + }))) + .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"); + let persisted = store + .get_quotas(&[account.id().clone()]) + .await + .expect("read persisted quota") + .into_iter() + .next() + .and_then(|observation| observation.quota) + .map(OpaqueProviderData::into_inner) + .map(Value::Object) + .expect("persisted quota"); + + assert_eq!(snapshot.windows().len(), 1); + assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0)); + assert_eq!( + snapshot.windows()[0] + .reset_at() + .map(|value| value.timestamp()), + Some(reset_at) + ); + assert!(snapshot.fact().exhausted()); + assert_eq!( + store + .account(account_id) + .expect("account after refresh") + .availability(), + AccountAvailability::QuotaExhausted + ); + assert!( + persisted.pointer("/rate_limit/secondary_window").is_none(), + "a null secondary window must not be projected as 100%" + ); +} + #[tokio::test] async fn passive_active_limit_keeps_the_top_level_codex_bucket_canonical() { let store = Arc::new(MemoryAccountStore::default()); @@ -831,6 +942,62 @@ async fn periodic_quota_refresh_drops_empty_secondary_window() { assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0)); } +#[tokio::test] +async fn periodic_quota_refresh_drops_null_secondary_window() { + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_periodic_null_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": 86, + "reset_at": 1_900_000_000, + "reset_after_seconds": 2_133_000, + "limit_window_seconds": 2_592_000 + }, + "secondary_window": null + } + }))) + .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());