feat: support multiple rate limits (#11260)

Added multi-limit support end-to-end by carrying limit_name in
rate-limit snapshots and handling multiple buckets instead of only
codex.
Extended /usage client parsing to consume additional_rate_limits
Updated TUI /status and in-memory state to store/render per-limit
snapshots
Extended app-server rate-limit read response: kept rate_limits and added
rate_limits_by_name.
Adjusted usage-limit error messaging for non-default codex limit buckets
This commit is contained in:
xl-openai
2026-02-10 20:09:31 -08:00
committed by GitHub
parent 641d5268fa
commit fdd0cd1de9
36 changed files with 1435 additions and 169 deletions
+42 -2
View File
@@ -36,6 +36,7 @@ use super::rate_limits::StatusRateLimitData;
use super::rate_limits::StatusRateLimitRow;
use super::rate_limits::StatusRateLimitValue;
use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::compose_rate_limit_data_many;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use crate::wrapping::RtOptions;
@@ -75,6 +76,7 @@ struct StatusHistoryCell {
rate_limits: StatusRateLimitData,
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output(
config: &Config,
@@ -90,6 +92,40 @@ pub(crate) fn new_status_output(
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> CompositeHistoryCell {
let snapshots = rate_limits.map(std::slice::from_ref).unwrap_or_default();
new_status_output_with_rate_limits(
config,
auth_manager,
token_info,
total_usage,
session_id,
thread_name,
forked_from,
snapshots,
plan_type,
now,
model_name,
collaboration_mode,
reasoning_effort_override,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output_with_rate_limits(
config: &Config,
auth_manager: &AuthManager,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: &[RateLimitSnapshotDisplay],
plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(
@@ -121,7 +157,7 @@ impl StatusHistoryCell {
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
rate_limits: &[RateLimitSnapshotDisplay],
plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
@@ -189,7 +225,11 @@ impl StatusHistoryCell {
output: total_usage.output_tokens,
context_window,
};
let rate_limits = compose_rate_limit_data(rate_limits, now);
let rate_limits = if rate_limits.len() <= 1 {
compose_rate_limit_data(rate_limits.first(), now)
} else {
compose_rate_limit_data_many(rate_limits, now)
};
Self {
model_name,
+4
View File
@@ -12,12 +12,16 @@ mod format;
mod helpers;
mod rate_limits;
#[cfg(test)]
pub(crate) use card::new_status_output;
pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact;
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
pub(crate) use rate_limits::RateLimitWindowDisplay;
#[cfg(test)]
pub(crate) use rate_limits::rate_limit_snapshot_display;
pub(crate) use rate_limits::rate_limit_snapshot_display_for_limit;
#[cfg(test)]
mod tests;
+222 -50
View File
@@ -86,6 +86,8 @@ impl RateLimitWindowDisplay {
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
/// Canonical limit identifier (for example: `codex` or `codex_other`).
pub limit_name: String,
/// Local timestamp representing when this display snapshot was captured.
pub captured_at: DateTime<Local>,
/// Primary usage window (typically short duration).
@@ -111,11 +113,21 @@ pub(crate) struct CreditsSnapshotDisplay {
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
/// older or newer `captured_at` can produce misleading reset labels and stale classification.
#[cfg(test)]
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
rate_limit_snapshot_display_for_limit(snapshot, "codex".to_string(), captured_at)
}
pub(crate) fn rate_limit_snapshot_display_for_limit(
snapshot: &RateLimitSnapshot,
limit_name: String,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
limit_name,
captured_at,
primary: snapshot
.primary
@@ -148,60 +160,123 @@ pub(crate) fn compose_rate_limit_data(
now: DateTime<Local>,
) -> StatusRateLimitData {
match snapshot {
Some(snapshot) => {
let mut rows = Vec::with_capacity(3);
if let Some(primary) = snapshot.primary.as_ref() {
let label: String = primary
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
let label = capitalize_first(&label);
rows.push(StatusRateLimitRow {
label: format!("{label} limit"),
value: StatusRateLimitValue::Window {
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
},
});
}
if let Some(secondary) = snapshot.secondary.as_ref() {
let label: String = secondary
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
let label = capitalize_first(&label);
rows.push(StatusRateLimitRow {
label: format!("{label} limit"),
value: StatusRateLimitValue::Window {
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
},
});
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
rows.push(row);
}
let is_stale = now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
if rows.is_empty() {
StatusRateLimitData::Available(vec![])
} else if is_stale {
StatusRateLimitData::Stale(rows)
} else {
StatusRateLimitData::Available(rows)
}
}
Some(snapshot) => compose_rate_limit_data_many(std::slice::from_ref(snapshot), now),
None => StatusRateLimitData::Missing,
}
}
pub(crate) fn compose_rate_limit_data_many(
snapshots: &[RateLimitSnapshotDisplay],
now: DateTime<Local>,
) -> StatusRateLimitData {
if snapshots.is_empty() {
return StatusRateLimitData::Missing;
}
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
let mut stale = false;
for snapshot in snapshots {
stale |= now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
let limit_bucket_label = snapshot.limit_name.clone();
let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
let primary_label = snapshot
.primary
.as_ref()
.map(|window| {
window
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string())
})
.map(|label| capitalize_first(&label));
let secondary_label = snapshot
.secondary
.as_ref()
.map(|window| {
window
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string())
})
.map(|label| capitalize_first(&label));
let window_count =
usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some());
let combine_non_codex_single_limit = show_limit_prefix && window_count == 1;
if show_limit_prefix && !combine_non_codex_single_limit {
rows.push(StatusRateLimitRow {
label: format!("{limit_bucket_label} limit"),
value: StatusRateLimitValue::Text(String::new()),
});
}
if let Some(primary) = snapshot.primary.as_ref() {
let label = if combine_non_codex_single_limit {
format!(
"{} {} limit",
limit_bucket_label,
primary_label.clone().unwrap_or_else(|| "5h".to_string())
)
} else {
format!(
"{} limit",
primary_label.clone().unwrap_or_else(|| "5h".to_string())
)
};
rows.push(StatusRateLimitRow {
label,
value: StatusRateLimitValue::Window {
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
},
});
}
if let Some(secondary) = snapshot.secondary.as_ref() {
let label = if combine_non_codex_single_limit {
format!(
"{} {} limit",
limit_bucket_label,
secondary_label
.clone()
.unwrap_or_else(|| "weekly".to_string())
)
} else {
format!(
"{} limit",
secondary_label
.clone()
.unwrap_or_else(|| "weekly".to_string())
)
};
rows.push(StatusRateLimitRow {
label,
value: StatusRateLimitValue::Window {
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
},
});
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
rows.push(row);
}
}
if rows.is_empty() {
StatusRateLimitData::Available(vec![])
} else if stale {
StatusRateLimitData::Stale(rows)
} else {
StatusRateLimitData::Available(rows)
}
}
/// Renders a fixed-width progress bar from remaining percentage.
///
/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input.
@@ -266,3 +341,100 @@ fn format_credit_balance(raw: &str) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::CreditsSnapshotDisplay;
use super::RateLimitSnapshotDisplay;
use super::RateLimitWindowDisplay;
use super::StatusRateLimitData;
use super::compose_rate_limit_data_many;
use chrono::Local;
use pretty_assertions::assert_eq;
fn window(used_percent: f64) -> RateLimitWindowDisplay {
RateLimitWindowDisplay {
used_percent,
resets_at: Some("soon".to_string()),
window_minutes: Some(300),
}
}
#[test]
fn non_codex_single_limit_renders_combined_row() {
let now = Local::now();
let codex = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
captured_at: now,
primary: Some(window(10.0)),
secondary: None,
credits: Some(CreditsSnapshotDisplay {
has_credits: true,
unlimited: false,
balance: Some("25".to_string()),
}),
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
captured_at: now,
primary: Some(window(20.0)),
secondary: None,
credits: Some(CreditsSnapshotDisplay {
has_credits: true,
unlimited: false,
balance: Some("99".to_string()),
}),
};
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
StatusRateLimitData::Available(rows) => rows,
other => panic!("unexpected status: {other:?}"),
};
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
assert_eq!(
labels,
vec![
"5h limit".to_string(),
"Credits".to_string(),
"codex-other 5h limit".to_string(),
"Credits".to_string(),
]
);
assert_eq!(rows.iter().filter(|row| row.label == "Credits").count(), 2);
}
#[test]
fn non_codex_multi_limit_keeps_group_row() {
let now = Local::now();
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
captured_at: now,
primary: Some(RateLimitWindowDisplay {
used_percent: 20.0,
resets_at: Some("soon".to_string()),
window_minutes: Some(60),
}),
secondary: Some(RateLimitWindowDisplay {
used_percent: 40.0,
resets_at: Some("later".to_string()),
window_minutes: None,
}),
credits: None,
};
let rows = match compose_rate_limit_data_many(&[other], now) {
StatusRateLimitData::Available(rows) => rows,
other => panic!("unexpected status: {other:?}"),
};
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
assert_eq!(
labels,
vec![
"codex-other limit".to_string(),
"1h limit".to_string(),
"Weekly limit".to_string(),
]
);
}
}
+22
View File
@@ -122,6 +122,8 @@ async fn status_snapshot_includes_reasoning_details() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
@@ -242,6 +244,8 @@ async fn status_snapshot_includes_monthly_limit() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(43_200),
@@ -291,6 +295,8 @@ async fn status_snapshot_shows_unlimited_credits() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -338,6 +344,8 @@ async fn status_snapshot_shows_positive_credits() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -385,6 +393,8 @@ async fn status_snapshot_hides_zero_credits() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -430,6 +440,8 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -533,6 +545,8 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
@@ -642,6 +656,8 @@ async fn status_snapshot_includes_credits_and_limits() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(300),
@@ -705,6 +721,8 @@ async fn status_snapshot_shows_empty_limits_message() {
};
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: None,
@@ -764,6 +782,8 @@ async fn status_snapshot_shows_stale_limits_message() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
@@ -828,6 +848,8 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 60.0,
window_minutes: Some(300),