@@ -2,20 +2,29 @@ use std::path::PathBuf;
22use std:: process:: Command ;
33use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
44
5- use crate :: models :: { UsageData , UsageSection } ;
5+ use serde :: Deserialize ;
66
7- const API_URL : & str = "https://api.anthropic.com/v1/messages" ;
7+ use crate :: models :: { UsageData , UsageSection } ;
88
9- const MODEL_FALLBACK_CHAIN : & [ & str ] = & [
10- "claude-3-haiku-20240307" ,
11- "claude-haiku-4-5-20251001" ,
12- ] ;
9+ const USAGE_URL : & str = "https://api.anthropic.com/api/oauth/usage" ;
1310
1411#[ derive( Debug ) ]
1512pub enum PollError {
1613 NoCredentials ,
1714 TokenExpired ,
18- AllModelsFailed ,
15+ RequestFailed ,
16+ }
17+
18+ #[ derive( Deserialize ) ]
19+ struct UsageResponse {
20+ five_hour : Option < UsageBucket > ,
21+ seven_day : Option < UsageBucket > ,
22+ }
23+
24+ #[ derive( Deserialize ) ]
25+ struct UsageBucket {
26+ utilization : f64 ,
27+ resets_at : Option < String > ,
1928}
2029
2130pub fn poll ( ) -> Result < UsageData , PollError > {
@@ -27,7 +36,6 @@ pub fn poll() -> Result<UsageData, PollError> {
2736 if is_token_expired ( creds. expires_at ) {
2837 cli_refresh_token ( ) ;
2938
30- // Re-read credentials in case the CLI refreshed them
3139 match read_credentials ( ) {
3240 Some ( refreshed) => creds = refreshed,
3341 None => return Err ( PollError :: NoCredentials ) ,
@@ -38,20 +46,17 @@ pub fn poll() -> Result<UsageData, PollError> {
3846 }
3947 }
4048
41- fetch_usage_with_fallback ( & creds. access_token )
49+ fetch_usage ( & creds. access_token )
4250}
4351
4452/// Invoke the Claude CLI with a minimal prompt to force its internal
45- /// OAuth token refresh. `claude -p "."` makes the CLI
46- /// authenticate (refreshing the access token if expired), perform a
47- /// tiny API call, and exit — updating the credentials file on disk.
53+ /// OAuth token refresh.
4854fn cli_refresh_token ( ) {
4955 let claude_path = resolve_claude_path ( ) ;
5056 let is_cmd = claude_path. to_lowercase ( ) . ends_with ( ".cmd" ) ;
5157
5258 let args: & [ & str ] = & [ "-p" , "." ] ;
5359
54- // Clear env vars that prevent nested Claude Code sessions
5560 let mut cmd = if is_cmd {
5661 let mut c = Command :: new ( "cmd.exe" ) ;
5762 c. arg ( "/c" ) . arg ( & claude_path) . args ( args) ;
@@ -70,12 +75,7 @@ fn cli_refresh_token() {
7075}
7176
7277/// Resolve the full path to the `claude` CLI executable.
73- /// First tries the bare command name (works if on PATH), then falls back
74- /// to `where.exe claude` which searches the system/user PATH from the
75- /// registry — important for processes started via the Windows Run key
76- /// that may not inherit the full shell PATH.
7778fn resolve_claude_path ( ) -> String {
78- // Quick check: try claude.cmd first (Windows npm wrapper), then bare "claude"
7979 for name in & [ "claude.cmd" , "claude" ] {
8080 if Command :: new ( name)
8181 . arg ( "--version" )
@@ -88,8 +88,6 @@ fn resolve_claude_path() -> String {
8888 }
8989 }
9090
91- // Use where.exe to search the system/user PATH from the registry.
92- // Try claude.cmd first (the Windows batch wrapper npm creates).
9391 for name in & [ "claude.cmd" , "claude" ] {
9492 if let Ok ( output) = Command :: new ( "where.exe" ) . arg ( name) . output ( ) {
9593 if output. status . success ( ) {
@@ -107,14 +105,35 @@ fn resolve_claude_path() -> String {
107105 "claude.cmd" . to_string ( )
108106}
109107
110- fn fetch_usage_with_fallback ( token : & str ) -> Result < UsageData , PollError > {
111- for model in MODEL_FALLBACK_CHAIN {
112- if let Some ( data) = try_model ( token, model) {
113- return Ok ( data) ;
114- }
108+ fn fetch_usage ( token : & str ) -> Result < UsageData , PollError > {
109+ let tls = native_tls:: TlsConnector :: new ( ) . map_err ( |_| PollError :: RequestFailed ) ?;
110+ let agent = ureq:: AgentBuilder :: new ( )
111+ . timeout ( Duration :: from_secs ( 30 ) )
112+ . tls_connector ( std:: sync:: Arc :: new ( tls) )
113+ . build ( ) ;
114+
115+ let response: UsageResponse = agent
116+ . get ( USAGE_URL )
117+ . set ( "Authorization" , & format ! ( "Bearer {token}" ) )
118+ . set ( "anthropic-beta" , "oauth-2025-04-20" )
119+ . call ( )
120+ . map_err ( |_| PollError :: RequestFailed ) ?
121+ . into_json ( )
122+ . map_err ( |_| PollError :: RequestFailed ) ?;
123+
124+ let mut data = UsageData :: default ( ) ;
125+
126+ if let Some ( bucket) = & response. five_hour {
127+ data. session . percentage = bucket. utilization ;
128+ data. session . resets_at = parse_iso8601 ( bucket. resets_at . as_deref ( ) ) ;
115129 }
116130
117- Err ( PollError :: AllModelsFailed )
131+ if let Some ( bucket) = & response. seven_day {
132+ data. weekly . percentage = bucket. utilization ;
133+ data. weekly . resets_at = parse_iso8601 ( bucket. resets_at . as_deref ( ) ) ;
134+ }
135+
136+ Ok ( data)
118137}
119138
120139struct Credentials {
@@ -148,100 +167,64 @@ fn is_token_expired(expires_at: Option<i64>) -> bool {
148167 now >= exp
149168}
150169
151- fn try_model ( token : & str , model : & str ) -> Option < UsageData > {
152- let tls = native_tls:: TlsConnector :: new ( ) . ok ( ) ?;
153- let agent = ureq:: AgentBuilder :: new ( )
154- . timeout ( Duration :: from_secs ( 30 ) )
155- . tls_connector ( std:: sync:: Arc :: new ( tls) )
156- . build ( ) ;
157-
158- let body = serde_json:: json!( {
159- "model" : model,
160- "max_tokens" : 1 ,
161- "messages" : [ { "role" : "user" , "content" : "." } ]
162- } ) ;
163-
164- let response = match agent
165- . post ( API_URL )
166- . set ( "Authorization" , & format ! ( "Bearer {token}" ) )
167- . set ( "anthropic-version" , "2023-06-01" )
168- . set ( "anthropic-beta" , "oauth-2025-04-20" )
169- . send_json ( & body)
170- {
171- Ok ( resp) => resp,
172- Err ( ureq:: Error :: Status ( _code, resp) ) => resp,
173- Err ( _) => return None ,
174- } ;
175-
176- let h5 = response. header ( "anthropic-ratelimit-unified-5h-utilization" ) ;
177- let h7 = response. header ( "anthropic-ratelimit-unified-7d-utilization" ) ;
178- let hs = response. header ( "anthropic-ratelimit-unified-status" ) ;
179-
180- let has_rate_limit_headers = h5. is_some ( ) || h7. is_some ( ) || hs. is_some ( ) ;
181-
182- if has_rate_limit_headers {
183- Some ( parse_headers ( & response) )
184- } else {
185- None
170+ /// Parse an ISO 8601 timestamp string into a SystemTime.
171+ fn parse_iso8601 ( s : Option < & str > ) -> Option < SystemTime > {
172+ let s = s?;
173+ // Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds
174+ // The API returns formats like "2026-03-05T08:00:00.321598+00:00"
175+ let datetime_part = s. split ( '+' ) . next ( ) . unwrap_or ( s) ;
176+ let datetime_part = datetime_part. split ( 'Z' ) . next ( ) . unwrap_or ( datetime_part) ;
177+
178+ // Try parsing with and without fractional seconds
179+ let formats = [ "%Y-%m-%dT%H:%M:%S%.f" , "%Y-%m-%dT%H:%M:%S" ] ;
180+ for fmt in & formats {
181+ if let Ok ( secs) = parse_datetime_to_unix ( datetime_part, fmt) {
182+ return Some ( UNIX_EPOCH + Duration :: from_secs ( secs) ) ;
183+ }
186184 }
185+ None
187186}
188187
189- fn parse_headers ( response : & ureq:: Response ) -> UsageData {
190- let mut data = UsageData :: default ( ) ;
191-
192- // Session (5-hour window)
193- data. session . percentage = get_header_f64 ( response, "anthropic-ratelimit-unified-5h-utilization" ) * 100.0 ;
194- data. session . resets_at = unix_to_system_time ( get_header_i64 ( response, "anthropic-ratelimit-unified-5h-reset" ) ) ;
195-
196- // Weekly (7-day window)
197- data. weekly . percentage = get_header_f64 ( response, "anthropic-ratelimit-unified-7d-utilization" ) * 100.0 ;
198- data. weekly . resets_at = unix_to_system_time ( get_header_i64 ( response, "anthropic-ratelimit-unified-7d-reset" ) ) ;
199-
200- // Overall reset/status fallback
201- let overall_reset = get_header_i64 ( response, "anthropic-ratelimit-unified-reset" ) ;
202-
203- if data. session . percentage == 0.0 && data. weekly . percentage == 0.0 {
204- let status = get_header_str ( response, "anthropic-ratelimit-unified-status" ) ;
205- if status. as_deref ( ) == Some ( "rejected" ) {
206- let claim = get_header_str ( response, "anthropic-ratelimit-unified-representative-claim" ) ;
207- match claim. as_deref ( ) {
208- Some ( "five_hour" ) => data. session . percentage = 100.0 ,
209- Some ( "seven_day" ) => data. weekly . percentage = 100.0 ,
210- _ => { }
211- }
212- }
188+ /// Minimal datetime parser — avoids pulling in chrono/time crates.
189+ fn parse_datetime_to_unix ( s : & str , _fmt : & str ) -> Result < u64 , ( ) > {
190+ // Extract date and time parts from "YYYY-MM-DDTHH:MM:SS[.frac]"
191+ let ( date_str, time_str) = s. split_once ( 'T' ) . ok_or ( ( ) ) ?;
192+ let date_parts: Vec < & str > = date_str. split ( '-' ) . collect ( ) ;
193+ if date_parts. len ( ) != 3 { return Err ( ( ) ) ; }
194+
195+ let year: u64 = date_parts[ 0 ] . parse ( ) . map_err ( |_| ( ) ) ?;
196+ let month: u64 = date_parts[ 1 ] . parse ( ) . map_err ( |_| ( ) ) ?;
197+ let day: u64 = date_parts[ 2 ] . parse ( ) . map_err ( |_| ( ) ) ?;
198+
199+ // Strip fractional seconds
200+ let time_base = time_str. split ( '.' ) . next ( ) . unwrap_or ( time_str) ;
201+ let time_parts: Vec < & str > = time_base. split ( ':' ) . collect ( ) ;
202+ if time_parts. len ( ) != 3 { return Err ( ( ) ) ; }
203+
204+ let hour: u64 = time_parts[ 0 ] . parse ( ) . map_err ( |_| ( ) ) ?;
205+ let min: u64 = time_parts[ 1 ] . parse ( ) . map_err ( |_| ( ) ) ?;
206+ let sec: u64 = time_parts[ 2 ] . parse ( ) . map_err ( |_| ( ) ) ?;
207+
208+ // Days from year (using a simplified calculation for dates after 1970)
209+ let mut days: u64 = 0 ;
210+ for y in 1970 ..year {
211+ days += if is_leap ( y) { 366 } else { 365 } ;
212+ }
213213
214- if data. session . resets_at . is_none ( ) && overall_reset. is_some ( ) {
215- data. session . resets_at = unix_to_system_time ( overall_reset) ;
214+ let month_days = [ 0 , 31 , 28 , 31 , 30 , 31 , 30 , 31 , 31 , 30 , 31 , 30 , 31 ] ;
215+ for m in 1 ..month {
216+ days += month_days[ m as usize ] ;
217+ if m == 2 && is_leap ( year) {
218+ days += 1 ;
216219 }
217220 }
221+ days += day - 1 ;
218222
219- data
220- }
221-
222- fn get_header_f64 ( response : & ureq:: Response , name : & str ) -> f64 {
223- response
224- . header ( name)
225- . and_then ( |s| s. parse :: < f64 > ( ) . ok ( ) )
226- . unwrap_or ( 0.0 )
223+ Ok ( days * 86400 + hour * 3600 + min * 60 + sec)
227224}
228225
229- fn get_header_i64 ( response : & ureq:: Response , name : & str ) -> Option < i64 > {
230- response
231- . header ( name)
232- . and_then ( |s| s. parse :: < i64 > ( ) . ok ( ) )
233- }
234-
235- fn get_header_str ( response : & ureq:: Response , name : & str ) -> Option < String > {
236- response. header ( name) . map ( String :: from)
237- }
238-
239- fn unix_to_system_time ( unix_secs : Option < i64 > ) -> Option < SystemTime > {
240- let secs = unix_secs?;
241- if secs < 0 {
242- return None ;
243- }
244- Some ( UNIX_EPOCH + Duration :: from_secs ( secs as u64 ) )
226+ fn is_leap ( y : u64 ) -> bool {
227+ ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0
245228}
246229
247230/// Format a usage section as "X% · Yh" style text
0 commit comments