@magpie-community/opencode-cursor-auth 0.2.32 → 0.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/index.mjs +123 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -259,6 +259,19 @@ own spends the team's pool: On-demand shows the pool's spend and limit,
259
259
  with the member's own spend beside it, and the account is used up when
260
260
  the pool is.
261
261
 
262
+ Free credit shows too, as Cursor's own IDE reads it. The plan's bonus
263
+ ("+$X free usage on us", `GetCurrentPeriodUsage`'s `bonusSpend` and
264
+ `remainingBonus`) is Free usage: what was spent from it this period, and
265
+ whether more is left; Cursor gives no amount for it. Each credit grant
266
+ (`GetUsageLimitStatusAndActiveGrants`' `activeGrants`, the IDE's "Use your
267
+ $X free credits") is a window of its own, named by its campaign: spent of
268
+ total, when it expires, and its limits. A grant limited to some models
269
+ lists them; one only Cursor's Cloud Agents (or another client) may spend
270
+ says so in its name, since magpie talks as `cursor-agent`. While a bonus
271
+ or a grant magpie can spend has some left, a spent included pool doesn't
272
+ count the account used up for the models it pays for. With "Included
273
+ usage only" on, free credit is shown only.
274
+
262
275
  An Enterprise account's period has no Cursor Models, Other Models or Total
263
276
  in `GetCurrentPeriodUsage`: Cursor reports its overall allowance through
264
277
  the website's `usage-summary` instead. Magpie shows that as Enterprise,
package/index.mjs CHANGED
@@ -541,6 +541,99 @@ async function hardLimit(tok) {
541
541
  // dollars, thousands grouped: a team's pool runs to six figures ($451,200.00)
542
542
  const dollars = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
543
543
 
544
+ // ---- free credit ------------------------------------------------------------------
545
+ //
546
+ // Cursor gives free usage two ways (CryptoSarom on X, "赠金"), as Cursor
547
+ // 3.x's own client reads them:
548
+ //
549
+ // - the plan's bonus, "+$X free usage on us" under the included usage in
550
+ // the IDE's usage tooltip: GetCurrentPeriodUsage's planUsage.bonusSpend
551
+ // (cents spent from it this period), remainingBonus (whether any is
552
+ // left; no amount is given) and bonusTooltip ("We work with model
553
+ // providers to give you free usage beyond what you've purchased. Amounts
554
+ // may vary."). It isn't tied to a model.
555
+ // - credit grants, the IDE's "Use your $X free … credits" banner:
556
+ // DashboardService/GetUsageLimitStatusAndActiveGrants' activeGrants, each
557
+ // with totalCents and remainingCents (int64 strings), expiresAtMs, and
558
+ // its limits: allowedModelIds, allowedModelTags and allowedModelSelections
559
+ // (the models it may be spent on; none listed is every model) and
560
+ // allowedClientTypes (the clients that may spend it; none listed is any;
561
+ // "glass" without "ide" is the IDE's Cloud Agent credits). magpie talks
562
+ // as cursor-agent, clientType "cli".
563
+ //
564
+ // creditGrants is that reply's activeGrants, [] when it couldn't be read.
565
+ async function creditGrants(tok) {
566
+ try {
567
+ const res = await fetch(API + "/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants", {
568
+ method: "POST",
569
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...await teamHeader(tok) },
570
+ body: "{}",
571
+ signal: AbortSignal.timeout(15_000),
572
+ })
573
+ if (!res.ok) return []
574
+ const g = (await res.json())?.activeGrants
575
+ return Array.isArray(g) ? g : []
576
+ } catch {
577
+ return []
578
+ }
579
+ }
580
+
581
+ // tagOf is a model name as the IDE matches a grant's allowedModelTags to
582
+ // it: lower case, "-" read as "."; a tag matches a name it is part of.
583
+ const tagOf = (s) => String(s).toLowerCase().replace(/-/g, ".")
584
+
585
+ // credits are the windows of the account's free credit, and what it lets
586
+ // Cursor go on serving once the included usage is spent: all (a bonus or
587
+ // grant magpie can spend on every model still has some left) and covers
588
+ // (the account's model ids a grant limited to some models still pays
589
+ // for). Every credit window is shown only: what stops an account is the
590
+ // included usage it outlasts, which usage() sets aside. plan is
591
+ // planUsage, resets its period's end, ids the account's model ids.
592
+ function credits(plan, grants, ids, resets, now = Date.now()) {
593
+ const windows = []
594
+ const covers = new Set()
595
+ let all = false
596
+ const bonus = Number(plan?.bonusSpend ?? 0) / 100
597
+ if (bonus > 0 || plan?.remainingBonus === true) {
598
+ windows.push({ name: "Free usage", used: 0, display: `+${dollars(bonus)} on us` + (plan?.remainingBonus === true ? ", more left" : ""), ...resets, aside: true })
599
+ if (plan?.remainingBonus === true) all = true
600
+ }
601
+ for (const g of grants) {
602
+ const total = Number(g?.totalCents ?? 0) / 100
603
+ const left = Math.max(0, Number(g?.remainingCents ?? 0) / 100)
604
+ if (!(total > 0) || !Number.isFinite(total) || !Number.isFinite(left)) continue
605
+ const exp = Number(g.expiresAtMs ?? 0)
606
+ if (exp > 0 && exp <= now) continue
607
+ const clients = (g.allowedClientTypes ?? []).map(String)
608
+ const mine = clients.length === 0 || clients.includes("cli")
609
+ const named = [...(g.allowedModelIds ?? []), ...(g.allowedModelSelections ?? []).map((m) => m?.modelId).filter(Boolean)].map(String)
610
+ const tags = (g.allowedModelTags ?? []).map(String)
611
+ const limited = named.length > 0 || tags.length > 0
612
+ const bases = new Set(named.map((m) => (poolBase(m) === "auto" ? "default" : poolBase(m))))
613
+ const fits = (id) => {
614
+ const b = poolBase(id) === "auto" ? "default" : poolBase(id)
615
+ return bases.has(b) || tags.some((t) => tagOf(id).includes(tagOf(t)))
616
+ }
617
+ const matched = limited ? ids.filter(fits) : []
618
+ const name = (String(g.campaignName ?? "").trim() || "Free credit") + (mine ? "" : clients.includes("glass") && !clients.includes("ide") ? " (Cloud Agents only)" : ` (${clients.join(", ")} only)`)
619
+ windows.push({
620
+ name,
621
+ used: Math.max(0, Math.min(100, (100 * (total - left)) / total)),
622
+ display: `${dollars(total - left)} / ${dollars(total)}`,
623
+ amount: total - left,
624
+ limit: total,
625
+ unit: "USD",
626
+ ...(exp > 0 ? { runsOutAt: new Date(exp).toISOString(), runsOut: left } : {}),
627
+ ...(limited ? { models: matched.length ? matched : [...named, ...tags] } : {}),
628
+ aside: true,
629
+ })
630
+ if (!mine || !(left > 0)) continue
631
+ if (!limited) all = true
632
+ else for (const id of matched) covers.add(id)
633
+ }
634
+ return { windows, all, covers }
635
+ }
636
+
544
637
  // INCLUDED_ONLY is the switch (magpie's plugin options, off by default)
545
638
  // that keeps an account in routing only while its included usage lasts
546
639
  // (magpie-community/plugins#485): on, the included pools (Cursor Models and
@@ -638,13 +731,14 @@ async function usage(tok, ids, includedOnly = false, plan = undefined) {
638
731
  }),
