@magpie-community/opencode-cursor-auth 0.1.5 → 0.1.7

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 +20 -1
  2. package/index.mjs +213 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,8 +43,18 @@ plugin's `fetch` answers them on Cursor's agent API:
43
43
  system prompt; the model calls them through Cursor's `CallDynamicTool`,
44
44
  and the calls come back as chat completion tool calls. Cursor's own tools
45
45
  (shell, file reads, edits) are never run.
46
+ - A conversation's Runs share one `conversation_id`, so Cursor sends them
47
+ to the machine that has the prompt cached (Grok on Cursor caches by
48
+ machine). It is made from what names the session — the request's
49
+ `prompt_cache_key` (Codex's thread id), else the session the
50
+ `chat.headers` hook is told — and the conversation's first user message,
51
+ so subagents under one session are conversations of their own. With
52
+ nothing naming the session, each Run has a new one.
46
53
  - Text, thinking (`reasoning_content`) and token usage come back streamed
47
- or not. Failures keep their statuses: 401 to sign in again, 429 at the
54
+ or not. Cursor's `input_tokens` counts the cached prompt too: the cache
55
+ read and written is taken out of it, and given as `cached_tokens` and
56
+ `cache_write_tokens`, so it is counted once; its reasoning tokens are
57
+ `reasoning_tokens`. Failures keep their statuses: 401 to sign in again, 429 at the
48
58
  usage limit, 400 for a prompt too long, 403 for a region refusal.
49
59
 
50
60
  ## Models
@@ -60,6 +70,15 @@ The `config` hook declares `auto` (Cursor's pick). Once signed in, the
60
70
  is. `-fast` and `-thinking` models stay models of their own; a
61
71
  `service_tier` of `priority` picks the fast one.
62
72
  - The context window is 1M for a model whose name says 1M, else 200K.
73
+ - Cursor's model picker (`AiService/AvailableModels`, as the CLI asks for
74
+ it) adds the models it offers that the usable list leaves out (GLM-5.3,
75
+ GLM-5.3 Flash), run with the picker variant's parameters; hidden and
76
+ Tab-only models stay out.
77
+ - A model Cursor serves only in Max Mode (the usable list's `maxMode`, a
78
+ picker model with no other mode, or a Max Mode variant) is asked for in
79
+ Max Mode, as the CLI turns it on for one. A model neither says it of is
80
+ asked again in Max Mode when Cursor answers "Max Mode Required", and so
81
+ from then on.
63
82
 
64
83
  ## Not included
65
84
 
package/index.mjs CHANGED
@@ -602,14 +602,19 @@ function modelID(raw, model, effort, fast) {
602
602
  return vs[fitEffort(effort, levels)]
603
603
  }
604
604
 
605
- // usable is the account's list, as `cursor-agent models` has it: its ids,
606
- // names and the id each is run by; kept a while, by token.
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.
607
609
  const lists = new Map()
608
610
  async function usable(tok) {
609
611
  const h = createHash("sha256").update(tok).digest("hex")
610
612
  const had = lists.get(h)
611
613
  if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
612
- const j = await unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 30_000)
614
+ const [j, picker] = await Promise.all([
615
+ unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 30_000),
616
+ parameterized(tok),
617
+ ])
613
618
  const raw = []
614
619
  for (const m of j?.models ?? []) {
615
620
  const id = m.displayModelId || m.modelId
@@ -617,13 +622,88 @@ async function usable(tok) {
617
622
  // some names come with zero-width spaces and doubled ones
618
623
  let name = String(m.displayName || id).replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
619
624
  name = name.replace(/\(default\)$/, "").trim().replace(/\(current\)$/, "").trim()
620
- raw.push({ id, name, context: contextOf(name), run: m.modelId || id })
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 } : {}) })
621
630
  }
622
631
  if (!raw.length) throw new Error("Cursor listed no models")
632
+ for (const m of picker.extra(raw)) raw.push(m)
623
633
  lists.set(h, { at: Date.now(), raw })
624
634
  return raw
625
635
  }
