forge_primitives/usage.rs
1//! Anthropic plan-usage snapshot data shapes.
2//!
3//! Type-only - the fetcher impls live in the forge-gateway
4//! backends. These are the wire shapes consumers see.
5//!
6//! Serde derived so the workspace can persist the latest snapshot per
7//! account to disk and rehydrate it at next boot - without the cache
8//! the launchpad shows empty bars until the live `/api/oauth/usage`
9//! probe lands, which Anthropic's per-IP rate limiter can stall for
10//! 30 s+.
11
12use serde::{Deserialize, Serialize};
13
14pub mod oauth;
15pub mod openrouter;
16pub mod zai;
17
18/// Origin of a [`UsageSnapshot`], and with it which half of the
19/// snapshot carries data: `Oauth` and `ZaiMonitor` fill the windows,
20/// `OpenRouterKey` fills [`UsageSnapshot::spend`].
21///
22/// Load-bearing across a config change: the snapshot is cached and
23/// rehydrated, so an account whose `provider` was edited still has a
24/// row in the old shape. A renderer that compares this against the
25/// account's declared provider can fall back to "no data" instead of
26/// reading windows as money.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum UsageSourceKind {
29 Oauth,
30 OpenRouterKey,
31 ZaiMonitor,
32}
33
34impl UsageSourceKind {
35 pub const fn label(self) -> &'static str {
36 match self {
37 Self::Oauth => "oauth",
38 Self::OpenRouterKey => "openrouter-key",
39 Self::ZaiMonitor => "zai-monitor",
40 }
41 }
42}
43
44/// Per-key spend for a pay-per-token account, in USD.
45///
46/// Not [`ExtraUsage`], which is Anthropic overage: that type's money
47/// fields arrive in minor units and are divided by 100 on the way in,
48/// and it carries a `utilization` percentage. These are decimal USD
49/// straight off the wire, and an uncapped key has no denominator to be
50/// a percentage of.
51///
52/// Every figure is scoped to one key. The account-wide balance comes
53/// from a different endpoint with a different scope and lives on
54/// [`UsageSnapshot::balance`], not here.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct ApiSpend {
57 pub daily: f64,
58 pub weekly: f64,
59 pub monthly: f64,
60 /// Spending cap on this key, in USD, with what is left of it and
61 /// the cadence it resets on. All `None` on an uncapped key, which
62 /// is a normal state - a cap can be added or removed from the
63 /// provider's dashboard between one poll and the next, so nothing
64 /// may assume a denominator exists.
65 pub limit: Option<f64>,
66 pub limit_remaining: Option<f64>,
67 pub limit_reset: Option<String>,
68 /// When the key stops working, when it says.
69 pub expires_at: Option<String>,
70}
71
72/// One named usage window inside a snapshot (5-hour, 7-day, etc.).
73/// The window's identity (5-hour vs 7-day vs 7-day-opus) is implicit
74/// in which `UsageSnapshot` field holds the value - no in-struct label.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct UsageWindow {
77 pub utilization: f64,
78 pub resets_at: Option<std::time::SystemTime>,
79 pub reset_description: Option<String>,
80 /// The provider's own verdict on this window (`allowed`,
81 /// `allowed_warning`, `rejected`), when it stated one: a window can be
82 /// rejected below 100%, so the percentage alone cannot say it is spent.
83 pub status: Option<String>,
84}
85
86impl UsageWindow {
87 /// True when this window is currently at or beyond its plan cap
88 /// AND the reset time has not yet passed. The pair matters: a
89 /// 100% utilization figure from a cached snapshot remains at 100%
90 /// after the reset window expires (the cache is stale by then),
91 /// so the predicate must consult `resets_at` to avoid forever
92 /// classifying an account as rate-limited based on a stale probe.
93 ///
94 /// `resets_at == None` returns `false`: a window with no scheduled
95 /// reset cannot transition out of the limit, so treating it as
96 /// permanently limited would strand the account. The usage probe
97 /// emits `resets_at = Some(...)` whenever a window is real; the
98 /// `None` case shows up in tests + edge-case snapshots only.
99 pub fn is_currently_limited(&self) -> bool {
100 if self.utilization < 100.0 {
101 return false;
102 }
103 self.resets_at.is_some_and(|when| when > std::time::SystemTime::now())
104 }
105}
106
107/// Bonus / overage credits view returned alongside the per-window
108/// utilization figures.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct ExtraUsage {
111 pub monthly_limit: Option<f64>,
112 pub used_credits: Option<f64>,
113 pub utilization: Option<f64>,
114 pub currency: Option<String>,
115}
116
117/// Snapshot of the user's Anthropic plan utilization at a point in time.
118/// Composed by the forge-gateway backends and rendered by
119/// a view.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct UsageSnapshot {
122 pub source: UsageSourceKind,
123 pub fetched_at: std::time::SystemTime,
124 pub five_hour: Option<UsageWindow>,
125 pub seven_day: Option<UsageWindow>,
126 pub seven_day_opus: Option<UsageWindow>,
127 pub seven_day_sonnet: Option<UsageWindow>,
128 pub extra_usage: Option<ExtraUsage>,
129 /// Per-key spend for an API-billed account. `None` for every
130 /// window-billed source, and for every row written before this
131 /// field existed - serde decodes a missing key on an `Option` to
132 /// `None`, which is what keeps the cached rows readable.
133 pub spend: Option<ApiSpend>,
134 /// The OpenRouter account's remaining credit pool, in USD:
135 /// `total_credits - total_usage` from `/v1/credits`. Account-wide -
136 /// every key on the account draws on the same pool - unlike the
137 /// per-key figures in [`ApiSpend`]. `None` when the credits fetch
138 /// failed, when the 200 carried no `data` envelope, and for every
139 /// cached row written before this field existed, decoded as absent
140 /// like `spend` above.
141 pub balance: Option<f64>,
142}
143
144impl UsageSnapshot {
145 /// `None` when the snapshot carries no five-hour window, which is a
146 /// documented steady state on the lenient mapper rather than a zero.
147 pub fn five_hour_util(&self) -> Option<f64> {
148 self.five_hour.as_ref().map(|w| w.utilization)
149 }
150
151 /// Binding 7-day utilisation: max across the three 7-day windows
152 /// (`seven_day`, `seven_day_opus`, `seven_day_sonnet`). Whichever
153 /// is most-used is the binding constraint for "is this account
154 /// 7-day rate-limited."
155 ///
156 /// `None` when all three are absent, which a 200 carrying only the
157 /// session window produces.
158 pub fn seven_day_util(&self) -> Option<f64> {
159 let windows = [
160 self.seven_day.as_ref().map(|w| w.utilization),
161 self.seven_day_opus.as_ref().map(|w| w.utilization),
162 self.seven_day_sonnet.as_ref().map(|w| w.utilization),
163 ];
164 windows.into_iter().flatten().reduce(f64::max)
165 }
166
167 /// When a rate-limited account unlocks: the latest `resets_at` among
168 /// windows currently at-or-over the cap (per `is_currently_limited`).
169 /// `None` when no window is currently capped, so a reset ETA is shown
170 /// only on rate-limited rows.
171 pub fn binding_reset_at(&self) -> Option<std::time::SystemTime> {
172 [
173 self.five_hour.as_ref(),
174 self.seven_day.as_ref(),
175 self.seven_day_opus.as_ref(),
176 self.seven_day_sonnet.as_ref(),
177 ]
178 .into_iter()
179 .flatten()
180 .filter(|w| w.is_currently_limited())
181 .filter_map(|w| w.resets_at)
182 .max()
183 }
184
185 /// Whether two snapshots say the same thing, `fetched_at` aside.
186 ///
187 /// **Derived `PartialEq` cannot answer this**: `fetched_at` moves on
188 /// every probe, so two successful polls that found an unchanged pool
189 /// compare unequal and every poll reads as news. The field says when a
190 /// reading was taken, not what it says, and it is the only one excluded
191 /// here - a new usage field belongs in the comparison below.
192 pub fn same_usage(&self, other: &Self) -> bool {
193 // Destructured without `..` on purpose: a field added to the
194 // snapshot is a compile error here rather than a comparison that
195 // silently ignores it and reads a changed pool as unchanged.
196 let Self {
197 source,
198 fetched_at: _,
199 five_hour,
200 seven_day,
201 seven_day_opus,
202 seven_day_sonnet,
203 extra_usage,
204 spend,
205 balance,
206 } = self;
207 source == &other.source
208 && five_hour == &other.five_hour
209 && seven_day == &other.seven_day
210 && seven_day_opus == &other.seven_day_opus
211 && seven_day_sonnet == &other.seven_day_sonnet
212 && extra_usage == &other.extra_usage
213 && spend == &other.spend
214 && balance == &other.balance
215 }
216}
217
218/// What an account has left, in the terms its backend bills in.
219///
220/// A view over a [`UsageSnapshot`], deliberately not part of it: the
221/// snapshot is persisted and this is not, so the stored type stays a
222/// struct and serde keeps decoding every cached row.
223#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum AccountBudget {
226 /// No usable snapshot: none has landed yet, or the cached one was
227 /// written under a different `provider` and no longer describes
228 /// this account.
229 ///
230 /// `spend_billed` carries the account's billing model anyway, so
231 /// unknown figures are named in the account's own terms rather
232 /// than as windows an API account has none of.
233 Unknown { spend_billed: bool },
234 /// Plan windows, as percentages of an allowance that resets. Each
235 /// column is `None` when the snapshot carried no window for it -
236 /// the lenient mapper documents three states where that happens,
237 /// and the strict one requires only the five-hour window, so a
238 /// present snapshot is not a promise of a present figure.
239 Subscription {
240 five_hour_util: Option<f64>,
241 /// Binding 7-day utilization: max across the three 7-day
242 /// windows, or `None` when all three are absent.
243 seven_day_util: Option<f64>,
244 /// When the account unlocks - `Some` only while it is at its
245 /// cap, so a capped account's row can show a reset ETA.
246 resets_at: Option<std::time::SystemTime>,
247 },
248 /// Per-key spend in USD over the three periods the backend
249 /// pre-computes. No allowance, so no percentage and no reset;
250 /// account-wide balance has a different scope and is not carried
251 /// here, so a row cannot imply both figures are per-key.
252 Api { daily: f64, weekly: f64, monthly: f64 },
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use std::time::{Duration, SystemTime};
259
260 fn window(utilization: f64, resets_at: Option<SystemTime>) -> UsageWindow {
261 UsageWindow { utilization, resets_at, reset_description: None, status: None }
262 }
263
264 #[test]
265 fn is_currently_limited_true_when_at_cap_with_future_reset() {
266 let future = SystemTime::now() + Duration::from_secs(60);
267 assert!(window(100.0, Some(future)).is_currently_limited());
268 }
269
270 #[test]
271 fn is_currently_limited_true_when_above_cap_with_future_reset() {
272 // utilization > 100% (e.g. server reports 101% during a brief
273 // overshoot) still counts as limited as long as the reset window
274 // is in the future.
275 let future = SystemTime::now() + Duration::from_secs(60);
276 assert!(window(101.0, Some(future)).is_currently_limited());
277 }
278
279 #[test]
280 fn is_currently_limited_false_when_at_cap_but_reset_passed() {
281 // Stale cached window: probe reported 100% an hour ago, the
282 // reset moment has come and gone but no fresh probe has yet
283 // overwritten the cache. Predicate must return false so the
284 // renderer stops painting the rate-limit label.
285 let past = SystemTime::now() - Duration::from_secs(60);
286 assert!(!window(100.0, Some(past)).is_currently_limited());
287 }
288
289 #[test]
290 fn is_currently_limited_false_when_below_cap() {
291 let future = SystemTime::now() + Duration::from_secs(60);
292 assert!(!window(99.9, Some(future)).is_currently_limited());
293 }
294
295 #[test]
296 fn is_currently_limited_false_when_resets_at_missing() {
297 // No scheduled reset means the predicate cannot prove the
298 // limit is current. Returning true would strand the account
299 // as forever-limited based on a single cached snapshot.
300 assert!(!window(100.0, None).is_currently_limited());
301 }
302
303 /// A poll that found the same pool says nothing new, and `fetched_at`
304 /// moving is not a change of usage.
305 ///
306 /// This is the comparison the usage poller announces on: `PartialEq`
307 /// would make every probe news because it carries the reading's own
308 /// clock, so a quiet account bought a full home read every poll.
309 #[test]
310 fn same_usage_ignores_when_the_reading_was_taken() {
311 // Every field populated, and one mutant per compared field below, so
312 // deleting any single comparison is a red test rather than a line no
313 // case reaches.
314 let snapshot = |fetched_at: SystemTime| UsageSnapshot {
315 source: UsageSourceKind::Oauth,
316 fetched_at,
317 five_hour: Some(window(42.0, None)),
318 seven_day: Some(window(43.0, None)),
319 seven_day_opus: Some(window(44.0, None)),
320 seven_day_sonnet: Some(window(45.0, None)),
321 extra_usage: Some(ExtraUsage {
322 monthly_limit: Some(10.0),
323 used_credits: Some(1.0),
324 utilization: Some(10.0),
325 currency: Some("USD".to_owned()),
326 }),
327 spend: Some(ApiSpend {
328 daily: 1.0,
329 weekly: 2.0,
330 monthly: 3.0,
331 limit: Some(5.0),
332 limit_remaining: Some(2.0),
333 limit_reset: Some("monthly".to_owned()),
334 expires_at: None,
335 }),
336 balance: Some(7.5),
337 };
338 let at = SystemTime::UNIX_EPOCH;
339 let later = at + Duration::from_secs(60);
340
341 assert!(
342 snapshot(at).same_usage(&snapshot(later)),
343 "a later probe that found the same figures is the same usage",
344 );
345
346 let mut moved = snapshot(later);
347 moved.source = UsageSourceKind::OpenRouterKey;
348 assert!(!snapshot(at).same_usage(&moved), "a moved source is news");
349
350 let mut moved = snapshot(later);
351 moved.five_hour = Some(window(42.5, None));
352 assert!(!snapshot(at).same_usage(&moved), "a moved five-hour window is news");
353
354 let mut moved = snapshot(later);
355 moved.seven_day = Some(window(43.5, None));
356 assert!(!snapshot(at).same_usage(&moved), "a moved seven-day window is news");
357
358 let mut moved = snapshot(later);
359 moved.seven_day_opus = Some(window(44.5, None));
360 assert!(!snapshot(at).same_usage(&moved), "a moved opus window is news");
361
362 let mut moved = snapshot(later);
363 moved.seven_day_sonnet = Some(window(45.5, None));
364 assert!(!snapshot(at).same_usage(&moved), "a moved sonnet window is news");
365
366 let mut moved = snapshot(later);
367 moved.extra_usage = Some(ExtraUsage {
368 monthly_limit: Some(11.0),
369 used_credits: Some(1.0),
370 utilization: Some(10.0),
371 currency: Some("USD".to_owned()),
372 });
373 assert!(!snapshot(at).same_usage(&moved), "moved extra usage is news");
374
375 let mut moved = snapshot(later);
376 moved.spend = Some(ApiSpend {
377 daily: 1.5,
378 weekly: 2.0,
379 monthly: 3.0,
380 limit: Some(5.0),
381 limit_remaining: Some(2.0),
382 limit_reset: Some("monthly".to_owned()),
383 expires_at: None,
384 });
385 assert!(!snapshot(at).same_usage(&moved), "a moved spend is news");
386
387 let mut moved = snapshot(later);
388 moved.balance = Some(7.25);
389 assert!(!snapshot(at).same_usage(&moved), "a moved balance is news");
390 }
391}