forge_primitives/usage/oauth.rs
1//! Anthropic OAuth usage API response shapes.
2//!
3//! Type-only - the HTTP fetcher lives in the forge-gateway
4//! backends. These are the JSON wire shapes; the fetcher
5//! deserializes into them.
6
7use serde::{Deserialize, Serialize};
8
9/// Top-level OAuth usage payload. All fields are optional because the
10/// API can omit any window for accounts that don't qualify (free tier,
11/// new account, etc.).
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
13pub struct OauthUsage {
14 /// Rolling 5-hour rate-limit window (the "session" budget).
15 pub five_hour: Option<OauthUsageWindow>,
16 /// Rolling 7-day rate-limit window across all models.
17 pub seven_day: Option<OauthUsageWindow>,
18 /// Rolling 7-day rate-limit window scoped to Opus.
19 pub seven_day_opus: Option<OauthUsageWindow>,
20 /// Rolling 7-day rate-limit window scoped to Sonnet.
21 pub seven_day_sonnet: Option<OauthUsageWindow>,
22 /// Pay-as-you-go credit balance, when the account opted in.
23 pub extra_usage: Option<OauthExtraUsage>,
24}
25
26/// Per-window utilisation. `utilization` is a percentage (0-100);
27/// `resets_at` is whatever the API emits (ISO-8601 string or numeric
28/// epoch). Consumers parse it themselves.
29#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
30pub struct OauthUsageWindow {
31 /// Percentage of the window consumed (0.0-100.0). `None` when the
32 /// API omits the field for this window.
33 pub utilization: Option<f64>,
34 /// When the window resets. Either an ISO-8601 string or a numeric
35 /// epoch - kept as raw `serde_json::Value` so callers can parse
36 /// whichever form they prefer.
37 pub resets_at: Option<serde_json::Value>,
38}
39
40/// "Extra usage" pay-as-you-go credit balance.
41///
42/// Money fields are in **minor units** (cents for USD) as the API
43/// returns them - consumers convert to major units (`/ 100.0`) for
44/// display.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct OauthExtraUsage {
47 /// `true` when the account has opted in to pay-as-you-go.
48 pub is_enabled: Option<bool>,
49 /// Monthly spending cap in minor units (e.g. cents for USD).
50 pub monthly_limit: Option<f64>,
51 /// Credits consumed in the current period in minor units.
52 pub used_credits: Option<f64>,
53 /// Percentage of `monthly_limit` consumed (0.0-100.0).
54 pub utilization: Option<f64>,
55 /// Currency code (e.g. `"USD"`) for the money fields.
56 pub currency: Option<String>,
57}
58
59/// Failure modes for the OAuth usage fetcher.
60#[derive(Debug, PartialEq, thiserror::Error)]
61pub enum OauthUsageError {
62 /// No OAuth credentials were resolved. Caller should advise the
63 /// account's own repair path.
64 #[error("No Claude OAuth credentials found")]
65 NoCredentials,
66 /// Credentials present but expired locally; caller should surface
67 /// the account's own repair path.
68 #[error("Claude OAuth credentials expired")]
69 Expired,
70 /// API returned 401/403. Token may be stale or revoked.
71 #[error("Claude OAuth usage request was rejected (HTTP {0})")]
72 Unauthorized(u16),
73 /// API returned 403 with `oauth_scope_insufficient`: the token
74 /// authenticated but lacks the `user:profile` scope this endpoint
75 /// requires. The verdict on a VALID setup token, not an auth
76 /// failure - setup tokens carry `user:inference` only.
77 #[error("Claude OAuth usage endpoint refused the token's scopes")]
78 ScopeInsufficient,
79 /// API returned 429. `retry_after` is the parsed `Retry-After`
80 /// header value (in seconds) when present - Anthropic returns
81 /// per-account hold-down durations that the caller should honour
82 /// to avoid hammering the endpoint and keeping the limit hot.
83 /// `snapshot` is the plan windows the response still carried: the
84 /// one answer that most needs them is the one saying which window
85 /// is spent.
86 #[error("Claude OAuth usage request was rate-limited (retry_after={retry_after:?})")]
87 RateLimited {
88 retry_after: Option<std::time::Duration>,
89 snapshot: Option<Box<super::UsageSnapshot>>,
90 },
91 /// API returned an unexpected non-success status (anything other
92 /// than 200 / 401 / 403 / 429).
93 #[error("Claude OAuth usage request failed with HTTP {0}{1}")]
94 HttpStatus(u16, String),
95 /// Network error reaching the API.
96 #[error("Claude OAuth network error: {0}")]
97 Network(String),
98 /// The `claude --version` probe that supplies the User-Agent
99 /// failed - a local exec problem, not a reachability verdict.
100 #[error("Claude CLI version probe failed: {0}")]
101 UaProbe(String),
102 /// Response body could not be parsed.
103 #[error("Failed to decode Claude OAuth usage response: {0}")]
104 Decode(String),
105}
106
107impl OauthUsageError {
108 /// True when this is a 429 rate-limit response. Used by callers
109 /// that schedule the next probe attempt against the
110 /// `Retry-After` value.
111 pub fn is_rate_limited(&self) -> bool {
112 matches!(self, Self::RateLimited { .. })
113 }
114}