mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
c8e5db16c9
## 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.
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
use codex_protocol::account::PlanType;
|
|
use codex_protocol::protocol::CreditsSnapshot;
|
|
use codex_protocol::protocol::RateLimitReachedType;
|
|
use codex_protocol::protocol::RateLimitSnapshot;
|
|
use codex_protocol::protocol::RateLimitWindow;
|
|
use http::HeaderMap;
|
|
use serde::Deserialize;
|
|
use std::collections::BTreeSet;
|
|
use std::fmt::Display;
|
|
|
|
#[derive(Debug)]
|
|
pub struct RateLimitError {
|
|
pub message: String,
|
|
}
|
|
|
|
impl Display for RateLimitError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.message)
|
|
}
|
|
}
|
|
|
|
/// Parses the default Codex rate-limit header family into a `RateLimitSnapshot`.
|
|
pub fn parse_default_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
|
parse_rate_limit_for_limit(headers, /*limit_id*/ None)
|
|
}
|
|
|
|
/// Parses all known rate-limit header families into update records keyed by limit id.
|
|
pub fn parse_all_rate_limits(headers: &HeaderMap) -> Vec<RateLimitSnapshot> {
|
|
let mut snapshots = Vec::new();
|
|
if let Some(snapshot) = parse_default_rate_limit(headers) {
|
|
snapshots.push(snapshot);
|
|
}
|
|
|
|
let mut limit_ids: BTreeSet<String> = BTreeSet::new();
|
|
|
|
for name in headers.keys() {
|
|
let header_name = name.as_str().to_ascii_lowercase();
|
|
if let Some(limit_id) = header_name_to_limit_id(&header_name)
|
|
&& limit_id != "codex"
|
|
{
|
|
limit_ids.insert(limit_id);
|
|
}
|
|
}
|
|
|
|
snapshots.extend(limit_ids.into_iter().filter_map(|limit_id| {
|
|
let snapshot = parse_rate_limit_for_limit(headers, Some(limit_id.as_str()))?;
|
|
has_rate_limit_data(&snapshot).then_some(snapshot)
|
|
}));
|
|
|
|
snapshots
|
|
}
|
|
|
|
/// Parses rate-limit headers for the provided limit id.
|
|
///
|
|
/// `limit_id` should match the server-provided metered limit id (e.g. `codex`,
|
|
/// `codex_other`). When omitted, this defaults to the legacy `codex` header family.
|
|
pub fn parse_rate_limit_for_limit(
|
|
headers: &HeaderMap,
|
|
limit_id: Option<&str>,
|
|
) -> Option<RateLimitSnapshot> {
|
|
let normalized_limit = limit_id
|
|
.map(str::trim)
|
|
.filter(|name| !name.is_empty())
|
|
.unwrap_or("codex")
|
|
.to_ascii_lowercase()
|
|
.replace('_', "-");
|
|
let prefix = format!("x-{normalized_limit}");
|
|
let primary = parse_rate_limit_window(
|
|
headers,
|
|
&format!("{prefix}-primary-used-percent"),
|
|
&format!("{prefix}-primary-window-minutes"),
|
|
&format!("{prefix}-primary-reset-at"),
|
|
);
|
|
|
|
let secondary = parse_rate_limit_window(
|
|
headers,
|
|
&format!("{prefix}-secondary-used-percent"),
|
|
&format!("{prefix}-secondary-window-minutes"),
|
|
&format!("{prefix}-secondary-reset-at"),
|
|
);
|
|
|
|
let normalized_limit_id = normalize_limit_id(normalized_limit);
|
|
let credits = parse_credits_snapshot(headers);
|
|
let limit_name_header = format!("{prefix}-limit-name");
|
|
let parsed_limit_name = parse_header_str(headers, &limit_name_header)
|
|
.map(str::trim)
|
|
.filter(|name| !name.is_empty())
|
|
.map(std::string::ToString::to_string);
|
|
|
|
Some(RateLimitSnapshot {
|
|
limit_id: Some(normalized_limit_id),
|
|
limit_name: parsed_limit_name,
|
|
primary,
|
|
secondary,
|
|
credits,
|
|
individual_limit: None,
|
|
plan_type: None,
|
|
rate_limit_reached_type: None,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RateLimitEventWindow {
|
|
used_percent: f64,
|
|
window_minutes: Option<i64>,
|
|
reset_at: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RateLimitEventDetails {
|
|
primary: Option<RateLimitEventWindow>,
|
|
secondary: Option<RateLimitEventWindow>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RateLimitEventCredits {
|
|
has_credits: bool,
|
|
unlimited: bool,
|
|
balance: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RateLimitEvent {
|
|
#[serde(rename = "type")]
|
|
kind: String,
|
|
plan_type: Option<PlanType>,
|
|
rate_limits: Option<RateLimitEventDetails>,
|
|
credits: Option<RateLimitEventCredits>,
|
|
metered_limit_name: Option<String>,
|
|
limit_name: Option<String>,
|
|
}
|
|
|
|
pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
|
let event: RateLimitEvent = serde_json::from_str(payload).ok()?;
|
|
if event.kind != "codex.rate_limits" {
|
|
return None;
|
|
}
|
|
let (primary, secondary) = if let Some(details) = event.rate_limits.as_ref() {
|
|
(
|
|
map_event_window(details.primary.as_ref()),
|
|
map_event_window(details.secondary.as_ref()),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
let credits = event.credits.map(|credits| CreditsSnapshot {
|
|
has_credits: credits.has_credits,
|
|
unlimited: credits.unlimited,
|
|
balance: credits.balance,
|
|
});
|
|
let limit_id = event
|
|
.metered_limit_name
|
|
.or(event.limit_name)
|
|
.map(normalize_limit_id);
|
|
Some(RateLimitSnapshot {
|
|
limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())),
|
|
limit_name: None,
|
|
primary,
|
|
secondary,
|
|
credits,
|
|
individual_limit: None,
|
|
plan_type: event.plan_type,
|
|
rate_limit_reached_type: None,
|
|
})
|
|
}
|
|
|
|
fn map_event_window(window: Option<&RateLimitEventWindow>) -> Option<RateLimitWindow> {
|
|
let window = window?;
|
|
Some(RateLimitWindow {
|
|
used_percent: window.used_percent,
|
|
window_minutes: window.window_minutes,
|
|
resets_at: window.reset_at,
|
|
})
|
|
}
|
|
|
|
/// Parses the bespoke Codex rate-limit headers into a `RateLimitSnapshot`.
|
|
pub fn parse_promo_message(headers: &HeaderMap) -> Option<String> {
|
|
parse_header_str(headers, "x-codex-promo-message")
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(std::string::ToString::to_string)
|
|
}
|
|
|
|
pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option<RateLimitReachedType> {
|
|
parse_header_str(headers, "x-codex-rate-limit-reached-type")?
|
|
.trim()
|
|
.parse()
|
|
.ok()
|
|
}
|
|
|
|
fn parse_rate_limit_window(
|
|
headers: &HeaderMap,
|
|
used_percent_header: &str,
|
|
window_minutes_header: &str,
|
|
resets_at_header: &str,
|
|
) -> Option<RateLimitWindow> {
|
|
let used_percent: Option<f64> = parse_header_f64(headers, used_percent_header);
|
|
|
|
used_percent.and_then(|used_percent| {
|
|
let window_minutes = parse_header_i64(headers, window_minutes_header);
|
|
let resets_at = parse_header_i64(headers, resets_at_header);
|
|
|
|
let has_data = used_percent != 0.0
|
|
|| window_minutes.is_some_and(|minutes| minutes != 0)
|
|
|| resets_at.is_some();
|
|
|
|
has_data.then_some(RateLimitWindow {
|
|
used_percent,
|
|
window_minutes,
|
|
resets_at,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn parse_credits_snapshot(headers: &HeaderMap) -> Option<CreditsSnapshot> {
|
|
let has_credits = parse_header_bool(headers, "x-codex-credits-has-credits")?;
|
|
let unlimited = parse_header_bool(headers, "x-codex-credits-unlimited")?;
|
|
let balance = parse_header_str(headers, "x-codex-credits-balance")
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(std::string::ToString::to_string);
|
|
Some(CreditsSnapshot {
|
|
has_credits,
|
|
unlimited,
|
|
balance,
|
|
})
|
|
}
|
|
|
|
fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option<f64> {
|
|
parse_header_str(headers, name)?
|
|
.parse::<f64>()
|
|
.ok()
|
|
.filter(|v| v.is_finite())
|
|
}
|
|
|
|
fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option<i64> {
|
|
parse_header_str(headers, name)?.parse::<i64>().ok()
|
|
}
|
|
|
|
fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
|
|
let raw = parse_header_str(headers, name)?;
|
|
if raw.eq_ignore_ascii_case("true") || raw == "1" {
|
|
Some(true)
|
|
} else if raw.eq_ignore_ascii_case("false") || raw == "0" {
|
|
Some(false)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
|
headers.get(name)?.to_str().ok()
|
|
}
|
|
|
|
fn has_rate_limit_data(snapshot: &RateLimitSnapshot) -> bool {
|
|
snapshot.primary.is_some() || snapshot.secondary.is_some() || snapshot.credits.is_some()
|
|
}
|
|
|
|
fn header_name_to_limit_id(header_name: &str) -> Option<String> {
|
|
let suffix = "-primary-used-percent";
|
|
let prefix = header_name.strip_suffix(suffix)?;
|
|
let limit = prefix.strip_prefix("x-")?;
|
|
Some(normalize_limit_id(limit.to_string()))
|
|
}
|
|
|
|
fn normalize_limit_id(name: impl Into<String>) -> String {
|
|
name.into().trim().to_ascii_lowercase().replace('-', "_")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use http::HeaderValue;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn parse_rate_limit_for_limit_defaults_to_codex_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-codex-primary-used-percent",
|
|
HeaderValue::from_static("12.5"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-primary-window-minutes",
|
|
HeaderValue::from_static("60"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-primary-reset-at",
|
|
HeaderValue::from_static("1704069000"),
|
|
);
|
|
|
|
let snapshot = parse_rate_limit_for_limit(&headers, /*limit_id*/ None).expect("snapshot");
|
|
assert_eq!(snapshot.limit_id.as_deref(), Some("codex"));
|
|
assert_eq!(snapshot.limit_name, None);
|
|
let primary = snapshot.primary.expect("primary");
|
|
assert_eq!(primary.used_percent, 12.5);
|
|
assert_eq!(primary.window_minutes, Some(60));
|
|
assert_eq!(primary.resets_at, Some(1704069000));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rate_limit_for_limit_reads_secondary_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-codex-secondary-primary-used-percent",
|
|
HeaderValue::from_static("80"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-secondary-primary-window-minutes",
|
|
HeaderValue::from_static("1440"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-secondary-primary-reset-at",
|
|
HeaderValue::from_static("1704074400"),
|
|
);
|
|
|
|
let snapshot =
|
|
parse_rate_limit_for_limit(&headers, Some("codex_secondary")).expect("snapshot");
|
|
assert_eq!(snapshot.limit_id.as_deref(), Some("codex_secondary"));
|
|
assert_eq!(snapshot.limit_name, None);
|
|
let primary = snapshot.primary.expect("primary");
|
|
assert_eq!(primary.used_percent, 80.0);
|
|
assert_eq!(primary.window_minutes, Some(1440));
|
|
assert_eq!(primary.resets_at, Some(1704074400));
|
|
assert_eq!(snapshot.secondary, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rate_limit_for_limit_prefers_limit_name_header() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-codex-bengalfox-primary-used-percent",
|
|
HeaderValue::from_static("80"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-bengalfox-limit-name",
|
|
HeaderValue::from_static("gpt-5.2-codex-sonic"),
|
|
);
|
|
|
|
let snapshot =
|
|
parse_rate_limit_for_limit(&headers, Some("codex_bengalfox")).expect("snapshot");
|
|
assert_eq!(snapshot.limit_id.as_deref(), Some("codex_bengalfox"));
|
|
assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-5.2-codex-sonic"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_all_rate_limits_reads_all_limit_families() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-codex-primary-used-percent",
|
|
HeaderValue::from_static("12.5"),
|
|
);
|
|
headers.insert(
|
|
"x-codex-secondary-primary-used-percent",
|
|
HeaderValue::from_static("80"),
|
|
);
|
|
|
|
let updates = parse_all_rate_limits(&headers);
|
|
assert_eq!(updates.len(), 2);
|
|
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
|
|
assert_eq!(updates[1].limit_id.as_deref(), Some("codex_secondary"));
|
|
assert_eq!(updates[0].limit_name, None);
|
|
assert_eq!(updates[1].limit_name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_all_rate_limits_includes_default_codex_snapshot() {
|
|
let headers = HeaderMap::new();
|
|
|
|
let updates = parse_all_rate_limits(&headers);
|
|
assert_eq!(updates.len(), 1);
|
|
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
|
|
assert_eq!(updates[0].limit_name, None);
|
|
assert_eq!(updates[0].primary, None);
|
|
assert_eq!(updates[0].secondary, None);
|
|
assert_eq!(updates[0].credits, None);
|
|
}
|
|
}
|