Skip to main content

seher/codex/
types.rs

1use chrono::{DateTime, TimeZone, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Deserialize, Serialize, Clone)]
5pub struct CodexWindow {
6    pub used_percent: f64,
7    pub limit_window_seconds: i64,
8    pub reset_after_seconds: i64,
9    pub reset_at: i64,
10}
11
12impl CodexWindow {
13    #[must_use]
14    pub fn is_limited(&self) -> bool {
15        self.used_percent >= 100.0
16    }
17
18    #[must_use]
19    pub fn reset_at_datetime(&self) -> Option<DateTime<Utc>> {
20        Utc.timestamp_opt(self.reset_at, 0).single()
21    }
22}
23
24#[derive(Debug, Deserialize, Serialize, Clone)]
25pub struct CodexRateLimit {
26    pub allowed: bool,
27    pub limit_reached: bool,
28    pub primary_window: Option<CodexWindow>,
29    pub secondary_window: Option<CodexWindow>,
30}
31
32impl CodexRateLimit {
33    #[must_use]
34    pub fn is_limited(&self) -> bool {
35        !self.allowed
36            || self.limit_reached
37            || [self.primary_window.as_ref(), self.secondary_window.as_ref()]
38                .into_iter()
39                .flatten()
40                .any(CodexWindow::is_limited)
41    }
42
43    #[must_use]
44    pub fn next_reset_time(&self) -> Option<DateTime<Utc>> {
45        [self.primary_window.as_ref(), self.secondary_window.as_ref()]
46            .into_iter()
47            .flatten()
48            .filter(|window| !self.allowed || self.limit_reached || window.is_limited())
49            .filter_map(CodexWindow::reset_at_datetime)
50            .max()
51    }
52}
53
54#[derive(Debug, Deserialize, Serialize)]
55pub struct CodexCredits {
56    pub has_credits: bool,
57    pub unlimited: bool,
58    pub balance: String,
59    pub approx_local_messages: Vec<i64>,
60    pub approx_cloud_messages: Vec<i64>,
61}
62
63#[derive(Debug, Deserialize, Serialize)]
64pub struct CodexUsageResponse {
65    pub user_id: String,
66    pub account_id: String,
67    pub email: String,
68    pub plan_type: String,
69    pub rate_limit: CodexRateLimit,
70    pub code_review_rate_limit: CodexRateLimit,
71    pub additional_rate_limits: Option<serde_json::Value>,
72    pub credits: CodexCredits,
73    pub promo: Option<serde_json::Value>,
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn parses_usage_response_and_keeps_unlimited_state() -> Result<(), serde_json::Error> {
82        let json = r#"
83        {
84          "user_id": "user-1",
85          "account_id": "user-1",
86          "email": "user@example.com",
87          "plan_type": "plus",
88          "rate_limit": {
89            "allowed": true,
90            "limit_reached": false,
91            "primary_window": {
92              "used_percent": 6,
93              "limit_window_seconds": 18000,
94              "reset_after_seconds": 13837,
95              "reset_at": 1773200619
96            },
97            "secondary_window": {
98              "used_percent": 2,
99              "limit_window_seconds": 604800,
100              "reset_after_seconds": 600637,
101              "reset_at": 1773787419
102            }
103          },
104          "code_review_rate_limit": {
105            "allowed": true,
106            "limit_reached": false,
107            "primary_window": {
108              "used_percent": 0,
109              "limit_window_seconds": 604800,
110              "reset_after_seconds": 604800,
111              "reset_at": 1773791583
112            },
113            "secondary_window": null
114          },
115          "additional_rate_limits": null,
116          "credits": {
117            "has_credits": false,
118            "unlimited": false,
119            "balance": "0",
120            "approx_local_messages": [0, 0],
121            "approx_cloud_messages": [0, 0]
122          },
123          "promo": null
124        }"#;
125
126        let usage: CodexUsageResponse = serde_json::from_str(json)?;
127
128        assert!(!usage.rate_limit.is_limited());
129        assert_eq!(usage.rate_limit.next_reset_time(), None);
130        let primary_used = usage
131            .code_review_rate_limit
132            .primary_window
133            .as_ref()
134            .map(|w| w.used_percent);
135        assert_eq!(primary_used, Some(0.0));
136        Ok(())
137    }
138
139    #[test]
140    fn picks_latest_reset_when_multiple_windows_are_limited() {
141        let limit = CodexRateLimit {
142            allowed: false,
143            limit_reached: true,
144            primary_window: Some(CodexWindow {
145                used_percent: 100.0,
146                limit_window_seconds: 18000,
147                reset_after_seconds: 100,
148                reset_at: 1_773_200_619,
149            }),
150            secondary_window: Some(CodexWindow {
151                used_percent: 100.0,
152                limit_window_seconds: 604_800,
153                reset_after_seconds: 200,
154                reset_at: 1_773_787_419,
155            }),
156        };
157
158        assert!(limit.is_limited());
159        assert_eq!(
160            limit.next_reset_time().map(|time| time.timestamp()),
161            Some(1_773_787_419)
162        );
163    }
164
165    #[test]
166    fn uses_reset_time_when_allowed_flag_blocks_usage_before_window_hits_100_percent() {
167        let limit = CodexRateLimit {
168            allowed: false,
169            limit_reached: false,
170            primary_window: Some(CodexWindow {
171                used_percent: 85.0,
172                limit_window_seconds: 18000,
173                reset_after_seconds: 100,
174                reset_at: 1_773_200_619,
175            }),
176            secondary_window: Some(CodexWindow {
177                used_percent: 20.0,
178                limit_window_seconds: 604_800,
179                reset_after_seconds: 200,
180                reset_at: 1_773_787_419,
181            }),
182        };
183
184        assert!(limit.is_limited());
185        assert_eq!(
186            limit.next_reset_time().map(|time| time.timestamp()),
187            Some(1_773_787_419)
188        );
189    }
190}