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

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 +9 -1
  2. package/index.mjs +237 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,7 +58,15 @@ plugin's `fetch` answers them on Cursor's agent API:
58
58
  (Cursor tells it only once the tools' results come back in the same
59
59
  Run), so once the Run is closed its usage is read from the dashboard's
60
60
  usage events, by the Run's conversation id: it shows in about 2.5 s,
61
- and is looked for up to 6 s, after which it is estimated as before.
61
+ and is looked for up to 6 s. Far from Cursor it shows later than that
62
+ (yetone/magpie#1053), so the step says 0 used and the conversation owes
63
+ it: its next steps collect the event in the background and count it
64
+ with their own, and a step that ends the turn waits for what is still
65
+ owed, so the conversation adds up to what Cursor counted. An account
66
+ whose events miss the wait 3 steps in a row stops waiting for them.
67
+ With nothing naming the session (so no later step to collect it), or
68
+ an account that can't read its usage events, the step is estimated as
69
+ before.
62
70
  Failures keep their statuses: 401 to sign in again, 429 at the
63
71
  usage limit, 400 for a prompt too long, 403 for a region refusal.
64
72
 
package/index.mjs CHANGED
@@ -954,6 +954,26 @@ function statusOf(status, code, msg) {
954
954
  const textOf = (c) =>
955
955
  typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : ""
956
956
 
957
+ // joinSplitCalls joins an assistant message onto the assistant message
958
+ // before it when that one made tool calls: Anthropic takes consecutive
959
+ // assistant messages as one turn, and an agent can send a turn's parallel
960
+ // calls split over two of them, answered together after (yetone/magpie#1275).
961
+ // Answered at the second message, the first calls would be marked
962
+ // interrupted and their real results dropped. msgs is left as it is.
963
+ function joinSplitCalls(msgs) {
964
+ const out = []
965
+ for (const m of msgs) {
966
+ const prev = out.at(-1)
967
+ if (m?.role === "assistant" && prev?.role === "assistant" && prev.tool_calls?.length) {
968
+ const content = [textOf(prev.content), textOf(m.content)].filter(Boolean).join("\n\n")
969
+ out[out.length - 1] = { ...prev, content, tool_calls: [...prev.tool_calls, ...(m.tool_calls ?? [])] }
970
+ continue
971
+ }
972
+ out.push(m)
973
+ }
974
+ return out
975
+ }
976
+
957
977
  function parseArgs(s) {
958
978
  if (s && typeof s === "object") return s
959
979
  try {
@@ -1035,7 +1055,7 @@ function conversation(chat, tools) {
1035
1055
  if (results.length) add({ role: "tool", content: results })
1036
1056
  results = []
1037
1057
  }
1038
- for (const m of chat.messages ?? []) {
1058
+ for (const m of joinSplitCalls(chat.messages ?? [])) {
1039
1059
  if (m.role === "system" || m.role === "developer") continue
1040
1060
  if (m.role === "tool") {
1041
1061
  const id = m.tool_call_id ?? ""
@@ -1174,47 +1194,218 @@ function usageOf(uf) {
1174
1194
  // was read from the cache and written to it, as TurnEndedUpdate does; one
1175
1195
  // shows about 2.5 s after the Run is closed. STEP_WAIT is how long a step's
1176
1196
  // is looked for: first after that long, then every so often until the
1177
- // end, after which it is guessed as before.
1178
- const STEP_WAIT = { first: 1500, every: 500, until: 6000 }
1197
+ // end (each ask given `ask` to answer).
1198
+ //
1199
+ // Far from Cursor none shows in that time (yetone/magpie#1053: a round
1200
+ // trip of 4.2 s, the event 4–5 s after the Run closes), and waiting longer
1201
+ // would hold every tool step longer. So a tool step's Run is owed by its
1202
+ // conversation (owed): the step counts every event of the conversation's
1203
+ // owed Runs that has shown by the end of its wait, its own or earlier
1204
+ // ones, and what hasn't shown is collected by the conversation's next
1205
+ // steps, in the background from `owedAt` after the Run closed. A step
1206
+ // whose count is still owed says 0 used; the conversation's steps add up
1207
+ // to what Cursor counted, some of it a step or two late. A step that ends
1208
+ // the turn waits for what is still owed, as nothing may follow it soon.
1209
+ // After `skipAfter` steps in a row of an account's left something owed,
1210
+ // its steps stop waiting (but every `retryEvery`th), and leave it to the
1211
+ // next. A debt not collected in `owedFor` is let go. With no
1212
+ // conversation_id naming the session nothing later could collect a Run's
1213
+ // event: its step looks for it as before, and is guessed without it.
1214
+ const STEP_WAIT = { first: 1500, every: 500, until: 6000, ask: 5000, skipAfter: 3, retryEvery: 10, owedAt: 5000, owedFor: 600_000 }
1179
1215
 
1180
1216
  // claimed are the usage events already counted, by conversation and time,
1181
1217
  // so that two steps of one conversation never count the same one.
1182
1218
  const claimed = new Map()
1183
1219
 
1184
- // stepUsage is the usage of the Run of conversation conv started at since,
1185
- // as the dashboard's usage events count it; null when none shows in time.
1186
- async function stepUsage(tok, conv, since, signal) {
1220
+ // owed are what each conversation still owes, by conversation_id: n Runs
1221
+ // whose events are to be counted, the earliest started at since, looked
1222
+ // for from ready on; and carry, a count collected that no step gave out.
1223
+ const owed = new Map()
1224
+
1225
+ // flights are each conversation's collections still going, which a step
1226
+ // that ends the turn waits for.
1227
+ const flights = new Map()
1228
+
1229
+ // unreadable are the accounts (by token) whose usage events Cursor won't
1230
+ // give (a 4xx), for owedFor: their steps are guessed, as before, and owe
1231
+ // nothing. misses are how many steps in a row of an account's left
1232
+ // something owed.
1233
+ const unreadable = new Map()
1234
+ const misses = new Map()
1235
+ const tokKey = (tok) => createHash("sha256").update(tok).digest("hex")
1236
+ function canRead(tok) {
1237
+ const at = unreadable.get(tokKey(tok))
1238
+ if (at === undefined) return true
1239
+ if (Date.now() - at < STEP_WAIT.owedFor) return false
1240
+ unreadable.delete(tokKey(tok))
1241
+ return true
1242
+ }
1243
+
1244
+ const ZERO = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
1245
+ function addUsage(a, b) {
1246
+ if (!b) return a
1247
+ if (!a) return { ...b }
1248
+ for (const k of Object.keys(a)) a[k] += b[k] ?? 0
1249
+ return a
1250
+ }
1251
+
1252
+ // owe adds to conversation conv's debt.
1253
+ function owe(conv, { since = Date.now(), n = 0, ready = Date.now(), carry = null }) {
1254
+ for (const [k, d] of owed) if (Date.now() - d.since > STEP_WAIT.owedFor) owed.delete(k)
1255
+ if (n <= 0 && !carry) return
1256
+ const d = owed.get(conv)
1257
+ if (!d) return void owed.set(conv, { since, n, ready, carry: carry && { ...carry } })
1258
+ d.since = Math.min(d.since, since)
1259
+ d.n += n
1260
+ d.ready = Math.min(d.ready, ready)
1261
+ d.carry = addUsage(d.carry, carry)
1262
+ }
1263
+
1264
+ // usageEvents are the dashboard's events of conversation conv from since
1265
+ // on, the `want` oldest not counted yet, summed: usage (null when none),
1266
+ // how many were found (got), and refused when the account can't read them.
1267
+ // Polling, they are looked for as STEP_WAIT says, from `first` on, until
1268
+ // all show; else asked for once.
1269
+ async function usageEvents(tok, conv, since, want, { poll = false, first = STEP_WAIT.first, signal } = {}) {
1187
1270
  const end = Date.now() + STEP_WAIT.until
1188
1271
  const wait = (ms) => new Promise((r) => {
1189
1272
  const t = setTimeout(r, ms)
1190
1273
  signal?.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
1191
1274
  })
1192
- for (const [k, at] of claimed) if (Date.now() - at > 600_000) claimed.delete(k)
1193
- await wait(STEP_WAIT.first)
1275
+ for (const [k, at] of claimed) if (Date.now() - at > 2 * STEP_WAIT.owedFor) claimed.delete(k)
1194
1276
  const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
1277
+ let usage = null
1278
+ let got = 0
1279
+ if (poll) await wait(first)
1195
1280
  while (!signal?.aborted) {
1196
1281
  try {
1197
1282
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1198
1283
  method: "POST",
1199
1284
  headers: await headersFor(tok),
1200
1285
  body: JSON.stringify({ teamId: 0, startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1201
- signal: AbortSignal.timeout(5000),
1286
+ signal: AbortSignal.timeout(STEP_WAIT.ask),
1202
1287
  })
1203
- if (res.status >= 400 && res.status < 500 && res.status !== 429) return null // not this account's to read
1288
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
1289
+ // not this account's to read
1290
+ unreadable.set(tokKey(tok), Date.now())
1291
+ return { usage, got, refused: true }
1292
+ }
1204
1293
  const evs = res.ok ? ((await res.json())?.usageEventsDisplay ?? []) : []
1205
- const ev = evs
1294
+ const found = evs
1206
1295
  .filter((e) => e?.conversationId === conv && e.tokenUsage && num(e.timestamp) >= since - 1000 && !claimed.has(conv + "\0" + e.timestamp))
1207
- .sort((a, b) => num(a.timestamp) - num(b.timestamp))[0]
1208
- if (ev) {
1296
+ .sort((a, b) => num(a.timestamp) - num(b.timestamp))
1297
+ .slice(0, want - got)
1298
+ for (const ev of found) {
1209
1299
  claimed.set(conv + "\0" + ev.timestamp, Date.now())
1210
1300
  const t = ev.tokenUsage
1211
- return { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens), reasoning: 0 }
1301
+ usage = addUsage(usage ?? ZERO(), { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens) })
1302
+ got++
1212
1303
  }
1304
+ if (got >= want) break
1213
1305
  } catch {}
1214
- if (Date.now() + STEP_WAIT.every > end) return null
1306
+ if (!poll || Date.now() + STEP_WAIT.every > end) break
1215
1307
  await wait(STEP_WAIT.every)
1216
1308
  }
1217
- return null
1309
+ return { usage, got, refused: false }
1310
+ }
1311
+
1312
+ // stepUsage is the usage of the Run of conversation conv started at since,
1313
+ // as the dashboard's usage events count it; null when none shows in time.
1314
+ async function stepUsage(tok, conv, since, signal) {
1315
+ return (await usageEvents(tok, conv, since, 1, { poll: true, signal })).usage
1316
+ }
1317
+
1318
+ // collect takes conversation conv's debt and asks for its events (polling
1319
+ // when poll): what was carried and what was found (usage), and whether
1320
+ // any is still owed (short), which is owed on. What comes back once the
1321
+ // Run that asked is over (over() true) is carried to the next instead.
1322
+ function collect(tok, conv, { poll = false, first, signal, over = () => false } = {}) {
1323
+ const d = owed.get(conv)
1324
+ if (!d) return Promise.resolve({ usage: null, short: false })
1325
+ owed.delete(conv)
1326
+ const p = (async () => {
1327
+ let r = { usage: null, got: 0 }
1328
+ if (d.n > 0 && canRead(tok)) r = await usageEvents(tok, conv, d.since, d.n, { poll, first, signal }).catch(() => r)
1329
+ const short = d.n > r.got && canRead(tok)
1330
+ if (short) owe(conv, { since: d.since, n: d.n - r.got, ready: d.ready })
1331
+ const usage = addUsage(d.carry, r.usage)
1332
+ if (!over()) return { usage, short }
1333
+ owe(conv, { carry: usage })
1334
+ return { usage: null, short }
1335
+ })()
1336
+ const set = flights.get(conv) ?? new Set()
1337
+ flights.set(conv, set.add(p))
1338
+ p.finally(() => {
1339
+ set.delete(p)
1340
+ if (!set.size && flights.get(conv) === set) flights.delete(conv)
1341
+ })
1342
+ return p
1343
+ }
1344
+
1345
+ // ledger is how a Run of conversation conv, started at since, has its
1346
+ // usage counted, for decode; kept when the conversation_id names the
1347
+ // session, so that a later Run of it can collect what this one owes. A
1348
+ // debt the conversation has is collected in the background. own() is a
1349
+ // step's count once it has called tools: its Run owed, what has shown of
1350
+ // the debt by the end of the wait (or at once, when the account's never
1351
+ // show in time); false when nothing has, null when the step is to be
1352
+ // guessed (not kept, and its event not found, or an account that can't
1353
+ // read its events). owed(final) is what the Run collected otherwise,
1354
+ // all that can be had when final. done() ends the Run: what was collected
1355
+ // and not given out goes back.
1356
+ function ledger(tok, conv, since, signal, kept) {
1357
+ const key = tokKey(tok)
1358
+ let pending = null // collected, not given out yet
1359
+ let over = false
1360
+ const gather = (opts) =>
1361
+ collect(tok, conv, { ...opts, over: () => over }).then((r) => {
1362
+ pending = addUsage(pending, r.usage)
1363
+ return r
1364
+ })
1365
+ let collecting = null
1366
+ const debt = kept ? owed.get(conv) : undefined
1367
+ const timer = debt ? setTimeout(() => (collecting = gather()), Math.max(0, debt.ready - Date.now())) : null
1368
+ timer?.unref?.()
1369
+ const take = () => {
1370
+ const u = pending
1371
+ pending = null
1372
+ return u
1373
+ }
1374
+ return {
1375
+ async own() {
1376
+ if (!canRead(tok)) return null
1377
+ const m = misses.get(key) ?? 0
1378
+ const waits = m < STEP_WAIT.skipAfter || m % STEP_WAIT.retryEvery === 0
1379
+ if (!kept) {
1380
+ const r = waits ? await usageEvents(tok, conv, since, 1, { poll: true, signal }).catch(() => ({})) : {}
1381
+ if (r.usage) misses.delete(key)
1382
+ else if (!r.refused) misses.set(key, m + 1)
1383
+ return r.usage ?? null
1384
+ }
1385
+ owe(conv, { since, n: 1, ready: Date.now() + STEP_WAIT.owedAt })
1386
+ if (waits) {
1387
+ const r = await gather({ poll: true, signal })
1388
+ if (!canRead(tok)) return null
1389
+ if (r.short) misses.set(key, m + 1)
1390
+ else misses.delete(key)
1391
+ } else misses.set(key, m + 1)
1392
+ return take() ?? false
1393
+ },
1394
+ async owed(final) {
1395
+ if (!kept) return null
1396
+ if (final) {
1397
+ await collecting
1398
+ await Promise.all([...(flights.get(conv) ?? [])])
1399
+ if (owed.has(conv)) await gather({ poll: owed.get(conv).n > 0, first: 0, signal })
1400
+ }
1401
+ return take()
1402
+ },
1403
+ done() {
1404
+ over = true
1405
+ clearTimeout(timer)
1406
+ if (pending) owe(conv, { carry: take() })
1407
+ },
1408
+ }
1218
1409
  }
1219
1410
 
1220
1411
  // buildRun is the Run's first message, an AgentClientMessage with its
@@ -1332,6 +1523,10 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1332
1523
  // its usage event is found by it
1333
1524
  const cid = convID || randomUUID()
1334
1525
  const since = Date.now()
1526
+ // how its usage is counted: a debt of the conversation's is collected
1527
+ // while it goes, which only a conversation_id that names the session
1528
+ // could have run up
1529
+ const book = ledger(tok, cid, since, signal, !!convID)
1335
1530
  const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, cid, { maxMode, params })
1336
1531
  const o = await open(
1337
1532
  base,
@@ -1348,7 +1543,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1348
1543
  },
1349
1544
  signal,
1350
1545
  )
1351
- if (o.error) return o
1546
+ if (o.error) return book.done(), o
1352
1547
  const { session, req } = o
1353
1548
  let closed = false
1354
1549
  const send = (msg) => {
@@ -1373,6 +1568,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1373
1568
  for await (const c of req) chunks.push(c)
1374
1569
  } catch {}
1375
1570
  close()
1571
+ book.done()
1376
1572
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1377
1573
  }
1378
1574
  const it = decode(frames(req), {
@@ -1381,19 +1577,21 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1381
1577
  tools,
1382
1578
  estimate: conv.estimate,
1383
1579
  close,
1384
- stepUsage: () => stepUsage(tok, cid, since, signal),
1580
+ stepUsage: book.own,
1581
+ owedUsage: book.owed,
1385
1582
  })
1386
1583
  const wrapped = (async function* () {
1387
1584
  try {
1388
1585
  yield* it
1389
1586
  } finally {
1390
1587
  close()
1588
+ book.done()
1391
1589
  }
1392
1590
  })()
1393
1591
  // an error comes before anything of the answer: out of quota, a model the
1394
1592
  // plan doesn't have — answered with its own status
1395
1593
  const head = await wrapped.next()
1396
- if (head.done) return { error: { status: 502, message: "an empty reply" } }
1594
+ if (head.done) return book.done(), { error: { status: 502, message: "an empty reply" } }
1397
1595
  if (head.value.error) {
1398
1596
  await wrapped.return()
1399
1597
  return { error: head.value.error }
@@ -1408,7 +1606,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1408
1606
 
1409
1607
  // decode follows the server's messages into the answer's pieces until the
1410
1608
  // turn ends, or the model has made its tool calls.
1411
- async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage }) {
1609
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
1412
1610
  let calls = 0
1413
1611
  // ended: Cursor said what the turn used
1414
1612
  let ended = false
@@ -1417,11 +1615,19 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1417
1615
  // wrote: the reply's text alone; a turn of thinking only says nothing
1418
1616
  let wrote = 0
1419
1617
  let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1420
- const finish = () => {
1421
- if (!usage.output) usage.output = Math.floor((said + 3) / 4)
1618
+ // counted: the step's usage is the dashboard's events, its own counted
1619
+ // with it or by a later step (ledger), never guessed
1620
+ let counted = false
1621
+ // finish is the step's end, with what earlier steps owed (more), which
1622
+ // is no reason not to guess the step's own
1623
+ const finish = (more) => {
1624
+ if (!counted && !usage.output) usage.output = Math.floor((said + 3) / 4)
1422
1625
  // a guess only when Cursor counted nothing: a prompt read whole from
1423
- // the cache leaves no uncached rest, and is no reason to guess
1424
- if (!usage.input && !usage.cacheRead && !usage.cacheWrite) usage.input = estimate
1626
+ // the cache leaves no uncached rest, and is no reason to guess; nor is
1627
+ // a prompt counted from the events
1628
+ if (!counted && !usage.input && !usage.cacheRead && !usage.cacheWrite) usage.input = estimate
1629
+ // the events' output is their steps', counted here as their prompt is
1630
+ if (more) for (const k of ["input", "output", "cacheRead", "cacheWrite"]) usage[k] += more[k]
1425
1631
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
1426
1632
  return {
1427
1633
  stop: calls > 0 ? "tool_calls" : "stop",
@@ -1435,14 +1641,17 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1435
1641
  }
1436
1642
  }
1437
1643
  // settled is finish once a step that called tools has its usage: the
1438
- // dashboard's count, the Run closed first, when the turn didn't end
1644
+ // dashboard's count, the Run closed first, when the turn didn't end (or
1645
+ // owed); and with what the conversation's earlier steps owed, which a
1646
+ // step that ends the turn waits for
1439
1647
  const settled = async () => {
1440
1648
  if (calls > 0 && !ended && stepUsage) {
1441
1649
  close?.()
1442
1650
  const u = await stepUsage().catch(() => null)
1443
1651
  if (u) usage = u
1652
+ counted = u === false || !!u
1444
1653
  }
1445
- return finish()
1654
+ return finish(owedUsage ? await owedUsage(calls === 0).catch(() => null) : null)
1446
1655
  }
1447
1656
  const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1448
1657
  try {
@@ -1485,7 +1694,7 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1485
1694
  if (calls === 0) {
1486
1695
  // nothing written and nothing called: an empty reply,
1487
1696
  // which the client asks again, not a finished turn
1488
- yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : finish()
1697
+ yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : await settled()
1489
1698
  return
1490
1699
  }
1491
1700
  break
@@ -1810,4 +2019,4 @@ export async function CursorAuthPlugin() {
1810
2019
  }
1811
2020
 
1812
2021
  // for tests
1813
- export const _internal = { STEP_WAIT, stepUsage, 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 }
2022
+ export const _internal = { STEP_WAIT, stepUsage, ledger, owed, flights, misses, unreadable, catalog, usable, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, splitID, families, byEffort, offered, modelID, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",