Skip to main content

ai_usagebar/orcarouter/
types.rs

1//! Wire types for OrcaRouter's `/v1/dashboard/billing/{usage,subscription}`
2//! endpoints (one-api/new-api lineage, named verbatim in OrcaRouter's docs).
3//!
4//! Two shape facts drive everything here:
5//!
6//! - `total_usage` is **US cents** (`275` = $2.75), so spend is parsed into
7//!   exact integer cents rather than a float dollar amount.
8//! - The subscription's three limit fields carry the *same* value and mean the
9//!   **total credit limit** (remaining + used) — not the remaining balance.
10//!   Unlimited-quota keys return `100000000` there, which must not render as a
11//!   $100M wallet.
12
13use serde::Deserialize;
14
15use crate::usage::{Cents, OrcaRouterSnapshot};
16
17/// The unlimited sentinel one-api deployments put in every limit field. A key
18/// reporting this has no cap, not a hundred-million-dollar one.
19pub const UNLIMITED_LIMIT_USD: f64 = 100_000_000.0;
20
21/// `GET /v1/dashboard/billing/usage` — cumulative total usage in US cents.
22///
23/// Date params exist on this endpoint but are ignored by the deployment; none
24/// are sent. `object` is tolerated absent (fixtures from the live adapter
25/// omit it).
26#[derive(Debug, Clone, Deserialize)]
27pub struct UsageResponse {
28    #[serde(default)]
29    pub object: Option<String>,
30    #[serde(deserialize_with = "de_cents")]
31    pub total_usage: Cents,
32}
33
34/// `GET /v1/dashboard/billing/subscription` — the key's credit limit and
35/// expiry. `object`, `has_payment_method`, and `access_until` are tolerated
36/// absent (`access_until: 0` also means "no expiry").
37#[derive(Debug, Clone, Deserialize)]
38pub struct SubscriptionResponse {
39    #[serde(default)]
40    pub object: Option<String>,
41    #[serde(default)]
42    pub has_payment_method: Option<bool>,
43    #[serde(default, deserialize_with = "de_opt_usd_cents")]
44    pub soft_limit_usd: Option<Cents>,
45    #[serde(default, deserialize_with = "de_opt_usd_cents")]
46    pub hard_limit_usd: Option<Cents>,
47    #[serde(default, deserialize_with = "de_opt_usd_cents")]
48    pub system_hard_limit_usd: Option<Cents>,
49    #[serde(default)]
50    pub access_until: i64,
51}
52
53impl SubscriptionResponse {
54    /// The total credit limit in exact cents, or `None` when the key is
55    /// unlimited (sentinel) or reported no limit field at all.
56    ///
57    /// The three wire fields are documented to carry the same value, so the
58    /// binding one (`hard`) is preferred and the others are fallbacks rather
59    /// than cross-checks — a deployment that disagrees with itself still gets
60    /// its most authoritative field, not a schema error.
61    pub fn limit_cents(&self) -> Option<i64> {
62        self.hard_limit_usd
63            .or(self.soft_limit_usd)
64            .or(self.system_hard_limit_usd)
65            .filter(|cents| cents.0 > 0)
66            .map(|cents| cents.0)
67    }
68
69    /// Key expiry as a timestamp; `None` for `0` (and absent) — no expiry.
70    pub fn access_until_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
71        (self.access_until > 0)
72            .then(|| chrono::DateTime::from_timestamp(self.access_until, 0))
73            .flatten()
74    }
75}
76
77/// Combine the two endpoint responses into the canonical snapshot.
78pub fn combine(usage: &UsageResponse, subscription: &SubscriptionResponse) -> OrcaRouterSnapshot {
79    OrcaRouterSnapshot {
80        spent_cents: usage.total_usage.0,
81        limit_cents: subscription.limit_cents(),
82        access_until: subscription.access_until_at(),
83    }
84}
85
86/// Parse a wire money-in-cents number into exact integer cents. Fractional
87/// cents (the deployment computes them from an integer quota) round to the
88/// nearest cent — the cent is the smallest unit any renderer can show.
89fn de_cents<'de, D>(d: D) -> Result<Cents, D::Error>
90where
91    D: serde::Deserializer<'de>,
92{
93    let raw = f64::deserialize(d)?;
94    if !raw.is_finite() {
95        return Err(serde::de::Error::custom(
96            "orcarouter `total_usage` is not a finite number",
97        ));
98    }
99    if raw < 0.0 {
100        return Err(serde::de::Error::custom(
101            "orcarouter `total_usage` cannot be negative",
102        ));
103    }
104    if raw > i64::MAX as f64 {
105        return Err(serde::de::Error::custom(
106            "orcarouter `total_usage` is out of range",
107        ));
108    }
109    Ok(Cents(raw.round() as i64))
110}
111
112/// Parse an optional wire money-in-USD field into exact integer cents. The
113/// unlimited sentinel maps to `Cents(0)`, which `limit_cents` filters out
114/// alongside any other non-positive limit — an unlimited key is no limit at
115/// all, never a $100M one.
116fn de_opt_usd_cents<'de, D>(d: D) -> Result<Option<Cents>, D::Error>
117where
118    D: serde::Deserializer<'de>,
119{
120    let raw: Option<f64> = Option::deserialize(d)?;
121    raw.map(|v| {
122        if v == UNLIMITED_LIMIT_USD {
123            Ok(Cents(0))
124        } else if !v.is_finite() || v < 0.0 {
125            Err(format!(
126                "orcarouter `limit` is not finite and non-negative: {v}"
127            ))
128        } else {
129            // The field is USD; the snapshot is exact cents. 12.5 USD → 1250.
130            let cents = v * 100.0;
131            if cents > i64::MAX as f64 {
132                Err(format!("orcarouter `limit` is out of range: {v}"))
133            } else {
134                Ok(Cents(cents.round() as i64))
135            }
136        }
137    })
138    .transpose()
139    .map_err(serde::de::Error::custom)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    const USAGE: &str = r#"{"object":"list","total_usage":275}"#;
147    const SUBSCRIPTION: &str = r#"{
148        "object":"billing_subscription",
149        "has_payment_method":true,
150        "soft_limit_usd":12.5,
151        "hard_limit_usd":12.5,
152        "system_hard_limit_usd":12.5,
153        "access_until":1790000000
154    }"#;
155
156    #[test]
157    fn parses_usage_verbatim_shape() {
158        let usage: UsageResponse = serde_json::from_str(USAGE).unwrap();
159        assert_eq!(usage.object.as_deref(), Some("list"));
160        assert_eq!(usage.total_usage.0, 275);
161    }
162
163    #[test]
164    fn parses_usage_without_object() {
165        let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":0}"#).unwrap();
166        assert!(usage.object.is_none());
167        assert_eq!(usage.total_usage.0, 0);
168    }
169
170    #[test]
171    fn missing_total_usage_is_schema_drift_not_zero() {
172        assert!(serde_json::from_str::<UsageResponse>(r#"{"object":"list"}"#).is_err());
173        assert!(serde_json::from_str::<UsageResponse>(r#"{}"#).is_err());
174    }
175
176    /// The wire unit is cents: 275 must reach the snapshot as 275 cents, which
177    /// formats as $2.75 — never as $275.00.
178    #[test]
179    fn combine_keeps_cents_exact() {
180        let usage: UsageResponse = serde_json::from_str(USAGE).unwrap();
181        let sub: SubscriptionResponse = serde_json::from_str(SUBSCRIPTION).unwrap();
182        let snap = combine(&usage, &sub);
183        assert_eq!(snap.spent_cents, 275);
184        assert!((snap.spent_usd() - 2.75).abs() < 1e-9);
185        assert_eq!(snap.limit_cents, Some(1250));
186        assert_eq!(snap.remaining_cents(), Some(975));
187        assert!((snap.remaining_usd().unwrap() - 9.75).abs() < 1e-9);
188        assert_eq!(snap.consumed_pct(), Some(22));
189        assert_eq!(
190            snap.access_until,
191            chrono::DateTime::from_timestamp(1_790_000_000, 0)
192        );
193    }
194
195    #[test]
196    fn fractional_cents_round_to_the_nearest_cent() {
197        let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275.4}"#).unwrap();
198        assert_eq!(usage.total_usage.0, 275);
199        let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275.5}"#).unwrap();
200        assert_eq!(usage.total_usage.0, 276);
201    }
202
203    #[test]
204    fn invalid_usage_money_is_schema_drift() {
205        for raw in ["-1", "null", "true", r#""275""#, "1e400"] {
206            let body = format!(r#"{{"total_usage":{raw}}}"#);
207            assert!(
208                serde_json::from_str::<UsageResponse>(&body).is_err(),
209                "{raw}"
210            );
211        }
212    }
213
214    #[test]
215    fn parses_subscription_without_optional_fields() {
216        let sub: SubscriptionResponse = serde_json::from_str(
217            r#"{"soft_limit_usd":5.0,"hard_limit_usd":5.0,"system_hard_limit_usd":5.0}"#,
218        )
219        .unwrap();
220        assert!(sub.object.is_none());
221        assert!(sub.has_payment_method.is_none());
222        assert_eq!(sub.access_until, 0);
223        assert!(sub.access_until_at().is_none());
224        assert_eq!(sub.limit_cents(), Some(500));
225    }
226
227    #[test]
228    fn limit_falls_back_when_only_some_fields_arrive() {
229        let sub: SubscriptionResponse =
230            serde_json::from_str(r#"{"system_hard_limit_usd":7.5}"#).unwrap();
231        assert_eq!(sub.limit_cents(), Some(750));
232        let none: SubscriptionResponse = serde_json::from_str("{}").unwrap();
233        assert_eq!(none.limit_cents(), None);
234    }
235
236    /// The sentinel is `100000000` in the limit fields. It must collapse to
237    /// "no limit" — a spend-only card — never a $100,000,000.00 wallet.
238    #[test]
239    fn unlimited_sentinel_is_no_limit_not_one_hundred_million() {
240        let sub: SubscriptionResponse = serde_json::from_str(
241            r#"{"soft_limit_usd":100000000,"hard_limit_usd":100000000,"system_hard_limit_usd":100000000}"#,
242        )
243        .unwrap();
244        assert_eq!(sub.limit_cents(), None);
245
246        let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275}"#).unwrap();
247        let snap = combine(&usage, &sub);
248        assert_eq!(snap.limit_cents, None);
249        assert_eq!(snap.remaining_cents(), None);
250        assert_eq!(snap.remaining_usd(), None);
251        assert_eq!(snap.consumed_pct(), None);
252        assert_eq!(snap.spent_cents, 275);
253    }
254
255    #[test]
256    fn access_until_zero_means_no_expiry() {
257        let sub: SubscriptionResponse =
258            serde_json::from_str(r#"{"hard_limit_usd":5.0,"access_until":0}"#).unwrap();
259        assert!(sub.access_until_at().is_none());
260    }
261
262    #[test]
263    fn invalid_limit_money_is_schema_drift() {
264        let body = r#"{"hard_limit_usd":-5.0}"#;
265        assert!(serde_json::from_str::<SubscriptionResponse>(body).is_err());
266    }
267
268    /// Spend past the limit keeps a signed remaining, exactly like OpenRouter
269    /// debt: the number is real and the user must top up.
270    #[test]
271    fn overrun_keeps_a_negative_remaining() {
272        let snap = OrcaRouterSnapshot {
273            spent_cents: 1300,
274            limit_cents: Some(1250),
275            access_until: None,
276        };
277        assert_eq!(snap.remaining_cents(), Some(-50));
278        assert_eq!(snap.consumed_pct(), Some(100));
279    }
280}