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}