Skip to main content

ai_usagebar/cursor/
types.rs

1//! Wire types for `GET cursor.com/api/usage-summary`.
2//!
3//! **Undocumented** — the endpoint the Cursor dashboard's own frontend calls
4//! to draw the "Cursor Models" / "Other Models" usage bars. Confirmed against a
5//! live Ultra account:
6//!
7//! ```json
8//! {
9//!   "billingCycleStart": "2026-07-04T00:35:51.000Z",
10//!   "billingCycleEnd":   "2026-08-04T00:35:51.000Z",
11//!   "membershipType": "ultra",
12//!   "isUnlimited": false,
13//!   "individualUsage": {
14//!     "plan": { "autoPercentUsed": 98.1, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
15//!     "onDemand": { "enabled": false }
16//!   }
17//! }
18//! ```
19//!
20//! Team accounts (and personal token-based enterprise contracts) report no
21//! `individualUsage.plan` at all — there's no per-seat request quota for
22//! `pct()` to read.
23//!
24//! **Unverified against a live team account** — we don't have one to test
25//! against. The fallback below is inferred from an independent
26//! reverse-engineering of this same endpoint
27//! (`github.com/WoojinAhn/CursorMeter`'s `UsageModels.swift`, whose doc
28//! comments say it was confirmed against live token-based enterprise
29//! contracts): on those accounts, `teamUsage` itself carries no percentages
30//! (only an `onDemand` flag) and `individualUsage.overall.limit` — the
31//! numerator needed to turn `used` cents into a percentage — comes back
32//! `null`; Cursor doesn't put the per-seat limit on *this* endpoint for that
33//! account type at all. The **only** percentage this payload exposes for such
34//! accounts is the human-readable `autoModelSelectedDisplayMessage` /
35//! `namedModelSelectedDisplayMessage` strings (e.g. `"You've used 42% of your
36//! included total usage"`), and those line up with the auto/named pools
37//! respectively — the same two pools `individualUsage.plan.{autoPercentUsed,
38//! apiPercentUsed}` reports on personal accounts (confirmed by the `SAMPLE`
39//! fixture below, where both messages' percentages match the corresponding
40//! `plan` fields exactly). So when `individualUsage.plan` is missing,
41//! `to_snapshot` parses both display messages and only accepts them as a
42//! team/enterprise snapshot if **both** parse — a single stray message is
43//! unrecognized schema drift, not a half-guessed snapshot. A payload with
44//! neither `individualUsage.plan` nor two parseable display messages is
45//! schema drift, never a fabricated zero.
46
47use chrono::{DateTime, Utc};
48use serde::Deserialize;
49
50use crate::error::{AppError, Result};
51use crate::usage::CursorSnapshot;
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct UsageSummary {
55    #[serde(rename = "membershipType", default)]
56    pub membership_type: String,
57    #[serde(rename = "isUnlimited", default)]
58    pub is_unlimited: bool,
59    /// RFC3339 end of the current billing cycle — when the pools reset.
60    /// Required: without it there is no reset to show, so its absence is
61    /// schema drift, not a "no reset" state.
62    #[serde(rename = "billingCycleEnd")]
63    pub billing_cycle_end: String,
64    #[serde(rename = "individualUsage")]
65    pub individual_usage: Option<IndividualUsage>,
66    /// Team-account usage. Present (possibly `{}`) whether or not the caller
67    /// is on a team; only its `onDemand` flag is modeled — see the module
68    /// doc for why the pool percentages come from the display-message
69    /// fallback below instead of from this object.
70    #[serde(rename = "teamUsage", default)]
71    pub team_usage: Option<TeamUsage>,
72    /// e.g. `"You've used 42% of your included total usage"` — the auto
73    /// (Cursor Models) pool's percentage in prose form. Redundant with
74    /// `individualUsage.plan.autoPercentUsed` on personal accounts; the only
75    /// source of that percentage on accounts with no `plan` object.
76    #[serde(rename = "autoModelSelectedDisplayMessage", default)]
77    pub auto_model_selected_display_message: Option<String>,
78    /// Same idea as above for the named/API pool, e.g. `"You've used 100% of
79    /// your included API usage"`.
80    #[serde(rename = "namedModelSelectedDisplayMessage", default)]
81    pub named_model_selected_display_message: Option<String>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct IndividualUsage {
86    pub plan: Option<PlanUsage>,
87    // ponytail: pre-existing field lacked `rename = "onDemand"`, so this
88    // never actually deserialized (always None) — `on_demand_enabled` was
89    // silently always false. Fixed in passing since the new `TeamUsage` below
90    // needs the same field to actually work.
91    #[serde(rename = "onDemand", default)]
92    pub on_demand: Option<OnDemand>,
93}
94
95#[derive(Debug, Clone, Deserialize)]
96pub struct TeamUsage {
97    #[serde(rename = "onDemand", default)]
98    pub on_demand: Option<OnDemand>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102pub struct PlanUsage {
103    /// "Cursor Models" pool (Auto + Composer).
104    #[serde(rename = "autoPercentUsed")]
105    pub auto_percent_used: f64,
106    /// "Other Models" pool (named / third-party).
107    #[serde(rename = "apiPercentUsed")]
108    pub api_percent_used: f64,
109    /// Overall included usage — the dashboard headline percentage.
110    /// Required because the Overview treats this value as authoritative; a
111    /// missing field is endpoint drift, not a real zero.
112    #[serde(rename = "totalPercentUsed")]
113    pub total_percent_used: f64,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct OnDemand {
118    #[serde(default)]
119    pub enabled: bool,
120}
121
122/// Round a wire percentage to an integer, matching the dashboard's whole-number
123/// display and the integer-percent convention used across every vendor here. A
124/// non-finite value (NaN/inf) means the payload wasn't what we think it is —
125/// surfaced as schema drift rather than silently rendered.
126fn pct(field: &str, v: f64) -> Result<i32> {
127    if !v.is_finite() {
128        return Err(AppError::Schema(format!(
129            "cursor: `{field}` is not a finite number"
130        )));
131    }
132    // Clamp only the low end: a pool can legitimately exceed 100% when it is
133    // over its included allowance, and callers clamp for bar width themselves.
134    // Reject absurd values before narrowing instead of saturating them to an
135    // unrelated i32 endpoint.
136    let rounded = v.round().max(0.0);
137    if rounded > f64::from(i32::MAX) {
138        return Err(AppError::Schema(format!(
139            "cursor: `{field}` is too large to represent"
140        )));
141    }
142    Ok(rounded as i32)
143}
144
145pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
146    let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
147        .map_err(|e| {
148            AppError::Schema(format!(
149                "cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
150                resp.billing_cycle_end
151            ))
152        })?
153        .with_timezone(&Utc);
154
155    let plan = title_case(&resp.membership_type);
156
157    // Unlimited plans report no meaningful pool percentages; represent them as
158    // zeros with the `unlimited` flag so renderers say "unlimited" rather than
159    // painting a bogus bar.
160    if resp.is_unlimited {
161        return Ok(CursorSnapshot {
162            plan,
163            auto_pct: 0,
164            api_pct: 0,
165            total_pct: 0,
166            unlimited: true,
167            on_demand_enabled: false,
168            reset_at: Some(reset_at),
169        });
170    }
171
172    // `onDemand` can live under either `individualUsage` (personal accounts)
173    // or `teamUsage` (per CursorMeter's `TeamUsage`, which models nothing
174    // else there) — check both rather than assuming one.
175    let on_demand_enabled = resp
176        .individual_usage
177        .as_ref()
178        .and_then(|u| u.on_demand.as_ref())
179        .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()))
180        .map(|o| o.enabled)
181        .unwrap_or(false);
182
183    if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
184        return Ok(CursorSnapshot {
185            plan,
186            auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
187            api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
188            total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
189            unlimited: false,
190            on_demand_enabled,
191            reset_at: Some(reset_at),
192        });
193    }
194
195    // No numeric `plan` object — the team/enterprise fallback described in
196    // the module doc. Both display messages must parse or this isn't the
197    // shape we think it is; see the module doc for why "both or neither".
198    let team_pcts = resp
199        .auto_model_selected_display_message
200        .as_deref()
201        .and_then(parse_percent_from_message)
202        .zip(
203            resp.named_model_selected_display_message
204                .as_deref()
205                .and_then(parse_percent_from_message),
206        );
207    if let Some((auto_raw, api_raw)) = team_pcts {
208        let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
209        let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
210        return Ok(CursorSnapshot {
211            // Flag this as the best-effort team path — distinct from the
212            // membership label alone, since the number came from prose, not
213            // the numeric `plan` object.
214            plan: format!("{plan} (team)"),
215            auto_pct,
216            api_pct,
217            // No distinct blended-total signal exists on this path (no
218            // `totalPercentUsed` equivalent message); the worse of the two
219            // pools is the best-effort stand-in.
220            total_pct: auto_pct.max(api_pct),
221            unlimited: false,
222            on_demand_enabled,
223            reset_at: Some(reset_at),
224        });
225    }
226
227    Err(AppError::Schema(
228        "cursor: response has no `individualUsage.plan` and no parseable team-usage \
229         display message (unrecognized team-account shape)"
230            .into(),
231    ))
232}
233
234/// Pulls the leading `N` or `N.N` out of a `"…N%…"` prose string, e.g.
235/// `"You've used 98% of your included total usage"` -> `Some(98.0)`. Returns
236/// `None` if there's no `%` or nothing number-shaped precedes it, so callers
237/// treat a reworded message as unparseable rather than misreading it.
238fn parse_percent_from_message(msg: &str) -> Option<f64> {
239    let pct_idx = msg.find('%')?;
240    let before = &msg[..pct_idx];
241    let start = before
242        .rfind(|c: char| !c.is_ascii_digit() && c != '.')
243        .map(|i| i + 1)
244        .unwrap_or(0);
245    before[start..].parse::<f64>().ok()
246}
247
248/// "ultra" -> "Ultra". Cursor's `membershipType` is lowercase; the dashboard
249/// shows it title-cased.
250fn title_case(s: &str) -> String {
251    let mut chars = s.chars();
252    match chars.next() {
253        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
254        None => "Cursor".to_string(),
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use chrono::TimeZone;
262
263    const SAMPLE: &str = r#"{
264        "billingCycleStart": "2026-07-04T00:35:51.000Z",
265        "billingCycleEnd": "2026-08-04T00:35:51.000Z",
266        "membershipType": "ultra",
267        "limitType": "user",
268        "isUnlimited": false,
269        "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
270        "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
271        "individualUsage": {
272            "plan": {
273                "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
274                "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
275            },
276            "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
277        },
278        "teamUsage": {}
279    }"#;
280
281    #[test]
282    fn parses_the_live_ultra_shape() {
283        let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
284        let snap = to_snapshot(resp).unwrap();
285        assert_eq!(snap.plan, "Ultra");
286        assert_eq!(snap.auto_pct, 98); // 98.109 rounds to 98 (matches the dashboard bar)
287        assert_eq!(snap.api_pct, 100);
288        assert_eq!(snap.total_pct, 99); // 98.5128 rounds to 99
289        assert!(!snap.unlimited);
290        assert!(!snap.on_demand_enabled);
291        assert_eq!(
292            snap.reset_at,
293            Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
294        );
295        assert_eq!(snap.worst_pct(), 100);
296    }
297
298    #[test]
299    fn over_allowance_percentage_is_kept_above_100() {
300        let raw = r#"{
301            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
302            "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
303        }"#;
304        let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
305        assert_eq!(
306            snap.auto_pct, 143,
307            "an over-quota pool must not clamp to 100"
308        );
309        assert_eq!(snap.worst_pct(), 143);
310    }
311
312    #[test]
313    fn unlimited_plan_reports_no_pool_percentages() {
314        let raw = r#"{
315            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
316            "isUnlimited": true
317        }"#;
318        let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
319        assert!(snap.unlimited);
320        assert_eq!(snap.worst_pct(), 0);
321        assert_eq!(snap.plan, "Enterprise");
322    }
323
324    #[test]
325    fn missing_individual_plan_is_schema_drift_not_zero() {
326        // A team-only or unexpected shape must not read as "0% used".
327        let raw = r#"{
328            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
329            "teamUsage": {}
330        }"#;
331        let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
332        assert!(matches!(err, AppError::Schema(_)));
333    }
334
335    #[test]
336    fn missing_billing_cycle_end_is_a_parse_error() {
337        let raw = r#"{ "membershipType": "pro",
338            "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
339        // `billingCycleEnd` is required by serde → a missing field fails to parse.
340        assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
341    }
342
343    #[test]
344    fn missing_total_percentage_is_a_parse_error_not_zero() {
345        let raw = r#"{
346            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
347            "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
348        }"#;
349        assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
350    }
351
352    #[test]
353    fn non_finite_percentage_is_rejected() {
354        // serde rejects a non-finite JSON literal at parse time, so exercise the
355        // guard directly: a NaN reaching a percentage field must be a schema
356        // error, never rendered as a bar.
357        let resp = UsageSummary {
358            membership_type: "pro".into(),
359            is_unlimited: false,
360            billing_cycle_end: "2026-08-04T00:00:00Z".into(),
361            individual_usage: Some(IndividualUsage {
362                plan: Some(PlanUsage {
363                    auto_percent_used: f64::NAN,
364                    api_percent_used: 2.0,
365                    total_percent_used: 1.0,
366                }),
367                on_demand: None,
368            }),
369            team_usage: None,
370            auto_model_selected_display_message: None,
371            named_model_selected_display_message: None,
372        };
373        assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
374    }
375
376    #[test]
377    fn percentage_too_large_for_the_snapshot_is_rejected() {
378        assert!(matches!(
379            pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
380            Err(AppError::Schema(_))
381        ));
382    }
383
384    /// The fixture backing the team-account fallback. **Unverified against a
385    /// live team account** — see the module doc for the reasoning: no
386    /// `individualUsage.plan`, `teamUsage` carries only `onDemand`, and the
387    /// two pool percentages come from the display-message strings instead.
388    const TEAM_SAMPLE: &str = r#"{
389        "billingCycleEnd": "2026-08-04T00:35:51.000Z",
390        "membershipType": "team",
391        "isUnlimited": false,
392        "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
393        "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
394        "teamUsage": { "onDemand": { "enabled": true } }
395    }"#;
396
397    #[test]
398    fn team_account_falls_back_to_display_message_percentages() {
399        let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
400        let snap = to_snapshot(resp).unwrap();
401        assert_eq!(snap.plan, "Team (team)");
402        assert_eq!(snap.auto_pct, 42);
403        assert_eq!(snap.api_pct, 15);
404        assert_eq!(
405            snap.total_pct, 42,
406            "no blended-total signal exists; worst pool stands in"
407        );
408        assert!(!snap.unlimited);
409        assert!(
410            snap.on_demand_enabled,
411            "onDemand lives under teamUsage for a team account, not individualUsage"
412        );
413        assert_eq!(snap.worst_pct(), 42);
414    }
415
416    #[test]
417    fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
418        // Guards the "both or neither" rule: a half-recognized shape must not
419        // silently render one real pool and one fabricated zero.
420        let raw = r#"{
421            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
422            "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
423            "namedModelSelectedDisplayMessage": "unavailable"
424        }"#;
425        let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
426        assert!(matches!(err, AppError::Schema(_)));
427    }
428
429    #[test]
430    fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
431        assert_eq!(
432            parse_percent_from_message("You've used 98% of your included total usage"),
433            Some(98.0)
434        );
435        assert_eq!(
436            parse_percent_from_message("You've used 100% of your included API usage"),
437            Some(100.0)
438        );
439        assert_eq!(parse_percent_from_message("no percent here"), None);
440        assert_eq!(parse_percent_from_message("unavailable"), None);
441    }
442}