1use serde::Deserialize;
7
8use crate::usage::OpenRouterSnapshot;
9
10#[derive(Debug, Clone, Deserialize)]
12pub struct OrEnvelope<T> {
13 pub data: T,
14}
15
16#[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#[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#[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
96pub 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}