639
732
  hardLimit(tok),
640
733
  ])
734
+ const grants = creditGrants(tok)
641
735
  if (!res.ok) return { error: statusText(res.status), windows: [] }
642
736
  const data = await res.json()
643
737
  const u = data?.planUsage
644
738
  if (!u) {
645
739
  // Prefer measured Enterprise usage before any reset-date-only fallback.
646
740
  const windows = await enterpriseUsage(tok, includedOnly)
647
- return windows ? { windows } : { windows: [] }
741
+ return { windows: windows ?? [], credits: credits(undefined, await grants, ids, {}).windows }
648
742
  }
649
743
  const ms = /^[+-]?\d+$/.test(data.billingCycleEnd ?? "") ? Number(data.billingCycleEnd) : 0
650
744
  const resets = ms > 0 ? { resetsAt: new Date(ms).toISOString() } : {}
@@ -665,10 +759,16 @@ async function usage(tok, ids, includedOnly = false, plan = undefined) {
665
759
  const pool = [...new Set(["auto", ...asked, ...(data.autoBucketModels ?? [])])].filter(inPool)
666
760
  const num = (v) => (typeof v === "number" ? v : 0)
667
761
  const od = onDemandOf(data, hard)
668
- const pooled = !includedOnly && (od.kind === "fixed" || od.kind === "unlimited") ? { aside: true } : {}
762
+ // free credit that magpie can spend on every model keeps the account
763
+ // served past its included usage, as on-demand that can pay does; one
764
+ // limited to some models keeps those models served past a spent pool.
765
+ // Neither does with includedOnly.
766
+ const free = credits(u, await grants, [...new Set(asked)], resets)
767
+ const pooled = !includedOnly && (od.kind === "fixed" || od.kind === "unlimited" || free.all) ? { aside: true } : {}
768
+ const covers = includedOnly ? new Set() : free.covers
669
769
  const onDemand =
670
770
  od.kind === "fixed"
671
- ? [{ name: "On-demand", used: Math.max(0, Math.min(100, (100 * od.usedDollars) / od.limitDollars)), display: `${dollars(od.usedDollars)} / ${dollars(od.limitDollars)}` + (od.mineDollars === undefined ? "" : ` (you: ${dollars(od.mineDollars)})`), ...resets, ...(includedOnly ? { aside: true } : {}) }]
771
+ ? [{ name: "On-demand", used: Math.max(0, Math.min(100, (100 * od.usedDollars) / od.limitDollars)), display: `${dollars(od.usedDollars)} / ${dollars(od.limitDollars)}` + (od.mineDollars === undefined ? "" : ` (you: ${dollars(od.mineDollars)})`), ...resets, ...(includedOnly || free.all ? { aside: true } : {}) }]
672
772
  : od.kind === "unlimited"
673
773
  ? [{ name: "On-demand", used: 0, display: dollars(od.usedDollars), ...resets, aside: true }]
674
774
  : []
@@ -676,23 +776,33 @@ async function usage(tok, ids, includedOnly = false, plan = undefined) {
676
776
  // model draws on, which the dashboard shows as the period's
677
777
  // totalPercentUsed, autoPercentUsed the same and apiPercentUsed 0 always.
678
778
  const info = await plan
779
+ // spent is whether a pool no longer pays: then the models a credit still
780
+ // pays for are left out of it
781
+ const spent = (used) => covers.size > 0 && !pooled.aside && used >= 100
679
782
  if (info?.merged === true) {
680
783
  const weekly = info.period === "weekly"
784
+ const used = num(u.totalPercentUsed)
681
785
  return {
682
786
  windows: [
683
- { name: weekly ? "Weekly" : info.period === "monthly" ? "Monthly" : "Included", used: num(u.totalPercentUsed), ...resets, ...(weekly ? { span: 604800 } : {}), ...pooled },
787
+ { name: weekly ? "Weekly" : info.period === "monthly" ? "Monthly" : "Included", used, ...resets, ...(weekly ? { span: 604800 } : {}), ...(spent(used) ? { notModels: [...covers] } : {}), ...pooled },
684
788
  ...onDemand,
685
789
  ],
790
+ credits: free.windows,
686
791
  }
687
792
  }
688
- // the two pools fit the line; the total goes in its tooltip
793
+ // the two pools fit the line; the total goes in its tooltip. A spent
794
+ // Cursor Models pool every model of which a credit pays for is aside:
795
+ // an empty models list would count every model.
796
+ const auto = num(u.autoPercentUsed), api = num(u.apiPercentUsed)
797
+ const cursorModels = spent(auto) ? pool.filter((m) => !covers.has(m)) : pool
689
798
  return {
690
799
  windows: [
691
- { name: "Cursor Models", used: num(u.autoPercentUsed), ...resets, models: pool, ...pooled },
692
- { name: "Other Models", used: num(u.apiPercentUsed), ...resets, notModels: pool, ...pooled },
800
+ { name: "Cursor Models", used: auto, ...resets, models: cursorModels.length ? cursorModels : pool, ...(cursorModels.length ? pooled : { aside: true }) },
801
+ { name: "Other Models", used: api, ...resets, notModels: spent(api) ? [...new Set([...pool, ...covers])] : pool, ...pooled },
693
802
  ...onDemand,
694
803
  { name: "Total", used: num(u.totalPercentUsed), ...resets, aside: true },
695
804
  ],
805
+ credits: free.windows,
696
806
  }
697
807
  }
698
808
 
@@ -3257,7 +3367,7 @@ export async function CursorAuthPlugin(input) {
3257
3367
  key: INCLUDED_ONLY,
3258
3368
  label: "Included usage only",
3259
3369
  description:
3260
- "Keeps an account in routing only while its included usage lasts: once an included pool (Cursor Models, Other Models, a newer plan's Weekly) is used up, magpie moves its models to the next account until it resets, even if on-demand could pay. On-demand and Total never keep an account in routing.",
3370
+ "Keeps an account in routing only while its included usage lasts: once an included pool (Cursor Models, Other Models, a newer plan's Weekly) is used up, magpie moves its models to the next account until it resets, even if on-demand or free credit could pay.",
3261
3371
  },
3262
3372
  ],
