Skip to main content

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}