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:
@@ -25,6 +25,7 @@
|
||||
//! the final answer. During streaming we hide the status row to avoid duplicate
|
||||
//! progress indicators; once commentary completes and stream queues drain, we
|
||||
//! re-show it so users still see turn-in-progress state between output bursts.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
@@ -41,6 +42,7 @@ use crate::bottom_pane::StatusLineSetupView;
|
||||
use crate::status::RateLimitWindowDisplay;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::status::rate_limit_snapshot_display_for_limit;
|
||||
use crate::text_formatting::proper_join;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -511,7 +513,7 @@ pub(crate) struct ChatWidget {
|
||||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
@@ -1498,10 +1500,18 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
let limit_label = snapshot
|
||||
.limit_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| limit_id.clone());
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
.and_then(|display| display.credits.as_ref())
|
||||
.map(|credits| CreditsSnapshot {
|
||||
has_credits: credits.has_credits,
|
||||
@@ -1512,32 +1522,38 @@ impl ChatWidget {
|
||||
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
let warnings = self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
);
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
let warnings = if is_codex_limit {
|
||||
self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let high_usage = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.primary
|
||||
let high_usage = is_codex_limit
|
||||
&& (snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false));
|
||||
|
||||
if high_usage
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
@@ -1550,8 +1566,10 @@ impl ChatWidget {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
|
||||
let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now());
|
||||
self.rate_limit_snapshot = Some(display);
|
||||
let display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
|
||||
if !warnings.is_empty() {
|
||||
for warning in warnings {
|
||||
@@ -1560,7 +1578,7 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
self.rate_limit_snapshot = None;
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
@@ -2608,7 +2626,7 @@ impl ChatWidget {
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
@@ -2773,7 +2791,7 @@ impl ChatWidget {
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
@@ -2927,7 +2945,7 @@ impl ChatWidget {
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
@@ -4228,7 +4246,12 @@ impl ChatWidget {
|
||||
.unwrap_or(&default_usage);
|
||||
let collaboration_mode = self.collaboration_mode_label();
|
||||
let reasoning_effort_override = Some(self.effective_reasoning_effort());
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
self.add_to_history(crate::status::new_status_output_with_rate_limits(
|
||||
&self.config,
|
||||
self.auth_manager.as_ref(),
|
||||
token_info,
|
||||
@@ -4236,7 +4259,7 @@ impl ChatWidget {
|
||||
&self.thread_id,
|
||||
self.thread_name.clone(),
|
||||
self.forked_from,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
rate_limit_snapshots.as_slice(),
|
||||
self.plan_type,
|
||||
Local::now(),
|
||||
self.model_display_name(),
|
||||
@@ -4382,8 +4405,8 @@ impl ChatWidget {
|
||||
.map(|used| format!("{used}% used")),
|
||||
StatusLineItem::FiveHourLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.primary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
@@ -4393,8 +4416,8 @@ impl ChatWidget {
|
||||
}
|
||||
StatusLineItem::WeeklyLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.secondary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
@@ -4578,9 +4601,10 @@ impl ChatWidget {
|
||||
loop {
|
||||
if let Some(auth) = auth_manager.auth().await
|
||||
&& auth.is_chatgpt_auth()
|
||||
&& let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await
|
||||
{
|
||||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||||
for snapshot in fetch_rate_limits(base_url.clone(), auth).await {
|
||||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||||
}
|
||||
}
|
||||
interval.tick().await;
|
||||
}
|
||||
@@ -7146,18 +7170,18 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option<RateLimitSnapshot> {
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Vec<RateLimitSnapshot> {
|
||||
match BackendClient::from_auth(base_url, &auth) {
|
||||
Ok(client) => match client.get_rate_limits().await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Ok(client) => match client.get_rate_limits_many().await {
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to fetch rate limits from /usage");
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to construct backend client for rate limits");
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -125,6 +126,8 @@ fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Co
|
||||
|
||||
fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: percent,
|
||||
window_minutes: Some(60),
|
||||
@@ -1064,7 +1067,7 @@ async fn make_chatwidget_manual(
|
||||
session_header: SessionHeader::new(resolved_model.clone()),
|
||||
initial_user_message: None,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
@@ -1280,6 +1283,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
@@ -1290,13 +1295,15 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
plan_type: None,
|
||||
}));
|
||||
let initial_balance = chat
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|snapshot| snapshot.credits.as_ref())
|
||||
.and_then(|credits| credits.balance.as_deref());
|
||||
assert_eq!(initial_balance, Some("17.5"));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 80.0,
|
||||
window_minutes: Some(60),
|
||||
@@ -1308,8 +1315,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
}));
|
||||
|
||||
let display = chat
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
let credits = display
|
||||
.credits
|
||||
@@ -1329,6 +1336,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(60),
|
||||
@@ -1345,6 +1354,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Plus));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 25.0,
|
||||
window_minutes: Some(30),
|
||||
@@ -1361,6 +1372,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(60),
|
||||
@@ -1377,6 +1390,61 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: Some("codex".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 20.0,
|
||||
window_minutes: Some(300),
|
||||
resets_at: Some(100),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("5.00".to_string()),
|
||||
}),
|
||||
plan_type: Some(PlanType::Pro),
|
||||
}));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 90.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
}));
|
||||
|
||||
let codex = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("codex snapshot should exist");
|
||||
let other = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex_other")
|
||||
.expect("codex_other snapshot should exist");
|
||||
|
||||
assert_eq!(codex.primary.as_ref().map(|w| w.used_percent), Some(20.0));
|
||||
assert_eq!(
|
||||
codex
|
||||
.credits
|
||||
.as_ref()
|
||||
.and_then(|credits| credits.balance.as_deref()),
|
||||
Some("5.00")
|
||||
);
|
||||
assert_eq!(other.primary.as_ref().map(|w| w.used_percent), Some(90.0));
|
||||
assert!(other.credits.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await;
|
||||
@@ -1391,6 +1459,31 @@ async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.auth_manager = AuthManager::from_auth_for_testing(auth);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 95.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: None,
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
}));
|
||||
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_shows_once_per_session() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
@@ -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