fix(admin): keep a model-scoped 429 reset out of the account quota slot

`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 <[email protected]>
This commit is contained in:
stabey
2026-08-23 15:22:29 +08:00
co-authored by Claude Opus 5
parent 1b1be918a9
commit 42deab67b3
+78 -16
View File
@@ -2167,18 +2167,24 @@ fn parse_codex_websocket_usage_limit_error(
result.insert("plan_type".to_string(), json!(plan_type)); result.insert("plan_type".to_string(), json!(plan_type));
} }
} }
if !result.contains_key("primary_reset_at") { // `resets_at` describes whichever limit rejected the request. When a named per-model limit
if let Some(reset_at) = error.get("resets_at").and_then(coerce_json_u64) { // owned the window headers it owns this timing too, so backfilling it into the account slot
result.insert("primary_reset_at".to_string(), json!(reset_at)); // 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 !result.contains_key("primary_reset_after_seconds") { if let Some(reset_after_seconds) =
if let Some(reset_after_seconds) = error.get("resets_in_seconds").and_then(coerce_json_u64) error.get("resets_in_seconds").and_then(coerce_json_u64)
{ {
result.insert( result.insert(
"primary_reset_after_seconds".to_string(), "primary_reset_after_seconds".to_string(),
json!(reset_after_seconds), json!(reset_after_seconds),
); );
}
} }
} }
@@ -2541,6 +2547,23 @@ pub fn parse_codex_backend_me_response(
Some(serde_json::Value::Object(result)) Some(serde_json::Value::Object(result))
} }
fn codex_normalized_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
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<String, String>) -> 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)`. /// 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 /// `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, updated_at_unix_secs: u64,
) -> Option<serde_json::Value> { ) -> Option<serde_json::Value> {
let mut result = serde_json::Map::new(); let mut result = serde_json::Map::new();
let normalized = headers let normalized = codex_normalized_headers(headers);
.iter() if !normalized
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) .keys()
.collect::<BTreeMap<_, _>>(); .any(|key| key.starts_with(CODEX_HEADER_PREFIX))
if !normalized.keys().any(|key| key.starts_with("x-codex-")) { {
return None; return None;
} }
@@ -5927,6 +5950,45 @@ mod tests {
assert!(codex_rate_limit_metadata_exhausted(&parsed)); 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] #[test]
fn codex_rate_limit_metadata_detects_explicit_and_window_exhaustion() { fn codex_rate_limit_metadata_detects_explicit_and_window_exhaustion() {
assert!(codex_rate_limit_metadata_exhausted(&json!({ assert!(codex_rate_limit_metadata_exhausted(&json!({