mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(rate-limits): track API reset timers across core and TUI
This commit is contained in:
@@ -72,8 +72,10 @@ struct RateLimitInfo {
|
||||
snapshot: Option<RateLimitSnapshotEvent>,
|
||||
#[serde(default)]
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, alias = "next_reset_at")]
|
||||
primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
next_reset_at: Option<DateTime<Utc>>,
|
||||
secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
last_usage_limit_hit_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -141,7 +143,8 @@ pub struct StoredRateLimitSnapshot {
|
||||
pub plan: Option<String>,
|
||||
pub snapshot: Option<RateLimitSnapshotEvent>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub next_reset_at: Option<DateTime<Utc>>,
|
||||
pub primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
pub secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
pub last_usage_limit_hit_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -252,6 +255,12 @@ pub fn record_rate_limit_snapshot(
|
||||
let mut info = data.rate_limit.take().unwrap_or_default();
|
||||
info.snapshot = Some(snapshot.clone());
|
||||
info.observed_at = Some(observed_at);
|
||||
info.primary_next_reset_at = snapshot
|
||||
.primary_reset_after_seconds
|
||||
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
|
||||
info.secondary_next_reset_at = snapshot
|
||||
.secondary_reset_after_seconds
|
||||
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
|
||||
data.rate_limit = Some(info);
|
||||
})
|
||||
}
|
||||
@@ -294,12 +303,17 @@ pub fn list_rate_limit_snapshots(
|
||||
Err(_) => continue,
|
||||
};
|
||||
let rate = data.rate_limit.unwrap_or_default();
|
||||
let primary_next_reset_at = rate.primary_next_reset_at;
|
||||
let secondary_next_reset_at = rate
|
||||
.secondary_next_reset_at
|
||||
.or(rate.primary_next_reset_at);
|
||||
results.push(StoredRateLimitSnapshot {
|
||||
account_id: data.account_id,
|
||||
plan: data.plan,
|
||||
snapshot: rate.snapshot,
|
||||
observed_at: rate.observed_at,
|
||||
next_reset_at: rate.next_reset_at,
|
||||
primary_next_reset_at,
|
||||
secondary_next_reset_at,
|
||||
last_usage_limit_hit_at: rate.last_usage_limit_hit_at,
|
||||
});
|
||||
}
|
||||
@@ -330,7 +344,8 @@ pub fn record_usage_limit_hint(
|
||||
info.last_usage_limit_hit_at = Some(observed_at);
|
||||
if let Some(seconds) = resets_in_seconds {
|
||||
let reset_at = observed_at + Duration::seconds(seconds as i64);
|
||||
info.next_reset_at = Some(reset_at);
|
||||
info.primary_next_reset_at = Some(reset_at);
|
||||
info.secondary_next_reset_at = Some(reset_at);
|
||||
}
|
||||
data.rate_limit = Some(info);
|
||||
})
|
||||
|
||||
@@ -703,6 +703,10 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
|
||||
parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?;
|
||||
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
|
||||
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
|
||||
let primary_reset_after_seconds =
|
||||
parse_header_u64(headers, "x-codex-primary-reset-after-seconds");
|
||||
let secondary_reset_after_seconds =
|
||||
parse_header_u64(headers, "x-codex-secondary-reset-after-seconds");
|
||||
|
||||
Some(RateLimitSnapshotEvent {
|
||||
primary_used_percent,
|
||||
@@ -710,6 +714,8 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
|
||||
primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes,
|
||||
secondary_window_minutes,
|
||||
primary_reset_after_seconds,
|
||||
secondary_reset_after_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4754,13 +4754,7 @@ async fn try_run_turn(
|
||||
}
|
||||
ResponseEvent::RateLimits(snapshot) => {
|
||||
let mut state = sess.state.lock().unwrap();
|
||||
state.latest_rate_limits = Some(RateLimitSnapshotEvent {
|
||||
primary_used_percent: snapshot.primary_used_percent,
|
||||
secondary_used_percent: snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent: snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: snapshot.secondary_window_minutes,
|
||||
});
|
||||
state.latest_rate_limits = Some(snapshot.clone());
|
||||
if let Some(ctx) = account_usage_context(sess) {
|
||||
let usage_home = ctx.codex_home.clone();
|
||||
let usage_account = ctx.account_id.clone();
|
||||
|
||||
@@ -596,12 +596,12 @@ fn rate_limit_snapshot_to_protocol(
|
||||
let primary = codex_protocol::protocol::RateLimitWindow {
|
||||
used_percent: snapshot.primary_used_percent,
|
||||
window_minutes: Some(snapshot.primary_window_minutes),
|
||||
resets_in_seconds: None,
|
||||
resets_in_seconds: snapshot.primary_reset_after_seconds,
|
||||
};
|
||||
let secondary = codex_protocol::protocol::RateLimitWindow {
|
||||
used_percent: snapshot.secondary_used_percent,
|
||||
window_minutes: Some(snapshot.secondary_window_minutes),
|
||||
resets_in_seconds: None,
|
||||
resets_in_seconds: snapshot.secondary_reset_after_seconds,
|
||||
};
|
||||
codex_protocol::protocol::RateLimitSnapshot {
|
||||
primary: Some(primary),
|
||||
@@ -629,11 +629,19 @@ fn rate_limit_snapshot_from_protocol(
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes)
|
||||
.unwrap_or(0);
|
||||
let primary_reset_after_seconds = snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.resets_in_seconds);
|
||||
let secondary_window_minutes = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes)
|
||||
.unwrap_or(0);
|
||||
let secondary_reset_after_seconds = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.resets_in_seconds);
|
||||
|
||||
let ratio_percent = match (primary_window_minutes, secondary_window_minutes) {
|
||||
(0, _) | (_, 0) => f64::NAN,
|
||||
@@ -649,6 +657,8 @@ fn rate_limit_snapshot_from_protocol(
|
||||
primary_to_secondary_ratio_percent: ratio_percent,
|
||||
primary_window_minutes,
|
||||
secondary_window_minutes,
|
||||
primary_reset_after_seconds,
|
||||
secondary_reset_after_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +993,12 @@ pub struct RateLimitSnapshotEvent {
|
||||
pub primary_window_minutes: u64,
|
||||
/// Rolling window duration for the secondary limit, in minutes.
|
||||
pub secondary_window_minutes: u64,
|
||||
/// Seconds until the primary window resets, if reported by the API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_reset_after_seconds: Option<u64>,
|
||||
/// Seconds until the secondary window resets, if reported by the API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secondary_reset_after_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
|
||||
+39
-181
@@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -123,8 +123,6 @@ use ratatui_image::picker::Picker;
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
fn history_cell_logging_enabled() -> bool {
|
||||
@@ -142,7 +140,6 @@ fn history_cell_logging_enabled() -> bool {
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::info;
|
||||
use fs2::FileExt;
|
||||
// use image::GenericImageView;
|
||||
|
||||
use crate::app_event::{
|
||||
@@ -274,7 +271,6 @@ struct RunningCommand {
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
|
||||
const RATE_LIMIT_REFRESH_INTERVAL: chrono::Duration = chrono::Duration::minutes(10);
|
||||
|
||||
const RATE_LIMIT_RESET_FILE: &str = "state/rate_limit_resets.json";
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -379,8 +375,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_fetch_inflight: bool,
|
||||
rate_limit_last_fetch_at: Option<DateTime<Utc>>,
|
||||
rate_limit_last_primary_reset_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
rate_limit_last_weekly_reset_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
rate_limit_primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
rate_limit_secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
content_buffer: String,
|
||||
// Buffer for streaming assistant answer text; we do not surface partial
|
||||
// We wait for the final AgentMessage event and then emit the full text
|
||||
@@ -2540,12 +2536,6 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Initialize image protocol for rendering screenshots
|
||||
|
||||
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
|
||||
let RateLimitResetCache {
|
||||
primary_last_reset,
|
||||
weekly_last_reset,
|
||||
} = reset_cache;
|
||||
|
||||
let mut new_widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
@@ -2572,8 +2562,8 @@ impl ChatWidget<'_> {
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_fetch_inflight: false,
|
||||
rate_limit_last_fetch_at: None,
|
||||
rate_limit_last_primary_reset_at: primary_last_reset,
|
||||
rate_limit_last_weekly_reset_at: weekly_last_reset,
|
||||
rate_limit_primary_next_reset_at: None,
|
||||
rate_limit_secondary_next_reset_at: None,
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -2694,6 +2684,14 @@ impl ChatWidget<'_> {
|
||||
system_cell_by_id: HashMap::new(),
|
||||
standard_terminal_mode: !config.tui.alternate_screen,
|
||||
};
|
||||
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
|
||||
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
|
||||
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
|
||||
new_widget.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
|
||||
new_widget.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed footer access indicator based on current config
|
||||
new_widget.apply_access_mode_indicator_from_config();
|
||||
// Insert the welcome cell as top-of-first-request so future model output
|
||||
@@ -2774,12 +2772,6 @@ impl ChatWidget<'_> {
|
||||
// Basic widget state mirrors `new`
|
||||
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
|
||||
|
||||
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
|
||||
let RateLimitResetCache {
|
||||
primary_last_reset,
|
||||
weekly_last_reset,
|
||||
} = reset_cache;
|
||||
|
||||
let mut w = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
@@ -2803,8 +2795,8 @@ impl ChatWidget<'_> {
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_fetch_inflight: false,
|
||||
rate_limit_last_fetch_at: None,
|
||||
rate_limit_last_primary_reset_at: primary_last_reset,
|
||||
rate_limit_last_weekly_reset_at: weekly_last_reset,
|
||||
rate_limit_primary_next_reset_at: None,
|
||||
rate_limit_secondary_next_reset_at: None,
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -2923,6 +2915,14 @@ impl ChatWidget<'_> {
|
||||
synthetic_system_req: None,
|
||||
system_cell_by_id: HashMap::new(),
|
||||
};
|
||||
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
|
||||
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
|
||||
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
|
||||
w.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
|
||||
w.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
w.set_standard_terminal_mode(!config.tui.alternate_screen);
|
||||
if show_welcome {
|
||||
w.history_push_top_next_req(history_cell::new_animated_welcome());
|
||||
@@ -3490,17 +3490,15 @@ impl ChatWidget<'_> {
|
||||
match record {
|
||||
Some(record) => {
|
||||
if let Some(snapshot) = record.snapshot.clone() {
|
||||
let view_snapshot = RateLimitSnapshotEvent {
|
||||
primary_used_percent: snapshot.primary_used_percent,
|
||||
secondary_used_percent: snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent:
|
||||
snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: snapshot.secondary_window_minutes,
|
||||
let view_snapshot = snapshot.clone();
|
||||
let view_reset = RateLimitResetInfo {
|
||||
primary_next_reset: record.primary_next_reset_at,
|
||||
secondary_next_reset: record.secondary_next_reset_at,
|
||||
..RateLimitResetInfo::default()
|
||||
};
|
||||
let view = build_limits_view(
|
||||
&view_snapshot,
|
||||
RateLimitResetInfo::default(),
|
||||
view_reset,
|
||||
DEFAULT_GRID_CONFIG,
|
||||
);
|
||||
let header = Self::account_header_lines(
|
||||
@@ -5867,8 +5865,7 @@ impl ChatWidget<'_> {
|
||||
self.last_token_usage = info.last_token_usage.clone();
|
||||
}
|
||||
if let Some(snapshot) = event.rate_limits {
|
||||
let previous_snapshot = self.rate_limit_snapshot.clone();
|
||||
self.update_rate_limit_resets(previous_snapshot.as_ref(), &snapshot);
|
||||
self.update_rate_limit_resets(&snapshot);
|
||||
let warnings = self
|
||||
.rate_limit_warnings
|
||||
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
|
||||
@@ -7211,8 +7208,8 @@ impl ChatWidget<'_> {
|
||||
let context_tokens_used = context_window.map(|_| self.last_token_usage.tokens_in_context_window());
|
||||
|
||||
RateLimitResetInfo {
|
||||
primary_last_reset: self.rate_limit_last_primary_reset_at,
|
||||
weekly_last_reset: self.rate_limit_last_weekly_reset_at,
|
||||
primary_next_reset: self.rate_limit_primary_next_reset_at,
|
||||
secondary_next_reset: self.rate_limit_secondary_next_reset_at,
|
||||
session_tokens_used,
|
||||
auto_compact_limit,
|
||||
overflow_auto_compact: true,
|
||||
@@ -7221,65 +7218,14 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_rate_limit_resets(
|
||||
&mut self,
|
||||
previous: Option<&RateLimitSnapshotEvent>,
|
||||
current: &RateLimitSnapshotEvent,
|
||||
) {
|
||||
fn update_rate_limit_resets(&mut self, current: &RateLimitSnapshotEvent) {
|
||||
let now = Utc::now();
|
||||
let mut changed_primary = false;
|
||||
let mut changed_weekly = false;
|
||||
|
||||
if let Some(prev) = previous {
|
||||
if current.primary_used_percent + 1.0 < prev.primary_used_percent {
|
||||
if self
|
||||
.rate_limit_last_primary_reset_at
|
||||
.map_or(true, |existing| now > existing)
|
||||
{
|
||||
self.rate_limit_last_primary_reset_at = Some(now);
|
||||
changed_primary = true;
|
||||
}
|
||||
}
|
||||
if current.secondary_used_percent + 0.5 < prev.secondary_used_percent {
|
||||
if self
|
||||
.rate_limit_last_weekly_reset_at
|
||||
.map_or(true, |existing| now > existing)
|
||||
{
|
||||
self.rate_limit_last_weekly_reset_at = Some(now);
|
||||
changed_weekly = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if current.primary_used_percent <= 1.0
|
||||
&& self.rate_limit_last_primary_reset_at.is_none()
|
||||
{
|
||||
self.rate_limit_last_primary_reset_at = Some(now);
|
||||
changed_primary = true;
|
||||
}
|
||||
if current.secondary_used_percent <= 1.0
|
||||
&& self.rate_limit_last_weekly_reset_at.is_none()
|
||||
{
|
||||
self.rate_limit_last_weekly_reset_at = Some(now);
|
||||
changed_weekly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed_primary || changed_weekly {
|
||||
let candidate_info = self.rate_limit_reset_info();
|
||||
let candidate_cache = RateLimitResetCache {
|
||||
primary_last_reset: candidate_info.primary_last_reset,
|
||||
weekly_last_reset: candidate_info.weekly_last_reset,
|
||||
};
|
||||
match persist_rate_limit_reset_cache(&self.config.codex_home, candidate_cache) {
|
||||
Ok(merged) => {
|
||||
self.rate_limit_last_primary_reset_at = merged.primary_last_reset;
|
||||
self.rate_limit_last_weekly_reset_at = merged.weekly_last_reset;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to persist rate limit resets: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rate_limit_primary_next_reset_at = current
|
||||
.primary_reset_after_seconds
|
||||
.map(|secs| now + ChronoDuration::seconds(secs as i64));
|
||||
self.rate_limit_secondary_next_reset_at = current
|
||||
.secondary_reset_after_seconds
|
||||
.map(|secs| now + ChronoDuration::seconds(secs as i64));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_update_command(&mut self) {
|
||||
@@ -17199,94 +17145,6 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
struct RateLimitResetCache {
|
||||
#[serde(default)]
|
||||
primary_last_reset: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
weekly_last_reset: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn load_rate_limit_reset_cache(codex_home: &Path) -> RateLimitResetCache {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(RATE_LIMIT_RESET_FILE));
|
||||
match std::fs::read_to_string(&read_path) {
|
||||
Ok(contents) => match serde_json::from_str::<RateLimitResetCache>(&contents) {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => {
|
||||
tracing::debug!("failed to parse rate limit reset cache: {err:?}");
|
||||
RateLimitResetCache::default()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
tracing::debug!("failed to read rate limit reset cache: {err:?}");
|
||||
}
|
||||
RateLimitResetCache::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_rate_limit_reset_cache(
|
||||
codex_home: &Path,
|
||||
candidate: RateLimitResetCache,
|
||||
) -> io::Result<RateLimitResetCache> {
|
||||
if candidate.primary_last_reset.is_none() && candidate.weekly_last_reset.is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
let path = rate_limit_reset_cache_path(codex_home);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
FileExt::lock_exclusive(&file)?;
|
||||
|
||||
let mut buf = String::new();
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.read_to_string(&mut buf)?;
|
||||
let mut existing: RateLimitResetCache = if buf.trim().is_empty() {
|
||||
RateLimitResetCache::default()
|
||||
} else {
|
||||
serde_json::from_str(&buf).unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
changed |= merge_timestamp(&mut existing.primary_last_reset, candidate.primary_last_reset);
|
||||
changed |= merge_timestamp(&mut existing.weekly_last_reset, candidate.weekly_last_reset);
|
||||
|
||||
if changed {
|
||||
let serialized = serde_json::to_string_pretty(&existing)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(serialized.as_bytes())?;
|
||||
file.flush()?;
|
||||
}
|
||||
|
||||
let _ = FileExt::unlock(&file);
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
fn rate_limit_reset_cache_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(RATE_LIMIT_RESET_FILE)
|
||||
}
|
||||
|
||||
fn merge_timestamp(dst: &mut Option<DateTime<Utc>>, candidate: Option<DateTime<Utc>>) -> bool {
|
||||
match (dst.as_ref(), candidate) {
|
||||
(_, None) => false,
|
||||
(Some(existing), Some(candidate_dt)) if *existing >= candidate_dt => false,
|
||||
_ => {
|
||||
*dst = candidate;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Top-level widget render timing
|
||||
|
||||
@@ -5,7 +5,7 @@ use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent};
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::ReasoningEffort;
|
||||
use codex_core::debug_logger::DebugLogger;
|
||||
use codex_core::protocol::{Event, EventMsg, TokenCountEvent, RateLimitSnapshotEvent as CoreRateLimitSnapshotEvent};
|
||||
use codex_core::protocol::{Event, EventMsg, RateLimitSnapshotEvent, TokenCountEvent};
|
||||
use codex_protocol::models::{ContentItem, ResponseItem};
|
||||
use futures::StreamExt;
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -80,13 +80,7 @@ fn run_refresh(
|
||||
|
||||
let proto_snapshot = snapshot.context("rate limit snapshot missing from response")?;
|
||||
|
||||
let snapshot = CoreRateLimitSnapshotEvent {
|
||||
primary_used_percent: proto_snapshot.primary_used_percent,
|
||||
secondary_used_percent: proto_snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent: proto_snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: proto_snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: proto_snapshot.secondary_window_minutes,
|
||||
};
|
||||
let snapshot: RateLimitSnapshotEvent = proto_snapshot.clone();
|
||||
|
||||
let event = Event {
|
||||
id: "rate-limit-refresh".to_string(),
|
||||
|
||||
@@ -196,7 +196,10 @@ fn limits_overlay_renders_snapshot() {
|
||||
primary_to_secondary_ratio_percent: 0.0,
|
||||
primary_window_minutes: 300,
|
||||
secondary_window_minutes: 10_080,
|
||||
primary_reset_after_seconds: Some(600),
|
||||
secondary_reset_after_seconds: Some(3_600),
|
||||
});
|
||||
chat.update_rate_limit_resets(chat.rate_limit_snapshot.as_ref().unwrap());
|
||||
chat.rate_limit_last_fetch_at = Some(Utc::now());
|
||||
|
||||
chat.add_limits_output();
|
||||
@@ -218,6 +221,11 @@ fn limits_overlay_renders_snapshot() {
|
||||
assert!(joined.contains("Weekly Limit"), "overlay text: {joined}");
|
||||
assert!(joined.contains(" Type: "));
|
||||
assert!(joined.contains(" Plan: "));
|
||||
assert!(joined.contains("Resets"), "overlay text: {joined}");
|
||||
assert!(
|
||||
!joined.contains("awaiting reset timing"),
|
||||
"expected reset timings to render: {joined}"
|
||||
);
|
||||
assert!(joined.contains(" Total: "));
|
||||
assert!(joined.contains("Chart"));
|
||||
assert!(joined.contains("7 Day History"));
|
||||
|
||||
@@ -87,8 +87,8 @@ pub(crate) struct GridConfig {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RateLimitResetInfo {
|
||||
pub(crate) primary_last_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) weekly_last_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) primary_next_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) secondary_next_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) session_tokens_used: Option<u64>,
|
||||
pub(crate) auto_compact_limit: Option<u64>,
|
||||
pub(crate) overflow_auto_compact: bool,
|
||||
@@ -178,11 +178,11 @@ fn build_summary_lines(
|
||||
));
|
||||
lines.push(build_hourly_window_line(
|
||||
metrics,
|
||||
reset_info.primary_last_reset,
|
||||
reset_info.primary_next_reset,
|
||||
));
|
||||
lines.push(build_hourly_reset_line(
|
||||
snapshot.primary_window_minutes,
|
||||
reset_info.primary_last_reset,
|
||||
reset_info.primary_next_reset,
|
||||
));
|
||||
|
||||
lines.push("".into());
|
||||
@@ -196,11 +196,11 @@ fn build_summary_lines(
|
||||
));
|
||||
lines.push(build_weekly_window_line(
|
||||
metrics.weekly_window_minutes,
|
||||
reset_info.weekly_last_reset,
|
||||
reset_info.secondary_next_reset,
|
||||
));
|
||||
lines.push(build_weekly_reset_line(
|
||||
snapshot.secondary_window_minutes,
|
||||
reset_info.weekly_last_reset,
|
||||
reset_info.secondary_next_reset,
|
||||
));
|
||||
|
||||
lines.push("".into());
|
||||
@@ -240,7 +240,7 @@ fn build_bar_line(label: &str, percent: f64, suffix: &str, style: Style) -> Line
|
||||
|
||||
fn build_hourly_window_line(
|
||||
metrics: &RateLimitMetrics,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
let prefix = field_prefix("Window");
|
||||
if metrics.primary_window_minutes == 0 {
|
||||
@@ -253,8 +253,8 @@ fn build_hourly_window_line(
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(last) = last_reset {
|
||||
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, last) {
|
||||
if let Some(next) = next_reset {
|
||||
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, next) {
|
||||
let window_secs = timing.window.as_secs_f64();
|
||||
if window_secs > 0.0 {
|
||||
let elapsed = timing.elapsed();
|
||||
@@ -278,25 +278,25 @@ fn build_hourly_window_line(
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(prefix),
|
||||
Span::styled(
|
||||
format!(
|
||||
"(unknown / {})",
|
||||
format_minutes_round_units(metrics.primary_window_minutes)
|
||||
),
|
||||
Style::default().fg(colors::dim()),
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix));
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
"(unknown / {})",
|
||||
format_minutes_round_units(metrics.primary_window_minutes)
|
||||
),
|
||||
])
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn build_hourly_reset_line(
|
||||
window_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
if let (Some(last), true) = (last_reset, window_minutes > 0) {
|
||||
if let (Some(next), true) = (next_reset, window_minutes > 0) {
|
||||
let prefix = field_prefix("Resets");
|
||||
if let Some(timing) = compute_window_timing(window_minutes, last) {
|
||||
if let Some(timing) = compute_window_timing(window_minutes, next) {
|
||||
let remaining = format_duration(timing.remaining);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix.clone()));
|
||||
@@ -320,7 +320,7 @@ fn build_hourly_reset_line(
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(field_prefix("Resets")));
|
||||
spans.push(Span::styled(
|
||||
"shown once next window detected".to_string(),
|
||||
"awaiting reset timing…".to_string(),
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
@@ -328,7 +328,7 @@ fn build_hourly_reset_line(
|
||||
|
||||
fn build_weekly_window_line(
|
||||
weekly_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
let prefix = field_prefix("Window");
|
||||
if weekly_minutes == 0 {
|
||||
@@ -341,8 +341,8 @@ fn build_weekly_window_line(
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(last) = last_reset {
|
||||
if let Some(timing) = compute_window_timing(weekly_minutes, last) {
|
||||
if let Some(next) = next_reset {
|
||||
if let Some(timing) = compute_window_timing(weekly_minutes, next) {
|
||||
let window_secs = timing.window.as_secs_f64();
|
||||
if window_secs > 0.0 {
|
||||
let elapsed = timing.elapsed();
|
||||
@@ -379,11 +379,11 @@ fn build_weekly_window_line(
|
||||
|
||||
fn build_weekly_reset_line(
|
||||
window_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
if let (Some(last), true) = (last_reset, window_minutes > 0) {
|
||||
if let (Some(next), true) = (next_reset, window_minutes > 0) {
|
||||
let prefix = field_prefix("Resets");
|
||||
if let Some(timing) = compute_window_timing(window_minutes, last) {
|
||||
if let Some(timing) = compute_window_timing(window_minutes, next) {
|
||||
let remaining = format_duration(timing.remaining);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix.clone()));
|
||||
@@ -406,7 +406,7 @@ fn build_weekly_reset_line(
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(field_prefix("Resets")));
|
||||
spans.push(Span::styled(
|
||||
"shown once next window detected".to_string(),
|
||||
"awaiting reset timing…".to_string(),
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
@@ -609,7 +609,7 @@ impl WindowTiming {
|
||||
|
||||
fn compute_window_timing(
|
||||
window_minutes: u64,
|
||||
last_reset: DateTime<Utc>,
|
||||
next_reset: DateTime<Utc>,
|
||||
) -> Option<WindowTiming> {
|
||||
let window_seconds = (window_minutes as i64).checked_mul(60)?;
|
||||
if window_seconds <= 0 {
|
||||
@@ -617,17 +617,16 @@ fn compute_window_timing(
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let elapsed_secs = now.signed_duration_since(last_reset).num_seconds();
|
||||
let elapsed_within_window = if elapsed_secs <= 0 {
|
||||
0
|
||||
} else {
|
||||
elapsed_secs.rem_euclid(window_seconds)
|
||||
};
|
||||
let remaining_secs = (window_seconds - elapsed_within_window).max(0);
|
||||
let mut remaining_secs = next_reset.signed_duration_since(now).num_seconds();
|
||||
if remaining_secs < 0 {
|
||||
remaining_secs = 0;
|
||||
}
|
||||
if remaining_secs > window_seconds {
|
||||
remaining_secs = window_seconds;
|
||||
}
|
||||
|
||||
let window = Duration::from_secs(window_seconds as u64);
|
||||
let remaining = Duration::from_secs(remaining_secs as u64);
|
||||
let next_reset = now + chrono::Duration::seconds(remaining_secs);
|
||||
Some(WindowTiming {
|
||||
remaining,
|
||||
window,
|
||||
@@ -1020,6 +1019,8 @@ mod tests {
|
||||
primary_to_secondary_ratio_percent: 40.0,
|
||||
primary_window_minutes: 300,
|
||||
secondary_window_minutes: 10_080,
|
||||
primary_reset_after_seconds: None,
|
||||
secondary_reset_after_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user