Skip to content

Commit eafdff2

Browse files
committed
Handle rate limit update notifications
Wire Codex RPC notifications into the limits refresh loop so `account/rateLimits/updated` triggers immediate polling instead of waiting for the interval. Expand rate-limit parsing to support richer response shapes (limit metadata, per-limit buckets, reset-credit availability, and extra window field aliases), and update the UI limits card to render up to three caption lines so an additional bucket summary can be shown alongside credits.
1 parent 13a8f21 commit eafdff2

3 files changed

Lines changed: 264 additions & 48 deletions

File tree

src/app/mod.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ pub(crate) const DEFAULT_ACTIVITY_PROJECT_LIMIT: usize = 5;
114114
pub(crate) const MIN_ACTIVITY_PROJECT_LIMIT: usize = 1;
115115
pub(crate) const MAX_ACTIVITY_PROJECT_LIMIT: usize = 50;
116116

117+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118+
enum LimitsWake {
119+
Poll,
120+
Stop,
121+
}
122+
117123
#[derive(Debug, Clone, PartialEq, Eq)]
118124
struct PersistedUiState {
119125
metric: UsageMetric,
@@ -337,18 +343,35 @@ async fn run_inner(
337343
}
338344
interval.tick().await;
339345
loop {
340-
tokio::select! {
346+
let wake = tokio::select! {
341347
_ = shutdown_rx.changed() => {
342-
rpc.kill().await;
343-
break;
348+
LimitsWake::Stop
349+
}
350+
_ = interval.tick() => {
351+
LimitsWake::Poll
344352
}
345-
_ = interval.tick() => {}
346353
recv = limits_refresh_rx.recv() => {
347354
if recv.is_none() {
348-
rpc.kill().await;
349-
break;
355+
LimitsWake::Stop
356+
} else {
357+
LimitsWake::Poll
358+
}
359+
}
360+
notification = rpc.recv_notification() => {
361+
match notification {
362+
Some(value)
363+
if crate::codex_rpc::is_account_rate_limits_updated_notification(&value) =>
364+
{
365+
LimitsWake::Poll
366+
}
367+
Some(_) => continue,
368+
None => LimitsWake::Stop,
350369
}
351370
}
371+
};
372+
if wake == LimitsWake::Stop {
373+
rpc.kill().await;
374+
break;
352375
}
353376
let res = rpc.read_account_rate_limits().await;
354377
if evt_tx.send(AppEvent::LimitsUpdated(res)).await.is_err() {

src/codex_rpc/mod.rs

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::sync::Arc;
1111
use std::time::Duration;
1212
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
1313
use tokio::process::{Child, ChildStdin, Command};
14-
use tokio::sync::{oneshot, Mutex};
14+
use tokio::sync::{mpsc, oneshot, Mutex};
1515
use tokio::time::timeout;
1616

1717
const MAX_RPC_LINE_BYTES: usize = 1024 * 1024;
@@ -32,11 +32,26 @@ pub struct CreditsSnapshot {
3232
pub balance: Option<String>,
3333
}
3434

35+
#[derive(Debug, Clone)]
36+
pub struct RateLimitSnapshot {
37+
pub limit_id: Option<String>,
38+
pub limit_name: Option<String>,
39+
pub plan_type: Option<String>,
40+
pub primary: Option<RateLimitWindow>,
41+
pub secondary: Option<RateLimitWindow>,
42+
pub credits: Option<CreditsSnapshot>,
43+
}
44+
3545
#[derive(Debug, Clone)]
3646
pub struct AccountRateLimits {
47+
pub limit_id: Option<String>,
48+
pub limit_name: Option<String>,
49+
pub plan_type: Option<String>,
3750
pub primary: Option<RateLimitWindow>,
3851
pub secondary: Option<RateLimitWindow>,
3952
pub credits: Option<CreditsSnapshot>,
53+
pub buckets: Vec<RateLimitSnapshot>,
54+
pub reset_credits_available: Option<i64>,
4055
}
4156

4257
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -108,6 +123,7 @@ pub struct CodexRpc {
108123
child: Mutex<Child>,
109124
stdin: Mutex<ChildStdin>,
110125
pending: Mutex<HashMap<u64, oneshot::Sender<Value>>>,
126+
notifications: Mutex<mpsc::Receiver<Value>>,
111127
next_id: Mutex<u64>,
112128
}
113129

@@ -134,10 +150,12 @@ impl CodexRpc {
134150
.take()
135151
.ok_or_else(|| anyhow!("missing stderr"))?;
136152

153+
let (notification_tx, notification_rx) = mpsc::channel(MAX_PENDING_REQUESTS);
137154
let rpc = Arc::new(Self {
138155
child: Mutex::new(child),
139156
stdin: Mutex::new(stdin),
140157
pending: Mutex::new(HashMap::new()),
158+
notifications: Mutex::new(notification_rx),
141159
next_id: Mutex::new(1),
142160
});
143161

@@ -186,7 +204,11 @@ impl CodexRpc {
186204
if let Some(tx) = rpc.pending.lock().await.remove(&id) {
187205
let _ = tx.send(value);
188206
}
207+
} else if value.get("method").is_some() {
208+
let _ = notification_tx.try_send(value);
189209
}
210+
} else if value.get("method").is_some() {
211+
let _ = notification_tx.try_send(value);
190212
}
191213
}
192214

@@ -309,13 +331,11 @@ impl CodexRpc {
309331
}
310332

311333
let result = value.get("result").cloned().unwrap_or(Value::Null);
312-
let rate_limits = result
313-
.get("rateLimits")
314-
.or_else(|| result.get("rate_limits"))
315-
.cloned()
316-
.unwrap_or(Value::Null);
334+
parse_account_rate_limits_result(&result)
335+
}
317336

318-
parse_rate_limits(&rate_limits)
337+
pub async fn recv_notification(&self) -> Option<Value> {
338+
self.notifications.lock().await.recv().await
319339
}
320340

321341
pub async fn kill(&self) {
@@ -324,6 +344,10 @@ impl CodexRpc {
324344
}
325345
}
326346

347+
pub fn is_account_rate_limits_updated_notification(value: &Value) -> bool {
348+
value.get("method").and_then(|v| v.as_str()) == Some("account/rateLimits/updated")
349+
}
350+
327351
fn non_empty_string(value: String) -> Option<String> {
328352
let trimmed = value.trim();
329353
if trimmed.is_empty() {
@@ -536,11 +560,67 @@ fn find_app_server_recursive(
536560
None
537561
}
538562

563+
fn parse_account_rate_limits_result(result: &Value) -> Result<AccountRateLimits> {
564+
let rate_limits = result
565+
.get("rateLimits")
566+
.or_else(|| result.get("rate_limits"))
567+
.cloned()
568+
.unwrap_or(Value::Null);
569+
let mut limits = parse_rate_limits(&rate_limits)?;
570+
571+
limits.reset_credits_available = result
572+
.get("rateLimitResetCredits")
573+
.or_else(|| result.get("rate_limit_reset_credits"))
574+
.and_then(|v| v.as_object())
575+
.and_then(|v| {
576+
v.get("availableCount")
577+
.or_else(|| v.get("available_count"))
578+
.and_then(as_i64_maybe_float)
579+
});
580+
581+
if let Some(by_limit_id) = result
582+
.get("rateLimitsByLimitId")
583+
.or_else(|| result.get("rate_limits_by_limit_id"))
584+
.and_then(|v| v.as_object())
585+
{
586+
let mut buckets = Vec::<RateLimitSnapshot>::with_capacity(by_limit_id.len());
587+
for (key, value) in by_limit_id {
588+
if let Some(mut snapshot) = parse_rate_limit_snapshot(value) {
589+
if snapshot.limit_id.is_none() {
590+
snapshot.limit_id = Some(key.clone());
591+
}
592+
buckets.push(snapshot);
593+
}
594+
}
595+
buckets.sort_by(|a, b| a.limit_id.cmp(&b.limit_id));
596+
limits.buckets = buckets;
597+
}
598+
599+
Ok(limits)
600+
}
601+
539602
fn parse_rate_limits(value: &Value) -> Result<AccountRateLimits> {
540-
let obj = value
541-
.as_object()
542-
.ok_or_else(|| anyhow!("rate limits missing"))?;
603+
let snapshot =
604+
parse_rate_limit_snapshot(value).ok_or_else(|| anyhow!("rate limits missing"))?;
605+
606+
Ok(AccountRateLimits {
607+
limit_id: snapshot.limit_id.clone(),
608+
limit_name: snapshot.limit_name.clone(),
609+
plan_type: snapshot.plan_type.clone(),
610+
primary: snapshot.primary.clone(),
611+
secondary: snapshot.secondary.clone(),
612+
credits: snapshot.credits.clone(),
613+
buckets: Vec::new(),
614+
reset_credits_available: None,
615+
})
616+
}
617+
618+
fn parse_rate_limit_snapshot(value: &Value) -> Option<RateLimitSnapshot> {
619+
let obj = value.as_object().filter(|_| !value.is_null())?;
543620

621+
let limit_id = read_string(obj.get("limitId").or_else(|| obj.get("limit_id")));
622+
let limit_name = read_string(obj.get("limitName").or_else(|| obj.get("limit_name")));
623+
let plan_type = read_string(obj.get("planType").or_else(|| obj.get("plan_type")));
544624
let primary = obj.get("primary").and_then(parse_window);
545625
let secondary = obj.get("secondary").and_then(parse_window);
546626

@@ -567,7 +647,10 @@ fn parse_rate_limits(value: &Value) -> Result<AccountRateLimits> {
567647
}
568648
});
569649

570-
Ok(AccountRateLimits {
650+
Some(RateLimitSnapshot {
651+
limit_id,
652+
limit_name,
653+
plan_type,
571654
primary,
572655
secondary,
573656
credits,
@@ -583,6 +666,7 @@ fn parse_window(value: &Value) -> Option<RateLimitWindow> {
583666
let window_duration_mins = obj
584667
.get("windowDurationMins")
585668
.or_else(|| obj.get("window_duration_mins"))
669+
.or_else(|| obj.get("window_minutes"))
586670
.and_then(as_f64);
587671
let resets_at = obj
588672
.get("resetsAt")
@@ -596,6 +680,11 @@ fn parse_window(value: &Value) -> Option<RateLimitWindow> {
596680
})
597681
}
598682

683+
fn read_string(value: Option<&Value>) -> Option<String> {
684+
let value = value?.as_str()?.trim();
685+
(!value.is_empty()).then(|| value.to_string())
686+
}
687+
599688
fn as_f64(value: &Value) -> Option<f64> {
600689
value
601690
.as_f64()
@@ -618,12 +707,16 @@ mod tests {
618707
#[test]
619708
fn parse_rate_limits_accepts_expected_shapes() {
620709
let v = json!({
710+
"limitId": "codex",
711+
"planType": "prolite",
621712
"primary": { "usedPercent": 0, "windowDurationMins": 300, "resetsAt": 1770118764 },
622-
"secondary": { "used_percent": 1.0, "window_duration_mins": "10080", "resets_at": "1770684472" },
713+
"secondary": { "used_percent": 1.0, "window_minutes": "10080", "resets_at": "1770684472" },
623714
"credits": { "hasCredits": true, "unlimited": false, "balance": 900.735 },
624715
});
625716

626717
let limits = parse_rate_limits(&v).expect("parse_rate_limits");
718+
assert_eq!(limits.limit_id.as_deref(), Some("codex"));
719+
assert_eq!(limits.plan_type.as_deref(), Some("prolite"));
627720
let p = limits.primary.expect("primary");
628721
assert_eq!(p.used_percent, Some(0.0));
629722
assert_eq!(p.window_duration_mins, Some(300.0));
@@ -640,6 +733,48 @@ mod tests {
640733
assert_eq!(c.balance.as_deref(), Some("900.735"));
641734
}
642735

736+
#[test]
737+
fn parse_account_rate_limits_result_accepts_multi_bucket_response() {
738+
let v = json!({
739+
"rateLimitResetCredits": { "availableCount": 2 },
740+
"rateLimits": {
741+
"limitId": "codex",
742+
"primary": { "usedPercent": 10, "windowDurationMins": 300 },
743+
"secondary": { "usedPercent": 20, "windowDurationMins": 10080 }
744+
},
745+
"rateLimitsByLimitId": {
746+
"codex_extra": {
747+
"limitName": "GPT-5.3-Codex-Spark",
748+
"primary": { "usedPercent": 30, "windowDurationMins": 300 }
749+
},
750+
"codex": {
751+
"primary": { "usedPercent": 10, "windowDurationMins": 300 }
752+
}
753+
}
754+
});
755+
756+
let limits =
757+
parse_account_rate_limits_result(&v).expect("parse_account_rate_limits_result");
758+
assert_eq!(limits.limit_id.as_deref(), Some("codex"));
759+
assert_eq!(limits.reset_credits_available, Some(2));
760+
assert_eq!(limits.buckets.len(), 2);
761+
assert_eq!(limits.buckets[0].limit_id.as_deref(), Some("codex"));
762+
assert_eq!(limits.buckets[1].limit_id.as_deref(), Some("codex_extra"));
763+
assert_eq!(
764+
limits.buckets[1].limit_name.as_deref(),
765+
Some("GPT-5.3-Codex-Spark")
766+
);
767+
}
768+
769+
#[test]
770+
fn recognizes_account_rate_limits_updated_notification() {
771+
let v = json!({
772+
"method": "account/rateLimits/updated",
773+
"params": { "rateLimits": { "primary": { "usedPercent": 3 } } }
774+
});
775+
assert!(is_account_rate_limits_updated_notification(&v));
776+
}
777+
643778
#[test]
644779
fn parse_rate_limits_rejects_non_object() {
645780
let err = parse_rate_limits(&Value::Null).unwrap_err().to_string();

0 commit comments

Comments
 (0)