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