@magpie-community/opencode-cursor-auth 0.2.22 → 0.2.24

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 +11 -0
  2. package/index.mjs +194 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -197,6 +197,17 @@ minutes), the way pi-cursor-sdk lists Cursor's catalog:
197
197
  has. A model the usable list has nothing of is the picker's alone
198
198
  (GLM-5.3) and is kept whole; so is every model when Cursor can't give
199
199
  the usable list.
200
+ - A family the usable list has and the picker doesn't (an account whose
201
+ picker lacks Claude Opus 5.5 while `cursor-agent models` lists
202
+ `claude-opus-5-5-low` to `-max`) is listed once under its family id
203
+ (`claude-opus-5-5`), its efforts as variants, and goes as the usable id
204
+ those parameters pick, never as the family id Cursor doesn't know. A
205
+ picker model whose chosen variant isn't one the account can use goes as
206
+ its family's nearest usable id the same way.
207
+ - An `@<size>` id the list no longer has (`claude-opus-5-5@1m` when only
208
+ 300k is listed, or the model is listed unsized) runs at the model's
209
+ nearest size, in Max Mode when the size asked for is over the model's
210
+ window, never as the literal `@<size>` id.
200
211
  - An id from before 0.2.0 still runs. A variant's own id
201
212
  (`claude-opus-5-5-high`) is that variant; a family 0.1.x listed
202
213
  (`claude-opus-5-5`, `claude-opus-5-5-fast`, `claude-haiku-5-5-thinking`,
package/index.mjs CHANGED
@@ -117,6 +117,68 @@ function authFile() {
117
117
  return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json")
118
118
  }
119
119
 
120
+ // cliConfigFile is cursor-agent's cli-config.json, where it keeps the team
121
+ // it signed in to (authInfo.activeTeamId); its directory found as the CLI
122
+ // finds it, on every OS.
123
+ function cliConfigFile() {
124
+ const dir = process.env.CURSOR_CONFIG_DIR?.trim()
125
+ || (process.env.XDG_CONFIG_HOME?.trim() ? join(process.env.XDG_CONFIG_HOME, "cursor") : join(homedir(), ".cursor"))
126
+ return join(dir, "cli-config.json")
127
+ }
128
+
129
+ // tokenSub is a JWT's subject (the auth id, "auth0|user_…"); "" when it
130
+ // doesn't say.
131
+ function tokenSub(tok) {
132
+ const parts = String(tok ?? "").split(".")
133
+ if (parts.length !== 3) return ""
134
+ try {
135
+ const sub = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))?.sub
136
+ return typeof sub === "string" ? sub : ""
137
+ } catch {
138
+ return ""
139
+ }
140
+ }
141
+
142
+ const teamOf = (v) => (Number.isSafeInteger(v) && v > 0 ? v : undefined)
143
+
144
+ // activeTeamId is the team a request is billed to, as cursor-agent sends it
145
+ // (x-cursor-team-id): cli-config.json's authInfo.activeTeamId. Without it
146
+ // Cursor takes the token's default team, which in an organization may be one
147
+ // with on-demand spend turned off ("Team budget limit reached"). The config's
148
+ // team is only taken for the account cursor-agent signed in to: a token
149
+ // whose subject isn't its authInfo.authId, or a config with none, gets none.
150
+ // Read again after 10 s; cursor-agent rewrites the file in place, so a read
151
+ // that fails to parse keeps the last one, and only a missing file clears it.
152
+ const TEAM_TTL = 10_000
153
+ const teamRead = { at: 0, info: null }
154
+ async function activeTeamId(tok) {
155
+ if (Date.now() - teamRead.at >= TEAM_TTL) {
156
+ try {
157
+ teamRead.info = JSON.parse(await readFile(cliConfigFile(), "utf8"))?.authInfo ?? null
158
+ } catch (e) {
159
+ if (e?.code === "ENOENT") teamRead.info = null
160
+ }
161
+ teamRead.at = Date.now()
162
+ }
163
+ const info = teamRead.info
164
+ if (!info) return undefined
165
+ if (!info.authId || info.authId !== tokenSub(tok)) return undefined
166
+ return teamOf(info.activeTeamId)
167
+ }
168
+
169
+ // teamHeader names that team in a request's headers, and teamBody in
170
+ // GetFilteredUsageEvents' body; neither says anything when there is none.
171
+ // GetHardLimit and GetCurrentPeriodUsage go out with "{}", as cursor-agent
172
+ // sends them: the team rides in the header only.
173
+ async function teamHeader(tok) {
174
+ const id = await activeTeamId(tok)
175
+ return id ? { "x-cursor-team-id": String(id) } : {}
176
+ }
177
+ async function teamBody(tok) {
178
+ const id = await activeTeamId(tok)
179
+ return id ? { teamId: id } : {}
180
+ }
181
+
120
182
  // TOKEN_TTL is how long a token read from the Keychain is taken as it: a
