Skip to content

Commit 8a7f946

Browse files
committed
Add spend control individual limit support
Parse and display `individualLimit` (spend control) data from the rate limits API response. Shows monthly usage as the primary card metric with rolling window limits as secondary lines. Adds `SpendControlLimitSnapshot` struct and formatting helpers for credit amounts.
1 parent eafdff2 commit 8a7f946

2 files changed

Lines changed: 176 additions & 12 deletions

File tree

src/codex_rpc/mod.rs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,20 @@ pub struct CreditsSnapshot {
3232
pub balance: Option<String>,
3333
}
3434

35+
#[derive(Debug, Clone)]
36+
pub struct SpendControlLimitSnapshot {
37+
pub limit: Option<String>,
38+
pub remaining_percent: Option<f64>,
39+
pub resets_at: Option<i64>,
40+
pub used: Option<String>,
41+
}
42+
3543
#[derive(Debug, Clone)]
3644
pub struct RateLimitSnapshot {
3745
pub limit_id: Option<String>,
3846
pub limit_name: Option<String>,
3947
pub plan_type: Option<String>,
48+
pub individual_limit: Option<SpendControlLimitSnapshot>,
4049
pub primary: Option<RateLimitWindow>,
4150
pub secondary: Option<RateLimitWindow>,
4251
pub credits: Option<CreditsSnapshot>,
@@ -47,6 +56,7 @@ pub struct AccountRateLimits {
4756
pub limit_id: Option<String>,
4857
pub limit_name: Option<String>,
4958
pub plan_type: Option<String>,
59+
pub individual_limit: Option<SpendControlLimitSnapshot>,
5060
pub primary: Option<RateLimitWindow>,
5161
pub secondary: Option<RateLimitWindow>,
5262
pub credits: Option<CreditsSnapshot>,
@@ -607,6 +617,7 @@ fn parse_rate_limits(value: &Value) -> Result<AccountRateLimits> {
607617
limit_id: snapshot.limit_id.clone(),
608618
limit_name: snapshot.limit_name.clone(),
609619
plan_type: snapshot.plan_type.clone(),
620+
individual_limit: snapshot.individual_limit.clone(),
610621
primary: snapshot.primary.clone(),
611622
secondary: snapshot.secondary.clone(),
612623
credits: snapshot.credits.clone(),
@@ -621,6 +632,10 @@ fn parse_rate_limit_snapshot(value: &Value) -> Option<RateLimitSnapshot> {
621632
let limit_id = read_string(obj.get("limitId").or_else(|| obj.get("limit_id")));
622633
let limit_name = read_string(obj.get("limitName").or_else(|| obj.get("limit_name")));
623634
let plan_type = read_string(obj.get("planType").or_else(|| obj.get("plan_type")));
635+
let individual_limit = obj
636+
.get("individualLimit")
637+
.or_else(|| obj.get("individual_limit"))
638+
.and_then(parse_individual_limit);
624639
let primary = obj.get("primary").and_then(parse_window);
625640
let secondary = obj.get("secondary").and_then(parse_window);
626641

@@ -651,12 +666,29 @@ fn parse_rate_limit_snapshot(value: &Value) -> Option<RateLimitSnapshot> {
651666
limit_id,
652667
limit_name,
653668
plan_type,
669+
individual_limit,
654670
primary,
655671
secondary,
656672
credits,
657673
})
658674
}
659675

676+
fn parse_individual_limit(value: &Value) -> Option<SpendControlLimitSnapshot> {
677+
let obj = value.as_object()?;
678+
Some(SpendControlLimitSnapshot {
679+
limit: read_scalar_string(obj.get("limit")),
680+
remaining_percent: obj
681+
.get("remainingPercent")
682+
.or_else(|| obj.get("remaining_percent"))
683+
.and_then(as_f64),
684+
resets_at: obj
685+
.get("resetsAt")
686+
.or_else(|| obj.get("resets_at"))
687+
.and_then(as_i64_maybe_float),
688+
used: read_scalar_string(obj.get("used")),
689+
})
690+
}
691+
660692
fn parse_window(value: &Value) -> Option<RateLimitWindow> {
661693
let obj = value.as_object()?;
662694
let used_percent = obj
@@ -685,6 +717,16 @@ fn read_string(value: Option<&Value>) -> Option<String> {
685717
(!value.is_empty()).then(|| value.to_string())
686718
}
687719

720+
fn read_scalar_string(value: Option<&Value>) -> Option<String> {
721+
let value = value?;
722+
value
723+
.as_str()
724+
.map(|s| s.trim().to_string())
725+
.or_else(|| value.as_i64().map(|n| n.to_string()))
726+
.or_else(|| value.as_f64().map(|n| n.to_string()))
727+
.filter(|s| !s.is_empty())
728+
}
729+
688730
fn as_f64(value: &Value) -> Option<f64> {
689731
value
690732
.as_f64()
@@ -739,23 +781,38 @@ mod tests {
739781
"rateLimitResetCredits": { "availableCount": 2 },
740782
"rateLimits": {
741783
"limitId": "codex",
742-
"primary": { "usedPercent": 10, "windowDurationMins": 300 },
743-
"secondary": { "usedPercent": 20, "windowDurationMins": 10080 }
784+
"individualLimit": {
785+
"limit": "60000",
786+
"remainingPercent": 99,
787+
"resetsAt": 1785523200,
788+
"used": "564"
789+
}
744790
},
745791
"rateLimitsByLimitId": {
746792
"codex_extra": {
747793
"limitName": "GPT-5.3-Codex-Spark",
748-
"primary": { "usedPercent": 30, "windowDurationMins": 300 }
794+
"primary": { "usedPercent": 30, "windowDurationMins": 300 },
795+
"secondary": { "usedPercent": 40, "windowDurationMins": 10080 }
749796
},
750797
"codex": {
751-
"primary": { "usedPercent": 10, "windowDurationMins": 300 }
798+
"individualLimit": {
799+
"limit": "60000",
800+
"remainingPercent": 99,
801+
"resetsAt": 1785523200,
802+
"used": "564"
803+
}
752804
}
753805
}
754806
});
755807

756808
let limits =
757809
parse_account_rate_limits_result(&v).expect("parse_account_rate_limits_result");
758810
assert_eq!(limits.limit_id.as_deref(), Some("codex"));
811+
let individual_limit = limits.individual_limit.expect("individual limit");
812+
assert_eq!(individual_limit.limit.as_deref(), Some("60000"));
813+
assert_eq!(individual_limit.remaining_percent, Some(99.0));
814+
assert_eq!(individual_limit.resets_at, Some(1785523200));
815+
assert_eq!(individual_limit.used.as_deref(), Some("564"));
759816
assert_eq!(limits.reset_credits_available, Some(2));
760817
assert_eq!(limits.buckets.len(), 2);
761818
assert_eq!(limits.buckets[0].limit_id.as_deref(), Some("codex"));

src/ui/mod.rs

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2052,32 +2052,88 @@ fn format_limit_compact_line(
20522052
format!("{label}{pct}{resets}")
20532053
}
20542054

2055-
fn format_limits_compact_lines(l: &crate::codex_rpc::AccountRateLimits) -> (String, String) {
2056-
let primary_label = l
2057-
.primary
2058-
.as_ref()
2055+
fn format_rolling_limit_lines(
2056+
primary: Option<&crate::codex_rpc::RateLimitWindow>,
2057+
secondary: Option<&crate::codex_rpc::RateLimitWindow>,
2058+
) -> (String, String) {
2059+
let primary_label = primary
20592060
.and_then(|w| w.window_duration_mins)
20602061
.and_then(format_window_label)
20612062
.map(|w| format!("{w} limit:"))
20622063
.unwrap_or_else(|| "5h limit:".to_string());
2063-
let l1 = format_limit_compact_line(&primary_label, l.primary.as_ref());
2064-
let l2 = format_limit_compact_line("Weekly:", l.secondary.as_ref());
2064+
let l1 = format_limit_compact_line(&primary_label, primary);
2065+
let l2 = format_limit_compact_line("Weekly:", secondary);
20652066
(l1, l2)
20662067
}
20672068

20682069
fn format_limits_compact_card_lines(
20692070
l: &crate::codex_rpc::AccountRateLimits,
20702071
) -> (String, Option<String>, Option<String>, Option<String>) {
2071-
let (primary, secondary) = format_limits_compact_lines(l);
2072-
let credits = format_credits_compact_line(l).unwrap_or_else(|| "Credits: --".to_string());
2072+
let (rolling_primary, rolling_secondary) = rolling_windows_for_limits(l);
2073+
let (primary, secondary) = format_rolling_limit_lines(rolling_primary, rolling_secondary);
2074+
if let Some((monthly, used)) = format_individual_limit_compact_lines(l) {
2075+
return (monthly, Some(used), Some(primary), Some(secondary));
2076+
}
20732077

20742078
if let Some(extra) = format_extra_bucket_compact_line(l) {
2079+
let credits = format_credits_compact_line(l).unwrap_or_else(|| "Credits: --".to_string());
20752080
(primary, Some(secondary), Some(extra), Some(credits))
20762081
} else {
2082+
let credits = format_credits_compact_line(l).unwrap_or_else(|| "Credits: --".to_string());
20772083
(primary, Some(secondary), Some(credits), None)
20782084
}
20792085
}
20802086

2087+
fn rolling_windows_for_limits(
2088+
l: &crate::codex_rpc::AccountRateLimits,
2089+
) -> (
2090+
Option<&crate::codex_rpc::RateLimitWindow>,
2091+
Option<&crate::codex_rpc::RateLimitWindow>,
2092+
) {
2093+
if l.primary.is_some() || l.secondary.is_some() {
2094+
return (l.primary.as_ref(), l.secondary.as_ref());
2095+
}
2096+
l.buckets
2097+
.iter()
2098+
.find(|bucket| bucket.primary.is_some() || bucket.secondary.is_some())
2099+
.map(|bucket| (bucket.primary.as_ref(), bucket.secondary.as_ref()))
2100+
.unwrap_or((None, None))
2101+
}
2102+
2103+
fn format_individual_limit_compact_lines(
2104+
l: &crate::codex_rpc::AccountRateLimits,
2105+
) -> Option<(String, String)> {
2106+
let individual_limit = l.individual_limit.as_ref().or_else(|| {
2107+
l.buckets
2108+
.iter()
2109+
.find_map(|bucket| bucket.individual_limit.as_ref())
2110+
})?;
2111+
2112+
const LABEL_W: usize = 10;
2113+
let remaining = individual_limit
2114+
.remaining_percent
2115+
.filter(|v| v.is_finite())
2116+
.map(|v| format!("{}%", v.round() as i64))
2117+
.unwrap_or_else(|| "--%".to_string());
2118+
let resets = resets_label(individual_limit.resets_at)
2119+
.map(|s| format!(" ({s})"))
2120+
.unwrap_or_default();
2121+
let monthly = format!("{:<LABEL_W$}{remaining}{resets}", "Monthly:");
2122+
2123+
let used = individual_limit
2124+
.used
2125+
.as_deref()
2126+
.map(format_credit_amount)
2127+
.unwrap_or_else(|| "--".to_string());
2128+
let limit = individual_limit
2129+
.limit
2130+
.as_deref()
2131+
.map(format_credit_amount)
2132+
.unwrap_or_else(|| "--".to_string());
2133+
let used_line = format!("{:<LABEL_W$}{used}/{limit} used", "Credits:");
2134+
Some((monthly, used_line))
2135+
}
2136+
20812137
fn format_extra_bucket_compact_line(l: &crate::codex_rpc::AccountRateLimits) -> Option<String> {
20822138
let active_id = l.limit_id.as_deref();
20832139
let active_name = l.limit_name.as_deref();
@@ -2109,6 +2165,15 @@ fn compact_bucket_label(bucket: &crate::codex_rpc::RateLimitSnapshot) -> String
21092165
format!("{}:", truncate_middle(cleaned, 9))
21102166
}
21112167

2168+
fn format_credit_amount(raw: &str) -> String {
2169+
let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect();
2170+
let cleaned = cleaned.trim();
2171+
let number = cleaned.parse::<f64>().ok().filter(|v| v.is_finite());
2172+
number
2173+
.map(|v| format_count(v.round() as i64))
2174+
.unwrap_or_else(|| truncate_middle(cleaned, 12))
2175+
}
2176+
21122177
fn format_credits_compact_line(l: &crate::codex_rpc::AccountRateLimits) -> Option<String> {
21132178
const LABEL_W: usize = 10;
21142179
let credits = l.credits.as_ref()?;
@@ -2255,6 +2320,48 @@ mod tests {
22552320
assert_eq!(truncate_middle("hello", 3), "...");
22562321
}
22572322

2323+
#[test]
2324+
fn limits_card_uses_monthly_and_named_rolling_bucket() {
2325+
let limits = crate::codex_rpc::AccountRateLimits {
2326+
limit_id: Some("codex".to_string()),
2327+
limit_name: None,
2328+
plan_type: Some("enterprise".to_string()),
2329+
individual_limit: Some(crate::codex_rpc::SpendControlLimitSnapshot {
2330+
limit: Some("60000".to_string()),
2331+
remaining_percent: Some(99.0),
2332+
resets_at: None,
2333+
used: Some("564".to_string()),
2334+
}),
2335+
primary: None,
2336+
secondary: None,
2337+
credits: None,
2338+
buckets: vec![crate::codex_rpc::RateLimitSnapshot {
2339+
limit_id: Some("codex_bengalfox".to_string()),
2340+
limit_name: Some("GPT-5.3-Codex-Spark-Preview".to_string()),
2341+
plan_type: Some("enterprise".to_string()),
2342+
individual_limit: None,
2343+
primary: Some(crate::codex_rpc::RateLimitWindow {
2344+
used_percent: Some(0.0),
2345+
window_duration_mins: Some(300.0),
2346+
resets_at: None,
2347+
}),
2348+
secondary: Some(crate::codex_rpc::RateLimitWindow {
2349+
used_percent: Some(0.0),
2350+
window_duration_mins: Some(10080.0),
2351+
resets_at: None,
2352+
}),
2353+
credits: None,
2354+
}],
2355+
reset_credits_available: None,
2356+
};
2357+
2358+
let (value, caption1, caption2, caption3) = format_limits_compact_card_lines(&limits);
2359+
assert_eq!(value, "Monthly: 99%");
2360+
assert_eq!(caption1.as_deref(), Some("Credits: 564/60,000 used"));
2361+
assert_eq!(caption2.as_deref(), Some("5h limit: 100%"));
2362+
assert_eq!(caption3.as_deref(), Some("Weekly: 100%"));
2363+
}
2364+
22582365
#[test]
22592366
fn horizontal_tokens_show_total_and_out_of_cache() {
22602367
let out =

0 commit comments

Comments
 (0)