mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
feat: show enterprise monthly credit limits in status (#24812)
## 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.
This commit is contained in:
1 parent
c955f73078
commit
c8e5db16c9
40 files changed
+822
-11
No files matched your search
@@ -150,8 +150,27 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RateLimitSnapshotSource {
|
||||
AccountUsage,
|
||||
RollingUpdate,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);
|
||||
}
|
||||
|
||||
pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) {
|
||||
// Rolling app-server notifications are sparse. Preserve metadata learned from the full read.
|
||||
self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate);
|
||||
}
|
||||
|
||||
fn on_rate_limit_snapshot_from(
|
||||
&mut self,
|
||||
snapshot: Option<RateLimitSnapshot>,
|
||||
source: RateLimitSnapshotSource,
|
||||
) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
@@ -172,7 +191,16 @@ impl ChatWidget {
|
||||
balance: credits.balance.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let preserved_individual_limit =
|
||||
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
|
||||
&& snapshot.individual_limit.is_none()
|
||||
{
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
.and_then(|display| display.individual_limit.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
@@ -234,8 +262,11 @@ impl ChatWidget {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
|
||||
let display =
|
||||
let mut display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
if display.individual_limit.is_none() {
|
||||
display.individual_limit = preserved_individual_limit;
|
||||
}
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::bottom_pane::goal_status_indicator_line;
|
||||
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
|
||||
use crate::chatwidget::rate_limits::get_limits_duration;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::backend::TestBackend;
|
||||
use serial_test::serial;
|
||||
@@ -580,6 +581,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -608,6 +610,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -640,6 +643,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -668,6 +672,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -696,6 +701,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -724,6 +730,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item()
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -752,6 +759,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
unlimited: false,
|
||||
balance: Some("17.5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -772,6 +780,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -793,6 +802,40 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut usage_limits = snapshot(/*percent*/ 10.0);
|
||||
usage_limits.individual_limit = Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: 1_800_000_000,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(usage_limits));
|
||||
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0));
|
||||
|
||||
let display = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
let individual_limit = display
|
||||
.individual_limit
|
||||
.as_ref()
|
||||
.expect("rolling updates should preserve monthly limits");
|
||||
assert_eq!(individual_limit.used, "8,000");
|
||||
assert_eq!(individual_limit.limit, "25,000");
|
||||
assert_eq!(individual_limit.percent_remaining, 68.0);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0)));
|
||||
let display = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
assert!(display.individual_limit.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -811,6 +854,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -830,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: Some(234),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -849,6 +894,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: Some(567),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -873,6 +919,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
unlimited: false,
|
||||
balance: Some("5.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -887,6 +934,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -940,6 +988,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -76,6 +76,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -233,6 +234,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user