fix: switch rate limit reset handling to timestamps (#5304)

This change ensures that we store the absolute time instead of relative
offsets of when the primary and secondary rate limits will reset.
Previously these got recalculated relative to current time, which leads
to the displayed reset times to change over time, including after doing
a codex resume.

For previously changed sessions, this will cause the reset times to not
show due to this being a breaking change:
<img width="524" height="55" alt="Screenshot 2025-10-17 at 5 14 18 PM"
src="https://github.com/user-attachments/assets/53ebd43e-da25-4fef-9c47-94a529d40265"
/>

Fixes https://github.com/openai/codex/issues/4761
This commit is contained in:
Thibault Sottiaux
2025-10-17 17:39:37 -07:00
committed by GitHub
parent 41900e9d0f
commit 0e08dd6055
7 changed files with 203 additions and 127 deletions

View File

@@ -7,3 +7,7 @@ disallowed-methods = [
{ path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." }, { path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." },
{ path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." }, { path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." },
] ]
# Increase the size threshold for result_large_err to accommodate
# richer error variants.
large-error-threshold = 256

View File

@@ -52,6 +52,8 @@ use crate::protocol::TokenUsage;
use crate::state::TaskKind; use crate::state::TaskKind;
use crate::token_data::PlanType; use crate::token_data::PlanType;
use crate::util::backoff; use crate::util::backoff;
use chrono::DateTime;
use chrono::Utc;
use codex_otel::otel_event_manager::OtelEventManager; use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -71,7 +73,7 @@ struct Error {
// Optional fields available on "usage_limit_reached" and "usage_not_included" errors // Optional fields available on "usage_limit_reached" and "usage_not_included" errors
plan_type: Option<PlanType>, plan_type: Option<PlanType>,
resets_in_seconds: Option<u64>, resets_at: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -419,10 +421,14 @@ impl ModelClient {
let plan_type = error let plan_type = error
.plan_type .plan_type
.or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type));
let resets_in_seconds = error.resets_in_seconds; let resets_at = error
.resets_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.with_timezone(&Utc));
let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type, plan_type,
resets_in_seconds, resets_at,
rate_limits: rate_limit_snapshot, rate_limits: rate_limit_snapshot,
}); });
return Err(StreamAttemptError::Fatal(codex_err)); return Err(StreamAttemptError::Fatal(codex_err));
@@ -605,14 +611,14 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
headers, headers,
"x-codex-primary-used-percent", "x-codex-primary-used-percent",
"x-codex-primary-window-minutes", "x-codex-primary-window-minutes",
"x-codex-primary-reset-after-seconds", "x-codex-primary-reset-at",
); );
let secondary = parse_rate_limit_window( let secondary = parse_rate_limit_window(
headers, headers,
"x-codex-secondary-used-percent", "x-codex-secondary-used-percent",
"x-codex-secondary-window-minutes", "x-codex-secondary-window-minutes",
"x-codex-secondary-reset-after-seconds", "x-codex-secondary-reset-at",
); );
Some(RateLimitSnapshot { primary, secondary }) Some(RateLimitSnapshot { primary, secondary })
@@ -628,16 +634,19 @@ fn parse_rate_limit_window(
used_percent.and_then(|used_percent| { used_percent.and_then(|used_percent| {
let window_minutes = parse_header_u64(headers, window_minutes_header); let window_minutes = parse_header_u64(headers, window_minutes_header);
let resets_in_seconds = parse_header_u64(headers, resets_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 has_data = used_percent != 0.0 let has_data = used_percent != 0.0
|| window_minutes.is_some_and(|minutes| minutes != 0) || window_minutes.is_some_and(|minutes| minutes != 0)
|| resets_in_seconds.is_some_and(|seconds| seconds != 0); || resets_at.is_some();
has_data.then_some(RateLimitWindow { has_data.then_some(RateLimitWindow {
used_percent, used_percent,
window_minutes, window_minutes,
resets_in_seconds, resets_at,
}) })
}) })
} }
@@ -1390,7 +1399,7 @@ mod tests {
message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
code: Some("rate_limit_exceeded".to_string()), code: Some("rate_limit_exceeded".to_string()),
plan_type: None, plan_type: None,
resets_in_seconds: None resets_at: None
}; };
let delay = try_parse_retry_after(&err); let delay = try_parse_retry_after(&err);
@@ -1404,20 +1413,19 @@ mod tests {
message: Some("Rate limit reached for gpt-5 in organization <ORG> on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), message: Some("Rate limit reached for gpt-5 in organization <ORG> on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
code: Some("rate_limit_exceeded".to_string()), code: Some("rate_limit_exceeded".to_string()),
plan_type: None, plan_type: None,
resets_in_seconds: None resets_at: None
}; };
let delay = try_parse_retry_after(&err); let delay = try_parse_retry_after(&err);
assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
} }
#[test] #[test]
fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() { fn error_response_deserializes_schema_known_plan_type_and_serializes_back() {
use crate::token_data::KnownPlan; use crate::token_data::KnownPlan;
use crate::token_data::PlanType; use crate::token_data::PlanType;
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#; let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#;
let resp: ErrorResponse = let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
serde_json::from_str(json).expect("should deserialize old schema");
assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro)));
@@ -1426,13 +1434,11 @@ mod tests {
} }
#[test] #[test]
fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() { fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() {
use crate::token_data::PlanType; use crate::token_data::PlanType;
let json = let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#;
r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#; let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
let resp: ErrorResponse =
serde_json::from_str(json).expect("should deserialize old schema");
assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip");

View File

@@ -2,6 +2,8 @@ use crate::exec::ExecToolCallOutput;
use crate::token_data::KnownPlan; use crate::token_data::KnownPlan;
use crate::token_data::PlanType; use crate::token_data::PlanType;
use crate::truncate::truncate_middle; use crate::truncate::truncate_middle;
use chrono::DateTime;
use chrono::Utc;
use codex_async_utils::CancelErr; use codex_async_utils::CancelErr;
use codex_protocol::ConversationId; use codex_protocol::ConversationId;
use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitSnapshot;
@@ -237,7 +239,7 @@ impl std::fmt::Display for RetryLimitReachedError {
#[derive(Debug)] #[derive(Debug)]
pub struct UsageLimitReachedError { pub struct UsageLimitReachedError {
pub(crate) plan_type: Option<PlanType>, pub(crate) plan_type: Option<PlanType>,
pub(crate) resets_in_seconds: Option<u64>, pub(crate) resets_at: Option<DateTime<Utc>>,
pub(crate) rate_limits: Option<RateLimitSnapshot>, pub(crate) rate_limits: Option<RateLimitSnapshot>,
} }
@@ -246,12 +248,12 @@ impl std::fmt::Display for UsageLimitReachedError {
let message = match self.plan_type.as_ref() { let message = match self.plan_type.as_ref() {
Some(PlanType::Known(KnownPlan::Plus)) => format!( Some(PlanType::Known(KnownPlan::Plus)) => format!(
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}",
retry_suffix_after_or(self.resets_in_seconds) retry_suffix_after_or(self.resets_at.as_ref())
), ),
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
format!( format!(
"You've hit your usage limit. To get more access now, send a request to your admin{}", "You've hit your usage limit. To get more access now, send a request to your admin{}",
retry_suffix_after_or(self.resets_in_seconds) retry_suffix_after_or(self.resets_at.as_ref())
) )
} }
Some(PlanType::Known(KnownPlan::Free)) => { Some(PlanType::Known(KnownPlan::Free)) => {
@@ -262,11 +264,11 @@ impl std::fmt::Display for UsageLimitReachedError {
| Some(PlanType::Known(KnownPlan::Enterprise)) | Some(PlanType::Known(KnownPlan::Enterprise))
| Some(PlanType::Known(KnownPlan::Edu)) => format!( | Some(PlanType::Known(KnownPlan::Edu)) => format!(
"You've hit your usage limit.{}", "You've hit your usage limit.{}",
retry_suffix(self.resets_in_seconds) retry_suffix(self.resets_at.as_ref())
), ),
Some(PlanType::Unknown(_)) | None => format!( Some(PlanType::Unknown(_)) | None => format!(
"You've hit your usage limit.{}", "You've hit your usage limit.{}",
retry_suffix(self.resets_in_seconds) retry_suffix(self.resets_at.as_ref())
), ),
}; };
@@ -274,8 +276,8 @@ impl std::fmt::Display for UsageLimitReachedError {
} }
} }
fn retry_suffix(resets_in_seconds: Option<u64>) -> String { fn retry_suffix(resets_at: Option<&DateTime<Utc>>) -> String {
if let Some(secs) = resets_in_seconds { if let Some(secs) = remaining_seconds(resets_at) {
let reset_duration = format_reset_duration(secs); let reset_duration = format_reset_duration(secs);
format!(" Try again in {reset_duration}.") format!(" Try again in {reset_duration}.")
} else { } else {
@@ -283,8 +285,8 @@ fn retry_suffix(resets_in_seconds: Option<u64>) -> String {
} }
} }
fn retry_suffix_after_or(resets_in_seconds: Option<u64>) -> String { fn retry_suffix_after_or(resets_at: Option<&DateTime<Utc>>) -> String {
if let Some(secs) = resets_in_seconds { if let Some(secs) = remaining_seconds(resets_at) {
let reset_duration = format_reset_duration(secs); let reset_duration = format_reset_duration(secs);
format!(" or try again in {reset_duration}.") format!(" or try again in {reset_duration}.")
} else { } else {
@@ -292,6 +294,29 @@ fn retry_suffix_after_or(resets_in_seconds: Option<u64>) -> String {
} }
} }
fn remaining_seconds(resets_at: Option<&DateTime<Utc>>) -> Option<u64> {
let resets_at = resets_at.cloned()?;
let now = now_for_retry();
let secs = resets_at.signed_duration_since(now).num_seconds();
Some(if secs <= 0 { 0 } else { secs as u64 })
}
#[cfg(test)]
thread_local! {
static NOW_OVERRIDE: std::cell::RefCell<Option<DateTime<Utc>>> =
const { std::cell::RefCell::new(None) };
}
fn now_for_retry() -> DateTime<Utc> {
#[cfg(test)]
{
if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) {
return now;
}
}
Utc::now()
}
fn format_reset_duration(total_secs: u64) -> String { fn format_reset_duration(total_secs: u64) -> String {
let days = total_secs / 86_400; let days = total_secs / 86_400;
let hours = (total_secs % 86_400) / 3_600; let hours = (total_secs % 86_400) / 3_600;
@@ -388,6 +413,10 @@ pub fn get_error_message_ui(e: &CodexErr) -> String {
mod tests { mod tests {
use super::*; use super::*;
use crate::exec::StreamOutput; use crate::exec::StreamOutput;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RateLimitWindow;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -396,21 +425,30 @@ mod tests {
primary: Some(RateLimitWindow { primary: Some(RateLimitWindow {
used_percent: 50.0, used_percent: 50.0,
window_minutes: Some(60), window_minutes: Some(60),
resets_in_seconds: Some(3600), resets_at: Some("2024-01-01T01:00:00Z".to_string()),
}), }),
secondary: Some(RateLimitWindow { secondary: Some(RateLimitWindow {
used_percent: 30.0, used_percent: 30.0,
window_minutes: Some(120), window_minutes: Some(120),
resets_in_seconds: Some(7200), resets_at: Some("2024-01-01T02:00:00Z".to_string()),
}), }),
} }
} }
fn with_now_override<T>(now: DateTime<Utc>, f: impl FnOnce() -> T) -> T {
NOW_OVERRIDE.with(|cell| {
*cell.borrow_mut() = Some(now);
let result = f();
*cell.borrow_mut() = None;
result
})
}
#[test] #[test]
fn usage_limit_reached_error_formats_plus_plan() { fn usage_limit_reached_error_formats_plus_plan() {
let err = UsageLimitReachedError { let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Plus)), plan_type: Some(PlanType::Known(KnownPlan::Plus)),
resets_in_seconds: None, resets_at: None,
rate_limits: Some(rate_limit_snapshot()), rate_limits: Some(rate_limit_snapshot()),
}; };
assert_eq!( assert_eq!(
@@ -490,7 +528,7 @@ mod tests {
fn usage_limit_reached_error_formats_free_plan() { fn usage_limit_reached_error_formats_free_plan() {
let err = UsageLimitReachedError { let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Free)), plan_type: Some(PlanType::Known(KnownPlan::Free)),
resets_in_seconds: Some(3600), resets_at: None,
rate_limits: Some(rate_limit_snapshot()), rate_limits: Some(rate_limit_snapshot()),
}; };
assert_eq!( assert_eq!(
@@ -503,7 +541,7 @@ mod tests {
fn usage_limit_reached_error_formats_default_when_none() { fn usage_limit_reached_error_formats_default_when_none() {
let err = UsageLimitReachedError { let err = UsageLimitReachedError {
plan_type: None, plan_type: None,
resets_in_seconds: None, resets_at: None,
rate_limits: Some(rate_limit_snapshot()), rate_limits: Some(rate_limit_snapshot()),
}; };
assert_eq!( assert_eq!(
@@ -514,22 +552,26 @@ mod tests {
#[test] #[test]
fn usage_limit_reached_error_formats_team_plan() { fn usage_limit_reached_error_formats_team_plan() {
let err = UsageLimitReachedError { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
plan_type: Some(PlanType::Known(KnownPlan::Team)), let resets_at = base + ChronoDuration::hours(1);
resets_in_seconds: Some(3600), with_now_override(base, move || {
rate_limits: Some(rate_limit_snapshot()), let err = UsageLimitReachedError {
}; plan_type: Some(PlanType::Known(KnownPlan::Team)),
assert_eq!( resets_at: Some(resets_at),
err.to_string(), rate_limits: Some(rate_limit_snapshot()),
"You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." };
); assert_eq!(
err.to_string(),
"You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour."
);
});
} }
#[test] #[test]
fn usage_limit_reached_error_formats_business_plan_without_reset() { fn usage_limit_reached_error_formats_business_plan_without_reset() {
let err = UsageLimitReachedError { let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Business)), plan_type: Some(PlanType::Known(KnownPlan::Business)),
resets_in_seconds: None, resets_at: None,
rate_limits: Some(rate_limit_snapshot()), rate_limits: Some(rate_limit_snapshot()),
}; };
assert_eq!( assert_eq!(
@@ -542,7 +584,7 @@ mod tests {
fn usage_limit_reached_error_formats_default_for_other_plans() { fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError { let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Pro)), plan_type: Some(PlanType::Known(KnownPlan::Pro)),
resets_in_seconds: None, resets_at: None,
rate_limits: Some(rate_limit_snapshot()), rate_limits: Some(rate_limit_snapshot()),
}; };
assert_eq!( assert_eq!(
@@ -553,53 +595,70 @@ mod tests {
#[test] #[test]
fn usage_limit_reached_includes_minutes_when_available() { fn usage_limit_reached_includes_minutes_when_available() {
let err = UsageLimitReachedError { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
plan_type: None, let resets_at = base + ChronoDuration::minutes(5);
resets_in_seconds: Some(5 * 60), with_now_override(base, move || {
rate_limits: Some(rate_limit_snapshot()), let err = UsageLimitReachedError {
}; plan_type: None,
assert_eq!( resets_at: Some(resets_at),
err.to_string(), rate_limits: Some(rate_limit_snapshot()),
"You've hit your usage limit. Try again in 5 minutes." };
); assert_eq!(
err.to_string(),
"You've hit your usage limit. Try again in 5 minutes."
);
});
} }
#[test] #[test]
fn usage_limit_reached_includes_hours_and_minutes() { fn usage_limit_reached_includes_hours_and_minutes() {
let err = UsageLimitReachedError { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
plan_type: Some(PlanType::Known(KnownPlan::Plus)), let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32);
resets_in_seconds: Some(3 * 3600 + 32 * 60), with_now_override(base, move || {
rate_limits: Some(rate_limit_snapshot()), let err = UsageLimitReachedError {
}; plan_type: Some(PlanType::Known(KnownPlan::Plus)),
assert_eq!( resets_at: Some(resets_at),
err.to_string(), rate_limits: Some(rate_limit_snapshot()),
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." };
); assert_eq!(
err.to_string(),
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes."
);
});
} }
#[test] #[test]
fn usage_limit_reached_includes_days_hours_minutes() { fn usage_limit_reached_includes_days_hours_minutes() {
let err = UsageLimitReachedError { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
plan_type: None, let resets_at =
resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60), base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5);
rate_limits: Some(rate_limit_snapshot()), with_now_override(base, move || {
}; let err = UsageLimitReachedError {
assert_eq!( plan_type: None,
err.to_string(), resets_at: Some(resets_at),
"You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." rate_limits: Some(rate_limit_snapshot()),
); };
assert_eq!(
err.to_string(),
"You've hit your usage limit. Try again in 2 days 3 hours 5 minutes."
);
});
} }
#[test] #[test]
fn usage_limit_reached_less_than_minute() { fn usage_limit_reached_less_than_minute() {
let err = UsageLimitReachedError { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
plan_type: None, let resets_at = base + ChronoDuration::seconds(30);
resets_in_seconds: Some(30), with_now_override(base, move || {
rate_limits: Some(rate_limit_snapshot()), let err = UsageLimitReachedError {
}; plan_type: None,
assert_eq!( resets_at: Some(resets_at),
err.to_string(), rate_limits: Some(rate_limit_snapshot()),
"You've hit your usage limit. Try again in less than a minute." };
); assert_eq!(
err.to_string(),
"You've hit your usage limit. Try again in less than a minute."
);
});
} }
} }

View File

@@ -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-secondary-used-percent", "40.0")
.insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-primary-window-minutes", "10")
.insert_header("x-codex-secondary-window-minutes", "60") .insert_header("x-codex-secondary-window-minutes", "60")
.insert_header("x-codex-primary-reset-after-seconds", "1800") .insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z")
.insert_header("x-codex-secondary-reset-after-seconds", "7200") .insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z")
.set_body_raw(sse_body, "text/event-stream"); .set_body_raw(sse_body, "text/event-stream");
Mock::given(method("POST")) Mock::given(method("POST"))
@@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() {
"primary": { "primary": {
"used_percent": 12.5, "used_percent": 12.5,
"window_minutes": 10, "window_minutes": 10,
"resets_in_seconds": 1800 "resets_at": "2024-01-01T00:30:00Z"
}, },
"secondary": { "secondary": {
"used_percent": 40.0, "used_percent": 40.0,
"window_minutes": 60, "window_minutes": 60,
"resets_in_seconds": 7200 "resets_at": "2024-01-01T02:00:00Z"
} }
} }
}) })
@@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() {
"primary": { "primary": {
"used_percent": 12.5, "used_percent": 12.5,
"window_minutes": 10, "window_minutes": 10,
"resets_in_seconds": 1800 "resets_at": "2024-01-01T00:30:00Z"
}, },
"secondary": { "secondary": {
"used_percent": 40.0, "used_percent": 40.0,
"window_minutes": 60, "window_minutes": 60,
"resets_in_seconds": 7200 "resets_at": "2024-01-01T02:00:00Z"
} }
} }
}) })
@@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() {
final_snapshot final_snapshot
.primary .primary
.as_ref() .as_ref()
.and_then(|window| window.resets_in_seconds), .and_then(|window| window.resets_at.as_deref()),
Some(1800) Some("2024-01-01T00:30:00Z")
); );
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; 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": { "error": {
"type": "usage_limit_reached", "type": "usage_limit_reached",
"message": "limit reached", "message": "limit reached",
"resets_in_seconds": 42, "resets_at": "2024-01-01T00:00:42Z",
"plan_type": "pro" "plan_type": "pro"
} }
})); }));
@@ -935,12 +935,12 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
"primary": { "primary": {
"used_percent": 100.0, "used_percent": 100.0,
"window_minutes": 15, "window_minutes": 15,
"resets_in_seconds": null "resets_at": null
}, },
"secondary": { "secondary": {
"used_percent": 87.5, "used_percent": 87.5,
"window_minutes": 60, "window_minutes": 60,
"resets_in_seconds": null "resets_at": null
} }
}); });

