ai_usagebar/orcarouter/
types.rs1use serde::Deserialize;
14
15use crate::usage::{Cents, OrcaRouterSnapshot};
16
17pub const UNLIMITED_LIMIT_USD: f64 = 100_000_000.0;
20
21#[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#[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 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 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
77pub 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
86fn 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
112fn 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 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 #[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 #[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 #[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}