121
183
  // sign-in reads it afresh, one made in cursor-agent is seen within it.
122
184
  // KEYCHAIN_DENIED is how long a read the user may have been asked about,
@@ -218,6 +280,7 @@ async function headersFor(tok) {
218
280
  "x-cursor-client-version": await clientVersion(),
219
281
  "x-cursor-client-type": "cli",
220
282
  "x-ghost-mode": "true",
283
+ ...await teamHeader(tok),
221
284
  }
222
285
  }
223
286
 
@@ -456,7 +519,7 @@ async function hardLimit(tok) {
456
519
  try {
457
520
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetHardLimit", {
458
521
  method: "POST",
459
- headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
522
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...await teamHeader(tok) },
460
523
  body: "{}",
461
524
  signal: AbortSignal.timeout(15_000),
462
525
  })
@@ -481,7 +544,7 @@ async function usage(tok, ids) {
481
544
  const [res, hard] = await Promise.all([
482
545
  fetch(API + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
483
546
  method: "POST",
484
- headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
547
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...await teamHeader(tok) },
485
548
  body: "{}",
486
549
  signal: AbortSignal.timeout(15_000),
487
550
  }),
@@ -672,12 +735,72 @@ function served(m, ids) {
672
735
  return got.some((v) => v.legacySlug) ? got : vs
673
736
  }
674
737
 
738
+ // familyOf is the family one of Cursor's own ids is of, fast or not:
739
+ // claude-opus-5-5-low-fast is of claude-opus-5-5, at low, fast.
740
+ function familyOf(id) {
741
+ let [family, effort] = splitID(id)
742
+ const fast = family.endsWith("-fast") && family.length > "-fast".length
743
+ if (fast) family = family.slice(0, -"-fast".length)
744
+ return { family, effort, fast }
745
+ }
746
+
747
+ // familiesOf is the usable list (GetUsableModels, what cursor-agent models
748
+ // shows: claude-opus-5-5-low, claude-opus-5-5-low-fast, …) by family: each
749
+ // id with its effort, whether it is fast, whether Cursor runs it in Max
750
+ // Mode, and its name.
751
+ function familiesOf(listed) {
752
+ const fams = new Map()
753
+ for (const u of listed ?? []) {
754
+ const id = String(u?.modelId ?? "")
755
+ if (!id) continue
756
+ const { family, effort, fast } = familyOf(id)
757
+ if (!fams.has(family)) fams.set(family, [])
758
+ fams.get(family).push({ id, effort, fast, maxMode: u.maxMode === true, name: clean(u.displayName || id) })
759
+ }
760
+ return fams
761
+ }
762
+
763
+ // fromUsable is the id of a family of the usable list's that a request
764
+ // runs as: fast as asked (when the family has both), at the effort asked or
765
+ // the nearest it has; with none asked, the one without an effort in its
766
+ // id, else medium, as Cursor's default is (claude-opus-5-5-medium), else
767
+ // the first.
768
+ function fromUsable(fam, effort, fast) {
769
+ let vs = fam.filter((u) => u.fast === !!fast)
770
+ if (!vs.length) vs = fam
771
+ if (effort === "off") effort = "none"
772
+ const at = effort ? fitEffort(effort, vs.map((u) => u.effort).filter(Boolean)) : ""
773
+ return (at && vs.find((u) => u.effort === at)) || vs.find((u) => u.effort === "") || vs.find((u) => u.effort === "medium") || vs[0]
774
+ }
775
+
776
+ // usableTitle is a usable id's name without what the id says apart: the
777
+ // effort, fast, and the 1M Cursor names every Max-capable Claude with
778
+ // ("Claude Opus 5.5 1M Low" is Claude Opus 5.5).
779
+ function usableTitle(name) {
780
+ const drop = new Set(["low", "medium", "high", "xhigh", "extra", "max", "minimal", "none", "fast", "1m", "(1m)"])
781
+ const ws = name.split(" ")
782
+ while (ws.length > 1 && drop.has(ws.at(-1).toLowerCase())) ws.pop()
783
+ return ws.join(" ")
784
+ }
785
+
675
786
  // modelsOf is the picker's models as offered: one entry for each context
676
787
  // size a model has, or one with Cursor's limit when it has none. A model
677
788
  // served in part is offered at the sizes and efforts it is served at.
678
- function modelsOf(picker, ids) {
789
+ // listed is the usable list: a family of its ids the picker has nothing of
790
+ // (a Team's picker can leave out what cursor-agent models lists, magpie
791
+ // #3150 ysm119 on Discord) is offered too, once, with its efforts, and each
792
+ // entry keeps its family's usable ids, so a request goes as one of those
793
+ // and never as a name Cursor would answer "AI Model Not Found".
794
+ function modelsOf(picker, ids, listed = []) {
679
795
  const out = []
680
796
  const used = new Set()
797
+ const fams = familiesOf(listed)
798
+ // every id the picker names at all, shown or left out
799
+ const named = new Set()
800
+ for (const m of picker) {
801
+ if (!m?.name) continue
802
+ for (const x of [m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? []), ...(m.variants ?? []).map((v) => v.legacySlug)]) if (x) named.add(x)
803
+ }
681
804
  for (const m of picker) {
682
805
  if (!m?.name || m.isHidden || m.isChatOnly || m.onlySupportsCmdK || m.supportsAgent === false || PICKER_SKIP.has(m.name)) continue
683
806
  const defs = paramDefs(m)
@@ -687,7 +810,8 @@ function modelsOf(picker, ids) {
687
810
  const def = defaultVariant({ variants })
688
811
  const { pid, levels: all } = effortsOf(defs)
689
812
  const levels = Object.fromEntries(Object.entries(all).filter(([, value]) => has(pid, value)))
690
- const base = { run: m.name, title: clean(m.clientDisplayName || m.name), pid, levels, fast: !!defs.fast?.length, variants, def, partial, model: m }
813
+ const fam = fams.get(familyOf(def?.legacySlug || m.name).family) ?? []
814
+ const base = { run: m.name, title: clean(m.clientDisplayName || m.name), pid, levels, fast: !!defs.fast?.length, variants, def, partial, model: m, fam, ids }
691
815
  const contexts = (defs.context ?? []).filter((c) => has("context", c))
692
816
  if (contexts.length) {
693
817
  for (const c of contexts) {
@@ -702,6 +826,15 @@ function modelsOf(picker, ids) {
702
826
  out.push({ ...base, id: m.name === "default" ? "auto" : m.name, name: base.title, context: limit > 0 ? limit : DEFAULT_CONTEXT, size: "", offersFast: fastAt(variants, "") })
703
827
  }
704
828
  }
829
+ for (const [family, fam] of fams) {
830
+ if (fam.some((u) => named.has(u.id)) || named.has(family) || used.has(family)) continue
831
+ used.add(family)
832
+ const levels = Object.fromEntries(fam.filter((u) => LEVEL_RANK.includes(u.effort)).map((u) => [u.effort, u.effort]))
833
+ const def = fromUsable(fam, "", false)
834
+ // Cursor's context for it isn't known: its default's, as for a model
835
+ // the picker gives no limit for
836
+ out.push({ id: family, run: family, name: usableTitle(def.name), title: usableTitle(def.name), pid: "", levels, fast: false, variants: [], def: undefined, partial: false, model: { name: family }, fam, ids, usableOnly: true, context: DEFAULT_CONTEXT, size: "", offersFast: fam.some((u) => u.fast) })
837
+ }
705
838
  return out
706
839
  }
707
840
 
@@ -734,6 +867,11 @@ function nearest(vs, want) {
734
867
  // variant the account has. seed is the variant an id of Cursor's own named
735
868
  // (request), in the default's place.
736
869
  function pick(entry, effort, fast, seed) {
870
+ // a family the usable list has and the picker doesn't: its own id
871
+ if (entry.usableOnly) {
872
+ const u = fromUsable(entry.fam, effort, fast)
873
+ return { id: u.id, params: [], maxMode: u.maxMode }
874
+ }
737
875
  const def = entry.def
738
876
  const want = { ...valuesOf(seed ?? def) }
739
877
  if (entry.size) want.context = entry.size
@@ -761,6 +899,14 @@ function pick(entry, effort, fast, seed) {
761
899
  // cursor-agent models lists it: claude-opus-5-5-high), not the picker's
762
900
  // model name; the default's when no variant matched
763
901
  const id = v?.legacySlug || def?.legacySlug || entry.run
902
+ // an id the usable list doesn't have, when it has the family's (a
903
+ // picker model without variants goes by its name, claude-opus-5-5,
904
+ // which Cursor doesn't serve): the family's usable id at the effort
905
+ if (entry.ids && !entry.ids.has(id) && entry.fam?.length) {
906
+ const level = entry.pid && entry.pid !== "thinking" ? String(want[entry.pid] ?? "").replace("extra-high", "xhigh") : ""
907
+ const u = fromUsable(entry.fam, effort || level, fast)
908
+ return { id: u.id, params: [], maxMode: maxMode || u.maxMode }
909
+ }
764
910
  return { id, params, maxMode }
765
911
  }
766
912
 
@@ -815,17 +961,15 @@ async function usable(tok) {
815
961
  const h = createHash("sha256").update(tok).digest("hex")
816
962
  const had = lists.get(h)
817
963
  if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
818
- const [j, ids] = await Promise.all([
964
+ const [j, listed] = await Promise.all([
819
965
  unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
820
966
  unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
821
- (u) => {
822
- const ids = new Set((u?.models ?? []).flatMap((m) => [m.modelId, m.displayModelId]).filter(Boolean))
823
- return ids.size ? ids : null
824
- },
825
- () => null,
967
+ (u) => (Array.isArray(u?.models) ? u.models : []),
968
+ () => [],
826
969
  ),
827
970
  ])
828
- const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids)
971
+ const ids = new Set(listed.flatMap((m) => [m?.modelId, m?.displayModelId]).filter(Boolean))
972
+ const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids.size ? ids : null, listed)
829
973
  if (!raw.length) throw new Error("Cursor listed no models")
830
974
  lists.set(h, { at: Date.now(), raw })
831
975
  return raw
@@ -858,8 +1002,33 @@ function named(raw, id) {
858
1002
  }
859
1003
  for (const e of models) {
860
1004
  const m = e.model
1005
+ if (e.usableOnly) continue
861
1006
  if ([m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? [])].includes(id)) return { entry: at(e, e.def), seed: undefined, effort: "", fast: false }
862
1007
  }
1008
+ // one of the usable list's own ids, or a family of them, that the picker
1009
+ // has nothing of: that family at the id's effort, fast when it is
1010
+ const of = familyOf(id)
1011
+ for (const e of models) {
1012
+ if (!e.usableOnly) continue
1013
+ const u = e.fam.find((x) => x.id === id)
1014
+ if (u) return { entry: e, seed: undefined, effort: u.effort, fast: u.fast }
1015
+ if (e.id === of.family) return { entry: e, seed: undefined, effort: of.effort, fast: of.fast }
1016
+ }
1017
+ // an id at a context size the list no longer has (claude-opus-5-5@1m,
1018
+ // picked from an earlier list, or from another account's): the model at
1019
+ // the nearest size it has; a model listed without sizes, in Max Mode for
1020
+ // a size over its window
1021
+ const sized = /^(.+)@([^@]+)$/.exec(id)
1022
+ if (sized) {
1023
+ const want = tokensOf(sized[2])
1024
+ const same = raw.filter((e) => e.run === sized[1] && e.size)
1025
+ if (same.length) {
1026
+ const near = same.reduce((a, b) => (Math.abs(tokensOf(b.size) - want) < Math.abs(tokensOf(a.size) - want) ? b : a))
1027
+ return { entry: near, seed: undefined, effort: "", fast: false }
1028
+ }
1029
+ const old = named(raw, sized[1])
1030
+ if (old) return { ...old, max: !old.entry.size && want > old.entry.context }
1031
+ }
863
1032
  return null
864
1033
  }
865
1034
 
@@ -870,8 +1039,17 @@ function named(raw, id) {
870
1039
  function request(raw, model, effort, fast) {
871
1040
  const entry = raw.find((m) => m.id === model)
872
1041
  if (entry) return pick(entry, effort, fast)
1042
+ const bare = raw.find((m) => m.id === model.replace(/@[^@]*$/, ""))
1043
+ if (bare && model !== bare.id && !bare.size) {
1044
+ // a model now listed without sizes, asked at one
1045
+ const r = pick(bare, effort, fast)
1046
+ return tokensOf(model.split("@").at(-1)) > bare.context ? { ...r, maxMode: true } : r
1047
+ }
873
1048
  const old = named(raw, model)
874
- if (old) return pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
1049
+ if (old) {
1050
+ const r = pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
1051
+ return old.max ? { ...r, maxMode: true } : r
1052
+ }
875
1053
  return { id: model === "auto" ? "default" : model, params: [], maxMode: false }
876
1054
  }
877
1055
 
@@ -1515,7 +1693,7 @@ async function usageEvents(tok, conv, since, want, { poll = false, first = STEP_
1515
1693
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1516
1694
  method: "POST",
1517
1695
  headers: await headersFor(tok),
1518
- body: JSON.stringify({ teamId: 0, startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1696
+ body: JSON.stringify({ ...await teamBody(tok), startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1519
1697
  signal: abortFetch && signal ? AbortSignal.any([signal, AbortSignal.timeout(STEP_WAIT.ask)]) : AbortSignal.timeout(STEP_WAIT.ask),
1520
1698
  })
1521
1699
  if (res.status >= 400 && res.status < 500 && res.status !== 429) {
@@ -2149,6 +2327,7 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2149
2327
  "x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
2150
2328
  "x-ghost-mode": "true", // privacy mode: nothing kept for training
2151
2329
  "x-request-id": randomUUID(),
2330
+ ...await teamHeader(tok),
2152
2331
  // none of Cursor's own tools, only the caller's
2153
2332
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2154
2333
  },
@@ -2237,6 +2416,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2237
2416
  "x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
2238
2417
  "x-ghost-mode": "true", // privacy mode: nothing kept for training
2239
2418
  "x-request-id": randomUUID(),
2419
+ ...await teamHeader(tok),
2240
2420
  // none of Cursor's own tools, only the caller's
2241
2421
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2242
2422
  },
@@ -2887,4 +3067,4 @@ export async function CursorAuthPlugin() {
2887
3067
  }
2888
3068
 
2889
3069
  // for tests
2890
- export const _internal = { keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, 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 }
3070
+ export const _internal = { 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.22",
3
+ "version": "0.2.24",
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",