mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user