diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6865e67f..0d48a87d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,7 +73,7 @@ struct Error { // Optional fields available on "usage_limit_reached" and "usage_not_included" errors plan_type: Option, - resets_at: Option, + resets_at: Option, } #[derive(Debug, Clone)] @@ -425,9 +425,7 @@ impl ModelClient { .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); let resets_at = error .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|dt| dt.with_timezone(&Utc)); + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, resets_at, @@ -636,10 +634,7 @@ fn parse_rate_limit_window( used_percent.and_then(|used_percent| { let window_minutes = parse_header_i64(headers, window_minutes_header); - let resets_at = parse_header_str(headers, resets_header) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(std::string::ToString::to_string); + let resets_at = parse_header_i64(headers, resets_header); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) @@ -1426,7 +1421,8 @@ mod tests { use crate::token_data::KnownPlan; use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#; + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":1704067200}}"#; let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); @@ -1439,7 +1435,8 @@ mod tests { fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#; + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":1704067260}}"#; let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 5597ffd4..459cc175 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -421,16 +421,24 @@ mod tests { use pretty_assertions::assert_eq; fn rate_limit_snapshot() -> RateLimitSnapshot { + let primary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 1, 0, 0) + .unwrap() + .timestamp(); + let secondary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 2, 0, 0) + .unwrap() + .timestamp(); RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_at: Some("2024-01-01T01:00:00Z".to_string()), + resets_at: Some(primary_reset_at), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_at: Some("2024-01-01T02:00:00Z".to_string()), + resets_at: Some(secondary_reset_at), }), } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d84d8c03..7bc22449 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -768,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() { .insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-secondary-window-minutes", "60") - .insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z") - .insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z") + .insert_header("x-codex-primary-reset-at", "1704069000") + .insert_header("x-codex-secondary-reset-at", "1704074400") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_at.as_deref()), - Some("2024-01-01T00:30:00Z") + .and_then(|window| window.resets_at), + Some(1704069000) ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -915,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_at": "2024-01-01T00:00:42Z", + "resets_at": 1704067242, "plan_type": "pro" } })); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ead086cc..e2298174 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -658,9 +658,9 @@ pub struct RateLimitWindow { /// Rolling window duration, in minutes. #[ts(type = "number | null")] pub window_minutes: Option, - /// Timestamp (RFC3339) when the window resets. - #[ts(type = "string | null")] - pub resets_at: Option, + /// Unix timestamp (seconds since epoch) when the window resets. + #[ts(type = "number | null")] + pub resets_at: Option, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index f0c0c973..ea63f189 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -3,6 +3,7 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; use chrono::Local; +use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -34,8 +35,7 @@ impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { let resets_at = window .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 5cb730c9..b8c945ee 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -62,10 +62,10 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } -fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> String { +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { (*captured_at + ChronoDuration::seconds(seconds)) .with_timezone(&Utc) - .to_rfc3339() + .timestamp() } #[test]