@magpie-community/opencode-cursor-auth 0.2.4 → 0.2.6

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 +18 -1
  2. package/index.mjs +111 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -124,9 +124,26 @@ minutes), the way pi-cursor-sdk lists Cursor's catalog:
124
124
  and `service_tier` counting as for a listed model. The list doesn't show
125
125
  these ids: pick the `@<size>` model for a window that is the model's.
126
126
 
127
+ ## Usage
128
+
129
+ magpie shows Cursor Models, Other Models and Total for the billing period.
130
+ Accounts with a Grok Bot allowance also show its usage and reset time;
131
+ active trials are labelled separately. A spent Bot allowance doesn't stop
132
+ Cursor requests.
133
+
134
+ An account that may spend on-demand once its included usage is gone (a
135
+ team or personal spend limit, as `cursor-agent`'s usage view shows it) also
136
+ shows On-demand: its spend, and its limit when it has one. Cursor keeps
137
+ serving such an account past the included usage, so magpie doesn't count
138
+ it used up when Cursor Models or Other Models reach 100%; it does when the
139
+ on-demand spend reaches its limit.
140
+
141
+ Sign in to the Cursor account linked in Grok Bot. A linked SuperGrok
142
+ subscription grants Bot access without a paid Cursor plan; see
143
+ [Grok Bot plans and billing](https://cursor.com/help/grok-bot/plans).
144
+
127
145
  ## Not included
128
146
 
129
- - Usage and plan limits.
130
147
  - Web search: Cursor's own web search is refused like its other tools.
131
148
  - Switching between several accounts. OpenCode keeps one sign-in per
132
149
  provider.
package/index.mjs CHANGED
@@ -380,16 +380,67 @@ function firstParty(model) {
380
380
  return ["grok-4.5", "grok-4.6", "grok-4.7"].some((b) => model === b || model.startsWith(b + "-"))
381
381
  }
382
382
 
383
+ // onDemandOf is what Cursor still serves once the included usage is spent,
384
+ // as cursor-agent's own usage view tells it (its On-demand line): from the
385
+ // period's spendLimitUsage (cents) and, where that sets no limit of the
386
+ // user's, GetHardLimit's reply (dollars; undefined when it couldn't be
387
+ // read). kind is "fixed" (up to limitDollars), "unlimited", "disabled" or
388
+ // "unavailable" (not known).
389
+ function onDemandOf(data, hard) {
390
+ const s = data?.spendLimitUsage
391
+ const usedDollars = Number(s?.individualUsed ?? 0) / 100
392
+ const own =
393
+ s?.individualLimit === undefined
394
+ ? undefined
395
+ : Number(s.individualLimit) > 0
396
+ ? { kind: "fixed", usedDollars, limitDollars: Number(s.individualLimit) / 100 }
397
+ : { kind: "disabled", usedDollars }
398
+ if (s?.limitType === "team") {
399
+ if (own) return own
400
+ if (hard) return hard.noUsageBasedAllowed || !(Number(hard.hardLimit) > 0) ? { kind: "disabled", usedDollars } : { kind: "unlimited", usedDollars }
401
+ return Number(s.pooledLimit ?? 0) > 0 ? { kind: "unlimited", usedDollars } : { kind: "unavailable", usedDollars }
402
+ }
403
+ if (!hard) return own ?? { kind: "unavailable", usedDollars }
404
+ if (hard.noUsageBasedAllowed) return { kind: "disabled", usedDollars }
405
+ const limit = Number(hard.hardLimit ?? 0)
406
+ if (limit >= 2147483647) return { kind: "unlimited", usedDollars }
407
+ return limit > 0 ? { kind: "fixed", usedDollars, limitDollars: limit } : { kind: "disabled", usedDollars }
408
+ }
409
+
410
+ // hardLimit is GetHardLimit's reply, undefined when it couldn't be read.
411
+ async function hardLimit(tok) {
412
+ try {
413
+ const res = await fetch(API + "/aiserver.v1.DashboardService/GetHardLimit", {
414
+ method: "POST",
415
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
416
+ body: "{}",
417
+ signal: AbortSignal.timeout(15_000),
418
+ })
419
+ return res.ok ? ((await res.json()) ?? undefined) : undefined
420
+ } catch {
421
+ return undefined
422
+ }
423
+ }
424
+
425
+ const dollars = (n) => "$" + n.toFixed(2)
426
+
383
427
  // usage is the account's windows this billing period; an enterprise plan
384
428
  // reports spend instead, and gets none. ids are the models it can be
385
- // asked for, each counted by the pool Cursor bills it to.
429
+ // asked for, each counted by the pool Cursor bills it to. An account that
430
+ // may spend on-demand once its included usage is gone (fottencity on
431
+ // Discord: a team that pays for more) is still served when the pools are
432
+ // spent, so they are shown aside, and its On-demand spend is what can run
433
+ // out: at its limit, or never when it has none.
386
434
  async function usage(tok, ids) {
387
- const res = await fetch(API + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
388
- method: "POST",
389
- headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
390
- body: "{}",
391
- signal: AbortSignal.timeout(15_000),
392
- })
435
+ const [res, hard] = await Promise.all([
436
+ fetch(API + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
437
+ method: "POST",
438
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
439
+ body: "{}",
440
+ signal: AbortSignal.timeout(15_000),
441
+ }),
442
+ hardLimit(tok),
443
+ ])
393
444
  if (!res.ok) return { error: statusText(res.status), windows: [] }
394
445
  const data = await res.json()
395
446
  const u = data?.planUsage
@@ -412,16 +463,62 @@ async function usage(tok, ids) {
412
463
  const asked = ids.flatMap((id) => [id, id.replace(/@[^@]*$/, "")])
413
464
  const pool = [...new Set(["auto", ...asked, ...(data.autoBucketModels ?? [])])].filter(inPool)
414
465
  const num = (v) => (typeof v === "number" ? v : 0)
466
+ const od = onDemandOf(data, hard)
467
+ const pooled = od.kind === "fixed" || od.kind === "unlimited" ? { aside: true } : {}
468
+ const onDemand =
469
+ od.kind === "fixed"
470
+ ? [{ name: "On-demand", used: Math.max(0, Math.min(100, (100 * od.usedDollars) / od.limitDollars)), display: `${dollars(od.usedDollars)} / ${dollars(od.limitDollars)}`, ...resets }]
471
+ : od.kind === "unlimited"
472
+ ? [{ name: "On-demand", used: 0, display: dollars(od.usedDollars), ...resets, aside: true }]
473
+ : []
415
474
  // the two pools fit the line; the total goes in its tooltip
416
475
  return {
417
476
  windows: [
418
- { name: "Cursor Models", used: num(u.autoPercentUsed), ...resets, models: pool },
419
- { name: "Other Models", used: num(u.apiPercentUsed), ...resets, notModels: pool },
477
+ { name: "Cursor Models", used: num(u.autoPercentUsed), ...resets, models: pool, ...pooled },
478
+ { name: "Other Models", used: num(u.apiPercentUsed), ...resets, notModels: pool, ...pooled },
479
+ ...onDemand,
420
480
  { name: "Total", used: num(u.totalPercentUsed), ...resets, aside: true },
421
481
  ],
422
482
  }
423
483
  }
424
484
 
485
+ // Grok Bot's separate allowance, using the CLI JWT as a dashboard session.
486
+ async function botUsage(tok) {
487
+ try {
488
+ const subject = JSON.parse(Buffer.from(tok.split(".")[1], "base64url").toString()).sub
489
+ if (typeof subject !== "string") return []
490
+ const user = subject.split("|").filter(Boolean).at(-1)
491
+ if (!user || !/^[a-zA-Z0-9._-]+$/.test(user)) return []
492
+ const res = await fetch(WEBSITE + "/api/dashboard/get-sand-usage-status", {
493
+ method: "POST",
494
+ headers: {
495
+ "Content-Type": "application/json",
496
+ Accept: "application/json",
497
+ Origin: WEBSITE,
498
+ Cookie: "WorkosCursorSessionToken=" + encodeURIComponent(user + "::" + tok),
499
+ },
500
+ body: "{}",
501
+ redirect: "error",
502
+ signal: AbortSignal.timeout(5_000),
503
+ })
504
+ if (!res.ok) return []
505
+ const data = await res.json()
506
+ const paid = typeof data?.includedLimitZero === "boolean"
507
+ ? !data.includedLimitZero : data?.hasNonZeroIncludedLimit === true
508
+ const trial = !paid && typeof data?.sandTrialExpiresAt === "string" && Date.parse(data.sandTrialExpiresAt) > Date.now()
509
+ if ((!paid && !trial) || typeof data?.usagePercent !== "number" || !Number.isFinite(data.usagePercent)) return []
510
+ const reset = paid && typeof data.nextResetTimestampUtc === "string" ? Date.parse(data.nextResetTimestampUtc) : NaN
511
+ return [{
512
+ name: trial ? "Grok Bot (trial)" : "Grok Bot",
513
+ used: Math.max(0, Math.min(100, data.usagePercent)),
514
+ aside: true, // Bot exhaustion must not stop Cursor model requests.
515
+ ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString(), span: 604800 } : {}),
516
+ }]
517
+ } catch {
518
+ return []
519
+ }
520
+ }
521
+
425
522
  // ---- models ----------------------------------------------------------------------
