@magpie-community/opencode-cursor-auth 0.1.11 → 0.2.1

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 +35 -18
  2. package/index.mjs +250 -240
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -73,25 +73,42 @@ plugin's `fetch` answers them on Cursor's agent API:
73
73
  ## Models
74
74
 
75
75
  The `config` hook declares `auto` (Cursor's pick). Once signed in, the
76
- `provider.models` hook lists the account's models
77
- (`AgentService/GetUsableModels`, cached 10 minutes), by the ids
78
- `cursor-agent models` shows:
76
+ `provider.models` hook lists the account's models from Cursor's model
77
+ picker (`AiService/AvailableModels`, as the CLI asks for it, cached 10
78
+ minutes), the way pi-cursor-sdk lists Cursor's catalog:
79
79
 
80
- - Cursor lists a model once per effort (`gpt-5.6-low`, `gpt-5.6-high`, …).
81
- Each family is one model here, with the efforts as variants; the
82
- request's `reasoning_effort` picks the id, fitted to the nearest one there
83
- is. `-fast` and `-thinking` models stay models of their own; a
84
- `service_tier` of `priority` picks the fast one.
85
- - The context window is 1M for a model whose name says 1M, else 200K.
86
- - Cursor's model picker (`AiService/AvailableModels`, as the CLI asks for
87
- it) adds the models it offers that the usable list leaves out (GLM-5.3,
88
- GLM-5.3 Flash), run with the picker variant's parameters; hidden and
89
- Tab-only models stay out.
90
- - A model Cursor serves only in Max Mode (the usable list's `maxMode`, a
91
- picker model with no other mode, or a Max Mode variant) is asked for in
92
- Max Mode, as the CLI turns it on for one. A model neither says it of is
93
- asked again in Max Mode when Cursor answers "Max Mode Required", and so
94
- from then on.
80
+ - A model with a `context` parameter is listed once per size, as
81
+ `<model>@<size>` named `<name> @ <size>` (`claude-opus-5-5@300k`,
82
+ "Claude Opus 5.5 @ 300k"), its window that many tokens. A model with none
83
+ is listed once, its window Cursor's `contextTokenLimit` (Max Mode's for a
84
+ model served only in Max Mode); 200K when Cursor gives none.
85
+ - Effort is not a model of its own: the model's efforts are its variants,
86
+ and the request's `reasoning_effort` (fitted to the nearest there is) is
87
+ sent as the model's effort parameter. `none` turns a Claude's thinking off.
88
+ - Fast is not a model of its own either: a `service_tier` of `priority`
89
+ or `fast` sends `fast=true`, anything else `fast=false` (it costs more,
90
+ so only when asked).
91
+ - A request goes as the picker's variant those parameters pick, in Max Mode
92
+ when that variant is Max Mode's (a 1M size, say), as the CLI turns it on.
93
+ A size with no variant goes in Max Mode when it is over Cursor's limit
94
+ outside it. A model Cursor still answers "Max Mode Required" for is asked
95
+ again in Max Mode, and so from then on.
96
+ - Hidden, Tab-only and chat-only models stay out, as in the CLI's picker.
97
+ - The usable list (`AgentService/GetUsableModels`, what `cursor-agent
98
+ models` shows) says which variants the account has: a variant whose own
99
+ id isn't in it is neither offered nor asked for (Sonnet 4.6 at any effort
100
+ but medium, say), and a request for one goes as the nearest the account
101
+ has. A model the usable list has nothing of is the picker's alone
102
+ (GLM-5.3) and is kept whole; so is every model when Cursor can't give
103
+ the usable list.
104
+ - An id from before 0.2.0 still runs. A variant's own id
105
+ (`claude-opus-5-5-high`) is that variant; a family 0.1.x listed
106
+ (`claude-opus-5-5`, `claude-opus-5-5-fast`, `claude-haiku-5-5-thinking`,
107
+ `cursor-grok-4.6`) is its variant nearest the default, fast for a `-fast`
108
+ one; the picker's name for a model or a legacy slug of it is its default.
109
+ Each is at the default context size, the request's `reasoning_effort`
110
+ and `service_tier` counting as for a listed model. The list doesn't show
111
+ these ids: pick the `@<size>` model for a window that is the model's.
95
112
 
96
113
  ## Not included
97
114
 
package/index.mjs CHANGED
@@ -39,7 +39,7 @@ const WEBSITE = "https://cursor.com"
39
39
  const AGENT = "https://agentn.global.api5.cursor.sh" // when the server config can't say
40
40
  const CHAT = "@ai-sdk/openai-compatible"
41
41
  const VERSION_FALLBACK = "2026.09.23-86fc751" // the CLI version said when no install names one
42
- const DEFAULT_CONTEXT = 200_000 // what Cursor gives a model it doesn't name as a 1M one
42
+ const DEFAULT_CONTEXT = 200_000 // a model Cursor gives no limit for
43
43
  const MODELS_KEEP = 10 * 60 * 1000
44
44
  const CALL = "CallDynamicTool" // how the model calls an MCP tool
