@magpie-community/opencode-cursor-auth 0.2.23 → 0.2.25

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 -0
  2. package/index.mjs +157 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -197,6 +197,24 @@ 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. Such a family is named
207
+ without the tier Cursor's names carry ("Claude Opus 5 1M Medium
208
+ Thinking" is listed as Claude Opus 5 Thinking): the tier is the
209
+ reasoning level the agent asks for (low to max, as the account has
210
+ them), fast the request's `service_tier`.
211
+ - An id for a model with thinking that the account lists only without it
212
+ (`claude-haiku-5-5-thinking` when the list has Claude Haiku 5.5 alone),
213
+ or the other way round, runs as the model the account has.
214
+ - An `@<size>` id the list no longer has (`claude-opus-5-5@1m` when only
215
+ 300k is listed, or the model is listed unsized) runs at the model's
216
+ nearest size, in Max Mode when the size asked for is over the model's
217
+ window, never as the literal `@<size>` id.
200
218
  - An id from before 0.2.0 still runs. A variant's own id
201
219
  (`claude-opus-5-5-high`) is that variant; a family 0.1.x listed
202
220
  (`claude-opus-5-5`, `claude-opus-5-5-fast`, `claude-haiku-5-5-thinking`,
package/index.mjs CHANGED
@@ -735,12 +735,89 @@ function served(m, ids) {
735
735
  return got.some((v) => v.legacySlug) ? got : vs
736
736
  }
737
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: its
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), wherever Cursor puts
779
+ // them: "Claude Opus 5 1M Medium Thinking" is Claude Opus 5 Thinking, as
780
+ // its efforts are magpie's reasoning levels and not models of their own
781
+ // (ysm119 on Discord read "Medium" in the name as the only tier). A word
782
+ // of the model's own name stays: GPT-5.1 Codex Max at high is "GPT-5.1
783
+ // Codex Max".
784
+ const EFFORT_WORDS = { none: ["None"], minimal: ["Minimal"], low: ["Low"], medium: ["Medium"], high: ["High"], xhigh: ["Extra High", "XHigh"], max: ["Max"] }
785
+ function usableTitle(u) {
786
+ const ws = u.name.split(" ")
787
+ // the last time a phrase is said, past the name's first word
788
+ const cutLast = (phrase) => {
789
+ const p = phrase.toLowerCase().split(" ")
790
+ for (let i = ws.length - p.length; i >= 1; i--) {
791
+ if (p.every((w, k) => ws[i + k].toLowerCase() === w)) {
792
+ ws.splice(i, p.length)
793
+ return true
794
+ }
795
+ }
796
+ return false
797
+ }
798
+ for (const p of EFFORT_WORDS[u.effort] ?? []) if (cutLast(p)) break
799
+ if (u.fast) cutLast("fast")
800
+ return ws.filter((w, i) => i === 0 || !/^\(?1m\)?$/i.test(w)).join(" ") || u.name
801
+ }
802
+
738
803
  // modelsOf is the picker's models as offered: one entry for each context
739
804
  // size a model has, or one with Cursor's limit when it has none. A model
740
805
  // served in part is offered at the sizes and efforts it is served at.