626
636
 
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
694
+ }
695
+ return { slugs, extra }
696
+ }
697
+
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]
703
+ }
704
+
705
+ const clean = (s) => String(s ?? "").replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
706
+
627
707
  function runtimeModel(m) {
628
708
  return {
629
709
  id: m.id,
@@ -1020,10 +1100,75 @@ function toolDef(t) {
1020
1100
 
1021
1101
  const env = () => pb().str(1, GOOS).str(2, tmpdir()).str(10, "UTC")
1022
1102
 
1103
+ // SESSION carries the session magpie (or OpenCode) names a request's
1104
+ // conversation by, from the chat.headers hook to the loader's fetch. It
1105
+ // goes no further: nothing of the request's headers is sent to Cursor.
1106
+ const SESSION = "x-magpie-cursor-session"
1107
+
1108
+ // sessionOf is the session a chat.headers hook is told, "" when it names
1109
+ // none. magpie, with no session header from the agent, makes one of the
1110
+ // conversation's first user message ("magpie-" and 24 hex digits): that
1111
+ // names no session, and is taken as none, as the built-in's
1112
+ // nativeSessionOf takes no such header as none.
1113
+ function sessionOf(input) {
1114
+ const s = String(input?.sessionID ?? "").trim()
1115
+ if (!s || /^magpie-[0-9a-f]{24}$/.test(s)) return ""
1116
+ return s.slice(0, 128)
1117
+ }
1118
+
1119
+ // uuidOf is a version 4 UUID made of a hash's first 16 bytes.
1120
+ function uuidOf(b) {
1121
+ b = Buffer.from(b.subarray(0, 16))
1122
+ b[6] = (b[6] & 0x0f) | 0x40
1123
+ b[8] = (b[8] & 0x3f) | 0x80
1124
+ const h = b.toString("hex")
1125
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
1126
+ }
1127
+
1128
+ // conversationID is the AgentRunRequest's conversation_id for a
1129
+ // conversation, as the built-in's cursorConversation makes it (magpie
1130
+ // #498): the same on every request of it, so that Cursor's backend keeps
1131
+ // sending it to the machine that has its prompt cached — Grok on Cursor
1132
+ // caches by machine. Cursor's own client keeps one conversationId per
1133
+ // agent session, so it is made from what names the session — the
1134
+ // client's prompt_cache_key (Codex's thread id), else the session magpie
1135
+ // or OpenCode names — together with the conversation's first user
1136
+ // message, which every later request repeats: subagents running at once
1137
+ // under one session (Claude Code's Task agents share its session id) are
1138
+ // separate conversations to Cursor, as they are to its own client. With
1139
+ // nothing naming the session it is "", and each Run gets a new id as
1140
+ // before: a first message alone ("hi") would put strangers' conversations
1141
+ // under one id.
1142
+ function conversationID(chat, session) {
1143
+ const key = (typeof chat?.prompt_cache_key === "string" ? chat.prompt_cache_key.trim() : "") || session || ""
1144
+ if (!key) return ""
1145
+ const msgs = Array.isArray(chat?.messages) ? chat.messages : []
1146
+ const first = msgs.find((m) => m?.role === "user") ?? msgs[0]
1147
+ const sum = createHash("sha256")
1148
+ .update("cursor conversation\0" + key + "\0" + (first === undefined ? "" : JSON.stringify(first)))
1149
+ .digest()
1150
+ return uuidOf(sum)
1151
+ }
1152
+
1153
+ // usageOf is a TurnEndedUpdate as magpie counts usage, as the built-in's
1154
+ // cursorUsage has it (magpie #498). Its input_tokens is the whole prompt,
1155
+ // what was read from the cache and written to it included, as Cursor's
1156
+ // own client has it (it takes both out to get the prompt's uncached
1157
+ // rest); input is that rest, the cache added back for prompt_tokens. Its
1158
+ // reasoning_tokens is kept as reasoning.
1159
+ function usageOf(uf) {
1160
+ const [inp, cr, cw] = [pbNum(uf, 1), pbNum(uf, 3), pbNum(uf, 4)]
1161
+ return { input: Math.max(inp - cr - cw, 0), output: pbNum(uf, 2), cacheRead: cr, cacheWrite: cw, reasoning: pbNum(uf, 5) }
1162
+ }
1163
+
1023
1164
  // buildRun is the Run's first message, an AgentClientMessage with its
1024
1165
  // run_request, and the blobs it names. The conversation state is the
1025
1166
  // messages and one turn, which the server wants there to sample at all.
1026
- function buildRun(msgs, lastUser, tools, model) {
1167
+ // conv is the conversation_id (conversationID), a new one when "". The
1168
+ // model goes as the CLI sends it: in Max Mode when maxMode is set (Cursor
1169
+ // refuses a model it serves only that way otherwise: "Max Mode
1170
+ // Required"), with the picker variant's parameters when it has them.
1171
+ function buildRun(msgs, lastUser, tools, model, conv = "", { maxMode = false, params = [] } = {}) {
1027
1172
  const blobs = new Map()
1028
1173
  const put = (b) => {
1029
1174
  const id = createHash("sha256").update(b).digest()
@@ -1044,13 +1189,22 @@ function buildRun(msgs, lastUser, tools, model) {
1044
1189
  mcp.bytes(1, d)
1045
1190
  }
1046
1191
  const action = pb().bytes(2, pb().bytes(2, rc)) // resume_action
1192
+ // ModelDetails: model_id, display_model_id, display_name, max_mode (7);
1193
+ // RequestedModel: model_id, max_mode (2), parameters (3)
1194
+ const details = pb().str(1, model).str(3, model).str(4, model)
1195
+ const requested = pb().str(1, model)
1196
+ if (maxMode) {
1197
+ details.varint(7, 1)
1198
+ requested.varint(2, 1)
1199
+ }
1200
+ for (const p of params) requested.bytes(3, pb().str(1, p.id).str(2, p.value))
1047
1201
  const rr = pb()
1048
1202
  .bytes(1, state)
1049
1203
  .bytes(2, action)
1050
- .bytes(3, pb().str(1, model).str(3, model).str(4, model))
1204
+ .bytes(3, details)
1051
1205
  .bytes(4, mcp)
1052
- .str(5, randomUUID())
1053
- .bytes(9, pb().str(1, model))
1206
+ .str(5, conv || randomUUID())
1207
+ .bytes(9, requested)
1054
1208
  .varint(19, 1) // inline images
1055
1209
  return { first: pb().bytes(1, rr).done(), blobs }
1056
1210
  }
@@ -1117,8 +1271,8 @@ function open(base, headers, signal) {
1117
1271
 
1118
1272
  // runOnce is one Run of the chat on the agent API at base: an async
1119
1273
  // iterator of the answer's pieces, or the error it failed with before any.
1120
- async function runOnce({ tok, base, id, tools, conv, signal }) {
1121
- const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id)
1274
+ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
1275
+ const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, convID, { maxMode, params })
1122
1276
  const o = await open(
1123
1277
  base,
1124
1278
  {
@@ -1191,10 +1345,12 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1191
1345
  let calls = 0
1192
1346
  let listed = 0
1193
1347
  let said = 0
1194
- let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1348
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1195
1349
  const finish = () => {
1196
1350
  if (!usage.output) usage.output = Math.floor((said + 3) / 4)
1197
- if (!usage.input) usage.input = estimate
1351
+ // a guess only when Cursor counted nothing: a prompt read whole from
1352
+ // the cache leaves no uncached rest, and is no reason to guess
1353
+ if (!usage.input && !usage.cacheRead && !usage.cacheWrite) usage.input = estimate
1198
1354
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
1199
1355
  return {
1200
1356
  stop: calls > 0 ? "tool_calls" : "stop",
@@ -1203,6 +1359,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1203
1359
  completion_tokens: usage.output,
1204
1360
  total_tokens: prompt + usage.output,
1205
1361
  prompt_tokens_details: { cached_tokens: usage.cacheRead, cache_write_tokens: usage.cacheWrite },
1362
+ ...(usage.reasoning ? { completion_tokens_details: { reasoning_tokens: usage.reasoning } } : {}),
1206
1363
  },
1207
1364
  }
1208
1365
  }
@@ -1241,7 +1398,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1241
1398
  break
1242
1399
  }
1243
1400
  case 14: // turn ended, with what it used
1244
- usage = { input: pbNum(uf, 1), output: pbNum(uf, 2), cacheRead: pbNum(uf, 3), cacheWrite: pbNum(uf, 4) }
1401
+ usage = usageOf(uf)
1245
1402
  if (calls === 0) {
1246
1403
  yield finish()
1247
1404
  return
@@ -1371,11 +1528,19 @@ function kept(res) {
1371
1528
  }
1372
1529
 
1373
1530
  // answer runs a chat completion on Cursor, the account kept (errorResponse).
1374
- const answer = async (auth, chat, signal) => kept(await answerOf(auth, chat, signal))
1531
+ const answer = async (auth, chat, signal, session) => kept(await answerOf(auth, chat, signal, session))
1532
+
1533
+ // maxOnly are the ids Cursor said it serves in Max Mode only.
1534
+ const maxOnly = new Set()
1535
+
1536
+ // maxRequired is Cursor's refusal of a model asked for without Max Mode
1537
+ // (the CLI's MAX_MODE_REQUIRED): "Max Mode Required: The model "x"
1538
+ // requires Max Mode to be enabled. …"
1539
+ const maxRequired = (msg) => /max[ _]mode[ _]required|requires max mode/i.test(String(msg ?? ""))
1375
1540
 
1376
1541
  // answerOf runs a chat completion on Cursor and answers it as one, streamed
1377
1542
  // or not.
1378
- async function answerOf(auth, chat, signal) {
1543
+ async function answerOf(auth, chat, signal, session = "") {
1379
1544
  let tok
1380
1545
  try {
1381
1546
  tok = await tokenOf(auth)
@@ -1389,17 +1554,34 @@ async function answerOf(auth, chat, signal) {
1389
1554
  } catch {}
1390
1555
  const fast = chat.service_tier === "priority" || chat.service_tier === "fast"
1391
1556
  let id = modelID(raw, model, chat.reasoning_effort ?? "", fast)
1392
- id = raw.find((m) => m.id === id)?.run ?? id
1557
+ const entry = raw.find((m) => m.id === id)
1558
+ id = entry?.run ?? id
1393
1559
  if (id === "auto") id = "default" // Cursor's pick, which its API calls default
1560
+ const params = entry?.params ?? []
1561
+ let maxMode = !!entry?.maxMode || maxOnly.has(id)
1394
1562
  const tools = toolsOf(chat)
1395
1563
  const conv = conversation(chat, tools)
1396
- const base = await agentURL(tok, false)
1397
- let r = await runOnce({ tok, base, id, tools, conv, signal })
1564
+ const convID = conversationID(chat, session)
1565
+ let base = await agentURL(tok, false)
1566
+ const once = () => runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params })
1567
+ let r = await once()
1398
1568
  if (r.error && regional(r.error.message)) {
1399
1569
  // the team moved, or the config was kept from before: once more with
1400
1570
  // what the config says now
1401
1571
  const fresh = await agentURL(tok, true)
1402
- if (fresh !== base) r = await runOnce({ tok, base: fresh, id, tools, conv, signal })
1572
+ if (fresh !== base) {
1573
+ base = fresh
1574
+ r = await once()
1575
+ }
1576
+ }
1577
+ if (r.error && !maxMode && maxRequired(r.error.message)) {
1578
+ // a model Cursor serves only in Max Mode that neither list said so of:
1579
+ // in Max Mode, as the CLI turns it on for such a model, and so from
1580
+ // now on. An account Max Mode isn't open to is answered as Cursor
1581
+ // answers that.
1582
+ maxMode = true
1583
+ maxOnly.add(id)
1584
+ r = await once()
1403
1585
  }
1404
1586
  if (r.error) return errorResponse(r.error)
1405
1587
  const it = r.events
@@ -1480,7 +1662,8 @@ export async function CursorAuthPlugin() {
1480
1662
  } catch {
1481
1663
  return errorResponse({ status: 400, message: "a request that isn't JSON" })
1482
1664
  }
1483
- return answer((await getAuth()) ?? auth, chat, init.signal ?? (input instanceof Request ? input.signal : undefined))
1665
+ const session = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined)).get(SESSION) ?? ""
1666
+ return answer((await getAuth()) ?? auth, chat, init.signal ?? (input instanceof Request ? input.signal : undefined), session)
1484
1667
  },
1485
1668
  }
1486
1669
  },
@@ -1503,6 +1686,15 @@ export async function CursorAuthPlugin() {
1503
1686
  return { ...u, ...(plan ? { plan } : {}), signIn: "kept" }
1504
1687
  },
1505
1688
  },
1689
+ // the session the request is part of, for the conversation_id
1690
+ // (conversationID): magpie and OpenCode name it here, and the loader's
1691
+ // fetch reads it back. Every plugin's hook sees every provider's
1692
+ // requests, so it is put on Cursor's alone.
1693
+ async "chat.headers"(input, output) {
1694
+ if (input?.model?.providerID !== ID && input?.provider?.info?.id !== ID) return
1695
+ const s = sessionOf(input)
1696
+ if (s) output.headers[SESSION] = s
1697
+ },
1506
1698
  async config(config) {
1507
1699
  config.provider ??= {}
1508
1700
  const was = config.provider[ID] ?? {}
@@ -1533,4 +1725,4 @@ export async function CursorAuthPlugin() {
1533
1725
  }
1534
1726
 
1535
1727
  // for tests
1536
- export const _internal = { errorResponse, kept, poolBase, splitID, families, byEffort, offered, modelID, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
1728
+ export const _internal = { 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",