@magpie-community/opencode-cursor-auth 0.1.10 → 0.2.0

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 +253 -243
  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,162 @@ 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))))
542
- }
543
-
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
553
+ return out
547
554
  }
548
555
 
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
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
554
567
  }
555
568
 
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) ?? (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
+ // fitEffort is the nearest effort a model has to the one asked for; max
607
+ // for ultra, and none only when asked.
556
608
  function fitEffort(want, levels) {
557
- if (want === "ultra" && !levels.includes(want)) want = "max"
609
+ if (want === "ultra") want = "max"
558
610
  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
611
+ const at = LEVEL_RANK.indexOf(want)
612
+ if (at < 0) return ""
613
+ let best = ""
614
+ let dist = LEVEL_RANK.length
563
615
  for (const l of levels) {
564
- const i = EFFORT_RANK.indexOf(l)
616
+ const i = LEVEL_RANK.indexOf(l)
565
617
  if (i < 0 || l === "none") continue
566
618
  const d = Math.abs(i - at)
567
619
  if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
@@ -569,142 +621,78 @@ function fitEffort(want, levels) {
569
621
  return best
570
622
  }
571
623
 
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.
624
+ // usable is the account's list, kept a while, by token: Cursor's picker,
625
+ // less the variants the usable list (AgentService/GetUsableModels, what
626
+ // cursor-agent models shows) says the account hasn't got. A picker Cursor
627
+ // can't give is a failure: magpie keeps the list it had. A usable list it
628
+ // can't give leaves the picker as it is.
609
629
  const lists = new Map()
610
630
  async function usable(tok) {
611
631
  const h = createHash("sha256").update(tok).digest("hex")
612
632
  const had = lists.get(h)
613
633
  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),
634
+ const [j, ids] = await Promise.all([
635
+ unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
636
+ unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
637
+ (u) => {
638
+ const ids = new Set((u?.models ?? []).flatMap((m) => [m.modelId, m.displayModelId]).filter(Boolean))
639
+ return ids.size ? ids : null
640
+ },
641
+ () => null,
642
+ ),
617
643
  ])
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
- }
644
+ const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids)
631
645
  if (!raw.length) throw new Error("Cursor listed no models")
632
- for (const m of picker.extra(raw)) raw.push(m)
633
646
  lists.set(h, { at: Date.now(), raw })
634
647
  return raw
635
648
  }
636
649
 
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
650
+ // named is the model and variant an id the list doesn't have stands for,
651
+ // when it is one of Cursor's own, as the CLI reads one and as 0.1.x listed
652
+ // them, so a model picked before the list changed still runs: a variant's
653
+ // own id (claude-opus-5-5-high) is that variant; a family of them, the id
654
+ // 0.1.x listed (claude-opus-5-5, claude-opus-5-5-fast, cursor-grok-4.6,
655
+ // or with an effort the account lacks), is the family's variant nearest
656
+ // the default, fast when the family is and at the id's effort; the picker's
657
+ // name for a model, or a legacy slug of it, is its default. Each at the
658
+ // variant's context size, the default's when the id doesn't say.
659
+ function named(raw, id) {
660
+ const models = raw.filter((e, i) => raw.findIndex((x) => x.model === e.model) === i)
661
+ const at = (e, v) => raw.find((x) => x.model === e.model && x.size === (valuesOf(v).context ?? "")) ?? e
662
+ const as = (e, vs, effort) => {
663
+ const seed = nearest(vs, valuesOf(e.def))
664
+ return { entry: at(e, seed), seed, effort, fast: valuesOf(seed).fast === "true" }
665
+ }
666
+ for (const e of models) {
667
+ const vs = e.variants.filter((v) => v.legacySlug === id)
668
+ if (vs.length) return as(e, vs, "")
669
+ }
670
+ const [family, effort] = splitID(id)
671
+ for (const e of models) {
672
+ const vs = e.variants.filter((v) => v.legacySlug && splitID(v.legacySlug)[0] === family)
673
+ if (vs.length) return as(e, vs, effort)
694
674
  }
695
- return { slugs, extra }
675
+ for (const e of models) {
676
+ const m = e.model
677
+ if ([m.name, ...(m.legacySlugs ?? []), ...(m.idAliases ?? [])].includes(id)) return { entry: at(e, e.def), seed: undefined, effort: "", fast: false }
678
+ }
679
+ return null
696
680
  }
697
681
 
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]
682
+ // request is how a chat's model is asked of Cursor: the variant's own id,
683
+ // its parameters and its mode. An id the list doesn't have that is one of
684
+ // Cursor's own goes as the variant it names, at the effort asked for, fast
685
+ // when it says so or the request does; any other goes as it is named.
686
+ function request(raw, model, effort, fast) {
687
+ const entry = raw.find((m) => m.id === model)
688
+ if (entry) return pick(entry, effort, fast)
689
+ const old = named(raw, model)
690
+ if (old) return pick(old.entry, effort || old.effort, fast || old.fast, old.seed)
691
+ return { id: model === "auto" ? "default" : model, params: [], maxMode: false }
703
692
  }
704
693
 
