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    #[serde(default)]
62    pub overage_limit_reached: bool,
63}
64
65#[derive(Debug, Deserialize, Serialize)]
66pub struct CodexUsageResponse {
67    pub user_id: String,
68    pub account_id: String,
69    pub email: String,
70    pub plan_type: String,
71    pub rate_limit: CodexRateLimit,
72    pub code_review_rate_limit: Option<CodexRateLimit>,
73    pub additional_rate_limits: Option<serde_json::Value>,
74    pub credits: CodexCredits,
75    pub spend_control: Option<serde_json::Value>,
76    pub promo: Option<serde_json::Value>,
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn parses_usage_response_and_keeps_unlimited_state() -> Result<(), serde_json::Error> {
85        let json = r#"
86        {
87          "user_id": "user-1",
88          "account_id": "user-1",
89          "email": "user@example.com",
90          "plan_type": "plus",
91          "rate_limit": {
92            "allowed": true,
93            "limit_reached": false,
94            "primary_window": {
95              "used_percent": 6,
96              "limit_window_seconds": 18000,
97              "reset_after_seconds": 13837,
98              "reset_at": 1773200619
99            },
100            "secondary_window": {
101              "used_percent": 2,
102              "limit_window_seconds": 604800,
103              "reset_after_seconds": 600637,
104              "reset_at": 1773787419
105            }
106          },
107          "code_review_rate_limit": {
108            "allowed": true,
109            "limit_reached": false,
110            "primary_window": {
111              "used_percent": 0,
112              "limit_window_seconds": 604800,
113              "reset_after_seconds": 604800,
114              "reset_at": 1773791583
115            },
116            "secondary_window": null
117          },
118          "additional_rate_limits": null,
119          "credits": {
120            "has_credits": false,
121            "unlimited": false,
122            "balance": "0",
123            "approx_local_messages": [0, 0],
124            "approx_cloud_messages": [0, 0]
125          },
126          "promo": null
127        }"#;
128
129        let usage: CodexUsageResponse = serde_json::from_str(json)?;
130
131        assert!(!usage.rate_limit.is_limited());
132        assert_eq!(usage.rate_limit.next_reset_time(), None);
133        let primary_used = usage
134            .code_review_rate_limit
135            .as_ref()
136            .and_then(|cr| cr.primary_window.as_ref())
137            .map(|w| w.used_percent);
138        assert_eq!(primary_used, Some(0.0));
139        Ok(())
140    }
141
142    #[test]
143    fn parses_null_code_review_rate_limit_and_missing_overage_field()
144    -> Result<(), serde_json::Error> {
145        let json = r#"
146        {
147          "user_id": "user-1",
148          "account_id": "user-1",
149          "email": "user@example.com",
150          "plan_type": "plus",
151          "rate_limit": {
152            "allowed": true,
153            "limit_reached": false,
154            "primary_window": null,
155            "secondary_window": null
156          },
157          "code_review_rate_limit": null,
158          "additional_rate_limits": null,
159          "credits": {
160            "has_credits": false,
161            "unlimited": false,
162            "balance": "0",
163            "approx_local_messages": [0, 0],
164            "approx_cloud_messages": [0, 0]
165          },
166          "spend_control": { "reached": false },
167          "promo": null
168        }"#;
169
170        let usage: CodexUsageResponse = serde_json::from_str(json)?;
171
172        assert!(usage.code_review_rate_limit.is_none());
173        assert!(!usage.credits.overage_limit_reached);
174        Ok(())
175    }
176
177    #[test]
178    fn picks_latest_reset_when_multiple_windows_are_limited() {
179        let limit = CodexRateLimit {
180            allowed: false,
181            limit_reached: true,
182            primary_window: Some(CodexWindow {
183                used_percent: 100.0,
184                limit_window_seconds: 18000,
185                reset_after_seconds: 100,
186                reset_at: 1_773_200_619,
187            }),
188            secondary_window: Some(CodexWindow {
189                used_percent: 100.0,
190                limit_window_seconds: 604_800,
191                reset_after_seconds: 200,
192                reset_at: 1_773_787_419,
193            }),
194        };
195
196        assert!(limit.is_limited());
197        assert_eq!(
198            limit.next_reset_time().map(|time| time.timestamp()),
199            Some(1_773_787_419)
200        );
201    }
202
203    #[test]
204    fn uses_reset_time_when_allowed_flag_blocks_usage_before_window_hits_100_percent() {
205        let limit = CodexRateLimit {
206            allowed: false,
207            limit_reached: false,
208            primary_window: Some(CodexWindow {
209                used_percent: 85.0,
210                limit_window_seconds: 18000,
211                reset_after_seconds: 100,
212                reset_at: 1_773_200_619,
213            }),
214            secondary_window: Some(CodexWindow {
215                used_percent: 20.0,
216                limit_window_seconds: 604_800,
217                reset_after_seconds: 200,
218                reset_at: 1_773_787_419,
219            }),
220        };
221
222        assert!(limit.is_limited());
223        assert_eq!(
224            limit.next_reset_time().map(|time| time.timestamp()),
225            Some(1_773_787_419)
226        );
227    }
228}