mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(openai): drop null secondary quota windows
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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<String, Value>) -> Map<String, Value> {
|
||||
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<String, Value>) -> Map<Str
|
||||
}
|
||||
|
||||
fn drop_secondary_window_placeholder(rate_limit: &mut Map<String, Value>) {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user