705
- const clean = (s) => String(s ?? "").replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
706
-
707
694
  function runtimeModel(m) {
695
+ const efforts = LEVEL_RANK.filter((l) => l in m.levels)
708
696
  return {
709
697
  id: m.id,
710
698
  providerID: ID,
@@ -717,7 +705,7 @@ function runtimeModel(m) {
717
705
  limit: { context: m.context || DEFAULT_CONTEXT, output: 0 },
718
706
  capabilities: {
719
707
  temperature: false,
720
- reasoning: m.efforts.length > 0,
708
+ reasoning: efforts.length > 0,
721
709
  attachment: true,
722
710
  toolcall: true,
723
711
  input: { text: true, image: true, audio: false, video: false, pdf: false },
@@ -725,10 +713,11 @@ function runtimeModel(m) {
725
713
  interleaved: false,
726
714
  },
727
715
  release_date: "",
728
- variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])),
716
+ variants: Object.fromEntries(efforts.map((e) => [e, { reasoningEffort: e }])),
729
717
  }
730
718
  }
731
719
 
720
+
732
721
  // ---- protobuf, by hand ------------------------------------------------------------
733
722
 
734
723
  class PB {
@@ -954,6 +943,26 @@ function statusOf(status, code, msg) {
954
943
  const textOf = (c) =>
955
944
  typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : ""
956
945
 
946
+ // joinSplitCalls joins an assistant message onto the assistant message
947
+ // before it when that one made tool calls: Anthropic takes consecutive
948
+ // assistant messages as one turn, and an agent can send a turn's parallel
949
+ // calls split over two of them, answered together after (yetone/magpie#1275).
950
+ // Answered at the second message, the first calls would be marked
951
+ // interrupted and their real results dropped. msgs is left as it is.
952
+ function joinSplitCalls(msgs) {
953
+ const out = []
954
+ for (const m of msgs) {
955
+ const prev = out.at(-1)
956
+ if (m?.role === "assistant" && prev?.role === "assistant" && prev.tool_calls?.length) {
957
+ const content = [textOf(prev.content), textOf(m.content)].filter(Boolean).join("\n\n")
958
+ out[out.length - 1] = { ...prev, content, tool_calls: [...prev.tool_calls, ...(m.tool_calls ?? [])] }
959
+ continue
960
+ }
961
+ out.push(m)
962
+ }
963
+ return out
964
+ }
965
+
957
966
  function parseArgs(s) {
958
967
  if (s && typeof s === "object") return s
959
968
  try {
@@ -1035,7 +1044,7 @@ function conversation(chat, tools) {
1035
1044
  if (results.length) add({ role: "tool", content: results })
1036
1045
  results = []
1037
1046
  }
1038
- for (const m of chat.messages ?? []) {
1047
+ for (const m of joinSplitCalls(chat.messages ?? [])) {
1039
1048
  if (m.role === "system" || m.role === "developer") continue
1040
1049
  if (m.role === "tool") {
1041
1050
  const id = m.tool_call_id ?? ""
@@ -1826,13 +1835,14 @@ async function answerOf(auth, chat, signal, session = "") {
1826
1835
  try {
1827
1836
  raw = await usable(tok)
1828
1837
  } catch {}
1829
- const fast = chat.service_tier === "priority" || chat.service_tier === "fast"
1830
- let id = modelID(raw, model, chat.reasoning_effort ?? "", fast)
1831
- const entry = raw.find((m) => m.id === id)
1832
- id = entry?.run ?? id
1833
- if (id === "auto") id = "default" // Cursor's pick, which its API calls default
1834
- const params = entry?.params ?? []
1835
- let maxMode = !!entry?.maxMode || maxOnly.has(id)
1838
+ // fast only when asked: a service_tier of priority or fast is on; any
1839
+ // other tier, or none, is off, unless the id is a fast one of Cursor's own
1840
+ const tier = chat.service_tier
1841
+ const fast = tier === "priority" || tier === "fast" ? true : tier ? false : undefined
1842
+ const req = request(raw, model, chat.reasoning_effort ?? "", fast)
1843
+ const id = req.id
1844
+ const params = req.params
1845
+ let maxMode = req.maxMode || maxOnly.has(id)
1836
1846
  const tools = toolsOf(chat)
1837
1847
  const conv = conversation(chat, tools)
1838
1848
  const convID = conversationID(chat, session)
@@ -1983,8 +1993,8 @@ export async function CursorAuthPlugin() {
1983
1993
  },
1984
1994
  }
1985
1995
  },
1986
- // the account's list, each family of Cursor's ids one model with the
1987
- // efforts there are
1996
+ // the account's list: each picker model once for each context size,
1997
+ // with that window
1988
1998
  provider: {
1989
1999
  id: ID,
1990
2000
  async models(provider, { auth } = {}) {
@@ -1992,11 +2002,11 @@ export async function CursorAuthPlugin() {
1992
2002
  // a list Cursor couldn't give is a failure, not the few models
1993
2003
  // configured: magpie keeps the list it had
1994
2004
  const tok = await tokenOf(auth)
1995
- return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
2005
+ return Object.fromEntries((await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
1996
2006
  },
1997
2007
  },
1998
2008
  }
1999
2009
  }
2000
2010
 
2001
2011
  // for tests
2002
- 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 }
2012
+ 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.10",
3
+ "version": "0.2.0",
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",