1use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::error::{AppError, Result};
23use crate::usage::{DeepRelayMeter, DeepRelayModelUsage, DeepRelaySnapshot};
24
25const MICRO_CENTS_PER_DOLLAR: f64 = 100_000_000.0;
27
28#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct Subscription {
31 pub subscribed: bool,
35 #[serde(default)]
36 pub cancel_at_period_end: bool,
37 #[serde(default)]
38 pub current_period_start: Option<String>,
39 #[serde(default)]
40 pub current_period_end: Option<String>,
41 #[serde(default)]
42 pub plan: Option<Plan>,
43 #[serde(default)]
44 pub quota: Option<Quota>,
45 #[serde(default)]
46 pub usage: Option<Usage>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct Plan {
51 #[serde(default)]
52 pub key: Option<String>,
53 #[serde(default)]
54 pub name: Option<String>,
55 #[serde(default)]
56 pub price: Option<Price>,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct Price {
61 #[serde(default)]
62 pub amount_cents: Option<i64>,
63 #[serde(default)]
64 pub currency: Option<String>,
65 #[serde(default)]
66 pub interval: Option<String>,
67 #[serde(default)]
68 pub interval_count: Option<i64>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct Quota {
73 pub input_tokens_monthly: i64,
74 pub output_tokens_monthly: i64,
75 pub weekly_input_tokens: i64,
76 pub weekly_output_tokens: i64,
77 pub max_usage_micro_cents: i64,
78 #[serde(default)]
79 pub searches_monthly: Option<i64>,
80}
81
82#[derive(Debug, Clone, Default, Deserialize, Serialize)]
83pub struct Usage {
84 pub weighted_input_tokens: i64,
85 pub weighted_output_tokens: i64,
86 pub weekly_weighted_input_tokens: i64,
87 pub weekly_weighted_output_tokens: i64,
88 pub cost_micro_cents: i64,
89 #[serde(default)]
90 pub recent_payg_calls_24h: Option<i64>,
91 #[serde(default)]
92 pub searches: Option<i64>,
93 #[serde(default)]
94 pub by_model: Vec<ModelUsage>,
95 #[serde(default)]
96 pub weekly_by_model: Vec<ModelUsage>,
97}
98
99#[derive(Debug, Clone, Default, Deserialize, Serialize)]
100pub struct ModelUsage {
101 pub model: String,
102 #[serde(default)]
103 pub input_tokens: i64,
104 #[serde(default)]
105 pub cached_input_tokens: i64,
106 #[serde(default)]
107 pub output_tokens: i64,
108 #[serde(default)]
109 pub usage_factor_milli: Option<i64>,
110 #[serde(default)]
111 pub weighted_input_tokens: i64,
112 #[serde(default)]
113 pub weighted_output_tokens: i64,
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize)]
118pub struct Balance {
119 pub balance_cents: i64,
122 #[serde(default)]
123 pub auto_pay_enabled: bool,
124 #[serde(default)]
125 pub auto_pay_threshold_cents: Option<i64>,
126 #[serde(default)]
127 pub burn_cents_per_hour: Option<i64>,
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct CachedPayload {
135 pub subscription: Subscription,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub balance: Option<Balance>,
138}
139
140const MODEL_ROWS: usize = 5;
143
144impl CachedPayload {
145 pub fn snapshot(&self) -> Result<DeepRelaySnapshot> {
146 if !self.subscription.subscribed && self.balance.is_none() {
147 return Err(AppError::Schema(
148 "DeepRelay pay-as-you-go balance is missing".into(),
149 ));
150 }
151 let mut snapshot = self.subscription.snapshot()?;
152 if let Some(balance) = &self.balance {
153 snapshot.balance_usd = Some(cents_to_dollars(balance.balance_cents));
154 }
155 Ok(snapshot)
156 }
157}
158
159impl Subscription {
160 pub fn snapshot(&self) -> Result<DeepRelaySnapshot> {
161 let period_start =
162 parse_instant(self.current_period_start.as_deref(), "current_period_start")?;
163 let period_end = parse_instant(self.current_period_end.as_deref(), "current_period_end")?;
164 let usage = self.usage.as_ref();
165 let quota = self.quota.as_ref();
166
167 let meters = (|| -> Result<(Option<DeepRelayMeter>, Option<DeepRelayMeter>)> {
168 let (Some(quota), Some(usage)) = (quota, usage) else {
169 return Ok((None, None));
170 };
171 Ok((
172 meter(usage.weighted_input_tokens, quota.input_tokens_monthly)?,
173 meter(usage.weighted_output_tokens, quota.output_tokens_monthly)?,
174 ))
175 })();
176 let (monthly_input, monthly_output) = meters?;
177
178 let weekly = (|| -> Result<(Option<DeepRelayMeter>, Option<DeepRelayMeter>)> {
179 let (Some(quota), Some(usage)) = (quota, usage) else {
180 return Ok((None, None));
181 };
182 Ok((
183 meter(
184 usage.weekly_weighted_input_tokens,
185 quota.weekly_input_tokens,
186 )?,
187 meter(
188 usage.weekly_weighted_output_tokens,
189 quota.weekly_output_tokens,
190 )?,
191 ))
192 })();
193 let (weekly_input, weekly_output) = weekly?;
194
195 let (backstop_used_usd, backstop_limit_usd) = match (quota, usage) {
196 (Some(quota), Some(usage)) if quota.max_usage_micro_cents > 0 => (
197 Some(micro_cents_to_dollars(usage.cost_micro_cents)?),
198 Some(micro_cents_to_dollars(quota.max_usage_micro_cents)?),
199 ),
200 _ => (None, None),
201 };
202
203 let searches = match (quota.and_then(|q| q.searches_monthly), usage) {
204 (Some(limit), Some(usage)) if limit > 0 => {
205 let used = non_negative(usage.searches.unwrap_or(0), "usage.searches")?;
206 Some((used as u64, limit as u64))
207 }
208 _ => None,
209 };
210
211 let payg_calls_24h = usage
212 .and_then(|usage| usage.recent_payg_calls_24h)
213 .map(|calls| non_negative(calls, "usage.recent_payg_calls_24h"))
214 .transpose()?
215 .map(|calls| calls as u64);
216
217 let plan = match (
218 self.subscribed,
219 self.plan.as_ref().and_then(|p| p.name.clone()),
220 ) {
221 (true, Some(name)) => name,
222 (true, None) => "DeepRelay".to_string(),
223 (false, _) => "Pay as you go".to_string(),
224 };
225 let price = self.plan.as_ref().and_then(|plan| plan.price.as_ref());
226 let plan_price_cents = price.and_then(|price| price.amount_cents);
227 let plan_interval = match price {
228 Some(price) => price
229 .interval
230 .clone()
231 .map(|interval| match price.interval_count {
232 Some(count) if count > 1 => format!("{count} {interval}s"),
233 _ => interval,
234 }),
235 None => None,
236 };
237
238 Ok(DeepRelaySnapshot {
239 plan,
240 plan_price_cents,
241 plan_interval,
242 subscribed: self.subscribed,
243 cancel_at_period_end: self.cancel_at_period_end,
244 monthly_input,
245 monthly_output,
246 weekly_input,
247 weekly_output,
248 backstop_used_usd,
249 backstop_limit_usd,
250 searches,
251 payg_calls_24h,
252 balance_usd: None,
253 period_start,
254 period_end,
255 models: top_models(usage),
256 })
257 }
258}
259
260fn meter(used: i64, limit: i64) -> Result<Option<DeepRelayMeter>> {
263 let used = non_negative(used, "weighted tokens")?;
264 let limit = non_negative(limit, "token allowance")?;
265 if limit == 0 {
266 return Ok(None);
267 }
268 Ok(Some(DeepRelayMeter { used, limit }))
269}
270
271fn non_negative(value: i64, field: &str) -> Result<i64> {
272 if value < 0 {
273 return Err(AppError::Schema(format!("DeepRelay {field} is negative")));
274 }
275 Ok(value)
276}
277
278fn micro_cents_to_dollars(value: i64) -> Result<f64> {
279 Ok(non_negative(value, "cost")? as f64 / MICRO_CENTS_PER_DOLLAR)
280}
281
282fn cents_to_dollars(value: i64) -> f64 {
285 value as f64 / 100.0
286}
287
288fn parse_instant(raw: Option<&str>, field: &str) -> Result<Option<DateTime<Utc>>> {
289 raw.map(|raw| {
290 DateTime::parse_from_rfc3339(raw)
291 .map(|when| when.with_timezone(&Utc))
292 .map_err(|_| AppError::Schema(format!("DeepRelay {field} is not an RFC 3339 instant")))
293 })
294 .transpose()
295}
296
297fn top_models(usage: Option<&Usage>) -> Vec<DeepRelayModelUsage> {
300 let Some(usage) = usage else {
301 return Vec::new();
302 };
303 let mut models: Vec<DeepRelayModelUsage> = usage
304 .by_model
305 .iter()
306 .map(|model| DeepRelayModelUsage {
307 model: model
308 .model
309 .strip_prefix("deeprelay/")
310 .unwrap_or(&model.model)
311 .to_string(),
312 weighted_input_tokens: model.weighted_input_tokens.max(0),
313 weighted_output_tokens: model.weighted_output_tokens.max(0),
314 })
315 .filter(|model| {
316 model
317 .weighted_input_tokens
318 .saturating_add(model.weighted_output_tokens)
319 > 0
320 })
321 .collect();
322 models.sort_by(|a, b| {
323 let a_total = a
324 .weighted_input_tokens
325 .saturating_add(a.weighted_output_tokens);
326 let b_total = b
327 .weighted_input_tokens
328 .saturating_add(b.weighted_output_tokens);
329 b_total.cmp(&a_total)
330 });
331 models.truncate(MODEL_ROWS);
332 models
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 const FULL: &str = include_str!("../../tests/fixtures/deeprelay/subscription.json");
340
341 fn parsed(raw: &str) -> DeepRelaySnapshot {
342 serde_json::from_str::<Subscription>(raw)
343 .expect("wire shape")
344 .snapshot()
345 .expect("snapshot")
346 }
347
348 #[test]
349 fn full_response_projects_every_meter() {
350 let snapshot = parsed(FULL);
351 assert_eq!(snapshot.plan, "Plus");
352 assert_eq!(snapshot.plan_price_cents, Some(499));
353 assert_eq!(snapshot.plan_interval.as_deref(), Some("month"));
354 assert!(snapshot.subscribed);
355 assert!(!snapshot.cancel_at_period_end);
356
357 let monthly_in = snapshot.monthly_input.expect("monthly input meter");
358 assert_eq!(monthly_in.used, 812_400_000);
359 assert_eq!(monthly_in.limit, 2_500_000_000);
360 assert_eq!(monthly_in.pct(), Some(32));
361
362 let weekly_in = snapshot.weekly_input.expect("weekly input meter");
363 assert_eq!(weekly_in.pct(), Some(50));
364 assert_eq!(snapshot.weekly_window().expect("window").resets_at, None);
366 assert_eq!(
367 snapshot.monthly_window().expect("window").resets_at,
368 snapshot.period_end
369 );
370
371 assert_eq!(snapshot.backstop_used_usd, Some(12.34));
372 assert_eq!(snapshot.backstop_limit_usd, Some(500.0));
373 assert_eq!(snapshot.searches, Some((7, 500)));
374 assert_eq!(snapshot.payg_calls_24h, Some(0));
375 assert_eq!(snapshot.models[0].model, "deepseek-v4.1-flash");
376 assert_eq!(snapshot.models.len(), 3);
377 }
378
379 #[test]
380 fn pay_as_you_go_accounts_have_no_meters() {
381 let snapshot = parsed(
382 r#"{"subscribed":false,"cancel_at_period_end":false,"plan":null,
383 "quota":null,"usage":null}"#,
384 );
385 assert_eq!(snapshot.plan, "Pay as you go");
386 assert!(snapshot.monthly_input.is_none());
387 assert!(snapshot.weekly_window().is_none());
388 assert!(snapshot.monthly_window().is_none());
389 }
390
391 #[test]
392 fn an_incomplete_usage_object_is_rejected_instead_of_fabricating_zero() {
393 let error = serde_json::from_str::<Subscription>(
394 r#"{"subscribed":true,"plan":{"name":"Base"},
395 "quota":{"input_tokens_monthly":500000000,"output_tokens_monthly":50000000,
396 "weekly_input_tokens":125000000,"weekly_output_tokens":12500000,
397 "max_usage_micro_cents":10000000000},
398 "usage":{"weighted_input_tokens":500000000,"weighted_output_tokens":0}}"#,
399 )
400 .expect_err("missing usage meters are unknown, not zero");
401 assert!(error.to_string().contains("weekly_weighted_input_tokens"));
402 }
403
404 #[test]
405 fn zero_ceiling_is_uncapped_and_negative_figures_are_rejected() {
406 let snapshot = parsed(
407 r#"{"subscribed":true,"plan":{"name":"Custom"},
408 "quota":{"input_tokens_monthly":0,"output_tokens_monthly":0,
409 "weekly_input_tokens":0,"weekly_output_tokens":0,
410 "max_usage_micro_cents":0},
411 "usage":{"weighted_input_tokens":0,"weighted_output_tokens":0,
412 "weekly_weighted_input_tokens":0,"weekly_weighted_output_tokens":0,
413 "cost_micro_cents":0}}"#,
414 );
415 assert!(snapshot.monthly_input.is_none());
416 assert_eq!(snapshot.backstop_limit_usd, None);
417
418 let error = serde_json::from_str::<Subscription>(
419 r#"{"subscribed":true,"quota":{"input_tokens_monthly":10,"output_tokens_monthly":10,
420 "weekly_input_tokens":10,"weekly_output_tokens":10,
421 "max_usage_micro_cents":10},"usage":{"weighted_input_tokens":-1,
422 "weighted_output_tokens":0,"weekly_weighted_input_tokens":0,
423 "weekly_weighted_output_tokens":0,"cost_micro_cents":0}}"#,
424 )
425 .unwrap()
426 .snapshot()
427 .expect_err("negative usage is invalid wire data");
428 assert!(matches!(error, AppError::Schema(_)));
429 }
430
431 #[test]
432 fn a_body_that_is_not_a_subscription_is_a_schema_mismatch() {
433 assert!(serde_json::from_str::<Subscription>(r#"{"error":"unauthorized"}"#).is_err());
434 }
435
436 #[test]
437 fn a_malformed_period_instant_is_rejected() {
438 let subscription: Subscription = serde_json::from_str(
439 r#"{"subscribed":false,"current_period_end":"tomorrow","usage":null}"#,
440 )
441 .unwrap();
442 let error = subscription.snapshot().expect_err("invalid period instant");
443 assert!(matches!(error, AppError::Schema(_)));
444 assert!(error.to_string().contains("current_period_end"));
445 }
446
447 #[test]
448 fn model_totals_saturate_at_the_wire_integer_limit() {
449 let usage = Usage {
450 by_model: vec![ModelUsage {
451 model: "deeprelay/large".into(),
452 weighted_input_tokens: i64::MAX,
453 weighted_output_tokens: i64::MAX,
454 ..Default::default()
455 }],
456 ..Default::default()
457 };
458 let models = top_models(Some(&usage));
459 assert_eq!(models[0].weighted_total(), i64::MAX);
460 }
461
462 #[test]
463 fn balance_is_read_in_cents_and_attached_to_the_snapshot() {
464 let payload: CachedPayload = serde_json::from_str(
465 r#"{"subscription":{"subscribed":false},
466 "balance":{"balance_cents":1234,"auto_pay_enabled":false}}"#,
467 )
468 .unwrap();
469 assert_eq!(payload.snapshot().unwrap().balance_usd, Some(12.34));
470 }
471
472 #[test]
473 fn payg_payload_requires_a_balance_but_accepts_zero_and_overruns() {
474 let mut payload: CachedPayload =
475 serde_json::from_str(r#"{"subscription":{"subscribed":false}}"#).unwrap();
476 assert!(matches!(payload.snapshot(), Err(AppError::Schema(_))));
477
478 for balance_cents in [0, -100] {
479 payload.balance = Some(Balance {
480 balance_cents,
481 auto_pay_enabled: false,
482 auto_pay_threshold_cents: None,
483 burn_cents_per_hour: None,
484 });
485 assert_eq!(
486 payload.snapshot().unwrap().balance_usd,
487 Some(balance_cents as f64 / 100.0)
488 );
489 }
490
491 payload.subscription.subscribed = true;
492 payload.balance = None;
493 assert!(payload.snapshot().unwrap().balance_usd.is_none());
494 }
495}