741
- function modelsOf(picker, ids) {
806
+ // listed is the usable list: a family of its ids the picker has nothing of
807
+ // (a Team's picker can leave out what cursor-agent models lists, magpie
808
+ // #3150 ysm119 on Discord) is offered too, once, with its efforts, and each
809
+ // entry keeps its family's usable ids, so a request goes as one of those
810
+ // and never as a name Cursor would answer "AI Model Not Found".
811
+ function modelsOf(picker, ids, listed = []) {
742
812
  const out = []
743
813
  const used = new Set()
814
+ const fams = familiesOf(listed)
815
+ // every id the picker names at all, shown or left out
816
+ const named = new Set()
817
+ for (const m of picker) {
818
+ if (!m?.name) continue
819
+ for (const x of [m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? []), ...(m.variants ?? []).map((v) => v.legacySlug)]) if (x) named.add(x)
820
+ }
744
821
  for (const m of picker) {
745
822
  if (!m?.name || m.isHidden || m.isChatOnly || m.onlySupportsCmdK || m.supportsAgent === false || PICKER_SKIP.has(m.name)) continue
746
823
  const defs = paramDefs(m)
@@ -750,7 +827,8 @@ function modelsOf(picker, ids) {
750
827
  const def = defaultVariant({ variants })
751
828
  const { pid, levels: all } = effortsOf(defs)
752
829
  const levels = Object.fromEntries(Object.entries(all).filter(([, value]) => has(pid, value)))
753
- const base = { run: m.name, title: clean(m.clientDisplayName || m.name), pid, levels, fast: !!defs.fast?.length, variants, def, partial, model: m }
830
+ const fam = fams.get(familyOf(def?.legacySlug || m.name).family) ?? []
831
+ const base = { run: m.name, title: clean(m.clientDisplayName || m.name), pid, levels, fast: !!defs.fast?.length, variants, def, partial, model: m, fam, ids }
754
832
  const contexts = (defs.context ?? []).filter((c) => has("context", c))
755
833
  if (contexts.length) {
756
834
  for (const c of contexts) {
@@ -765,6 +843,15 @@ function modelsOf(picker, ids) {
765
843
  out.push({ ...base, id: m.name === "default" ? "auto" : m.name, name: base.title, context: limit > 0 ? limit : DEFAULT_CONTEXT, size: "", offersFast: fastAt(variants, "") })
766
844
  }
767
845
  }
846
+ for (const [family, fam] of fams) {
847
+ if (fam.some((u) => named.has(u.id)) || named.has(family) || used.has(family)) continue
848
+ used.add(family)
849
+ const levels = Object.fromEntries(fam.filter((u) => LEVEL_RANK.includes(u.effort)).map((u) => [u.effort, u.effort]))
850
+ const def = fromUsable(fam, "", false)
851
+ // Cursor's context for it isn't known: its default's, as for a model
852
+ // the picker gives no limit for
853
+ out.push({ id: family, run: family, name: usableTitle(def), title: usableTitle(def), 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) })
854
+ }
768
855
  return out
769
856
  }
770
857
 
@@ -797,6 +884,11 @@ function nearest(vs, want) {
797
884
  // variant the account has. seed is the variant an id of Cursor's own named
798
885
  // (request), in the default's place.
799
886
  function pick(entry, effort, fast, seed) {
887
+ // a family the usable list has and the picker doesn't: its own id
888
+ if (entry.usableOnly) {
889
+ const u = fromUsable(entry.fam, effort, fast)
890
+ return { id: u.id, params: [], maxMode: u.maxMode }
891
+ }
800
892
  const def = entry.def
801
893
  const want = { ...valuesOf(seed ?? def) }
802
894
  if (entry.size) want.context = entry.size
@@ -824,6 +916,14 @@ function pick(entry, effort, fast, seed) {
824
916
  // cursor-agent models lists it: claude-opus-5-5-high), not the picker's
825
917
  // model name; the default's when no variant matched
826
918
  const id = v?.legacySlug || def?.legacySlug || entry.run
919
+ // an id the usable list doesn't have, when it has the family's (a
920
+ // picker model without variants goes by its name, claude-opus-5-5,
921
+ // which Cursor doesn't serve): the family's usable id at the effort
922
+ if (entry.ids && !entry.ids.has(id) && entry.fam?.length) {
923
+ const level = entry.pid && entry.pid !== "thinking" ? String(want[entry.pid] ?? "").replace("extra-high", "xhigh") : ""
924
+ const u = fromUsable(entry.fam, effort || level, fast)
925
+ return { id: u.id, params: [], maxMode: maxMode || u.maxMode }
926
+ }
827
927
  return { id, params, maxMode }
828
928
  }
829
929
 
@@ -878,17 +978,15 @@ async function usable(tok) {
878
978
  const h = createHash("sha256").update(tok).digest("hex")
879
979
  const had = lists.get(h)
880
980
  if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
881
- const [j, ids] = await Promise.all([
981
+ const [j, listed] = await Promise.all([
882
982
  unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
883
983
  unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
884
- (u) => {
885
- const ids = new Set((u?.models ?? []).flatMap((m) => [m.modelId, m.displayModelId]).filter(Boolean))
886
- return ids.size ? ids : null
887
- },
888
- () => null,
984
+ (u) => (Array.isArray(u?.models) ? u.models : []),
985
+ () => [],
889
986
  ),
890
987
  ])
891
- const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids)
988
+ const ids = new Set(listed.flatMap((m) => [m?.modelId, m?.displayModelId]).filter(Boolean))
989
+ const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids.size ? ids : null, listed)
892
990
  if (!raw.length) throw new Error("Cursor listed no models")
893
991
  lists.set(h, { at: Date.now(), raw })
894
992
  return raw
@@ -921,8 +1019,48 @@ function named(raw, id) {
921
1019
  }
922
1020
  for (const e of models) {
923
1021
  const m = e.model
1022
+ if (e.usableOnly) continue
924
1023
  if ([m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? [])].includes(id)) return { entry: at(e, e.def), seed: undefined, effort: "", fast: false }
925
1024
  }
1025
+ // one of the usable list's own ids, or a family of them, that the picker
1026
+ // has nothing of: that family at the id's effort, fast when it is
1027
+ const of = familyOf(id)
1028
+ for (const e of models) {
1029
+ if (!e.usableOnly) continue
1030
+ const u = e.fam.find((x) => x.id === id)
1031
+ if (u) return { entry: e, seed: undefined, effort: u.effort, fast: u.fast }
1032
+ if (e.id === of.family) return { entry: e, seed: undefined, effort: of.effort, fast: of.fast }
1033
+ }
1034
+ // a family neither has, where the account has the same model with
1035
+ // thinking or without it (claude-haiku-5-5-thinking, which 0.1.x listed,
1036
+ // on a Team account whose list has Claude Haiku 5.5 without a thinking
1037
+ // id; ysm119 on Discord): that one, at the id's effort, never the id as
1038
+ // it is, which Cursor answers "AI Model Not Found"
1039
+ const loose = (f) => f.replace(/-thinking$/, "")
1040
+ const key = loose(of.family)
1041
+ for (const e of models) {
1042
+ if (e.usableOnly) {
1043
+ if (loose(e.id) === key) return { entry: e, seed: undefined, effort: of.effort, fast: of.fast }
1044
+ continue
1045
+ }
1046
+ const vs = e.variants.filter((v) => v.legacySlug && loose(familyOf(v.legacySlug).family) === key)
1047
+ if (vs.length) return as(e, vs, of.effort)
1048
+ }
1049
+ // an id at a context size the list no longer has (claude-opus-5-5@1m,
1050
+ // picked from an earlier list, or from another account's): the model at
1051
+ // the nearest size it has; a model listed without sizes, in Max Mode for
1052
+ // a size over its window
1053
+ const sized = /^(.+)@([^@]+)$/.exec(id)
1054
+ if (sized) {
1055
+ const want = tokensOf(sized[2])
1056
+ const same = raw.filter((e) => e.run === sized[1] && e.size)
1057
+ if (same.length) {
1058
+ const near = same.reduce((a, b) => (Math.abs(tokensOf(b.size) - want) < Math.abs(tokensOf(a.size) - want) ? b : a))
1059
+ return { entry: near, seed: undefined, effort: "", fast: false }
1060
+ }
1061
+ const old = named(raw, sized[1])
1062
+ if (old) return { ...old, max: !old.entry.size && want > old.entry.context }
1063
+ }
926
1064
  return null
927
1065
  }
928
1066
 
@@ -933,8 +1071,17 @@ function named(raw, id) {
933
1071
  function request(raw, model, effort, fast) {
934
1072
  const entry = raw.find((m) => m.id === model)
935
1073
  if (entry) return pick(entry, effort, fast)
1074
+ const bare = raw.find((m) => m.id === model.replace(/@[^@]*$/, ""))
1075
+ if (bare && model !== bare.id && !bare.size) {
1076
+ // a model now listed without sizes, asked at one
1077
+ const r = pick(bare, effort, fast)
1078
+ return tokensOf(model.split("@").at(-1)) > bare.context ? { ...r, maxMode: true } : r
1079
+ }
936
1080
  const old = named(raw, model)
937
- if (old) return pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
1081
+ if (old) {
1082
+ const r = pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
1083
+ return old.max ? { ...r, maxMode: true } : r
1084
+ }
938
1085
  return { id: model === "auto" ? "default" : model, params: [], maxMode: false }
939
1086
  }
940
1087
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",