Skip to main content

ai_usagebar/deeprelay/
types.rs

1//! DeepRelay wire types and their projection into [`DeepRelaySnapshot`].
2//!
3//! One endpoint carries every plan figure — `GET /v1/billing/subscription`
4//! (scope `billing:read`): the plan, four weighted-token meters, and the dollar
5//! usage ceiling. `GET /v1/billing/balance` is the pay-as-you-go wallet and is
6//! fetched only when it can matter.
7//!
8//! Two units need care:
9//!
10//! - Token figures are **weighted** tokens — a model with usage factor ×24
11//!   draws 24 tokens of allowance per token served — so the meters are directly
12//!   comparable with their limits and with each other.
13//! - `*_micro_cents` is a millionth of a cent, so dollars = value / 1e8.
14//!
15//! The rolling 7-day caps have **no reset instant** by design ("It slides
16//! rather than resets"), so `resets_at` stays `None` for them; never synthesize
17//! a date. The monthly meters reset at the end of the billing period.
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::error::{AppError, Result};
23use crate::usage::{DeepRelayMeter, DeepRelayModelUsage, DeepRelaySnapshot};
24
25/// One micro-cent unit expressed as a fraction of a dollar.
26const MICRO_CENTS_PER_DOLLAR: f64 = 100_000_000.0;
27
28/// `GET /v1/billing/subscription` (`billing:read`).
29#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct Subscription {
31    /// Required on purpose: without it the body is not a subscription at all,
32    /// and a 200 carrying something else must fail as a schema mismatch rather
33    /// than project an all-empty snapshot.
34    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/// `GET /v1/billing/balance` (`billing:read`).
117#[derive(Debug, Clone, Deserialize, Serialize)]
118pub struct Balance {
119    /// Cents are exact; `balance_dollars` is the same figure as a float and is
120    /// deliberately not read, so two representations cannot disagree.
121    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/// What the vendor cache stores: the subscription plus the wallet when the
131/// second request was worth making. Keeping both in one payload means a cached
132/// or stale read still carries the balance it was fetched with.
133#[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
140/// How many per-model rows the tooltip keeps. The response can carry dozens of
141/// models; the figure that matters is the allowance, not the tail.
142const 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
260/// A meter exists only when the plan states a ceiling. An omitted or zero limit
261/// is "uncapped", not "0% used": `used = 0, limit = 0` is not a reading.
262fn 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
282/// A balance can legitimately be negative if an overrun was recorded, so this
283/// deliberately does not reject the sign.
284fn 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
297/// The heaviest models of the billing period, for the tooltip. The wire ids
298/// carry a `deeprelay/` namespace that is noise next to the plan's own label.
299fn 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        // Rolling caps have no reset instant.
365        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}