fix(tui/limits): persist rate-limit warning logs

This commit is contained in:
James Peter
2025-09-25 23:09:32 +10:00
parent e8ed566d1c
commit 36175417ff
2 changed files with 241 additions and 13 deletions
+154
View File
@@ -13,6 +13,22 @@ use crate::protocol::TokenUsage;
const USAGE_VERSION: u32 = 1;
const USAGE_SUBDIR: &str = "usage";
const HOURLY_HISTORY_DAYS: i64 = 7;
const UNKNOWN_RESET_RELOG_INTERVAL: Duration = Duration::hours(24);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum RateLimitWarningScope {
Primary,
Secondary,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct RateLimitWarningRecord {
threshold: f64,
#[serde(default)]
reset_at: Option<DateTime<Utc>>,
#[serde(default)]
logged_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenTotals {
@@ -78,6 +94,10 @@ struct RateLimitInfo {
secondary_next_reset_at: Option<DateTime<Utc>>,
#[serde(default)]
last_usage_limit_hit_at: Option<DateTime<Utc>>,
#[serde(default)]
primary_threshold_logs: Vec<RateLimitWarningRecord>,
#[serde(default)]
secondary_threshold_logs: Vec<RateLimitWarningRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -167,6 +187,10 @@ fn usage_dir(codex_home: &Path) -> PathBuf {
codex_home.join(USAGE_SUBDIR)
}
fn warning_log_path(codex_home: &Path) -> PathBuf {
usage_dir(codex_home).join("rate_limit_warnings.log")
}
fn usage_file_path(codex_home: &Path, account_id: &str) -> PathBuf {
usage_dir(codex_home).join(format!("{account_id}.json"))
}
@@ -351,6 +375,136 @@ pub fn record_usage_limit_hint(
})
}
fn reset_changed(prev: Option<DateTime<Utc>>, next: Option<DateTime<Utc>>) -> bool {
match (prev, next) {
(Some(a), Some(b)) => a
.signed_duration_since(b)
.num_seconds()
.abs()
> 5,
(None, None) => false,
_ => true,
}
}
fn record_threshold_log(
logs: &mut Vec<RateLimitWarningRecord>,
threshold: f64,
reset_at: Option<DateTime<Utc>>,
observed_at: DateTime<Utc>,
) -> bool {
if let Some(existing) = logs.iter_mut().find(|entry| {
(entry.threshold - threshold).abs() < f64::EPSILON
}) {
if reset_changed(existing.reset_at, reset_at) {
existing.logged_at = None;
} else if reset_at.is_none()
&& existing
.logged_at
.is_some_and(|logged| observed_at.signed_duration_since(logged) >= UNKNOWN_RESET_RELOG_INTERVAL)
{
existing.logged_at = None;
}
existing.reset_at = reset_at;
if existing.logged_at.is_none() {
existing.logged_at = Some(observed_at);
return true;
}
return false;
}
logs.push(RateLimitWarningRecord {
threshold,
reset_at,
logged_at: Some(observed_at),
});
true
}
fn append_rate_limit_warning_log(
codex_home: &Path,
account_id: &str,
plan: Option<&str>,
scope: RateLimitWarningScope,
threshold: f64,
reset_at: Option<DateTime<Utc>>,
observed_at: DateTime<Utc>,
message: &str,
) -> std::io::Result<()> {
let dir = usage_dir(codex_home);
fs::create_dir_all(&dir)?;
let path = warning_log_path(codex_home);
let mut file = OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(&path)?;
file.lock_exclusive()?;
let scope_field = match scope {
RateLimitWarningScope::Primary => "primary",
RateLimitWarningScope::Secondary => "secondary",
};
let plan_field = plan.unwrap_or("-");
let reset_field = reset_at
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| "-".to_string());
let line = format!(
"{}\t{}\t{}\t{:.0}\t{}\t{}\t{}\n",
observed_at.to_rfc3339(),
account_id,
plan_field,
threshold,
scope_field,
reset_field,
message,
);
let write_res = file.write_all(line.as_bytes());
let unlock_res = file.unlock();
write_res?;
unlock_res?;
Ok(())
}
pub fn record_rate_limit_warning(
codex_home: &Path,
account_id: &str,
plan: Option<&str>,
scope: RateLimitWarningScope,
threshold: f64,
reset_at: Option<DateTime<Utc>>,
observed_at: DateTime<Utc>,
message: &str,
) -> std::io::Result<bool> {
let mut should_log = false;
with_usage_file(codex_home, account_id, plan, |data| {
data.last_updated = observed_at;
let mut info = data.rate_limit.take().unwrap_or_default();
let logs = match scope {
RateLimitWarningScope::Primary => &mut info.primary_threshold_logs,
RateLimitWarningScope::Secondary => &mut info.secondary_threshold_logs,
};
if record_threshold_log(logs, threshold, reset_at, observed_at) {
should_log = true;
}
data.rate_limit = Some(info);
})?;
if should_log {
append_rate_limit_warning_log(
codex_home,
account_id,
plan,
scope,
threshold,
reset_at,
observed_at,
message,
)?;
}
Ok(should_log)
}
pub fn load_account_usage(
codex_home: &Path,
account_id: &str,
+87 -13
View File
@@ -28,7 +28,12 @@ use codex_core::config_types::TextVerbosity;
use codex_core::plan_tool::{PlanItemArg, StepStatus, UpdatePlanArgs};
use codex_core::model_family::derive_default_model_family;
use codex_core::model_family::find_family_for_model;
use codex_core::account_usage::{self, StoredRateLimitSnapshot, StoredUsageSummary};
use codex_core::account_usage::{
self,
RateLimitWarningScope,
StoredRateLimitSnapshot,
StoredUsageSummary,
};
use codex_core::auth_accounts::{self, StoredAccount};
use codex_login::AuthManager;
use codex_login::AuthMode;
@@ -273,6 +278,13 @@ const RATE_LIMIT_REFRESH_INTERVAL: chrono::Duration = chrono::Duration::minutes(
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
#[derive(Clone)]
struct RateLimitWarning {
scope: RateLimitWarningScope,
threshold: f64,
message: String,
}
#[derive(Default)]
struct RateLimitWarningState {
weekly_index: usize,
@@ -280,26 +292,38 @@ struct RateLimitWarningState {
}
impl RateLimitWarningState {
fn take_warnings(&mut self, weekly_used_percent: f64, hourly_used_percent: f64) -> Vec<String> {
fn take_warnings(
&mut self,
secondary_used_percent: f64,
primary_used_percent: f64,
) -> Vec<RateLimitWarning> {
let mut warnings = Vec::new();
while self.weekly_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
&& weekly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]
{
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index];
warnings.push(format!(
"Secondary usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
));
warnings.push(RateLimitWarning {
scope: RateLimitWarningScope::Secondary,
threshold,
message: format!(
"Secondary usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
),
});
self.weekly_index += 1;
}
while self.hourly_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
&& hourly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]
{
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index];
warnings.push(format!(
"Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
));
warnings.push(RateLimitWarning {
scope: RateLimitWarningScope::Primary,
threshold,
message: format!(
"Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
),
});
self.hourly_index += 1;
}
@@ -5873,10 +5897,16 @@ impl ChatWidget<'_> {
let warnings = self
.rate_limit_warnings
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
if !warnings.is_empty() {
for warning in warnings {
self.history_push(history_cell::new_warning_event(warning));
let mut displayed_warning = false;
for warning in warnings {
if self.log_and_should_display_warning(&warning) {
self.history_push(history_cell::new_warning_event(
warning.message.clone(),
));
displayed_warning = true;
}
}
if displayed_warning {
self.request_redraw();
}
@@ -13303,6 +13333,50 @@ impl ChatWidget<'_> {
);
}
fn log_and_should_display_warning(&self, warning: &RateLimitWarning) -> bool {
let reset_at = match warning.scope {
RateLimitWarningScope::Primary => self.rate_limit_primary_next_reset_at,
RateLimitWarningScope::Secondary => self.rate_limit_secondary_next_reset_at,
};
let account_id = auth_accounts::get_active_account_id(&self.config.codex_home)
.ok()
.flatten()
.unwrap_or_else(|| "_default".to_string());
let plan = if account_id == "_default" {
None
} else {
match account_usage::list_rate_limit_snapshots(&self.config.codex_home) {
Ok(records) => records
.into_iter()
.find(|record| record.account_id == account_id)
.and_then(|record| record.plan.clone()),
Err(err) => {
tracing::warn!(?err, "failed to load rate limit snapshots while logging warning");
None
}
}
};
match account_usage::record_rate_limit_warning(
&self.config.codex_home,
&account_id,
plan.as_deref(),
warning.scope,
warning.threshold,
reset_at,
Utc::now(),
&warning.message,
) {
Ok(result) => result,
Err(err) => {
tracing::warn!(?err, "failed to persist rate limit warning log");
true
}
}
}
/// Export transcript for buffer-mode mirroring: omit internal sentinels
/// and include gutter icons and a blank line between items for readability.
pub(crate) fn export_transcript_lines_for_buffer(&self) -> Vec<ratatui::text::Line<'static>> {