426
523
  //
427
524
  // The list is Cursor's model picker (AiService/AvailableModels, asked as the
@@ -2158,8 +2255,10 @@ export async function CursorAuthPlugin() {
2158
2255
  } catch (e) {
2159
2256
  return { error: e.message, windows: [], signIn: "kept" }
2160
2257
  }
2161
- const [u, plan] = await Promise.all([usage(tok, Object.keys(provider?.models ?? {})), planOf(tok)])
2162
- return { ...u, ...(plan ? { plan } : {}), signIn: "kept" }
2258
+ const [u, plan, bot] = await Promise.all([usage(tok, Object.keys(provider?.models ?? {})), planOf(tok), botUsage(tok)])
2259
+ // Put Bot before Total for magpie's three menu-bar rings.
2260
+ const windows = [...u.windows.filter((w) => w.name !== "Total"), ...bot, ...u.windows.filter((w) => w.name === "Total")]
2261
+ return { ...u, windows, ...(plan ? { plan } : {}), signIn: "kept" }
2163
2262
  },
2164
2263
  },
2165
2264
  // the session the request is part of, for the conversation_id
@@ -2201,4 +2300,4 @@ export async function CursorAuthPlugin() {
2201
2300
  }
2202
2301
 
2203
2302
  // for tests
2204
- export const _internal = { resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, STEP_WAIT, stepUsage, 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 }
2303
+ export const _internal = { onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, STEP_WAIT, stepUsage, 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.4",
3
+ "version": "0.2.6",
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",