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}