fix (#4251)
# External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes.
This commit is contained in:
@@ -572,12 +572,21 @@ fn parse_rate_limit_window(
|
||||
window_minutes_header: &str,
|
||||
resets_header: &str,
|
||||
) -> Option<RateLimitWindow> {
|
||||
let used_percent = parse_header_f64(headers, used_percent_header)?;
|
||||
let used_percent: Option<f64> = parse_header_f64(headers, used_percent_header);
|
||||
|
||||
Some(RateLimitWindow {
|
||||
used_percent,
|
||||
window_minutes: parse_header_u64(headers, window_minutes_header),
|
||||
resets_in_seconds: parse_header_u64(headers, resets_header),
|
||||
used_percent.and_then(|used_percent| {
|
||||
let window_minutes = parse_header_u64(headers, window_minutes_header);
|
||||
let resets_in_seconds = parse_header_u64(headers, resets_header);
|
||||
|
||||
let has_data = used_percent != 0.0
|
||||
|| window_minutes.is_some_and(|minutes| minutes != 0)
|
||||
|| resets_in_seconds.is_some_and(|seconds| seconds != 0);
|
||||
|
||||
has_data.then_some(RateLimitWindow {
|
||||
used_percent,
|
||||
window_minutes,
|
||||
resets_in_seconds,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ impl RateLimitWarningState {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_limits_duration(windows_minutes: u64) -> String {
|
||||
pub(crate) fn get_limits_duration(windows_minutes: u64) -> String {
|
||||
const MINUTES_PER_HOUR: u64 = 60;
|
||||
const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR;
|
||||
const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY;
|
||||
|
||||
@@ -145,7 +145,7 @@ impl StatusHistoryCell {
|
||||
Span::from(" "),
|
||||
Span::from(format_status_limit_summary(row.percent_used)),
|
||||
];
|
||||
let base_spans = formatter.full_spans(row.label, value_spans);
|
||||
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
|
||||
let base_line = Line::from(base_spans.clone());
|
||||
|
||||
if let Some(resets_at) = row.resets_at.as_ref() {
|
||||
@@ -176,18 +176,14 @@ impl StatusHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_rate_limit_labels(
|
||||
&self,
|
||||
seen: &mut BTreeSet<&'static str>,
|
||||
labels: &mut Vec<&'static str>,
|
||||
) {
|
||||
fn collect_rate_limit_labels(&self, seen: &mut BTreeSet<String>, labels: &mut Vec<String>) {
|
||||
match &self.rate_limits {
|
||||
StatusRateLimitData::Available(rows) => {
|
||||
if rows.is_empty() {
|
||||
push_label(labels, seen, "Limits");
|
||||
} else {
|
||||
for row in rows {
|
||||
push_label(labels, seen, row.label);
|
||||
push_label(labels, seen, row.label.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,9 +220,12 @@ impl HistoryCell for StatusHistoryCell {
|
||||
}
|
||||
});
|
||||
|
||||
let mut labels: Vec<&'static str> =
|
||||
vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"];
|
||||
let mut seen: BTreeSet<&'static str> = labels.iter().copied().collect();
|
||||
let mut labels: Vec<String> =
|
||||
vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
@@ -237,7 +236,7 @@ impl HistoryCell for StatusHistoryCell {
|
||||
push_label(&mut labels, &mut seen, "Token Usage");
|
||||
self.collect_rate_limit_labels(&mut seen, &mut labels);
|
||||
|
||||
let formatter = FieldFormatter::from_labels(labels.iter().copied());
|
||||
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
|
||||
let value_width = formatter.value_width(available_inner_width);
|
||||
|
||||
let mut model_spans = vec![Span::from(self.model_name.clone())];
|
||||
|
||||
@@ -15,10 +15,13 @@ pub(crate) struct FieldFormatter {
|
||||
impl FieldFormatter {
|
||||
pub(crate) const INDENT: &'static str = " ";
|
||||
|
||||
pub(crate) fn from_labels(labels: impl IntoIterator<Item = &'static str>) -> Self {
|
||||
pub(crate) fn from_labels<S>(labels: impl IntoIterator<Item = S>) -> Self
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let label_width = labels
|
||||
.into_iter()
|
||||
.map(UnicodeWidthStr::width)
|
||||
.map(|label| UnicodeWidthStr::width(label.as_ref()))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let indent_width = UnicodeWidthStr::width(Self::INDENT);
|
||||
@@ -53,7 +56,7 @@ impl FieldFormatter {
|
||||
|
||||
pub(crate) fn full_spans(
|
||||
&self,
|
||||
label: &'static str,
|
||||
label: &str,
|
||||
mut value_spans: Vec<Span<'static>>,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::with_capacity(value_spans.len() + 1);
|
||||
@@ -79,14 +82,14 @@ impl FieldFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_label(
|
||||
labels: &mut Vec<&'static str>,
|
||||
seen: &mut BTreeSet<&'static str>,
|
||||
label: &'static str,
|
||||
) {
|
||||
if seen.insert(label) {
|
||||
labels.push(label);
|
||||
pub(crate) fn push_label(labels: &mut Vec<String>, seen: &mut BTreeSet<String>, label: &str) {
|
||||
if seen.contains(label) {
|
||||
return;
|
||||
}
|
||||
|
||||
let owned = label.to_string();
|
||||
seen.insert(owned.clone());
|
||||
labels.push(owned);
|
||||
}
|
||||
|
||||
pub(crate) fn line_display_width(line: &Line<'static>) -> usize {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::chatwidget::get_limits_duration;
|
||||
|
||||
use super::helpers::format_reset_timestamp;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
@@ -13,7 +15,7 @@ pub(crate) const RESET_BULLET: &str = "·";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusRateLimitRow {
|
||||
pub label: &'static str,
|
||||
pub label: String,
|
||||
pub percent_used: f64,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
@@ -28,6 +30,7 @@ pub(crate) enum StatusRateLimitData {
|
||||
pub(crate) struct RateLimitWindowDisplay {
|
||||
pub used_percent: f64,
|
||||
pub resets_at: Option<String>,
|
||||
pub window_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
impl RateLimitWindowDisplay {
|
||||
@@ -41,6 +44,7 @@ impl RateLimitWindowDisplay {
|
||||
Self {
|
||||
used_percent: window.used_percent,
|
||||
resets_at,
|
||||
window_minutes: window.window_minutes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,16 +79,26 @@ pub(crate) fn compose_rate_limit_data(
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
|
||||
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: "5h limit",
|
||||
label: format!("{label} limit"),
|
||||
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: "Weekly limit",
|
||||
label: format!("{label} limit"),
|
||||
percent_used: secondary.used_percent,
|
||||
resets_at: secondary.resets_at.clone(),
|
||||
});
|
||||
@@ -115,3 +129,15 @@ pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String {
|
||||
pub(crate) fn format_status_limit_summary(percent_used: f64) -> String {
|
||||
format!("{percent_used:.0}% used")
|
||||
}
|
||||
|
||||
fn capitalize_first(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let mut capitalized = first.to_uppercase().collect::<String>();
|
||||
capitalized.push_str(chars.as_str());
|
||||
capitalized
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token Usage: 1.2K total (800 input + 400 output) │
|
||||
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used · resets 7 May (07:08) │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭──────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token Usage: 750 total (500 input + 250 output) │
|
||||
│ Limits: data not available yet │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
@@ -93,7 +93,7 @@ fn status_snapshot_includes_reasoning_details() {
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 45.0,
|
||||
window_minutes: Some(1_440),
|
||||
window_minutes: Some(10080),
|
||||
resets_in_seconds: Some(1_200),
|
||||
}),
|
||||
};
|
||||
@@ -114,6 +114,47 @@ fn status_snapshot_includes_reasoning_details() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_includes_monthly_limit() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home);
|
||||
config.model = "gpt-5-codex".to_string();
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 400,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1_200,
|
||||
};
|
||||
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 12.0,
|
||||
window_minutes: Some(43_200),
|
||||
resets_in_seconds: Some(86_400),
|
||||
}),
|
||||
secondary: None,
|
||||
};
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_card_token_usage_excludes_cached_tokens() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -181,3 +222,29 @@ fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_shows_missing_limits_message() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home);
|
||||
config.model = "gpt-5-codex".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 500,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 250,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 750,
|
||||
};
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, None);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user