Skip to main content

ai_usagebar/openrouter/
types.rs

1//! Wire types for OpenRouter's `/api/v1/credits` and `/api/v1/key`.
2//!
3//! Both endpoints wrap their payload in `{ "data": { ... } }`, hence the
4//! generic [`OrEnvelope`] wrapper.
5
6use serde::Deserialize;
7
8use crate::usage::OpenRouterSnapshot;
9
10/// Wrapper used by all OpenRouter v1 endpoints.
11#[derive(Debug, Clone, Deserialize)]
12pub struct OrEnvelope<T> {
13    pub data: T,
14}
15
16/// `GET /api/v1/credits` — total_credits and total_usage, both USD doubles.
17#[derive(Debug, Clone, Deserialize)]
18pub struct CreditsData {
19    #[serde(deserialize_with = "de_nonnegative_finite")]
20    pub total_credits: f64,
21    #[serde(deserialize_with = "de_nonnegative_finite")]
22    pub total_usage: f64,
23}
24
25/// `GET /api/v1/key` — per-key usage and free-tier flag.
26#[derive(Debug, Clone, Deserialize)]
27pub struct KeyData {
28    #[serde(default)]
29    pub label: String,
30    #[serde(default, deserialize_with = "de_opt_nonnegative_finite")]
31    pub limit: Option<f64>,
32    #[serde(default, deserialize_with = "de_opt_finite")]
33    pub limit_remaining: Option<f64>,
34    #[serde(deserialize_with = "de_nonnegative_finite")]
35    pub usage_daily: f64,
36    #[serde(deserialize_with = "de_nonnegative_finite")]
37    pub usage_weekly: f64,
38    #[serde(deserialize_with = "de_nonnegative_finite")]
39    pub usage_monthly: f64,
40    pub is_free_tier: bool,
41}
42
43fn checked_finite<E: serde::de::Error>(value: f64) -> Result<f64, E> {
44    if value.is_finite() {
45        Ok(value)
46    } else {
47        Err(E::custom("money value is not finite"))
48    }
49}
50
51fn checked_nonnegative<E: serde::de::Error>(value: f64) -> Result<f64, E> {
52    let value = checked_finite(value)?;
53    if value >= 0.0 {
54        Ok(value)
55    } else {
56        Err(E::custom("money value cannot be negative"))
57    }
58}
59
60fn de_nonnegative_finite<'de, D>(d: D) -> Result<f64, D::Error>
61where
62    D: serde::Deserializer<'de>,
63{
64    checked_nonnegative(f64::deserialize(d)?)
65}
66
67fn de_opt_nonnegative_finite<'de, D>(d: D) -> Result<Option<f64>, D::Error>
68where
69    D: serde::Deserializer<'de>,
70{
71    Option::<f64>::deserialize(d)?
72        .map(checked_nonnegative)
73        .transpose()
74}
75
76fn de_opt_finite<'de, D>(d: D) -> Result<Option<f64>, D::Error>
77where
78    D: serde::Deserializer<'de>,
79{
80    Option::<f64>::deserialize(d)?
81        .map(checked_finite)
82        .transpose()
83}
84
85/// An item from `GET /api/v1/activity`.
86#[derive(Debug, Clone, Deserialize)]
87pub struct ActivityItem {
88    pub date: Option<String>,
89    pub model: Option<String>,
90    #[serde(default)]
91    pub usage: f64,
92    #[serde(default)]
93    pub requests: u64,
94}
95
96/// Combine the endpoint responses into the canonical snapshot.
97pub fn combine(
98    credits: CreditsData,
99    key: KeyData,
100    recent_models: Vec<String>,
101) -> OpenRouterSnapshot {
102    let label = if key.label.is_empty() {
103        "OpenRouter".to_string()
104    } else {
105        format!("OpenRouter — {}", key.label)
106    };
107    OpenRouterSnapshot {
108        label,
109        total_credits: credits.total_credits,
110        total_usage: credits.total_usage,
111        usage_daily: key.usage_daily,
112        usage_weekly: key.usage_weekly,
113        usage_monthly: key.usage_monthly,
114        is_free_tier: key.is_free_tier,
115        limit: key.limit,
116        limit_remaining: key.limit_remaining,
117        recent_models,
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn parses_credits_envelope() {
127        let raw = r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#;
128        let env: OrEnvelope<CreditsData> = serde_json::from_str(raw).unwrap();
129        assert_eq!(env.data.total_credits, 100.0);
130        assert_eq!(env.data.total_usage, 25.5);
131    }
132
133    #[test]
134    fn parses_key_envelope_with_nulls() {
135        let raw = r#"{"data":{
136            "label":"my-key",
137            "limit":null,"limit_remaining":null,
138            "usage":12.34,"usage_daily":1.0,"usage_weekly":3.0,"usage_monthly":12.0,
139            "is_free_tier":false
140        }}"#;
141        let env: OrEnvelope<KeyData> = serde_json::from_str(raw).unwrap();
142        assert_eq!(env.data.label, "my-key");
143        assert!(env.data.limit.is_none());
144        assert_eq!(env.data.usage_monthly, 12.0);
145        assert!(!env.data.is_free_tier);
146    }
147
148    #[test]
149    fn combine_builds_snapshot() {
150        let c = CreditsData {
151            total_credits: 100.0,
152            total_usage: 30.0,
153        };
154        let k = KeyData {
155            label: "key-A".into(),
156            limit: Some(50.0),
157            limit_remaining: Some(20.0),
158            usage_daily: 1.0,
159            usage_weekly: 5.0,
160            usage_monthly: 30.0,
161            is_free_tier: false,
162        };
163        let snap = combine(c, k, Vec::new());
164        assert_eq!(snap.label, "OpenRouter — key-A");
165        assert!((snap.balance() - 70.0).abs() < 1e-9);
166        assert_eq!(snap.consumed_pct(), 30);
167        assert_eq!(snap.usage_monthly, 30.0);
168    }
169
170    #[test]
171    fn combine_with_empty_label() {
172        let snap = combine(
173            CreditsData {
174                total_credits: 0.0,
175                total_usage: 0.0,
176            },
177            KeyData {
178                label: String::new(),
179                limit: None,
180                limit_remaining: None,
181                usage_daily: 0.0,
182                usage_weekly: 0.0,
183                usage_monthly: 0.0,
184                is_free_tier: false,
185            },
186            Vec::new(),
187        );
188        assert_eq!(snap.label, "OpenRouter");
189    }
190
191    #[test]
192    fn missing_required_money_does_not_deserialize_as_zero() {
193        assert!(serde_json::from_str::<OrEnvelope<CreditsData>>(r#"{"data":{}}"#).is_err());
194        assert!(
195            serde_json::from_str::<OrEnvelope<KeyData>>(
196                r#"{"data":{"label":"key","is_free_tier":false}}"#
197            )
198            .is_err()
199        );
200    }
201
202    #[test]
203    fn invalid_money_values_are_schema_drift() {
204        for total in ["-1", "1e400", "true", r#""zero""#] {
205            let raw = format!(r#"{{"data":{{"total_credits":{total},"total_usage":0}}}}"#);
206            assert!(
207                serde_json::from_str::<OrEnvelope<CreditsData>>(&raw).is_err(),
208                "{raw}"
209            );
210        }
211    }
212
213    #[test]
214    fn consumed_pct_handles_zero_credits() {
215        let s = OpenRouterSnapshot {
216            label: "x".into(),
217            total_credits: 0.0,
218            total_usage: 5.0,
219            usage_daily: 0.0,
220            usage_weekly: 0.0,
221            usage_monthly: 0.0,
222            is_free_tier: true,
223            limit: None,
224            limit_remaining: None,
225            recent_models: Vec::new(),
226        };
227        assert_eq!(s.consumed_pct(), 0);
228    }
229}