mirror of
https://github.com/openai/codex.git
synced 2026-05-29 15:30:22 +00:00
Harden CLI rate limit window labels
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
//! - Permissions profile
|
||||
//! - Approval mode
|
||||
//! - Context usage (remaining %, used %, window size)
|
||||
//! - Usage limits (5-hour, weekly)
|
||||
//! - Usage limits (primary, secondary)
|
||||
//! - Session info (thread title, thread ID, tokens used)
|
||||
//! - Application version
|
||||
|
||||
@@ -100,10 +100,10 @@ pub(crate) enum StatusLineItem {
|
||||
#[strum(to_string = "context-used", serialize = "context-usage")]
|
||||
ContextUsed,
|
||||
|
||||
/// Remaining usage on the 5-hour rate limit.
|
||||
/// Remaining usage on the primary rate limit.
|
||||
FiveHourLimit,
|
||||
|
||||
/// Remaining usage on the weekly rate limit.
|
||||
/// Remaining usage on the secondary rate limit.
|
||||
WeeklyLimit,
|
||||
|
||||
/// Codex application version.
|
||||
@@ -163,10 +163,10 @@ impl StatusLineItem {
|
||||
"Percentage of context window used (omitted when unknown)"
|
||||
}
|
||||
StatusLineItem::FiveHourLimit => {
|
||||
"Remaining usage on 5-hour usage limit (omitted when unavailable)"
|
||||
"Remaining usage on the primary usage limit (omitted when unavailable)"
|
||||
}
|
||||
StatusLineItem::WeeklyLimit => {
|
||||
"Remaining usage on weekly usage limit (omitted when unavailable)"
|
||||
"Remaining usage on the secondary usage limit (omitted when unavailable)"
|
||||
}
|
||||
StatusLineItem::CodexVersion => "Codex application version",
|
||||
StatusLineItem::ContextWindowSize => {
|
||||
|
||||
@@ -51,8 +51,8 @@ impl StatusSurfacePreviewItem {
|
||||
StatusSurfacePreviewItem::ApprovalMode => "on-request",
|
||||
StatusSurfacePreviewItem::ContextRemaining => "Context 0% left",
|
||||
StatusSurfacePreviewItem::ContextUsed => "Context 0% used",
|
||||
StatusSurfacePreviewItem::FiveHourLimit => "5h 0%",
|
||||
StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%",
|
||||
StatusSurfacePreviewItem::FiveHourLimit => "primary 0%",
|
||||
StatusSurfacePreviewItem::WeeklyLimit => "secondary 0%",
|
||||
StatusSurfacePreviewItem::CodexVersion => "0.0.0",
|
||||
StatusSurfacePreviewItem::ContextWindowSize => "0 window",
|
||||
StatusSurfacePreviewItem::UsedTokens => "0 used",
|
||||
|
||||
@@ -60,9 +60,9 @@ pub(crate) enum TerminalTitleItem {
|
||||
/// Percentage of context window used.
|
||||
#[strum(to_string = "context-used", serialize = "context-usage")]
|
||||
ContextUsed,
|
||||
/// Remaining usage on the 5-hour rate limit.
|
||||
/// Remaining usage on the primary rate limit.
|
||||
FiveHourLimit,
|
||||
/// Remaining usage on the weekly rate limit.
|
||||
/// Remaining usage on the secondary rate limit.
|
||||
WeeklyLimit,
|
||||
/// Codex application version.
|
||||
CodexVersion,
|
||||
@@ -107,10 +107,10 @@ impl TerminalTitleItem {
|
||||
"Percentage of context window used (omitted when unknown)"
|
||||
}
|
||||
TerminalTitleItem::FiveHourLimit => {
|
||||
"Remaining usage on 5-hour usage limit (omitted when unavailable)"
|
||||
"Remaining usage on the primary usage limit (omitted when unavailable)"
|
||||
}
|
||||
TerminalTitleItem::WeeklyLimit => {
|
||||
"Remaining usage on weekly usage limit (omitted when unavailable)"
|
||||
"Remaining usage on the secondary usage limit (omitted when unavailable)"
|
||||
}
|
||||
TerminalTitleItem::CodexVersion => "Codex application version",
|
||||
TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)",
|
||||
|
||||
@@ -375,6 +375,7 @@ use self::rate_limits::RateLimitErrorKind;
|
||||
use self::rate_limits::RateLimitSwitchPromptState;
|
||||
use self::rate_limits::RateLimitWarningState;
|
||||
use self::rate_limits::app_server_rate_limit_error_kind;
|
||||
pub(crate) use self::rate_limits::fallback_limit_label;
|
||||
pub(crate) use self::rate_limits::get_limits_duration;
|
||||
use self::rate_limits::is_app_server_cyber_policy_error;
|
||||
mod realtime;
|
||||
|
||||
@@ -7,6 +7,8 @@ pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage";
|
||||
const SECONDARY_LIMIT_FALLBACK_LABEL: &str = "secondary usage";
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RateLimitWarningState {
|
||||
@@ -42,7 +44,7 @@ impl RateLimitWarningState {
|
||||
if let Some(threshold) = highest_secondary {
|
||||
let limit_label = secondary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
.unwrap_or_else(|| SECONDARY_LIMIT_FALLBACK_LABEL.to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
@@ -61,7 +63,7 @@ impl RateLimitWarningState {
|
||||
if let Some(threshold) = highest_primary {
|
||||
let limit_label = primary_window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
.unwrap_or_else(|| PRIMARY_LIMIT_FALLBACK_LABEL.to_string());
|
||||
let remaining_percent = 100.0 - threshold;
|
||||
warnings.push(format!(
|
||||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||||
@@ -82,16 +84,66 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
||||
|
||||
let windows_minutes = windows_minutes.max(0);
|
||||
|
||||
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
if windows_minutes == 0 {
|
||||
PRIMARY_LIMIT_FALLBACK_LABEL.to_string()
|
||||
} else if let Some(months) =
|
||||
approximate_window_count(windows_minutes, MINUTES_PER_MONTH, MINUTES_PER_DAY)
|
||||
{
|
||||
if months == 1 {
|
||||
"monthly".to_string()
|
||||
} else {
|
||||
format!("{months}-month")
|
||||
}
|
||||
} else if let Some(weeks) =
|
||||
approximate_window_count(windows_minutes, MINUTES_PER_WEEK, ROUNDING_BIAS_MINUTES)
|
||||
{
|
||||
if weeks == 1 {
|
||||
"weekly".to_string()
|
||||
} else {
|
||||
format!("{weeks}-week")
|
||||
}
|
||||
} else if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
|
||||
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
|
||||
format!("{hours}h")
|
||||
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"weekly".to_string()
|
||||
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||||
"monthly".to_string()
|
||||
} else if let Some(days) =
|
||||
approximate_window_count(windows_minutes, MINUTES_PER_DAY, ROUNDING_BIAS_MINUTES)
|
||||
{
|
||||
format!("{days}-day")
|
||||
} else if windows_minutes < MINUTES_PER_WEEK {
|
||||
let days = windows_minutes.saturating_add(MINUTES_PER_DAY - 1) / MINUTES_PER_DAY;
|
||||
format!("{days}-day")
|
||||
} else if windows_minutes < MINUTES_PER_MONTH {
|
||||
let weeks = windows_minutes.saturating_add(MINUTES_PER_WEEK - 1) / MINUTES_PER_WEEK;
|
||||
format!("{weeks}-week")
|
||||
} else {
|
||||
"annual".to_string()
|
||||
let months = windows_minutes.saturating_add(MINUTES_PER_MONTH - 1) / MINUTES_PER_MONTH;
|
||||
format!("{months}-month")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str {
|
||||
if is_secondary {
|
||||
SECONDARY_LIMIT_FALLBACK_LABEL
|
||||
} else {
|
||||
PRIMARY_LIMIT_FALLBACK_LABEL
|
||||
}
|
||||
}
|
||||
|
||||
fn approximate_window_count(
|
||||
minutes: i64,
|
||||
unit_minutes: i64,
|
||||
tolerance_minutes: i64,
|
||||
) -> Option<i64> {
|
||||
if minutes <= 0 || unit_minutes <= 0 {
|
||||
return None;
|
||||
}
|
||||
let count = std::cmp::max(1, (minutes + unit_minutes / 2) / unit_minutes);
|
||||
let target_minutes = count.saturating_mul(unit_minutes);
|
||||
if (minutes - target_minutes).abs() <= tolerance_minutes {
|
||||
Some(count)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::branch_summary;
|
||||
use crate::chatwidget::fallback_limit_label;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::status::format_tokens_compact;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -610,7 +611,7 @@ impl ChatWidget {
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
.unwrap_or_else(|| fallback_limit_label(false).to_string());
|
||||
self.status_line_limit_display(window, &label)
|
||||
}
|
||||
StatusLineItem::WeeklyLimit => {
|
||||
@@ -621,7 +622,7 @@ impl ChatWidget {
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
.unwrap_or_else(|| fallback_limit_label(true).to_string());
|
||||
self.status_line_limit_display(window, &label)
|
||||
}
|
||||
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::goal_status_indicator_line;
|
||||
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
|
||||
use crate::chatwidget::rate_limits::get_limits_duration;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::backend::TestBackend;
|
||||
use serial_test::serial;
|
||||
@@ -517,6 +518,36 @@ async fn test_rate_limit_warnings_monthly() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_duration_labels_preserve_non_standard_windows() {
|
||||
assert_eq!(get_limits_duration(2 * 60), "2h");
|
||||
assert_eq!(get_limits_duration(2 * 24 * 60), "2-day");
|
||||
assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "2-week");
|
||||
assert_eq!(get_limits_duration(30 * 24 * 60), "monthly");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit_warnings_use_generic_fallback_labels() {
|
||||
let mut state = RateLimitWarningState::default();
|
||||
|
||||
assert_eq!(
|
||||
state.take_warnings(
|
||||
/*secondary_used_percent*/ Some(75.0),
|
||||
/*secondary_window_minutes*/ None,
|
||||
/*primary_used_percent*/ Some(75.0),
|
||||
/*primary_window_minutes*/ None,
|
||||
),
|
||||
vec![
|
||||
String::from(
|
||||
"Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.",
|
||||
),
|
||||
String::from(
|
||||
"Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! The key contract is that time-sensitive values are interpreted relative to a caller-provided
|
||||
//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle.
|
||||
use crate::chatwidget::fallback_limit_label;
|
||||
use crate::chatwidget::get_limits_duration;
|
||||
use crate::text_formatting::capitalize_first;
|
||||
|
||||
@@ -23,7 +24,7 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusRateLimitRow {
|
||||
/// Human-readable row label, such as `"5h limit"` or `"Credits"`.
|
||||
/// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`.
|
||||
pub label: String,
|
||||
/// Value payload for the row.
|
||||
pub value: StatusRateLimitValue,
|
||||
@@ -92,9 +93,9 @@ pub(crate) struct RateLimitSnapshotDisplay {
|
||||
pub limit_name: String,
|
||||
/// Local timestamp representing when this display snapshot was captured.
|
||||
pub captured_at: DateTime<Local>,
|
||||
/// Primary usage window (typically short duration).
|
||||
/// Primary usage window.
|
||||
pub primary: Option<RateLimitWindowDisplay>,
|
||||
/// Secondary usage window (typically weekly).
|
||||
/// Secondary usage window.
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
/// Optional credits metadata when available.
|
||||
pub credits: Option<CreditsSnapshotDisplay>,
|
||||
@@ -191,7 +192,7 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
window
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string())
|
||||
.unwrap_or_else(|| fallback_limit_label(false).to_string())
|
||||
})
|
||||
.map(|label| capitalize_first(&label));
|
||||
let secondary_label = snapshot
|
||||
@@ -201,7 +202,7 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
window
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
.unwrap_or_else(|| fallback_limit_label(true).to_string())
|
||||
})
|
||||
.map(|label| capitalize_first(&label));
|
||||
let window_count =
|
||||
@@ -220,12 +221,16 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
format!(
|
||||
"{} {} limit",
|
||||
limit_bucket_label,
|
||||
primary_label.clone().unwrap_or_else(|| "5h".to_string())
|
||||
primary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| capitalize_first(fallback_limit_label(false)))
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} limit",
|
||||
primary_label.clone().unwrap_or_else(|| "5h".to_string())
|
||||
primary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| capitalize_first(fallback_limit_label(false)))
|
||||
)
|
||||
};
|
||||
rows.push(StatusRateLimitRow {
|
||||
@@ -244,14 +249,14 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
limit_bucket_label,
|
||||
secondary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
.unwrap_or_else(|| capitalize_first(fallback_limit_label(true)))
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} limit",
|
||||
secondary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
.unwrap_or_else(|| capitalize_first(fallback_limit_label(true)))
|
||||
)
|
||||
};
|
||||
rows.push(StatusRateLimitRow {
|
||||
@@ -435,7 +440,7 @@ mod tests {
|
||||
vec![
|
||||
"codex-other limit".to_string(),
|
||||
"1h limit".to_string(),
|
||||
"Weekly limit".to_string(),
|
||||
"Secondary usage limit".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user