View File

@@ -644,9 +644,9 @@ pub struct RateLimitWindow {
/// Rolling window duration, in minutes. /// Rolling window duration, in minutes.
#[ts(type = "number | null")] #[ts(type = "number | null")]
pub window_minutes: Option<u64>, pub window_minutes: Option<u64>,
/// Seconds until the window resets. /// Timestamp (RFC3339) when the window resets.
#[ts(type = "number | null")] #[ts(type = "string | null")]
pub resets_in_seconds: Option<u64>, pub resets_at: Option<String>,
} }
// Includes prompts, tools and space to call compact. // Includes prompts, tools and space to call compact.

View File

@@ -2,11 +2,9 @@ use crate::chatwidget::get_limits_duration;
use super::helpers::format_reset_timestamp; use super::helpers::format_reset_timestamp;
use chrono::DateTime; use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local; use chrono::Local;
use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow; use codex_core::protocol::RateLimitWindow;
use std::convert::TryFrom;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = ""; const STATUS_LIMIT_BAR_FILLED: &str = "";
@@ -35,9 +33,10 @@ pub(crate) struct RateLimitWindowDisplay {
impl RateLimitWindowDisplay { impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self { fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window let resets_at = window
.resets_in_seconds .resets_at
.and_then(|seconds| i64::try_from(seconds).ok()) .as_deref()
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.with_timezone(&Local))
.map(|dt| format_reset_timestamp(dt, captured_at)); .map(|dt| format_reset_timestamp(dt, captured_at));
Self { Self {

View File

@@ -1,7 +1,9 @@
use super::new_status_output; use super::new_status_output;
use super::rate_limit_snapshot_display; use super::rate_limit_snapshot_display;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use chrono::Duration as ChronoDuration;
use chrono::TimeZone; use chrono::TimeZone;
use chrono::Utc;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::config::ConfigOverrides; use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml; use codex_core::config::ConfigToml;
@@ -60,6 +62,12 @@ fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
.collect() .collect()
} }
fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, seconds: i64) -> String {
(*captured_at + ChronoDuration::seconds(seconds))
.with_timezone(&Utc)
.to_rfc3339()
}
#[test] #[test]
fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_reasoning_details() {
let temp_home = TempDir::new().expect("temp home"); let temp_home = TempDir::new().expect("temp home");
@@ -85,22 +93,22 @@ fn status_snapshot_includes_reasoning_details() {
total_tokens: 2_250, total_tokens: 2_250,
}; };
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_in_seconds: Some(600),
}),
secondary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(10080),
resets_in_seconds: Some(1_200),
}),
};
let captured_at = chrono::Local let captured_at = chrono::Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single() .single()
.expect("timestamp"); .expect("timestamp");
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_at: Some(reset_at_from(&captured_at, 600)),
}),
secondary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(10080),
resets_at: Some(reset_at_from(&captured_at, 1_200)),
}),
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
@@ -130,18 +138,18 @@ fn status_snapshot_includes_monthly_limit() {
total_tokens: 1_200, total_tokens: 1_200,
}; };
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(43_200),
resets_in_seconds: Some(86_400),
}),
secondary: None,
};
let captured_at = chrono::Local let captured_at = chrono::Local
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9) .with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
.single() .single()
.expect("timestamp"); .expect("timestamp");
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(43_200),
resets_at: Some(reset_at_from(&captured_at, 86_400)),
}),
secondary: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
@@ -197,18 +205,18 @@ fn status_snapshot_truncates_in_narrow_terminal() {
total_tokens: 2_250, total_tokens: 2_250,
}; };
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_in_seconds: Some(600),
}),
secondary: None,
};
let captured_at = chrono::Local let captured_at = chrono::Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single() .single()
.expect("timestamp"); .expect("timestamp");
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_at: Some(reset_at_from(&captured_at, 600)),
}),
secondary: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));