45
45
  const NO_RESULT = "Tool use was interrupted and did not produce a result."
@@ -351,10 +351,11 @@ const statusText = (s) =>
351
351
 
352
352
  const POOL_VARIANT = /-(fast|none|low|medium|high|xhigh|extra-high|max|thinking)$/
353
353
 
354
- // poolBase names a model's family: lower case, without cursor- and the
355
- // effort and speed the CLI adds to it.
354
+ // poolBase names a model's family: lower case, without cursor-, the
355
+ // context size the list adds to it (@300k) and the effort and speed the
356
+ // CLI adds to it.
356
357
  function poolBase(model) {
357
- model = String(model).trim().toLowerCase()
358
+ model = String(model).trim().toLowerCase().replace(/@[^@]*$/, "")
358
359
  if (model.startsWith("cursor-")) model = model.slice("cursor-".length)
359
360
  for (;;) {
360
361
  const b = model.replace(POOL_VARIANT, "")
@@ -368,6 +369,7 @@ function poolBase(model) {
368
369
  // Keep those documented families alongside the server's exact model list.
369
370
  // See https://cursor.com/docs/models-and-pricing.
370
371
  function firstParty(model) {
372
+ model = model.replace(/@[^@]*$/, "")
371
373
  if (model.startsWith("cursor-")) model = model.slice("cursor-".length)
372
374
  if (model === "default" || model === "composer" || model.startsWith("composer-")) return true
373
375
  return ["grok-4.5", "grok-4.6", "grok-4.7"].some((b) => model === b || model.startsWith(b + "-"))
@@ -413,26 +415,29 @@ async function usage(tok, ids) {
413
415
 
414
416
  // ---- models ----------------------------------------------------------------------
415
417
  //
416
- // Cursor lists a model once for each effort and speed it serves it at
417
- // ("grok-4.7-low", "grok-4.7-low-fast", … "grok-4.7-xhigh-fast"), and a
418
- // Claude with thinking apart from one without. One model a family is
419
- // offered, with the efforts there are, and the request asks for the id the
420
- // effort picks. Fast stays a model of its own.
421
-
422
- // the effort words of Cursor's ids, the levels they are and the words
423
- // Cursor's names say them in; extra-high before high
424
- const EFFORTS = [
425
- ["extra-high", "xhigh", "Extra High"], ["xhigh", "xhigh", "Extra High"], ["minimal", "minimal", "Minimal"],
426
- ["none", "none", "None"], ["low", "low", "Low"], ["medium", "medium", "Medium"], ["high", "high", "High"], ["max", "max", "Max"],
427
- ]
418
+ // The list is Cursor's model picker (AiService/AvailableModels, asked as the
419
+ // CLI asks it), the way pi-cursor-sdk lists Cursor's SDK catalog: each model
420
+ // is offered once for each of its context sizes, named "Claude Opus 5.5 @
421
+ // 300k" and with that many tokens as its window; one without a context
422
+ // parameter is offered once, with Cursor's limit for it. Effort is the
423
+ // request's reasoning_effort and fast its service_tier, sent as the model's
424
+ // own parameters, so neither is a model of its own. A request goes as the
425
+ // variant those parameters pick, in Max Mode when that variant is Max Mode's.
426
+
428
427
  const LEVEL_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
429
- const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
428
+ const EFFORT_PARAMS = ["effort", "reasoning_effort", "reasoning"]
429
+
430
+ // the effort words of Cursor's own ids and the levels they are; extra-high
431
+ // before high
432
+ const ID_EFFORTS = [["extra-high", "xhigh"], ["xhigh", "xhigh"], ["minimal", "minimal"], ["none", "none"], ["low", "low"], ["medium", "medium"], ["high", "high"], ["max", "max"]]
430
433
 
431
434
  const cut = (s, suffix) => (s.endsWith(suffix) && s.length > suffix.length ? s.slice(0, -suffix.length) : null)
432
435
 
433
- // splitID is a Cursor id taken apart: its family, and the effort ("" for
434
- // none named) it is at. Thinking comes before the effort or after it; in
435
- // the family it goes after, as does fast.
436
+ // splitID is one of Cursor's own ids (a variant's legacySlug, as
437
+ // cursor-agent models lists it) taken apart: its family, and the effort
438
+ // ("" for none named) it is at. Thinking comes before the effort or after
439
+ // it; in the family it goes after, as does fast. The family is the id 0.1.x
440
+ // listed a model by.
436
441
  function splitID(id) {
437
442
  let s = id
438
443
  let fast = false
@@ -441,7 +446,7 @@ function splitID(id) {
441
446
  let b
442
447
  if ((b = cut(s, "-fast")) !== null) [s, fast] = [b, true]
443
448
  if ((b = cut(s, "-thinking")) !== null) [s, thinking] = [b, true]
444
- for (const [word, level] of EFFORTS) {
449
+ for (const [word, level] of ID_EFFORTS) {
445
450
  if ((b = cut(s, "-" + word)) !== null) {
446
451
  ;[s, effort] = [b, level]
447
452
  break
@@ -453,115 +458,182 @@ function splitID(id) {
453
458
  return [s, effort]
454
459
  }
455
460
 
456
- const labelOf = (level) => EFFORTS.find((e) => e[1] === level)?.[2] ?? ""
461
+ // left out of the CLI's picker as it leaves them out (model-service.ts)
462
+ const PICKER_SKIP = new Set(["claude-4.5-haiku", "claude-4.5-haiku-thinking", "gemini-2.5-pro", "gemini-2.5-flash", "claude-haiku-4-5"])
457
463
 
458
- // withoutWords is name with the first run of these words taken out, and
459
- // whether it had them.
460
- function withoutWords(name, words) {
461
- const ns = name.split(/\s+/).filter(Boolean)
462
- const ws = words.split(/\s+/).filter(Boolean)
463
- if (!ws.length) return [name, false]
464
- for (let i = 0; i + ws.length <= ns.length; i++)
465
- if (ws.every((w, j) => ns[i + j] === w)) return [[...ns.slice(0, i), ...ns.slice(i + ws.length)].join(" "), true]
466
- return [name, false]
467
- }
464
+ const clean = (s) => String(s ?? "").replaceAll("\u200b", "").split(/\s+/).filter(Boolean).join(" ")
468
465
 
469
- function families(raw) {
470
- const out = []
471
- const by = new Map()
472
- for (const m of raw) {
473
- const [id, effort] = splitID(m.id)
474
- let f = by.get(id)
475
- if (!f) {
476
- f = { id, variants: [], efforts: [] }
477
- by.set(id, f)
478
- out.push(f)
479
- }
480
- f.variants.push(m)
481
- f.efforts.push(effort)
482
- }
483
- return out
466
+ // tokensOf is a context value as tokens: "300k" 300000, "1m" 1000000.
467
+ function tokensOf(value) {
468
+ const m = /^(\d+(?:\.\d+)?)([km])$/i.exec(String(value ?? "").trim())
469
+ return m ? Math.round(Number(m[1]) * (m[2].toLowerCase() === "m" ? 1_000_000 : 1_000)) : 0
484
470
  }
485
471
 
486
- // byEffort is the id for each effort, "" the one Cursor picks by default:
487
- // the one without an effort in its id, else the one whose name doesn't say
488
- // its effort, else medium, else the first.
489
- function byEffort(f) {
472
+ // paramDefs is a picker model's parameters: id to the values it takes.
473
+ function paramDefs(m) {
490
474
  const out = {}
491
- let def = f.efforts.indexOf("")
492
- f.variants.forEach((v, i) => {
493
- const e = f.efforts[i]
494
- if (!(e in out)) out[e] = v.id
495
- if (def < 0 && e !== "" && !withoutWords(v.name, labelOf(e))[1]) def = i
496
- })
497
- if (def < 0) def = Math.max(0, f.efforts.indexOf("medium"))
498
- out[""] = f.variants[def].id
475
+ for (const d of m.parameterDefinitions ?? []) {
476
+ const t = d?.parameterType ?? {}
477
+ const vs = (t.enumParameter ?? t.booleanParameter)?.values ?? []
478
+ if (d?.id) out[d.id] = vs.map((v) => String(v.value))
479
+ }
499
480
  return out
500
481
  }
501
482
 
502
- function familyModel(f) {
503
- const def = byEffort(f)[""]
504
- const m = { id: f.id, name: f.id, context: 0, efforts: [] }
505
- f.variants.forEach((v, i) => {
506
- if (v.id === def) m.name = withoutWords(v.name, labelOf(f.efforts[i]))[0]
507
- if (v.context > 0 && (!m.context || v.context < m.context)) m.context = v.context
508
- })
509
- for (const l of LEVEL_RANK) if (f.efforts.includes(l)) m.efforts.push(l)
510
- return m
511
- }
483
+ const valuesOf = (v) => Object.fromEntries((v?.parameterValues ?? []).map((p) => [String(p.id), String(p.value)]))
512
484
 
513
- // contextOf is how much of a conversation Cursor lets a model hold: what
514
- // the name says ("Claude Opus 5.5 1M"), else Cursor's default.
515
- function contextOf(name) {
516
- const m = /\b(\d+)M\b/.exec(name)
517
- return m ? Number(m[1]) * 1_000_000 : DEFAULT_CONTEXT
485
+ // defaultVariant is the variant the CLI picks for a model asked for by
486
+ // name: its default without Max Mode, else with it, else the first.
487
+ const defaultVariant = (m) => {
488
+ const vs = m?.variants ?? []
489
+ return vs.find((v) => v.isDefaultNonMaxConfig) ?? vs.find((v) => v.isDefaultMaxConfig) ?? vs[0]
518
490
  }
519
491
 
520
- const CAPACITY = /\(\s*\d+M\s*\)|\b\d+M\b/g
492
+ // needsMax is whether a variant is run in Max Mode only, as the CLI reads
493
+ // it: a model with no other mode, or a Max Mode variant.
494
+ const needsMax = (m, v) => m?.supportsNonMaxMode === false || v?.isMaxMode === true
521
495
 
522
- // withoutCapacity takes the context out of each name, which is shown apart;
523
- // a name that would then be another model's keeps it.
524
- function withoutCapacity(ms) {
525
- const strip = (n) => n.replace(CAPACITY, " ").split(/\s+/).filter(Boolean).join(" ")
526
- const names = new Map()
527
- const add = (n) => names.set(n.toLowerCase(), (names.get(n.toLowerCase()) ?? 0) + 1)
528
- for (const m of ms) {
529
- add(m.name)
530
- const s = strip(m.name)
531
- if (s !== m.name) add(s)
496
+ // effortsOf is the efforts a model takes, as pi's levels, each with the
497
+ // value Cursor's parameter has for it (extra-high is xhigh).
498
+ function effortsOf(defs) {
499
+ const pid = EFFORT_PARAMS.find((p) => defs[p]?.length)
500
+ // thinking alone, on or off: high and none
501
+ if (!pid && defs.thinking?.includes("true") && defs.thinking.includes("false")) return { pid: "thinking", levels: { none: "false", high: "true" } }
502
+ if (!pid) return { pid: "", levels: {} }
503
+ const levels = {}
504
+ for (const v of defs[pid]) {
505
+ const l = v === "extra-high" ? "xhigh" : v
506
+ if (LEVEL_RANK.includes(l)) levels[l] = v
507
+ }
508
+ return { pid, levels }
509
+ }
510
+
511
+ // served is a picker model's variants the account is served. With the
512
+ // usable list at hand (the ids cursor-agent models shows), a variant whose
513
+ // own id isn't in it is one the account hasn't got (Sonnet 4.6 at any effort
514
+ // but medium, say). A model the usable list has nothing of is the picker's
515
+ // alone (GLM-5.3), and is kept whole.
516
+ function served(m, ids) {
517
+ const vs = m.variants ?? []
518
+ if (!ids) return vs
519
+ const got = vs.filter((v) => !v.legacySlug || ids.has(v.legacySlug))
520
+ return got.some((v) => v.legacySlug) ? got : vs
521
+ }
522
+
523
+ // modelsOf is the picker's models as offered: one entry for each context
524
+ // size a model has, or one with Cursor's limit when it has none. A model
525
+ // served in part is offered at the sizes and efforts it is served at.
526
+ function modelsOf(picker, ids) {
527
+ const out = []
528
+ const used = new Set()
529
+ for (const m of picker) {
530
+ if (!m?.name || m.isHidden || m.isChatOnly || m.onlySupportsCmdK || m.supportsAgent === false || PICKER_SKIP.has(m.name)) continue
531
+ const defs = paramDefs(m)
532
+ const variants = served(m, ids)
533
+ const partial = variants.length < (m.variants ?? []).length
534
+ const has = (pid, value) => !partial || !variants.some((v) => pid in valuesOf(v)) || variants.some((v) => valuesOf(v)[pid] === value)
535
+ const def = defaultVariant({ variants })
536
+ const { pid, levels: all } = effortsOf(defs)
537
+ const levels = Object.fromEntries(Object.entries(all).filter(([, value]) => has(pid, value)))
538
+ const base = { run: m.name, title: clean(m.clientDisplayName || m.name), pid, levels, fast: !!defs.fast?.length, variants, def, partial, model: m }
539
+ const contexts = (defs.context ?? []).filter((c) => has("context", c))
540
+ if (contexts.length) {
541
+ for (const c of contexts) {
542
+ const id = m.name + "@" + c
543
+ if (used.has(id)) continue
544
+ used.add(id)
545
+ out.push({ ...base, id, name: base.title + " @ " + c, context: tokensOf(c) || DEFAULT_CONTEXT, size: c })
546
+ }
547
+ } else if (!used.has(m.name)) {
548
+ used.add(m.name)
549
+ const limit = needsMax(m, def) ? m.contextTokenLimitForMaxMode || m.contextTokenLimit : m.contextTokenLimit
550
+ out.push({ ...base, id: m.name === "default" ? "auto" : m.name, name: base.title, context: limit > 0 ? limit : DEFAULT_CONTEXT, size: "" })
551
+ }
532
552
  }
533
- return ms.map((m) => {
534
- const s = strip(m.name)
535
- return s === m.name || !s || names.get(s.toLowerCase()) > 1 ? m : { ...m, name: s }
536
- })
537
- }
538
-
539
- // offered is Cursor's list as it is offered: each family one model.
540
- function offered(raw) {
541
- return withoutCapacity(families(raw).map((f) => (f.variants.length === 1 ? { ...f.variants[0], efforts: [] } : familyModel(f))))
553
+ return out
542
554
  }
543
555
 
544
- function variantsIn(raw, model) {
545
- const f = families(raw).find((f) => f.id === model && f.variants.length > 1)
546
- return f ? byEffort(f) : null
556
+ // nearest is the variant of vs most like the parameters wanted, one at
557
+ // their context size before any other.
558
+ function nearest(vs, want) {
559
+ let best
560
+ let most = -1
561
+ for (const v of vs) {
562
+ const have = valuesOf(v)
563
+ const n = Object.keys(want).filter((k) => have[k] === want[k]).length + (have.context === want.context ? 100 : 0)
564
+ if (n > most) [best, most] = [v, n]
565
+ }
566
+ return best
547
567
  }
548
568
 
549
- function baseIn(raw, id) {
550
- const [family, effort] = splitID(id)
551
- if (family === id) return null
552
- const f = families(raw).find((f) => f.id === family && f.variants.length > 1 && f.variants.some((m) => m.id === id))
553
- return f ? [family, effort] : null
569
+ // pick is the variant a request runs as: the model's context size, the
570
+ // effort asked for (else the default's) and fast as asked, matched against
571
+ // Cursor's variants. With no variant that matches, the parameters go as
572
+ // they are, from the default's; of a model served in part, as the nearest
573
+ // variant the account has. seed is the variant an id of Cursor's own named
574
+ // (request), in the default's place.
575
+ function pick(entry, effort, fast, seed) {
576
+ const def = entry.def
577
+ const want = { ...valuesOf(seed ?? def) }
578
+ if (entry.size) want.context = entry.size
579
+ if (effort === "off") effort = "none"
580
+ if (entry.pid && entry.pid !== "thinking" && effort === "none" && !("none" in entry.levels) && "thinking" in want) {
581
+ // a Claude asked not to think: thinking off at its default effort
582
+ want.thinking = "false"
583
+ } else if (entry.pid) {
584
+ const at = effort ? fitEffort(effort, Object.keys(entry.levels)) : ""
585
+ if (at && entry.levels[at]) want[entry.pid] = entry.levels[at]
586
+ // a variant named says for itself whether it thinks
587
+ if (!seed && entry.pid !== "thinking" && "thinking" in want && at) want.thinking = "true"
588
+ }
589
+ // fast only when asked: it costs more
590
+ if (entry.fast) want.fast = fast ? "true" : "false"
591
+ const same = (v) => {
592
+ const have = valuesOf(v)
593
+ return Object.keys({ ...have, ...want }).every((k) => have[k] === undefined || want[k] === undefined || have[k] === want[k])
594
+ }
595
+ const v = entry.variants.find(same) ?? (seed ? branch(entry, want) : undefined) ?? (entry.partial ? nearest(entry.variants, want) : undefined)
596
+ const params = Object.entries(v ? { ...want, ...valuesOf(v) } : want).map(([id, value]) => ({ id, value }))
597
+ // no variant: Max Mode when the size is more than Cursor allows outside it
598
+ const maxMode = v ? needsMax(entry.model, v) : needsMax(entry.model, def) || tokensOf(entry.size) > (entry.model.contextTokenLimit || Infinity)
599
+ // the agent API takes the variant's own id (its legacySlug, as
600
+ // cursor-agent models lists it: claude-opus-5-5-high), not the picker's
601
+ // model name; the default's when no variant matched
602
+ const id = v?.legacySlug || def?.legacySlug || entry.run
603
+ return { id, params, maxMode }
604
+ }
605
+
606
+ // branch is, for an id of Cursor's own asked at an effort no variant has
607
+ // with the rest of its parameters (claude-opus-5-low at xhigh: Opus 5
608
+ // without thinking stops at high), the variant with those parameters at the
609
+ // nearest effort it has, not the default's id with parameters that
610
+ // contradict it (claude-opus-5-thinking-high with thinking=false). Where
611
+ // the default differs from what is asked in the effort alone, the effort
612
+ // goes as asked, as for a listed model.
613
+ function branch(entry, want) {
614
+ if (!entry.pid || entry.pid === "thinking") return undefined
615
+ const def = valuesOf(entry.def)
616
+ if (Object.keys(want).every((k) => k === entry.pid || k === "context" || def[k] === undefined || def[k] === want[k])) return undefined
617
+ const vs = entry.variants.filter((v) => {
618
+ const have = valuesOf(v)
619
+ return entry.pid in have && Object.keys(want).every((k) => k === entry.pid || have[k] === undefined || have[k] === want[k])
620
+ })
621
+ const level = (x) => (x === "extra-high" ? "xhigh" : x)
622
+ const at = fitEffort(level(want[entry.pid]), vs.map((v) => level(valuesOf(v)[entry.pid])))
623
+ return at ? vs.find((v) => level(valuesOf(v)[entry.pid]) === at) : undefined
554
624
  }
555
625
 
626
+ // fitEffort is the nearest effort a model has to the one asked for; max
627
+ // for ultra, and none only when asked.
556
628
  function fitEffort(want, levels) {
557
- if (want === "ultra" && !levels.includes(want)) want = "max"
629
+ if (want === "ultra") want = "max"
558
630
  if (!levels.length || levels.includes(want)) return want
559
- const at = EFFORT_RANK.indexOf(want)
560
- if (at < 0) return want
561
- let best = want
562
- let dist = EFFORT_RANK.length
631
+ const at = LEVEL_RANK.indexOf(want)
632
+ if (at < 0) return ""
633
+ let best = ""
634
+ let dist = LEVEL_RANK.length
563
635
  for (const l of levels) {
564
- const i = EFFORT_RANK.indexOf(l)
636
+ const i = LEVEL_RANK.indexOf(l)
565
637
  if (i < 0 || l === "none") continue
566
638
  const d = Math.abs(i - at)
567
639
  if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
@@ -569,142 +641,78 @@ function fitEffort(want, levels) {
569
641
  return best
570
642
  }
571
643
 
572
- // modelID is Cursor's id for a model offered, at the effort asked for: the
573
- // family's variant at it; else, for an effort between the ones it has, the
574
- // one Cursor picks by default; else the nearest. Fast, when asked, is from
575
- // the family's fast one. An id of Cursor's own at an effort is at the
576
- // effort asked for where Cursor has that id; else it goes as it is.
577
- function modelID(raw, model, effort, fast) {
578
- const base = baseIn(raw, model)
579
- if (base) {
580
- let [b, at] = base
581
- effort ||= at
582
- if (fast && !b.endsWith("-fast") && variantsIn(raw, b + "-fast")) b += "-fast"
583
- const vs = variantsIn(raw, b)
584
- return vs && effort && vs[effort] ? vs[effort] : model
585
- }
586
- if (fast && !model.endsWith("-fast") && variantsIn(raw, model + "-fast")) model += "-fast"
587
- const vs = variantsIn(raw, model)
588
- if (!vs) return model
589
- if (!effort) return vs[""]
590
- if (vs[effort]) return vs[effort]
591
- const levels = []
592
- let unnamed = true // the default has no effort of its own
593
- for (const l of EFFORT_RANK) {
594
- if (vs[l]) {
595
- levels.push(l)
596
- unnamed &&= vs[l] !== vs[""]
597
- }
598
- }
599
- if (!levels.length) return vs[""]
600
- const at = EFFORT_RANK.indexOf(effort)
601
- if (unnamed && at > EFFORT_RANK.indexOf(levels[0]) && at < EFFORT_RANK.indexOf(levels.at(-1))) return vs[""]
602
- return vs[fitEffort(effort, levels)]
603
- }
604
-
605
- // usable is the account's list: what `cursor-agent models` has
606
- // (GetUsableModels: its ids, names and the id each is run by, and whether
607
- // Cursor serves it in Max Mode), and the models Cursor's model picker
608
- // offers that it leaves out (parameterized). Kept a while, by token.
644
+ // usable is the account's list, kept a while, by token: Cursor's picker,
645
+ // less the variants the usable list (AgentService/GetUsableModels, what
646
+ // cursor-agent models shows) says the account hasn't got. A picker Cursor
647
+ // can't give is a failure: magpie keeps the list it had. A usable list it
648
+ // can't give leaves the picker as it is.
609
649
  const lists = new Map()
610
650
  async function usable(tok) {
611
651
  const h = createHash("sha256").update(tok).digest("hex")
612
652
  const had = lists.get(h)
613
653
  if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
614
- const [j, picker] = await Promise.all([
615
- unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 30_000),
616
- parameterized(tok),
654
+ const [j, ids] = await Promise.all([
655
+ unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
656
+ unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
657
+ (u) => {
658
+ const ids = new Set((u?.models ?? []).flatMap((m) => [m.modelId, m.displayModelId]).filter(Boolean))
659
+ return ids.size ? ids : null
660
+ },
661
+ () => null,
662
+ ),
617
663
  ])
618
- const raw = []
619
- for (const m of j?.models ?? []) {
620
- const id = m.displayModelId || m.modelId
621
- if (!id) continue
622
- // some names come with zero-width spaces and doubled ones
623
- let name = String(m.displayName || id).replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
624
- name = name.replace(/\(default\)$/, "").trim().replace(/\(current\)$/, "").trim()
625
- const run = m.modelId || id
626
- // Max Mode as the CLI sends it: the list's own say, else the picker's
627
- // (a model with no other mode, or a variant that is Max Mode's)
628
- const maxMode = m.maxMode === true || !!(picker.slugs.get(id)?.maxMode || picker.slugs.get(run)?.maxMode)
629
- raw.push({ id, name, context: contextOf(name), run, ...(maxMode ? { maxMode } : {}) })
630
- }
664
+ const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids)
631
665
  if (!raw.length) throw new Error("Cursor listed no models")
632
- for (const m of picker.extra(raw)) raw.push(m)
633
666
  lists.set(h, { at: Date.now(), raw })
634
667
  return raw
635
668
  }
636
669
 
637
- // left out of the CLI's picker as it leaves them out (model-service.ts)
638
- const PICKER_SKIP = new Set(["claude-4.5-haiku", "claude-4.5-haiku-thinking", "gemini-2.5-pro", "gemini-2.5-flash"])
639
-
640
- // needsMax is whether a variant of a picker's model is run in Max Mode
641
- // only, as the CLI reads it: a model with no other mode, or a Max Mode
642
- // variant.
643
- const needsMax = (m, v) => m?.supportsNonMaxMode === false || v?.isMaxMode === true
644
-
645
- // parameterized is Cursor's model picker (AiService/AvailableModels, as
646
- // the CLI asks for it): each id a model goes by (its name, legacy slugs,
647
- // each variant's slug) with the model and variant it is, and extra(raw),
648
- // the picker's models the usable list doesn't have, as entries of it (a
649
- // model added since, such as GLM-5.3, or one the account hasn't turned
650
- // on). A picker Cursor can't give is none.
651
- async function parameterized(tok) {
652
- let models = []
653
- try {
654
- const j = await unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 8_000)
655
- models = Array.isArray(j?.models) ? j.models : []
656
- } catch {}
657
- const slugs = new Map()
658
- const namesOf = (m) => {
659
- const vs = m.variants ?? []
660
- return [m.name, m.serverModelName, ...(m.legacySlugs ?? []), ...(m.idAliases ?? []), ...vs.flatMap((v) => [v.legacySlug, v.variantStringRepresentation])].filter(Boolean)
661
- }
662
- for (const m of models) {
663
- const vs = m.variants ?? []
664
- for (const v of vs) for (const s of [v.legacySlug, v.variantStringRepresentation]) if (s && !slugs.has(s)) slugs.set(s, { maxMode: needsMax(m, v) })
665
- for (const s of [m.name, m.serverModelName, ...(m.legacySlugs ?? [])]) if (s && !slugs.has(s)) slugs.set(s, { maxMode: needsMax(m, defaultVariant(m)) })
666
- }
667
- const extra = (raw) => {
668
- const have = new Set(raw.flatMap((r) => [r.id, r.run]))
669
- const out = []
670
- for (const m of models) {
671
- if (!m?.name || m.isHidden || m.isChatOnly || m.onlySupportsCmdK || m.supportsAgent === false || PICKER_SKIP.has(m.name)) continue
672
- if (namesOf(m).some((s) => have.has(s))) continue
673
- const title = clean(m.clientDisplayName || m.name)
674
- const params = (v) => (v?.parameterValues ?? []).map((p) => ({ id: String(p.id ?? ""), value: String(p.value ?? "") })).filter((p) => p.id)
675
- const entry = (id, name, v) => {
676
- const maxMode = needsMax(m, v)
677
- const limit = (maxMode && m.contextTokenLimitForMaxMode) || m.contextTokenLimit
678
- return { id, name, context: limit > 0 ? limit : contextOf(name), run: m.name, params: params(v), ...(maxMode ? { maxMode } : {}) }
679
- }
680
- const named = (m.variants ?? []).filter((v) => v.legacySlug)
681
- if (named.length) {
682
- for (const v of named) {
683
- if (have.has(v.legacySlug)) continue
684
- have.add(v.legacySlug)
685
- const label = clean(v.displayNameOutsidePicker || [title, v.displayName].filter(Boolean).join(" "))
686
- out.push(entry(v.legacySlug, label, v))
687
- }
688
- } else if (!have.has(m.name)) {
689
- have.add(m.name)
690
- out.push(entry(m.name, title, defaultVariant(m)))
691
- }
692
- }
693
- return out
670
+ // named is the model and variant an id the list doesn't have stands for,
671
+ // when it is one of Cursor's own, as the CLI reads one and as 0.1.x listed
672
+ // them, so a model picked before the list changed still runs: a variant's
673
+ // own id (claude-opus-5-5-high) is that variant; a family of them, the id
674
+ // 0.1.x listed (claude-opus-5-5, claude-opus-5-5-fast, cursor-grok-4.6,
675
+ // or with an effort the account lacks), is the family's variant nearest
676
+ // the default, fast when the family is and at the id's effort; the picker's
677
+ // name for a model, or a legacy slug of it, is its default. Each at the
678
+ // variant's context size, the default's when the id doesn't say.
679
+ function named(raw, id) {
680
+ const models = raw.filter((e, i) => raw.findIndex((x) => x.model === e.model) === i)
681
+ const at = (e, v) => raw.find((x) => x.model === e.model && x.size === (valuesOf(v).context ?? "")) ?? e
682
+ const as = (e, vs, effort) => {
683
+ const seed = nearest(vs, valuesOf(e.def))
684
+ return { entry: at(e, seed), seed, effort, fast: valuesOf(seed).fast === "true" }
685
+ }
686
+ for (const e of models) {
687
+ const vs = e.variants.filter((v) => v.legacySlug === id)
688
+ if (vs.length) return as(e, vs, "")
694
689
  }
695
- return { slugs, extra }
690
+ const [family, effort] = splitID(id)
691
+ for (const e of models) {
692
+ const vs = e.variants.filter((v) => v.legacySlug && splitID(v.legacySlug)[0] === family)
693
+ if (vs.length) return as(e, vs, effort)
694
+ }
695
+ for (const e of models) {
696
+ const m = e.model
697
+ if ([m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? [])].includes(id)) return { entry: at(e, e.def), seed: undefined, effort: "", fast: false }
698
+ }
699
+ return null
696
700
  }
697
701
 
698
- // defaultVariant is the variant the CLI picks for a model asked for by
699
- // name: its default without Max Mode, else with it, else the first.
700
- const defaultVariant = (m) => {
701
- const vs = m?.variants ?? []
702
- return vs.find((v) => v.isDefaultNonMaxConfig) ?? vs.find((v) => v.isDefaultMaxConfig) ?? vs[0]
702
+ // request is how a chat's model is asked of Cursor: the variant's own id,
703
+ // its parameters and its mode. An id the list doesn't have that is one of
704
+ // Cursor's own goes as the variant it names, at the effort asked for, fast
705
+ // when it says so or the request does; any other goes as it is named.
706
+ function request(raw, model, effort, fast) {
707
+ const entry = raw.find((m) => m.id === model)
708
+ if (entry) return pick(entry, effort, fast)
709
+ const old = named(raw, model)
710
+ if (old) return pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
711
+ return { id: model === "auto" ? "default" : model, params: [], maxMode: false }
703
712
  }
704
713
 
705
- const clean = (s) => String(s ?? "").replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
706
-
707
714
  function runtimeModel(m) {
715
+ const efforts = LEVEL_RANK.filter((l) => l in m.levels)
708
716
  return {
709
717
  id: m.id,
710
718
  providerID: ID,
@@ -717,7 +725,7 @@ function runtimeModel(m) {
717
725
  limit: { context: m.context || DEFAULT_CONTEXT, output: 0 },
718
726
  capabilities: {
719
727
  temperature: false,
720
- reasoning: m.efforts.length > 0,
728
+ reasoning: efforts.length > 0,
721
729
  attachment: true,
722
730
  toolcall: true,
723
731
  input: { text: true, image: true, audio: false, video: false, pdf: false },
@@ -725,10 +733,11 @@ function runtimeModel(m) {
725
733
  interleaved: false,
726
734
  },
727
735
  release_date: "",
728
- variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])),
736
+ variants: Object.fromEntries(efforts.map((e) => [e, { reasoningEffort: e }])),
729
737
  }
730
738
  }
731
739
 
740
+
732
741
  // ---- protobuf, by hand ------------------------------------------------------------
733
742
 
734
743
  class PB {
@@ -1846,13 +1855,14 @@ async function answerOf(auth, chat, signal, session = "") {
1846
1855
  try {
1847
1856
  raw = await usable(tok)
1848
1857
  } catch {}
1849
- const fast = chat.service_tier === "priority" || chat.service_tier === "fast"
1850
- let id = modelID(raw, model, chat.reasoning_effort ?? "", fast)
1851
- const entry = raw.find((m) => m.id === id)
1852
- id = entry?.run ?? id
1853
- if (id === "auto") id = "default" // Cursor's pick, which its API calls default
1854
- const params = entry?.params ?? []
1855
- let maxMode = !!entry?.maxMode || maxOnly.has(id)
1858
+ // fast only when asked: a service_tier of priority or fast is on; any
1859
+ // other tier, or none, is off, unless the id is a fast one of Cursor's own
1860
+ const tier = chat.service_tier
1861
+ const fast = tier === "priority" || tier === "fast" ? true : tier ? false : undefined
1862
+ const req = request(raw, model, chat.reasoning_effort ?? "", fast)
1863
+ const id = req.id
1864
+ const params = req.params
1865
+ let maxMode = req.maxMode || maxOnly.has(id)
1856
1866
  const tools = toolsOf(chat)
1857
1867
  const conv = conversation(chat, tools)
1858
1868
  const convID = conversationID(chat, session)
@@ -2003,8 +2013,8 @@ export async function CursorAuthPlugin() {
2003
2013
  },
2004
2014
  }
2005
2015
  },
2006
- // the account's list, each family of Cursor's ids one model with the
2007
- // efforts there are
2016
+ // the account's list: each picker model once for each context size,
2017
+ // with that window
2008
2018
  provider: {
2009
2019
  id: ID,
2010
2020
  async models(provider, { auth } = {}) {
@@ -2012,11 +2022,11 @@ export async function CursorAuthPlugin() {
2012
2022
  // a list Cursor couldn't give is a failure, not the few models
2013
2023
  // configured: magpie keeps the list it had
2014
2024
  const tok = await tokenOf(auth)
2015
- return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
2025
+ return Object.fromEntries((await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
2016
2026
  },
2017
2027
  },
2018
2028
  }
2019
2029
  }
2020
2030
 
2021
2031
  // for tests
2022
- export const _internal = { STEP_WAIT, stepUsage, ledger, owed, flights, misses, unreadable, catalog, usable, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, splitID, families, byEffort, offered, modelID, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
2032
+ export const _internal = { 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.1.11",
3
+ "version": "0.2.1",
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",