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        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
255/// A meter exists only when the plan states a ceiling. An omitted or zero limit
256/// is "uncapped", not "0% used": `used = 0, limit = 0` is not a reading.
257fn 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
277/// A balance can legitimately be negative if an overrun was recorded, so this
278/// deliberately does not reject the sign.
279fn 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
292/// The heaviest models of the billing period, for the tooltip. The wire ids
293/// carry a `deeprelay/` namespace that is noise next to the plan's own label.
294fn 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        // Rolling caps have no reset instant.
360        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}