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