Skip to main content

seher/codexbar/
limit.rs

1//! Maps a codexbar usage payload to an [`AgentLimit`].
2//!
3//! Mirrors `seher-ts/packages/sdk/src/codexbar/limit.ts`: every rate window
4//! (primary/secondary/tertiary + extra windows) at `usedPercent >= 100` counts
5//! as limited, and the earliest reset is returned so the agent waits the minimum
6//! amount of time.
7
8use chrono::{DateTime, Utc};
9
10use super::client::{RunCodexBarUsageOptions, run_codexbar_usage};
11use super::errors::CodexBarError;
12use super::types::{CodexBarUsageResponse, CodexBarWindow};
13
14/// Outcome of a rate-limit check for a single provider.
15#[derive(Debug, Clone)]
16pub enum AgentLimit {
17    /// The provider has quota available.
18    NotLimited,
19    /// The provider is at-limit; `reset_time` is the earliest moment it frees up
20    /// (when known).
21    Limited { reset_time: Option<DateTime<Utc>> },
22}
23
24/// Reset fallback when codexbar reports a limited window without a parseable
25/// `resetsAt` (matches seher-ts's 5-minute fallback).
26const FALLBACK_RESET_SECS: i64 = 5 * 60;
27
28fn parse_resets_at(resets_at: Option<&str>, now: DateTime<Utc>) -> DateTime<Utc> {
29    if let Some(s) = resets_at
30        && let Ok(parsed) = DateTime::parse_from_rfc3339(s)
31    {
32        return parsed.with_timezone(&Utc);
33    }
34    now + chrono::Duration::seconds(FALLBACK_RESET_SECS)
35}
36
37fn is_limited(window: &CodexBarWindow) -> bool {
38    window.used_percent >= 100.0
39}
40
41fn classify(response: &CodexBarUsageResponse, now: DateTime<Utc>) -> AgentLimit {
42    let usage = &response.usage;
43    let mut windows: Vec<&CodexBarWindow> = Vec::new();
44    windows.extend(usage.primary.as_ref());
45    windows.extend(usage.secondary.as_ref());
46    windows.extend(usage.tertiary.as_ref());
47    if let Some(extra) = &usage.extra_rate_windows {
48        windows.extend(extra.iter().map(|named| &named.window));
49    }
50
51    let earliest = windows
52        .into_iter()
53        .filter(|w| is_limited(w))
54        .map(|w| parse_resets_at(w.resets_at.as_deref(), now))
55        .min();
56
57    match earliest {
58        Some(reset_time) => AgentLimit::Limited {
59            reset_time: Some(reset_time),
60        },
61        None => AgentLimit::NotLimited,
62    }
63}
64
65/// Determine whether `provider` is rate-limited by invoking codexbar with default options.
66///
67/// # Errors
68///
69/// Propagates [`CodexBarError`] from [`run_codexbar_usage`].
70pub async fn check_limit(provider: &str) -> Result<AgentLimit, CodexBarError> {
71    check_limit_with(provider, &RunCodexBarUsageOptions::default()).await
72}
73
74/// Like [`check_limit`] but with explicit [`RunCodexBarUsageOptions`].
75///
76/// # Errors
77///
78/// Propagates [`CodexBarError`] from [`run_codexbar_usage`].
79pub async fn check_limit_with(
80    provider: &str,
81    opts: &RunCodexBarUsageOptions,
82) -> Result<AgentLimit, CodexBarError> {
83    let response = run_codexbar_usage(provider, opts).await?;
84    Ok(classify(&response, Utc::now()))
85}
86
87#[cfg(test)]
88#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
89mod tests {
90    use super::*;
91
92    fn window(used_percent: f64, resets_at: Option<&str>) -> CodexBarWindow {
93        CodexBarWindow {
94            used_percent,
95            window_minutes: None,
96            resets_at: resets_at.map(ToString::to_string),
97            reset_description: None,
98            next_regen_percent: None,
99        }
100    }
101
102    fn response(primary: CodexBarWindow, secondary: CodexBarWindow) -> CodexBarUsageResponse {
103        CodexBarUsageResponse {
104            provider: "codex".to_string(),
105            usage: super::super::types::CodexBarUsage {
106                primary: Some(primary),
107                secondary: Some(secondary),
108                tertiary: None,
109                extra_rate_windows: None,
110            },
111        }
112    }
113
114    #[test]
115    fn not_limited_when_all_windows_under_100() {
116        let resp = response(window(50.0, None), window(30.0, None));
117        assert!(matches!(
118            classify(&resp, Utc::now()),
119            AgentLimit::NotLimited
120        ));
121    }
122
123    #[test]
124    fn limited_when_any_window_at_100() {
125        let resp = response(
126            window(100.0, Some("2099-01-01T00:00:00Z")),
127            window(30.0, None),
128        );
129        match classify(&resp, Utc::now()) {
130            AgentLimit::Limited { reset_time } => {
131                let reset = reset_time.expect("reset present");
132                assert_eq!(
133                    reset,
134                    DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
135                        .expect("parse")
136                        .with_timezone(&Utc)
137                );
138            }
139            AgentLimit::NotLimited => panic!("expected limited"),
140        }
141    }
142
143    #[test]
144    fn picks_earliest_reset_across_limited_windows() {
145        let resp = response(
146            window(100.0, Some("2099-06-01T00:00:00Z")),
147            window(100.0, Some("2099-01-01T00:00:00Z")),
148        );
149        match classify(&resp, Utc::now()) {
150            AgentLimit::Limited { reset_time } => {
151                let reset = reset_time.expect("reset present");
152                assert_eq!(
153                    reset,
154                    DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
155                        .expect("parse")
156                        .with_timezone(&Utc)
157                );
158            }
159            AgentLimit::NotLimited => panic!("expected limited"),
160        }
161    }
162
163    #[test]
164    fn limited_window_without_resets_at_uses_fallback() {
165        let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
166            .expect("parse")
167            .with_timezone(&Utc);
168        let resp = response(window(100.0, None), window(10.0, None));
169        match classify(&resp, now) {
170            AgentLimit::Limited { reset_time } => {
171                let reset = reset_time.expect("reset present");
172                assert_eq!(reset, now + chrono::Duration::seconds(FALLBACK_RESET_SECS));
173            }
174            AgentLimit::NotLimited => panic!("expected limited"),
175        }
176    }
177
178    #[test]
179    fn counts_extra_rate_windows() {
180        let mut resp = response(window(10.0, None), window(20.0, None));
181        resp.usage.extra_rate_windows = Some(vec![super::super::types::NamedCodexBarWindow {
182            id: "daily".to_string(),
183            title: "Daily".to_string(),
184            window: window(100.0, Some("2099-03-03T00:00:00Z")),
185        }]);
186        assert!(matches!(
187            classify(&resp, Utc::now()),
188            AgentLimit::Limited { .. }
189        ));
190    }
191}