From 42deab67b3ccef37c870e9fe568a13ba5b1b4308 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:35:51 +0800 Subject: [PATCH] fix(admin): keep a model-scoped 429 reset out of the account quota slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_codex_websocket_usage_limit_error` backfills `primary_reset_at` and `primary_reset_after_seconds` from the error body whenever the embedded headers did not supply them. Now that a named per-model limit no longer claims the unprefixed window headers, that absence is exactly what a Spark 429 produces — and `resets_at` on such an error is the Spark window's reset, so the backfill put model-scoped timing back on the account's own quota. Gate the backfill on the same ownership rule the window parsing uses. Reported by Cursor Bugbot on the fork PR. Co-Authored-By: Claude Opus 5 --- crates/aether-admin/src/provider/quota.rs | 94 +++++++++++++++++++---- 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index 241a45276..bf30c500a 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -2167,18 +2167,24 @@ fn parse_codex_websocket_usage_limit_error( result.insert("plan_type".to_string(), json!(plan_type)); } } - if !result.contains_key("primary_reset_at") { - if let Some(reset_at) = error.get("resets_at").and_then(coerce_json_u64) { - result.insert("primary_reset_at".to_string(), json!(reset_at)); + // `resets_at` describes whichever limit rejected the request. When a named per-model limit + // owned the window headers it owns this timing too, so backfilling it into the account slot + // would put a model window's reset on the account's own quota. + if codex_headers_carry_account_windows(&headers) { + if !result.contains_key("primary_reset_at") { + if let Some(reset_at) = error.get("resets_at").and_then(coerce_json_u64) { + result.insert("primary_reset_at".to_string(), json!(reset_at)); + } } - } - if !result.contains_key("primary_reset_after_seconds") { - if let Some(reset_after_seconds) = error.get("resets_in_seconds").and_then(coerce_json_u64) - { - result.insert( - "primary_reset_after_seconds".to_string(), - json!(reset_after_seconds), - ); + if !result.contains_key("primary_reset_after_seconds") { + if let Some(reset_after_seconds) = + error.get("resets_in_seconds").and_then(coerce_json_u64) + { + result.insert( + "primary_reset_after_seconds".to_string(), + json!(reset_after_seconds), + ); + } } } @@ -2541,6 +2547,23 @@ pub fn parse_codex_backend_me_response( Some(serde_json::Value::Object(result)) } +fn codex_normalized_headers(headers: &BTreeMap) -> BTreeMap { + headers + .iter() + .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect() +} + +/// Whether the account's own quota windows can be read from these headers' unprefixed fields. +/// +/// False when a named per-model limit governed the request: the unprefixed fields — and any reset +/// timing reported alongside them — then describe that limit instead. +fn codex_headers_carry_account_windows(headers: &BTreeMap) -> bool { + let normalized = codex_normalized_headers(headers); + let named_limits = codex_named_limit_headers(&normalized); + !codex_named_limit_owns_unprefixed_windows(&normalized, &named_limits) +} + /// Lists the named per-model limits the response headers announce, as `(feature, limit_name)`. /// /// `feature` is the header segment that carries the limit's windows @@ -2588,11 +2611,11 @@ pub fn parse_codex_usage_headers( updated_at_unix_secs: u64, ) -> Option { let mut result = serde_json::Map::new(); - let normalized = headers - .iter() - .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) - .collect::>(); - if !normalized.keys().any(|key| key.starts_with("x-codex-")) { + let normalized = codex_normalized_headers(headers); + if !normalized + .keys() + .any(|key| key.starts_with(CODEX_HEADER_PREFIX)) + { return None; } @@ -5927,6 +5950,45 @@ mod tests { assert!(codex_rate_limit_metadata_exhausted(&parsed)); } + #[test] + fn codex_usage_limit_error_does_not_backfill_a_model_scoped_reset_into_the_account() { + let parsed = parse_codex_websocket_rate_limits_response( + &json!({ + "type": "error", + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + "resets_at": 1_787_430_252u64, + "resets_in_seconds": 20_722u64, + }, + "status_code": 429, + "headers": { + "X-Codex-Plan-Type": "pro", + "X-Codex-Active-Limit": "codex_bengalfox", + "X-Codex-Primary-Used-Percent": "100", + "X-Codex-Primary-Window-Minutes": "300", + "X-Codex-Primary-Reset-At": "1787430252", + "X-Codex-Bengalfox-Limit-Name": "GPT-5.3-Codex-Spark", + "X-Codex-Bengalfox-Primary-Used-Percent": "100", + "X-Codex-Bengalfox-Primary-Window-Minutes": "300", + "X-Codex-Bengalfox-Primary-Reset-At": "1787430252", + }, + }), + 1_787_409_530, + ) + .expect("Codex usage-limit error should parse as quota metadata"); + + assert!( + parsed.get("primary_reset_at").is_none(), + "the Spark limit rejected this request, so its reset timing must not claim the account window: {parsed}" + ); + assert!(parsed.get("primary_reset_after_seconds").is_none()); + assert_eq!( + parsed.get("spark_primary_reset_at"), + Some(&json!(1_787_430_252u64)) + ); + } + #[test] fn codex_rate_limit_metadata_detects_explicit_and_window_exhaustion() { assert!(codex_rate_limit_metadata_exhausted(&json!({