mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
## Summary
Enterprise users can have an effective monthly credit limit, but Codex
`/status` currently drops that metadata from the account-usage response.
This change adds the optional `spend_control.individual_limit`
projection to the existing rate-limit snapshot flow. The backend client
reads the monthly limit, app-server exposes it as `individualLimit`, and
the TUI renders a `Monthly credit limit` row through the existing
progress-bar renderer.
When the backend does not return an effective monthly limit, existing
rate-limit behavior is unchanged.
## Existing backend state
The account-usage backend already returns the effective monthly limit
and current usage together:
```json
{
"spend_control": {
"reached": false,
"individual_limit": {
"limit": "25000",
"used": "8000",
"remaining": "17000",
"used_percent": 32,
"remaining_percent": 68,
"reset_after_seconds": 86400,
"reset_at": 1778137680
}
}
}
```
Before this change, Codex projected rolling `primary` and `secondary`
windows plus `credits`. It ignored `spend_control.individual_limit`, so
app-server clients and `/status` could not render the monthly cap.
The updated flow is:
```text
account usage backend
-> backend-client reads spend_control.individual_limit
-> existing rate-limit snapshot carries optional individual_limit
-> app-server exposes optional individualLimit
-> TUI renders Monthly credit limit
```
## App-server contract
`account/rateLimits/read` and sparse `account/rateLimits/updated`
notifications now include an additive nullable
`rateLimits.individualLimit` field:
```json
{
"individualLimit": {
"limit": "25000",
"used": "8000",
"remainingPercent": 68,
"resetsAt": 1778137680
}
}
```
In an `account/rateLimits/read` response, `null` means no monthly limit
is available. `account/rateLimits/updated` remains a sparse rolling
notification: clients merge available values into their most recent
`account/rateLimits/read` snapshot or refetch. Nullable account metadata
in a rolling notification does not clear a previously observed value.
## Design decisions
- Extend the existing rate-limit snapshot instead of introducing a
separate request or wire-level update protocol.
- Keep the Codex projection narrow: `/status` needs the effective limit,
current usage, remaining percentage, and reset timestamp.
- Render the monthly row through the existing progress-bar renderer,
with one optional detail line for `8,000 of 25,000 credits used`.
- Keep the backend response optional so existing accounts and older
usage states preserve their current behavior.
- Preserve cached monthly metadata when sparse rolling notifications
omit it. Live account-usage reads remain authoritative and can clear a
removed limit.
## Visual evidence
```text
Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May)
8,000 of 25,000 credits used
```
Snapshot:
`codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap`
## Testing
Tests: generated app-server schema verification, protocol tests,
backend-client tests, app-server integration coverage, TUI snapshot
coverage, formatting, and workspace lint cleanup.
511 lines
18 KiB
Rust
511 lines
18 KiB
Rust
//! Rate-limit and credits display shaping for status surfaces.
|
|
//!
|
|
//! This module maps `RateLimitSnapshot` protocol payloads into display-oriented rows that the TUI
|
|
//! can render in `/status` and status-line contexts without duplicating formatting logic.
|
|
//!
|
|
//! The key contract is that time-sensitive values are interpreted relative to a caller-provided
|
|
//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle.
|
|
use crate::chatwidget::fallback_limit_label;
|
|
use crate::chatwidget::limit_label_for_window;
|
|
use crate::text_formatting::capitalize_first;
|
|
|
|
use super::helpers::format_reset_timestamp;
|
|
use chrono::DateTime;
|
|
use chrono::Duration as ChronoDuration;
|
|
use chrono::Local;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
|
use codex_app_server_protocol::RateLimitSnapshot;
|
|
use codex_app_server_protocol::RateLimitWindow;
|
|
use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
|
|
use codex_protocol::num_format::format_with_separators;
|
|
|
|
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
|
|
const STATUS_LIMIT_BAR_FILLED: &str = "█";
|
|
const STATUS_LIMIT_BAR_EMPTY: &str = "░";
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct StatusRateLimitRow {
|
|
/// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`.
|
|
pub label: String,
|
|
/// Value payload for the row.
|
|
pub value: StatusRateLimitValue,
|
|
}
|
|
|
|
/// Display value variants for a single rate-limit row.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum StatusRateLimitValue {
|
|
/// Percent-based usage window with optional reset timestamp text.
|
|
Window {
|
|
/// Percent of the window that has been consumed.
|
|
percent_used: f64,
|
|
/// Localized reset string, or `None` when unknown.
|
|
resets_at: Option<String>,
|
|
/// Optional detail line rendered beneath the progress bar.
|
|
details: Option<String>,
|
|
},
|
|
/// Plain text value used for non-window rows.
|
|
Text(String),
|
|
}
|
|
|
|
/// Availability state for rate-limit data shown in status output.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum StatusRateLimitData {
|
|
/// Snapshot data is recent enough for normal rendering.
|
|
Available(Vec<StatusRateLimitRow>),
|
|
/// Snapshot data exists but is older than the staleness threshold.
|
|
Stale(Vec<StatusRateLimitRow>),
|
|
/// The refresh completed, but the response did not include displayable usage data.
|
|
Unavailable,
|
|
/// No snapshot data is currently available.
|
|
Missing,
|
|
}
|
|
|
|
/// Maximum age before a snapshot is considered stale in status output.
|
|
pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15;
|
|
|
|
/// Display-friendly representation of one usage window from a snapshot.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct RateLimitWindowDisplay {
|
|
/// Percent used for the window.
|
|
pub used_percent: f64,
|
|
/// Human-readable local reset time.
|
|
pub resets_at: Option<String>,
|
|
/// Window length in minutes when provided by the server.
|
|
pub window_minutes: Option<i64>,
|
|
}
|
|
|
|
impl RateLimitWindowDisplay {
|
|
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
|
|
let resets_at_utc = window
|
|
.resets_at
|
|
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
|
|
.map(|dt| dt.with_timezone(&Local));
|
|
let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at));
|
|
|
|
Self {
|
|
used_percent: f64::from(window.used_percent),
|
|
resets_at,
|
|
window_minutes: window.window_duration_mins,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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.
|
|
pub primary: Option<RateLimitWindowDisplay>,
|
|
/// Secondary usage window.
|
|
pub secondary: Option<RateLimitWindowDisplay>,
|
|
/// Optional credits metadata when available.
|
|
pub credits: Option<CreditsSnapshotDisplay>,
|
|
/// Optional effective monthly credit limit from workspace spend controls.
|
|
pub individual_limit: Option<SpendControlLimitSnapshotDisplay>,
|
|
}
|
|
|
|
/// Display-ready credits state extracted from protocol snapshots.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct CreditsSnapshotDisplay {
|
|
/// Whether credits tracking is enabled for the account.
|
|
pub has_credits: bool,
|
|
/// Whether the account has unlimited credits.
|
|
pub unlimited: bool,
|
|
/// Raw balance text as provided by the backend.
|
|
pub balance: Option<String>,
|
|
}
|
|
|
|
/// Display-ready effective monthly limit extracted from spend controls.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct SpendControlLimitSnapshotDisplay {
|
|
/// Local timestamp representing when this monthly usage value was captured.
|
|
pub captured_at: DateTime<Local>,
|
|
pub percent_remaining: f64,
|
|
pub used: String,
|
|
pub limit: String,
|
|
pub resets_at: Option<String>,
|
|
}
|
|
|
|
/// Converts a protocol snapshot into UI-friendly display data.
|
|
///
|
|
/// 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
|
|
.as_ref()
|
|
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
|
secondary: snapshot
|
|
.secondary
|
|
.as_ref()
|
|
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
|
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
|
|
individual_limit: snapshot
|
|
.individual_limit
|
|
.as_ref()
|
|
.and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)),
|
|
}
|
|
}
|
|
|
|
impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
|
|
fn from(value: &CoreCreditsSnapshot) -> Self {
|
|
Self {
|
|
has_credits: value.has_credits,
|
|
unlimited: value.unlimited,
|
|
balance: value.balance.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SpendControlLimitSnapshotDisplay {
|
|
fn from_limit(
|
|
value: &CoreSpendControlLimitSnapshot,
|
|
captured_at: DateTime<Local>,
|
|
) -> Option<Self> {
|
|
Some(Self {
|
|
captured_at,
|
|
percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)),
|
|
used: format_credit_amount(&value.used)?,
|
|
limit: format_credit_amount(&value.limit)?,
|
|
resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0)
|
|
.map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Builds display rows from a snapshot and marks stale data by capture age.
|
|
///
|
|
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
|
|
/// fresh data appear stale or prevent stale warnings from appearing.
|
|
pub(crate) fn compose_rate_limit_data(
|
|
snapshot: Option<&RateLimitSnapshotDisplay>,
|
|
now: DateTime<Local>,
|
|
) -> StatusRateLimitData {
|
|
match snapshot {
|
|
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);
|
|
stale |= snapshot
|
|
.individual_limit
|
|
.as_ref()
|
|
.map(|limit| {
|
|
now.signed_duration_since(limit.captured_at)
|
|
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES)
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
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| {
|
|
limit_label_for_window(window.window_minutes, /*is_secondary*/ false)
|
|
})
|
|
.map(|label| capitalize_first(&label));
|
|
let secondary_label = snapshot
|
|
.secondary
|
|
.as_ref()
|
|
.map(|window| limit_label_for_window(window.window_minutes, /*is_secondary*/ true))
|
|
.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(|| capitalize_first(
|
|
fallback_limit_label(/*is_secondary*/ false)
|
|
))
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} limit",
|
|
primary_label.clone().unwrap_or_else(|| capitalize_first(
|
|
fallback_limit_label(/*is_secondary*/ false)
|
|
))
|
|
)
|
|
};
|
|
rows.push(StatusRateLimitRow {
|
|
label,
|
|
value: StatusRateLimitValue::Window {
|
|
percent_used: primary.used_percent,
|
|
resets_at: primary.resets_at.clone(),
|
|
details: None,
|
|
},
|
|
});
|
|
}
|
|
|
|
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(|| capitalize_first(
|
|
fallback_limit_label(/*is_secondary*/ true)
|
|
))
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} limit",
|
|
secondary_label.clone().unwrap_or_else(|| capitalize_first(
|
|
fallback_limit_label(/*is_secondary*/ true)
|
|
))
|
|
)
|
|
};
|
|
rows.push(StatusRateLimitRow {
|
|
label,
|
|
value: StatusRateLimitValue::Window {
|
|
percent_used: secondary.used_percent,
|
|
resets_at: secondary.resets_at.clone(),
|
|
details: None,
|
|
},
|
|
});
|
|
}
|
|
|
|
if let Some(credits) = snapshot.credits.as_ref()
|
|
&& let Some(row) = credit_status_row(credits)
|
|
{
|
|
rows.push(row);
|
|
}
|
|
if let Some(individual_limit) = snapshot.individual_limit.as_ref() {
|
|
rows.push(StatusRateLimitRow {
|
|
label: "Monthly credit limit".to_string(),
|
|
value: StatusRateLimitValue::Window {
|
|
percent_used: 100.0 - individual_limit.percent_remaining,
|
|
resets_at: individual_limit.resets_at.clone(),
|
|
details: Some(format!(
|
|
"{} of {} credits used",
|
|
individual_limit.used, individual_limit.limit
|
|
)),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if rows.is_empty() {
|
|
StatusRateLimitData::Unavailable
|
|
} 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.
|
|
/// Passing a used percentage by mistake will invert the bar and mislead users.
|
|
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
|
|
let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
|
|
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
|
|
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
|
|
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
|
|
format!(
|
|
"[{}{}]",
|
|
STATUS_LIMIT_BAR_FILLED.repeat(filled),
|
|
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
|
|
)
|
|
}
|
|
|
|
/// Formats a compact textual summary from remaining percentage.
|
|
pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
|
|
format!("{percent_remaining:.0}% left")
|
|
}
|
|
|
|
/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates
|
|
/// that the account has credit tracking enabled. When credits are unlimited we
|
|
/// show that fact explicitly; otherwise we render the rounded balance in
|
|
/// credits. Accounts with credits = 0 skip this section entirely.
|
|
fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimitRow> {
|
|
if !credits.has_credits {
|
|
return None;
|
|
}
|
|
if credits.unlimited {
|
|
return Some(StatusRateLimitRow {
|
|
label: "Credits".to_string(),
|
|
value: StatusRateLimitValue::Text("Unlimited".to_string()),
|
|
});
|
|
}
|
|
let balance = credits.balance.as_ref()?;
|
|
let display_balance = format_credit_balance(balance)?;
|
|
Some(StatusRateLimitRow {
|
|
label: "Credits".to_string(),
|
|
value: StatusRateLimitValue::Text(format!("{display_balance} credits")),
|
|
})
|
|
}
|
|
|
|
fn format_credit_balance(raw: &str) -> Option<String> {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if let Ok(int_value) = trimmed.parse::<i64>()
|
|
&& int_value > 0
|
|
{
|
|
return Some(int_value.to_string());
|
|
}
|
|
|
|
if let Ok(value) = trimmed.parse::<f64>()
|
|
&& value > 0.0
|
|
{
|
|
let rounded = value.round() as i64;
|
|
return Some(rounded.to_string());
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn format_credit_amount(raw: &str) -> Option<String> {
|
|
let value = raw.trim().parse::<f64>().ok()?;
|
|
if !value.is_finite() || value < 0.0 {
|
|
return None;
|
|
}
|
|
Some(format_with_separators(value.round() as i64))
|
|
}
|
|
|
|
#[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(/*used_percent*/ 10.0)),
|
|
secondary: None,
|
|
credits: Some(CreditsSnapshotDisplay {
|
|
has_credits: true,
|
|
unlimited: false,
|
|
balance: Some("25".to_string()),
|
|
}),
|
|
individual_limit: None,
|
|
};
|
|
let other = RateLimitSnapshotDisplay {
|
|
limit_name: "codex-other".to_string(),
|
|
captured_at: now,
|
|
primary: Some(window(/*used_percent*/ 20.0)),
|
|
secondary: None,
|
|
credits: Some(CreditsSnapshotDisplay {
|
|
has_credits: true,
|
|
unlimited: false,
|
|
balance: Some("99".to_string()),
|
|
}),
|
|
individual_limit: None,
|
|
};
|
|
|
|
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: Some(2 * 60),
|
|
}),
|
|
credits: None,
|
|
individual_limit: 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(),
|
|
"Usage limit".to_string(),
|
|
"Secondary usage limit".to_string(),
|
|
]
|
|
);
|
|
}
|
|
}
|