@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.
- package/README.md +35 -18
- package/index.mjs +253 -243
- 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
|
-
(`
|
|
78
|
-
|
|
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
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
is
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Max Mode, as the CLI turns it on
|
|
93
|
-
|
|
94
|
-
|
|
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 //
|
|
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
|
|
355
|
-
//
|
|
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
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
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
|
|
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
|
|
434
|
-
//
|
|
435
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
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
|
-
//
|
|
487
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
return
|
|
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
|
-
|
|
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
|
-
//
|
|
523
|
-
//
|
|
524
|
-
function
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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"
|
|
609
|
+
if (want === "ultra") want = "max"
|
|
558
610
|
if (!levels.length || levels.includes(want)) return want
|
|
559
|
-
const at =
|
|
560
|
-
if (at < 0) return
|
|
561
|
-
let best =
|
|
562
|
-
let dist =
|
|
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 =
|
|
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
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
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,
|
|
615
|
-
unary(API, "
|
|
616
|
-
|
|
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
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
// variant
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
const
|
|
658
|
-
const
|
|
659
|
-
const vs =
|
|
660
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
const
|
|
1835
|
-
|
|
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
|
|
1987
|
-
//
|
|
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(
|
|
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,
|
|
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.
|
|
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",
|