From 7de51e9f703581e5ce880435e043f8f0011be645 Mon Sep 17 00:00:00 2001 From: Tyler South Date: Tue, 15 Sep 2026 18:18:21 -0400 Subject: [PATCH 1/2] Harden Codex usage parsing for moved and renamed API fields --- rust/src/providers/codex/api.rs | 294 +++++++++++++++--- .../providers/fixtures/codex/astra-era.json | 61 ++++ 2 files changed, 317 insertions(+), 38 deletions(-) create mode 100644 rust/src/providers/fixtures/codex/astra-era.json diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 9194980e1..14cb45efc 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -390,9 +390,18 @@ impl CodexApi { .filter(|window| !is_placeholder_window(window)) .map(|w| self.parse_window(w)); + // Codex moved the code-review meter to a top-level + // `code_review_rate_limit`; the nested `rate_limit.code_review_window` + // is the older shape, so accept either. let code_review = rate_limit .get("code_review_window") - .map(|w| self.parse_window(w)); + .filter(|w| !is_placeholder_window(w)) + .map(|w| self.parse_window(w)) + .or_else(|| { + json.get("code_review_rate_limit") + .filter(|w| !w.is_null() && !is_placeholder_window(w)) + .map(|w| self.parse_window(w)) + }); let (primary, secondary, five_hour_not_enforced) = normalize_codex_windows(primary_opt, secondary_opt); @@ -429,13 +438,14 @@ impl CodexApi { let window_minutes = window .get("limit_window_seconds") - .and_then(|v| v.as_i64()) + .and_then(json_i64) .map(|s| (s / 60) as u32); let reset_at = window .get("reset_at") - .and_then(|v| v.as_i64()) - .and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + .and_then(json_i64) + .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .or_else(|| reset_after_to_instant(window)); RateWindow::with_details( used_percent, @@ -450,11 +460,11 @@ impl CodexApi { .and_then(|v| v.as_array()) .into_iter() .flatten() - .filter_map(|entry| self.parse_additional_rate_limit(entry)) + .flat_map(|entry| self.parse_additional_rate_limit(entry)) .collect() } - fn parse_additional_rate_limit(&self, entry: &serde_json::Value) -> Option { + fn parse_additional_rate_limit(&self, entry: &serde_json::Value) -> Vec { let metered_feature = entry .get("metered_feature") .and_then(|v| v.as_str()) @@ -467,44 +477,81 @@ impl CodexApi { .filter(|v| !v.is_empty()); let rate_limit = entry.get("rate_limit").unwrap_or(entry); - let primary = rate_limit.get("primary_window"); - let secondary = rate_limit.get("secondary_window"); - let window = primary.or(secondary)?; - if is_placeholder_window(window) { - return None; + let primary = rate_limit + .get("primary_window") + .filter(|window| !is_placeholder_window(window)); + let secondary = rate_limit + .get("secondary_window") + .filter(|window| !is_placeholder_window(window)); + if primary.is_none() && secondary.is_none() { + return Vec::new(); } - let parsed = self.parse_window(window); let feature = metered_feature.unwrap_or_default(); let limit = limit_name.unwrap_or_default(); + // Spark's metered feature was renamed to `codex_bengalfox`, but its + // limit name still carries "Spark"; accept either so the dedicated + // Spark rows keep showing instead of degrading to a generic extra. let is_spark = feature.eq_ignore_ascii_case("codex_spark") || feature.eq_ignore_ascii_case("spark") + || feature.eq_ignore_ascii_case("codex_bengalfox") || limit.to_ascii_lowercase().contains("spark"); if is_spark { - let is_weekly = secondary.is_some() && primary.is_none() - || parsed + // Spark now reports a five-hour AND a weekly window in one entry; + // surface both rather than dropping whichever is not primary. + let mut windows = Vec::new(); + for window in [primary, secondary].into_iter().flatten() { + let parsed = self.parse_window(window); + let is_weekly = parsed .window_minutes - .is_some_and(|mins| mins >= 7 * 24 * 60); - let (id, title) = if is_weekly { - ("codex-spark-weekly", "Codex Spark Weekly") - } else { - ("codex-spark", "Codex Spark 5-hour") - }; - return Some(NamedRateWindow::new(id, title, parsed)); + .is_some_and(|minutes| minutes >= 7 * 24 * 60); + let (id, title) = if is_weekly { + ("codex-spark-weekly", "Codex Spark Weekly") + } else { + ("codex-spark", "Codex Spark 5-hour") + }; + if !windows + .iter() + .any(|existing: &NamedRateWindow| existing.id == id) + { + windows.push(NamedRateWindow::new(id, title, parsed)); + } + } + return windows; } - let label = limit_name.or(metered_feature)?; + let Some(label) = limit_name.or(metered_feature) else { + return Vec::new(); + }; let slug = slugify(label); if slug.is_empty() { - return None; + return Vec::new(); } - - Some(NamedRateWindow::new( - format!("codex-{slug}"), - titleize_limit_label(label), - parsed, - )) + let title = titleize_limit_label(label); + + let mut windows = Vec::new(); + if let Some(window) = primary { + windows.push(NamedRateWindow::new( + format!("codex-{slug}"), + title.clone(), + self.parse_window(window), + )); + if let Some(window) = secondary { + windows.push(NamedRateWindow::new( + format!("codex-{slug}-weekly"), + format!("{title} Weekly"), + self.parse_window(window), + )); + } + } else if let Some(window) = secondary { + windows.push(NamedRateWindow::new( + format!("codex-{slug}"), + title, + self.parse_window(window), + )); + } + windows } /// The remaining credit balance, when the account has metered credits. @@ -527,7 +574,7 @@ impl CodexApi { { return None; } - credits.get("balance").and_then(|v| v.as_f64()) + credits.get("balance").and_then(json_f64) } /// Money actually spent against a credit allowance. @@ -539,10 +586,16 @@ impl CodexApi { /// balance is surfaced separately as its own line instead. fn extract_credits(&self, json: &serde_json::Value) -> Option { let balance = Self::credit_balance(json)?; - let limit = json.get("individual_limit").or_else(|| { - json.get("rate_limit") - .and_then(|r| r.get("individual_limit")) - })?; + // OpenAI now nests the spend control under `spend_control`; the + // top-level and `rate_limit` copies are older shapes. + let limit = json + .get("spend_control") + .and_then(|spend_control| spend_control.get("individual_limit")) + .or_else(|| json.get("individual_limit")) + .or_else(|| { + json.get("rate_limit") + .and_then(|r| r.get("individual_limit")) + })?; serde_json::from_value::(limit.clone()) .ok()? .to_cost_snapshot(balance) @@ -692,14 +745,55 @@ struct CreditDetails { #[derive(Debug, Deserialize)] struct SpendControlLimitSnapshot { + #[serde(default, deserialize_with = "flexible_number::f64")] limit: Option, + #[serde(default, deserialize_with = "flexible_number::f64")] used: Option, - #[serde(default, alias = "remainingPercent")] + #[serde( + default, + alias = "remainingPercent", + deserialize_with = "flexible_number::f64" + )] remaining_percent: Option, - #[serde(default, alias = "resetsAt")] + #[serde(default, alias = "resetsAt", deserialize_with = "flexible_number::i64")] resets_at: Option, } +/// OpenAI mixes JSON numbers and decimal strings for the same field across +/// responses, so accept either shape when decoding money and reset instants. +mod flexible_number { + use serde::{Deserialize, Deserializer}; + + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberOrString { + Number(f64), + String(String), + } + + pub fn f64<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Ok(match Option::::deserialize(deserializer)? { + Some(NumberOrString::Number(value)) => Some(value), + Some(NumberOrString::String(value)) => value.trim().parse::().ok(), + None => None, + }) + } + + pub fn i64<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Ok(match Option::::deserialize(deserializer)? { + Some(NumberOrString::Number(value)) => Some(value as i64), + Some(NumberOrString::String(value)) => value.trim().parse::().ok(), + None => None, + }) + } +} + #[derive(Debug, Clone, Deserialize)] struct ResetCredits { #[serde(default)] @@ -820,6 +914,21 @@ fn json_f64(value: &serde_json::Value) -> Option { .or_else(|| value.as_str()?.trim().parse::().ok()) } +/// Integers that OpenAI may encode as JSON numbers or as decimal strings. +fn json_i64(value: &serde_json::Value) -> Option { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| value.as_f64().map(|value| value as i64)) + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +/// Derive an absolute reset instant when the API only reports a countdown. +fn reset_after_to_instant(window: &serde_json::Value) -> Option> { + let seconds = window.get("reset_after_seconds").and_then(json_i64)?; + (seconds > 0).then(|| Utc::now() + chrono::Duration::seconds(seconds)) +} + fn is_placeholder_window(window: &serde_json::Value) -> bool { let has_usage = window .get("used_percent") @@ -828,9 +937,9 @@ fn is_placeholder_window(window: &serde_json::Value) -> bool { .is_some(); let has_duration = window .get("limit_window_seconds") - .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse::().ok())) + .and_then(json_i64) .is_some(); - let has_reset = window.get("reset_at").is_some(); + let has_reset = window.get("reset_at").is_some() || window.get("reset_after_seconds").is_some(); !has_usage && !has_duration && !has_reset } @@ -1314,6 +1423,115 @@ mod tests { assert_eq!(restored.extra_rate_windows[0].id, "codex-spark-weekly"); } + #[test] + fn astra_era_payload_surfaces_weekly_primary_and_both_spark_windows() { + let api = CodexApi::new(); + let json: serde_json::Value = + serde_json::from_str(include_str!("../fixtures/codex/astra-era.json")) + .expect("astra-era fixture"); + + let (usage, cost) = api.build_result_from_json(&json).expect("codex usage"); + + // The rate-limited account reports only the weekly window; it stays the + // reading, and the lifted five-hour window stays explicit. + assert_eq!(usage.primary.window_minutes, Some(10_080)); + assert_eq!(usage.primary.used_percent, 100.0); + assert!(usage.secondary.is_none()); + assert_eq!(usage.inactive_rate_windows.len(), 1); + assert_eq!(usage.inactive_rate_windows[0].id, "codex-five-hour"); + + // Spark reports a five-hour and a weekly window in one entry; both rows + // must survive instead of only the primary one. + let spark: Vec<_> = usage + .extra_rate_windows + .iter() + .filter(|window| window.id.starts_with("codex-spark")) + .collect(); + assert_eq!(spark.len(), 2, "got {:?}", usage.extra_rate_windows); + let five_hour = spark + .iter() + .find(|window| window.id == "codex-spark") + .expect("spark five-hour row"); + assert_eq!(five_hour.window.window_minutes, Some(300)); + let weekly = spark + .iter() + .find(|window| window.id == "codex-spark-weekly") + .expect("spark weekly row"); + assert_eq!(weekly.window.window_minutes, Some(10_080)); + + assert!( + cost.is_none(), + "no credits and no spend limit means no cost" + ); + } + + #[test] + fn string_encoded_numbers_and_spend_control_still_produce_a_cost() { + let api = CodexApi::new(); + let (usage, cost) = api + .build_result_from_json(&json!({ + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": "12.5", + "limit_window_seconds": "604800" + } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": "10.0" }, + "spend_control": { + "reached": false, + "individual_limit": { "limit": "50", "resetsAt": "1790113323" } + } + })) + .expect("codex usage"); + + assert_eq!(usage.primary.used_percent, 12.5); + assert_eq!(usage.primary.window_minutes, Some(10_080)); + + let cost = cost.expect("spend_control.individual_limit must be honored"); + assert!((cost.used - 40.0).abs() < 0.01, "used = limit - balance"); + assert_eq!(cost.limit, Some(50.0)); + assert!(cost.resets_at.is_some()); + } + + #[test] + fn top_level_code_review_rate_limit_is_surfaced() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "code_review_rate_limit": { + "used_percent": 30, + "limit_window_seconds": 604800 + } + })) + .expect("codex usage"); + + let code_review = usage.model_specific.expect("code review window"); + assert_eq!(code_review.used_percent, 30.0); + assert_eq!(code_review.window_minutes, Some(10_080)); + } + + #[test] + fn reset_countdown_only_payload_still_has_a_reset_instant() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { + "used_percent": 5, + "limit_window_seconds": 18000, + "reset_after_seconds": 3600 + } + } + })) + .expect("codex usage"); + + assert!(usage.primary.resets_at.is_some()); + } + #[test] fn normalizes_reordered_codex_windows_by_cadence() { let weekly = RateWindow::with_details(40.0, Some(10_080), None, None); diff --git a/rust/src/providers/fixtures/codex/astra-era.json b/rust/src/providers/fixtures/codex/astra-era.json new file mode 100644 index 000000000..fc2cf2d26 --- /dev/null +++ b/rust/src/providers/fixtures/codex/astra-era.json @@ -0,0 +1,61 @@ +{ + "plan_type": "pro", + "rate_limit": { + "allowed": false, + "limit_reached": true, + "primary_window": { + "used_percent": 100, + "limit_window_seconds": 604800, + "reset_after_seconds": 296921, + "reset_at": 1789805444 + }, + "secondary_window": null + }, + "code_review_rate_limit": null, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 18000, + "reset_after_seconds": 18000, + "reset_at": 1789526523 + }, + "secondary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_after_seconds": 604800, + "reset_at": 1790113323 + } + }, + "normal_model_slug": null + } + ], + "model_usage": { + "gpt-6-astra": { + "available": false, + "available_at": "2026-09-19T08:10:44.809490Z", + "credits_would_enable": true + } + }, + "credits": { + "has_credits": false, + "unlimited": false, + "overage_limit_reached": false, + "balance": "0", + "approx_local_messages": [0, 0], + "approx_cloud_messages": [0, 0] + }, + "spend_control": { + "reached": false, + "individual_limit": null + }, + "rate_limit_reset_credits": { + "available_count": 0, + "applicable_available_count": 0 + } +} From 249854eaa6eac8229fc1a295d4c3f610127006b9 Mon Sep 17 00:00:00 2001 From: Tyler South Date: Tue, 15 Sep 2026 18:27:10 -0400 Subject: [PATCH 2/2] Treat a null spend control as absent when falling back --- rust/src/providers/codex/api.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 14cb45efc..80b6d6ae1 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -587,14 +587,20 @@ impl CodexApi { fn extract_credits(&self, json: &serde_json::Value) -> Option { let balance = Self::credit_balance(json)?; // OpenAI now nests the spend control under `spend_control`; the - // top-level and `rate_limit` copies are older shapes. + // top-level and `rate_limit` copies are older shapes. A present but + // `null` value must not block the fallbacks. let limit = json .get("spend_control") .and_then(|spend_control| spend_control.get("individual_limit")) - .or_else(|| json.get("individual_limit")) + .filter(|limit| !limit.is_null()) + .or_else(|| { + json.get("individual_limit") + .filter(|limit| !limit.is_null()) + }) .or_else(|| { json.get("rate_limit") .and_then(|r| r.get("individual_limit")) + .filter(|limit| !limit.is_null()) })?; serde_json::from_value::(limit.clone()) .ok()? @@ -1494,6 +1500,25 @@ mod tests { assert!(cost.resets_at.is_some()); } + #[test] + fn null_nested_spend_limit_falls_back_to_the_top_level_limit() { + let api = CodexApi::new(); + let (_, cost) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": 10.0 }, + "spend_control": { "reached": false, "individual_limit": null }, + "individual_limit": { "limit": 50.0 } + })) + .expect("codex usage"); + + let cost = cost.expect("a null nested limit must not hide the top-level one"); + assert!((cost.used - 40.0).abs() < 0.01); + assert_eq!(cost.limit, Some(50.0)); + } + #[test] fn top_level_code_review_rate_limit_is_surfaced() { let api = CodexApi::new();