Skip to main content

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}