3263
3373
  // magpie's: how much of the plan's included usage is gone (the plan
@@ -3276,9 +3386,11 @@ export async function CursorAuthPlugin(input) {
3276
3386
  // before an On-demand with no limit, which is only a dollar figure on
3277
3387
  // a ring that never fills (magpie #2213, Morganwan90). An On-demand
3278
3388
  // with a limit stays ahead of it: that is what can stop the account.
3389
+ // Free credit goes after them, with Total: it is shown only.
3279
3390
  const last = (w) => w.name === "Total" || (w.name === "On-demand" && w.aside)
3280
- const windows = [...u.windows.filter((w) => !last(w)), ...bot, ...u.windows.filter(last)]
3281
- return { ...u, windows, ...(plan ? { plan } : {}), signIn: "kept" }
3391
+ const { credits: free = [], ...read } = u
3392
+ const windows = [...read.windows.filter((w) => !last(w)), ...bot, ...free, ...read.windows.filter(last)]
3393
+ return { ...read, windows, ...(plan ? { plan } : {}), signIn: "kept" }
3282
3394
  },
3283
3395
  },
3284
3396
  // the session the request is part of, for the conversation_id
@@ -3320,4 +3432,4 @@ export async function CursorAuthPlugin(input) {
3320
3432
  }
3321
3433
 
3322
3434
  // for tests
3323
- export const _internal = { kvReply, listFor, lists, keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
3435
+ export const _internal = { credits, kvReply, listFor, lists, keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Cursor subscriptions (Pro, Pro+, Ultra, Teams) in OpenCode and magpie, through the API cursor-agent talks to",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",