Skip to content

Commit c8bd968

Browse files
committed
v1.0.9
- Fallback to previous method of retrieving usage details if failure occurs
1 parent ad4467e commit c8bd968

2 files changed

Lines changed: 133 additions & 12 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ A lightweight Windows taskbar widget that displays your Claude API rate limit us
44

55
![Windows](https://img.shields.io/badge/platform-Windows-blue)
66
![Rust](https://img.shields.io/badge/language-Rust-orange)
7+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
78

89
![Screenshot](.github/screenshot.png)
910

@@ -18,9 +19,9 @@ Each bar shows the current utilization percentage and a countdown until the rate
1819

1920
## How it works
2021

21-
1. Reads your Claude OAuth token from `~/.claude/.credentials.json`
22-
2. Sends a minimal API request to the Anthropic Messages API
23-
3. Parses rate limit headers (`anthropic-ratelimit-unified-*`) from the response
22+
1. Reads your Claude OAuth token from `~/.claude/.credentials.json` (automatically refreshes expired tokens via the Claude CLI)
23+
2. Queries the dedicated Anthropic OAuth usage endpoint (`/api/oauth/usage`) for utilization data
24+
3. Falls back to the Messages API with rate limit header parsing (`anthropic-ratelimit-unified-*`) if the usage endpoint is unavailable
2425
4. Renders the widget using Win32 GDI, embedded as a child window of the taskbar
2526
5. Polls every 15 minutes by default (adjustable via context menu) and updates countdown timers between polls
2627

src/poller.rs

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ use serde::Deserialize;
77
use crate::models::{UsageData, UsageSection};
88

99
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
10+
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
11+
12+
const MODEL_FALLBACK_CHAIN: &[&str] = &[
13+
"claude-3-haiku-20240307",
14+
"claude-haiku-4-5-20251001",
15+
];
1016

1117
#[derive(Debug)]
1218
pub enum PollError {
@@ -46,7 +52,7 @@ pub fn poll() -> Result<UsageData, PollError> {
4652
}
4753
}
4854

49-
fetch_usage(&creds.access_token)
55+
fetch_usage_with_fallback(&creds.access_token)
5056
}
5157

5258
/// Invoke the Claude CLI with a minimal prompt to force its internal
@@ -105,22 +111,51 @@ fn resolve_claude_path() -> String {
105111
"claude.cmd".to_string()
106112
}
107113

108-
fn fetch_usage(token: &str) -> Result<UsageData, PollError> {
114+
fn build_agent() -> Result<ureq::Agent, PollError> {
109115
let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?;
110-
let agent = ureq::AgentBuilder::new()
116+
Ok(ureq::AgentBuilder::new()
111117
.timeout(Duration::from_secs(30))
112118
.tls_connector(std::sync::Arc::new(tls))
113-
.build();
119+
.build())
120+
}
121+
122+
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
123+
// Try the dedicated usage endpoint first
124+
if let Some(data) = try_usage_endpoint(token) {
125+
// If reset timers are missing, fill them in from the Messages API
126+
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
127+
if let Ok(fallback) = fetch_usage_via_messages(token) {
128+
let mut merged = data;
129+
if merged.session.resets_at.is_none() {
130+
merged.session.resets_at = fallback.session.resets_at;
131+
}
132+
if merged.weekly.resets_at.is_none() {
133+
merged.weekly.resets_at = fallback.weekly.resets_at;
134+
}
135+
return Ok(merged);
136+
}
137+
}
138+
return Ok(data);
139+
}
140+
141+
// Fall back to Messages API with rate limit headers
142+
fetch_usage_via_messages(token)
143+
}
114144

115-
let response: UsageResponse = agent
145+
fn try_usage_endpoint(token: &str) -> Option<UsageData> {
146+
let agent = build_agent().ok()?;
147+
148+
let resp = match agent
116149
.get(USAGE_URL)
117150
.set("Authorization", &format!("Bearer {token}"))
118151
.set("anthropic-beta", "oauth-2025-04-20")
119152
.call()
120-
.map_err(|_| PollError::RequestFailed)?
121-
.into_json()
122-
.map_err(|_| PollError::RequestFailed)?;
153+
{
154+
Ok(resp) => resp,
155+
_ => return None,
156+
};
123157

158+
let response: UsageResponse = resp.into_json().ok()?;
124159
let mut data = UsageData::default();
125160

126161
if let Some(bucket) = &response.five_hour {
@@ -133,7 +168,92 @@ fn fetch_usage(token: &str) -> Result<UsageData, PollError> {
133168
data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref());
134169
}
135170

136-
Ok(data)
171+
Some(data)
172+
}
173+
174+
fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
175+
let agent = build_agent()?;
176+
177+
for model in MODEL_FALLBACK_CHAIN {
178+
let body = serde_json::json!({
179+
"model": model,
180+
"max_tokens": 1,
181+
"messages": [{"role": "user", "content": "."}]
182+
});
183+
184+
let response = match agent
185+
.post(MESSAGES_URL)
186+
.set("Authorization", &format!("Bearer {token}"))
187+
.set("anthropic-version", "2023-06-01")
188+
.set("anthropic-beta", "oauth-2025-04-20")
189+
.send_json(&body)
190+
{
191+
Ok(resp) => resp,
192+
Err(ureq::Error::Status(_code, resp)) => resp,
193+
Err(_) => continue,
194+
};
195+
196+
let h5 = response.header("anthropic-ratelimit-unified-5h-utilization");
197+
let h7 = response.header("anthropic-ratelimit-unified-7d-utilization");
198+
let hs = response.header("anthropic-ratelimit-unified-status");
199+
200+
if h5.is_some() || h7.is_some() || hs.is_some() {
201+
return Ok(parse_rate_limit_headers(&response));
202+
}
203+
}
204+
205+
Err(PollError::RequestFailed)
206+
}
207+
208+
fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData {
209+
let mut data = UsageData::default();
210+
211+
data.session.percentage = get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0;
212+
data.session.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-5h-reset"));
213+
214+
data.weekly.percentage = get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0;
215+
data.weekly.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-7d-reset"));
216+
217+
let overall_reset = get_header_i64(response, "anthropic-ratelimit-unified-reset");
218+
219+
if data.session.percentage == 0.0 && data.weekly.percentage == 0.0 {
220+
let status = response.header("anthropic-ratelimit-unified-status");
221+
if status == Some("rejected") {
222+
let claim = response.header("anthropic-ratelimit-unified-representative-claim");
223+
match claim {
224+
Some("five_hour") => data.session.percentage = 100.0,
225+
Some("seven_day") => data.weekly.percentage = 100.0,
226+
_ => {}
227+
}
228+
}
229+
230+
if data.session.resets_at.is_none() && overall_reset.is_some() {
231+
data.session.resets_at = unix_to_system_time(overall_reset);
232+
}
233+
}
234+
235+
data
236+
}
237+
238+
fn get_header_f64(response: &ureq::Response, name: &str) -> f64 {
239+
response
240+
.header(name)
241+
.and_then(|s| s.parse::<f64>().ok())
242+
.unwrap_or(0.0)
243+
}
244+
245+
fn get_header_i64(response: &ureq::Response, name: &str) -> Option<i64> {
246+
response
247+
.header(name)
248+
.and_then(|s| s.parse::<i64>().ok())
249+
}
250+
251+
fn unix_to_system_time(unix_secs: Option<i64>) -> Option<SystemTime> {
252+
let secs = unix_secs?;
253+
if secs < 0 {
254+
return None;
255+
}
256+
Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
137257
}
138258

139259
struct Credentials {

0 commit comments

Comments
 (0)