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 let mut snapshot = self.subscription.snapshot()?;
147 if let Some(balance) = &self.balance {
148 snapshot.balance_usd = Some(cents_to_dollars(balance.balance_cents));
149 }
150 Ok(snapshot)
151 }
152}
153
154impl Subscription {
155 pub fn snapshot(&self) -> Result<DeepRelaySnapshot> {
156 let period_start =
157 parse_instant(self.current_period_start.as_deref(), "current_period_start")?;
158 let period_end = parse_instant(self.current_period_end.as_deref(), "current_period_end")?;
159 let usage = self.usage.as_ref();
160 let quota = self.quota.as_ref();
161
162 let meters = (|| -> Result<(Option<DeepRelayMeter>, Option<DeepRelayMeter>)> {
163 let (Some(quota), Some(usage)) = (quota, usage) else {
164 return Ok((None, None));
165 };
166 Ok((
167 meter(usage.weighted_input_tokens, quota.input_tokens_monthly)?,
168 meter(usage.weighted_output_tokens, quota.output_tokens_monthly)?,
169 ))
170 })();
171 let (monthly_input, monthly_output) = meters?;
172
173 let weekly = (|| -> Result<(Option<DeepRelayMeter>, Option<DeepRelayMeter>)> {
174 let (Some(quota), Some(usage)) = (quota, usage) else {
175 return Ok((None, None));
176 };
177 Ok((
178 meter(
179 usage.weekly_weighted_input_tokens,
180 quota.weekly_input_tokens,
181 )?,
182 meter(
183 usage.weekly_weighted_output_tokens,
184 quota.weekly_output_tokens,
185 )?,
186 ))
187 })();
188 let (weekly_input, weekly_output) = weekly?;
189
190 let (backstop_used_usd, backstop_limit_usd) = match (quota, usage) {
191 (Some(quota), Some(usage)) if quota.max_usage_micro_cents > 0 => (
192 Some(micro_cents_to_dollars(usage.cost_micro_cents)?),
193 Some(micro_cents_to_dollars(quota.max_usage_micro_cents)?),
194 ),
195 _ => (None, None),
196 };
197
198 let searches = match (quota.and_then(|q| q.searches_monthly), usage) {
199 (Some(limit), Some(usage)) if limit > 0 => {
200 let used = non_negative(usage.searches.unwrap_or(0), "usage.searches")?;
201 Some((used as u64, limit as u64))
202 }
203 _ => None,
204 };
205
206 let payg_calls_24h = usage
207 .and_then(|usage| usage.recent_payg_calls_24h)
208 .map(|calls| non_negative(calls, "usage.recent_payg_calls_24h"))
209 .transpose()?
210 .map(|calls| calls as u64);
211
212 let plan = match (
213 self.subscribed,
214 self.plan.as_ref().and_then(|p| p.name.clone()),
215 ) {
216 (true, Some(name)) => name,
217 (true, None) => "DeepRelay".to_string(),
218 (false, _) => "Pay as you go".to_string(),
219 };
220 let price = self.plan.as_ref().and_then(|plan| plan.price.as_ref());
221 let plan_price_cents = price.and_then(|price| price.amount_cents);
222 let plan_interval = match price {
223 Some(price) => price
224 .interval
225 .clone()
226 .map(|interval| match price.interval_count {
227 Some(count) if count > 1 => format!("{count} {interval}s"),
228 _ => interval,
229 }),
230 None => None,
231 };
232
233 Ok(DeepRelaySnapshot {
234 plan,
235 plan_price_cents,
236 plan_interval,
237 subscribed: self.subscribed,
238 cancel_at_period_end: self.cancel_at_period_end,
239 monthly_input,
240 monthly_output,
241 weekly_input,
242 weekly_output,
243 backstop_used_usd,
244 backstop_limit_usd,
245 searches,
246 payg_calls_24h,
247 balance_usd: None,
248 period_start,
249 period_end,
250 models: top_models(usage),
251 })
252 }
253}
254
255fn meter(used: i64, limit: i64) -> Result<Option<DeepRelayMeter>> {
258 let used = non_negative(used, "weighted tokens")?;
259 let limit = non_negative(limit, "token allowance")?;
260 if limit == 0 {
261 return Ok(None);
262 }
263 Ok(Some(DeepRelayMeter { used, limit }))
264}
265
266fn non_negative(value: i64, field: &str) -> Result<i64> {
267 if value < 0 {
268 return Err(AppError::Schema(format!("DeepRelay {field} is negative")));
269 }
270 Ok(value)
271}
272
273fn micro_cents_to_dollars(value: i64) -> Result<f64> {
274 Ok(non_negative(value, "cost")? as f64 / MICRO_CENTS_PER_DOLLAR)
275}
276
277fn cents_to_dollars(value: i64) -> f64 {
280 value as f64 / 100.0
281}
282
283fn parse_instant(raw: Option<&str>, field: &str) -> Result<Option<DateTime<Utc>>> {
284 raw.map(|raw| {
285 DateTime::parse_from_rfc3339(raw)
286 .map(|when| when.with_timezone(&Utc))
287 .map_err(|_| AppError::Schema(format!("DeepRelay {field} is not an RFC 3339 instant")))
288 })
289 .transpose()
290}
291
292fn top_models(usage: Option<&Usage>) -> Vec<DeepRelayModelUsage> {
295 let Some(usage) = usage else {
296 return Vec::new();
297 };
298 let mut models: Vec<DeepRelayModelUsage> = usage
299 .by_model
300 .iter()
301 .map(|model| DeepRelayModelUsage {
302 model: model
303 .model
304 .strip_prefix("deeprelay/")
305 .unwrap_or(&model.model)
306 .to_string(),
307 weighted_input_tokens: model.weighted_input_tokens.max(0),
308 weighted_output_tokens: model.weighted_output_tokens.max(0),
309 })
310 .filter(|model| {
311 model
312 .weighted_input_tokens
313 .saturating_add(model.weighted_output_tokens)
314 > 0
315 })
316 .collect();
317 models.sort_by(|a, b| {
318 let a_total = a
319 .weighted_input_tokens
320 .saturating_add(a.weighted_output_tokens);
321 let b_total = b
322 .weighted_input_tokens
323 .saturating_add(b.weighted_output_tokens);
324 b_total.cmp(&a_total)
325 });
326 models.truncate(MODEL_ROWS);
327 models
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 const FULL: &str = include_str!("../../tests/fixtures/deeprelay/subscription.json");
335
336 fn parsed(raw: &str) -> DeepRelaySnapshot {
337 serde_json::from_str::<Subscription>(raw)
338 .expect("wire shape")
339 .snapshot()
340 .expect("snapshot")
341 }
342
343 #[test]
344 fn full_response_projects_every_meter() {
345 let snapshot = parsed(FULL);
346 assert_eq!(snapshot.plan, "Plus");
347 assert_eq!(snapshot.plan_price_cents, Some(499));
348 assert_eq!(snapshot.plan_interval.as_deref(), Some("month"));
349 assert!(snapshot.subscribed);
350 assert!(!snapshot.cancel_at_period_end);
351
352 let monthly_in = snapshot.monthly_input.expect("monthly input meter");
353 assert_eq!(monthly_in.used, 812_400_000);
354 assert_eq!(monthly_in.limit, 2_500_000_000);
355 assert_eq!(monthly_in.pct(), Some(32));
356
357 let weekly_in = snapshot.weekly_input.expect("weekly input meter");
358 assert_eq!(weekly_in.pct(), Some(50));
359 assert_eq!(snapshot.weekly_window().expect("window").resets_at, None);
361 assert_eq!(
362 snapshot.monthly_window().expect("window").resets_at,
363 snapshot.period_end
364 );
365
366 assert_eq!(snapshot.backstop_used_usd, Some(12.34));
367 assert_eq!(snapshot.backstop_limit_usd, Some(500.0));
368 assert_eq!(snapshot.searches, Some((7, 500)));
369 assert_eq!(snapshot.payg_calls_24h, Some(0));
370 assert_eq!(snapshot.models[0].model, "deepseek-v4.1-flash");
371 assert_eq!(snapshot.models.len(), 3);
372 }
373
374 #[test]
375 fn pay_as_you_go_accounts_have_no_meters() {
376 let snapshot = parsed(
377 r#"{"subscribed":false,"cancel_at_period_end":false,"plan":null,
378 "quota":null,"usage":null}"#,
379 );
380 assert_eq!(snapshot.plan, "Pay as you go");
381 assert!(snapshot.monthly_input.is_none());
382 assert!(snapshot.weekly_window().is_none());
383 assert!(snapshot.monthly_window().is_none());
384 }
385
386 #[test]
387 fn an_incomplete_usage_object_is_rejected_instead_of_fabricating_zero() {
388 let error = serde_json::from_str::<Subscription>(
389 r#"{"subscribed":true,"plan":{"name":"Base"},
390 "quota":{"input_tokens_monthly":500000000,"output_tokens_monthly":50000000,
391 "weekly_input_tokens":125000000,"weekly_output_tokens":12500000,
392 "max_usage_micro_cents":10000000000},
393 "usage":{"weighted_input_tokens":500000000,"weighted_output_tokens":0}}"#,
394 )
395 .expect_err("missing usage meters are unknown, not zero");
396 assert!(error.to_string().contains("weekly_weighted_input_tokens"));
397 }
398
399 #[test]
400 fn zero_ceiling_is_uncapped_and_negative_figures_are_rejected() {
401 let snapshot = parsed(
402 r#"{"subscribed":true,"plan":{"name":"Custom"},
403 "quota":{"input_tokens_monthly":0,"output_tokens_monthly":0,
404 "weekly_input_tokens":0,"weekly_output_tokens":0,
405 "max_usage_micro_cents":0},
406 "usage":{"weighted_input_tokens":0,"weighted_output_tokens":0,
407 "weekly_weighted_input_tokens":0,"weekly_weighted_output_tokens":0,
408 "cost_micro_cents":0}}"#,
409 );
410 assert!(snapshot.monthly_input.is_none());
411 assert_eq!(snapshot.backstop_limit_usd, None);
412
413 let error = serde_json::from_str::<Subscription>(
414 r#"{"subscribed":true,"quota":{"input_tokens_monthly":10,"output_tokens_monthly":10,
415 "weekly_input_tokens":10,"weekly_output_tokens":10,
416 "max_usage_micro_cents":10},"usage":{"weighted_input_tokens":-1,
417 "weighted_output_tokens":0,"weekly_weighted_input_tokens":0,
418 "weekly_weighted_output_tokens":0,"cost_micro_cents":0}}"#,
419 )
420 .unwrap()
421 .snapshot()
422 .expect_err("negative usage is invalid wire data");
423 assert!(matches!(error, AppError::Schema(_)));
424 }
425
426 #[test]
427 fn a_body_that_is_not_a_subscription_is_a_schema_mismatch() {
428 assert!(serde_json::from_str::<Subscription>(r#"{"error":"unauthorized"}"#).is_err());
429 }
430
431 #[test]
432 fn a_malformed_period_instant_is_rejected() {
433 let subscription: Subscription = serde_json::from_str(
434 r#"{"subscribed":false,"current_period_end":"tomorrow","usage":null}"#,
435 )
436 .unwrap();
437 let error = subscription.snapshot().expect_err("invalid period instant");
438 assert!(matches!(error, AppError::Schema(_)));
439 assert!(error.to_string().contains("current_period_end"));
440 }
441
442 #[test]
443 fn model_totals_saturate_at_the_wire_integer_limit() {
444 let usage = Usage {
445 by_model: vec![ModelUsage {
446 model: "deeprelay/large".into(),
447 weighted_input_tokens: i64::MAX,
448 weighted_output_tokens: i64::MAX,
449 ..Default::default()
450 }],
451 ..Default::default()
452 };
453 let models = top_models(Some(&usage));
454 assert_eq!(models[0].weighted_total(), i64::MAX);
455 }
456
457 #[test]
458 fn balance_is_read_in_cents_and_attached_to_the_snapshot() {
459 let payload: CachedPayload = serde_json::from_str(
460 r#"{"subscription":{"subscribed":false},
461 "balance":{"balance_cents":1234,"auto_pay_enabled":false}}"#,
462 )
463 .unwrap();
464 assert_eq!(payload.snapshot().unwrap().balance_usd, Some(12.34));
465 }
466}