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