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

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 +14 -1
  2. package/index.mjs +282 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,7 +54,20 @@ plugin's `fetch` answers them on Cursor's agent API:
54
54
  or not. Cursor's `input_tokens` counts the cached prompt too: the cache
55
55
  read and written is taken out of it, and given as `cached_tokens` and
56
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
57
+ `reasoning_tokens`. A step that calls tools gets no usage in its Run
58
+ (Cursor tells it only once the tools' results come back in the same
59
+ Run), so once the Run is closed its usage is read from the dashboard's
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. 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.
70
+ Failures keep their statuses: 401 to sign in again, 429 at the
58
71
  usage limit, 400 for a prompt too long, 403 for a region refusal.
59
72
 
60
73
  ## Models
package/index.mjs CHANGED
@@ -1164,6 +1164,230 @@ function usageOf(uf) {
1164
1164
  return { input: Math.max(inp - cr - cw, 0), output: pbNum(uf, 2), cacheRead: cr, cacheWrite: cw, reasoning: pbNum(uf, 5) }
1165
1165
  }
1166
1166
 
1167
+ // A step that calls the caller's tools has no TurnEndedUpdate: Cursor ends
1168
+ // the turn, and tells what it used, only once the client has run the tools
1169
+ // and sent their results in the same Run, and the caller runs them after
1170
+ // the Run is closed (magpie #676). Before that it sends nothing more than
1171
+ // the conversation's checkpoint and heartbeats, and a cancelled Run ends
1172
+ // with an error and no usage either. The dashboard's usage events count
1173
+ // every Run, by its conversation_id, the uncached input apart from what
1174
+ // was read from the cache and written to it, as TurnEndedUpdate does; one
1175
+ // shows about 2.5 s after the Run is closed. STEP_WAIT is how long a step's
1176
+ // is looked for: first after that long, then every so often until the
1177
+ // end (each ask given `ask` to answer).
1178
+ //
1179
+ // Far from Cursor none shows in that time (yetone/magpie#1053: a round
1180
+ // trip of 4.2 s, the event 4–5 s after the Run closes), and waiting longer
1181
+ // would hold every tool step longer. So a tool step's Run is owed by its
1182
+ // conversation (owed): the step counts every event of the conversation's
1183
+ // owed Runs that has shown by the end of its wait, its own or earlier
1184
+ // ones, and what hasn't shown is collected by the conversation's next
1185
+ // steps, in the background from `owedAt` after the Run closed. A step
1186
+ // whose count is still owed says 0 used; the conversation's steps add up
1187
+ // to what Cursor counted, some of it a step or two late. A step that ends
1188
+ // the turn waits for what is still owed, as nothing may follow it soon.
1189
+ // After `skipAfter` steps in a row of an account's left something owed,
1190
+ // its steps stop waiting (but every `retryEvery`th), and leave it to the
1191
+ // next. A debt not collected in `owedFor` is let go. With no
1192
+ // conversation_id naming the session nothing later could collect a Run's
1193
+ // event: its step looks for it as before, and is guessed without it.
1194
+ const STEP_WAIT = { first: 1500, every: 500, until: 6000, ask: 5000, skipAfter: 3, retryEvery: 10, owedAt: 5000, owedFor: 600_000 }
1195
+
1196
+ // claimed are the usage events already counted, by conversation and time,
1197
+ // so that two steps of one conversation never count the same one.
1198
+ const claimed = new Map()
1199
+
1200
+ // owed are what each conversation still owes, by conversation_id: n Runs
1201
+ // whose events are to be counted, the earliest started at since, looked
1202
+ // for from ready on; and carry, a count collected that no step gave out.
1203
+ const owed = new Map()
1204
+
1205
+ // flights are each conversation's collections still going, which a step
1206
+ // that ends the turn waits for.
1207
+ const flights = new Map()
1208
+
1209
+ // unreadable are the accounts (by token) whose usage events Cursor won't
1210
+ // give (a 4xx), for owedFor: their steps are guessed, as before, and owe
1211
+ // nothing. misses are how many steps in a row of an account's left
1212
+ // something owed.
1213
+ const unreadable = new Map()
1214
+ const misses = new Map()
1215
+ const tokKey = (tok) => createHash("sha256").update(tok).digest("hex")
1216
+ function canRead(tok) {
1217
+ const at = unreadable.get(tokKey(tok))
1218
+ if (at === undefined) return true
1219
+ if (Date.now() - at < STEP_WAIT.owedFor) return false
1220
+ unreadable.delete(tokKey(tok))
1221
+ return true
1222
+ }
1223
+
1224
+ const ZERO = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
1225
+ function addUsage(a, b) {
1226
+ if (!b) return a
1227
+ if (!a) return { ...b }
1228
+ for (const k of Object.keys(a)) a[k] += b[k] ?? 0
1229
+ return a
1230
+ }
1231
+
1232
+ // owe adds to conversation conv's debt.
1233
+ function owe(conv, { since = Date.now(), n = 0, ready = Date.now(), carry = null }) {
1234
+ for (const [k, d] of owed) if (Date.now() - d.since > STEP_WAIT.owedFor) owed.delete(k)
1235
+ if (n <= 0 && !carry) return
1236
+ const d = owed.get(conv)
1237
+ if (!d) return void owed.set(conv, { since, n, ready, carry: carry && { ...carry } })
1238
+ d.since = Math.min(d.since, since)
1239
+ d.n += n
1240
+ d.ready = Math.min(d.ready, ready)
1241
+ d.carry = addUsage(d.carry, carry)
1242
+ }
1243
+
1244
+ // usageEvents are the dashboard's events of conversation conv from since
1245
+ // on, the `want` oldest not counted yet, summed: usage (null when none),
1246
+ // how many were found (got), and refused when the account can't read them.
1247
+ // Polling, they are looked for as STEP_WAIT says, from `first` on, until
1248
+ // all show; else asked for once.
1249
+ async function usageEvents(tok, conv, since, want, { poll = false, first = STEP_WAIT.first, signal } = {}) {
1250
+ const end = Date.now() + STEP_WAIT.until
1251
+ const wait = (ms) => new Promise((r) => {
1252
+ const t = setTimeout(r, ms)
1253
+ signal?.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
1254
+ })
1255
+ for (const [k, at] of claimed) if (Date.now() - at > 2 * STEP_WAIT.owedFor) claimed.delete(k)
1256
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
1257
+ let usage = null
1258
+ let got = 0
1259
+ if (poll) await wait(first)
1260
+ while (!signal?.aborted) {
1261
+ try {
1262
+ const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1263
+ method: "POST",
1264
+ headers: await headersFor(tok),
1265
+ body: JSON.stringify({ teamId: 0, startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1266
+ signal: AbortSignal.timeout(STEP_WAIT.ask),
1267
+ })
1268
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
1269
+ // not this account's to read
1270
+ unreadable.set(tokKey(tok), Date.now())
1271
+ return { usage, got, refused: true }
1272
+ }
1273
+ const evs = res.ok ? ((await res.json())?.usageEventsDisplay ?? []) : []
1274
+ const found = evs
1275
+ .filter((e) => e?.conversationId === conv && e.tokenUsage && num(e.timestamp) >= since - 1000 && !claimed.has(conv + "\0" + e.timestamp))
1276
+ .sort((a, b) => num(a.timestamp) - num(b.timestamp))
1277
+ .slice(0, want - got)
1278
+ for (const ev of found) {
1279
+ claimed.set(conv + "\0" + ev.timestamp, Date.now())
1280
+ const t = ev.tokenUsage
1281
+ usage = addUsage(usage ?? ZERO(), { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens) })
1282
+ got++
1283
+ }
1284
+ if (got >= want) break
1285
+ } catch {}
1286
+ if (!poll || Date.now() + STEP_WAIT.every > end) break
1287
+ await wait(STEP_WAIT.every)
1288
+ }
1289
+ return { usage, got, refused: false }
1290
+ }
1291
+
1292
+ // stepUsage is the usage of the Run of conversation conv started at since,
1293
+ // as the dashboard's usage events count it; null when none shows in time.
1294
+ async function stepUsage(tok, conv, since, signal) {
1295
+ return (await usageEvents(tok, conv, since, 1, { poll: true, signal })).usage
1296
+ }
1297
+
1298
+ // collect takes conversation conv's debt and asks for its events (polling
1299
+ // when poll): what was carried and what was found (usage), and whether
1300
+ // any is still owed (short), which is owed on. What comes back once the
1301
+ // Run that asked is over (over() true) is carried to the next instead.
1302
+ function collect(tok, conv, { poll = false, first, signal, over = () => false } = {}) {
1303
+ const d = owed.get(conv)
1304
+ if (!d) return Promise.resolve({ usage: null, short: false })
1305
+ owed.delete(conv)
1306
+ const p = (async () => {
1307
+ let r = { usage: null, got: 0 }
1308
+ if (d.n > 0 && canRead(tok)) r = await usageEvents(tok, conv, d.since, d.n, { poll, first, signal }).catch(() => r)
1309
+ const short = d.n > r.got && canRead(tok)
1310
+ if (short) owe(conv, { since: d.since, n: d.n - r.got, ready: d.ready })
1311
+ const usage = addUsage(d.carry, r.usage)
1312
+ if (!over()) return { usage, short }
1313
+ owe(conv, { carry: usage })
1314
+ return { usage: null, short }
1315
+ })()
1316
+ const set = flights.get(conv) ?? new Set()
1317
+ flights.set(conv, set.add(p))
1318
+ p.finally(() => {
1319
+ set.delete(p)
1320
+ if (!set.size && flights.get(conv) === set) flights.delete(conv)
1321
+ })
1322
+ return p
1323
+ }
1324
+
1325
+ // ledger is how a Run of conversation conv, started at since, has its
1326
+ // usage counted, for decode; kept when the conversation_id names the
1327
+ // session, so that a later Run of it can collect what this one owes. A
1328
+ // debt the conversation has is collected in the background. own() is a
1329
+ // step's count once it has called tools: its Run owed, what has shown of
1330
+ // the debt by the end of the wait (or at once, when the account's never
1331
+ // show in time); false when nothing has, null when the step is to be
1332
+ // guessed (not kept, and its event not found, or an account that can't
1333
+ // read its events). owed(final) is what the Run collected otherwise,
1334
+ // all that can be had when final. done() ends the Run: what was collected
1335
+ // and not given out goes back.
1336
+ function ledger(tok, conv, since, signal, kept) {
1337
+ const key = tokKey(tok)
1338
+ let pending = null // collected, not given out yet
1339
+ let over = false
1340
+ const gather = (opts) =>
1341
+ collect(tok, conv, { ...opts, over: () => over }).then((r) => {
1342
+ pending = addUsage(pending, r.usage)
1343
+ return r
1344
+ })
1345
+ let collecting = null
1346
+ const debt = kept ? owed.get(conv) : undefined
1347
+ const timer = debt ? setTimeout(() => (collecting = gather()), Math.max(0, debt.ready - Date.now())) : null
1348
+ timer?.unref?.()
1349
+ const take = () => {
1350
+ const u = pending
1351
+ pending = null
1352
+ return u
1353
+ }
1354
+ return {
1355
+ async own() {
1356
+ if (!canRead(tok)) return null
1357
+ const m = misses.get(key) ?? 0
1358
+ const waits = m < STEP_WAIT.skipAfter || m % STEP_WAIT.retryEvery === 0
1359
+ if (!kept) {
1360
+ const r = waits ? await usageEvents(tok, conv, since, 1, { poll: true, signal }).catch(() => ({})) : {}
1361
+ if (r.usage) misses.delete(key)
1362
+ else if (!r.refused) misses.set(key, m + 1)
1363
+ return r.usage ?? null
1364
+ }
1365
+ owe(conv, { since, n: 1, ready: Date.now() + STEP_WAIT.owedAt })
1366
+ if (waits) {
1367
+ const r = await gather({ poll: true, signal })
1368
+ if (!canRead(tok)) return null
1369
+ if (r.short) misses.set(key, m + 1)
1370
+ else misses.delete(key)
1371
+ } else misses.set(key, m + 1)
1372
+ return take() ?? false
1373
+ },
1374
+ async owed(final) {
1375
+ if (!kept) return null
1376
+ if (final) {
1377
+ await collecting
1378
+ await Promise.all([...(flights.get(conv) ?? [])])
1379
+ if (owed.has(conv)) await gather({ poll: owed.get(conv).n > 0, first: 0, signal })
1380
+ }
1381
+ return take()
1382
+ },
1383
+ done() {
1384
+ over = true
1385
+ clearTimeout(timer)
1386
+ if (pending) owe(conv, { carry: take() })
1387
+ },
1388
+ }
1389
+ }
1390
+
1167
1391
  // buildRun is the Run's first message, an AgentClientMessage with its
1168
1392
  // run_request, and the blobs it names. The conversation state is the
1169
1393
  // messages and one turn, which the server wants there to sample at all.
@@ -1275,7 +1499,15 @@ function open(base, headers, signal) {
1275
1499
  // runOnce is one Run of the chat on the agent API at base: an async
1276
1500
  // iterator of the answer's pieces, or the error it failed with before any.
1277
1501
  async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
1278
- const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, convID, { maxMode, params })
1502
+ // the Run's conversation_id, a new one when nothing names the session:
1503
+ // its usage event is found by it
1504
+ const cid = convID || randomUUID()
1505
+ const since = Date.now()
1506
+ // how its usage is counted: a debt of the conversation's is collected
1507
+ // while it goes, which only a conversation_id that names the session
1508
+ // could have run up
1509
+ const book = ledger(tok, cid, since, signal, !!convID)
1510
+ const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, cid, { maxMode, params })
1279
1511
  const o = await open(
1280
1512
  base,
1281
1513
  {
@@ -1291,7 +1523,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1291
1523
  },
1292
1524
  signal,
1293
1525
  )
1294
- if (o.error) return o
1526
+ if (o.error) return book.done(), o
1295
1527
  const { session, req } = o
1296
1528
  let closed = false
1297
1529
  const send = (msg) => {
@@ -1316,20 +1548,30 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1316
1548
  for await (const c of req) chunks.push(c)
1317
1549
  } catch {}
1318
1550
  close()
1551
+ book.done()
1319
1552
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1320
1553
  }
1321
- const it = decode(frames(req), { send, blobs, tools, estimate: conv.estimate })
1554
+ const it = decode(frames(req), {
1555
+ send,
1556
+ blobs,
1557
+ tools,
1558
+ estimate: conv.estimate,
1559
+ close,
1560
+ stepUsage: book.own,
1561
+ owedUsage: book.owed,
1562
+ })
1322
1563
  const wrapped = (async function* () {
1323
1564
  try {
1324
1565
  yield* it
1325
1566
  } finally {
1326
1567
  close()
1568
+ book.done()
1327
1569
  }
1328
1570
  })()
1329
1571
  // an error comes before anything of the answer: out of quota, a model the
1330
1572
  // plan doesn't have — answered with its own status
1331
1573
  const head = await wrapped.next()
1332
- if (head.done) return { error: { status: 502, message: "an empty reply" } }
1574
+ if (head.done) return book.done(), { error: { status: 502, message: "an empty reply" } }
1333
1575
  if (head.value.error) {
1334
1576
  await wrapped.return()
1335
1577
  return { error: head.value.error }
@@ -1344,18 +1586,28 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1344
1586
 
1345
1587
  // decode follows the server's messages into the answer's pieces until the
1346
1588
  // turn ends, or the model has made its tool calls.
1347
- async function* decode(stream, { send, blobs, tools, estimate }) {
1589
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
1348
1590
  let calls = 0
1591
+ // ended: Cursor said what the turn used
1592
+ let ended = false
1349
1593
  let listed = 0
1350
1594
  let said = 0
1351
1595
  // wrote: the reply's text alone; a turn of thinking only says nothing
1352
1596
  let wrote = 0
1353
1597
  let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1354
- const finish = () => {
1355
- if (!usage.output) usage.output = Math.floor((said + 3) / 4)
1598
+ // counted: the step's usage is the dashboard's events, its own counted
1599
+ // with it or by a later step (ledger), never guessed
1600
+ let counted = false
1601
+ // finish is the step's end, with what earlier steps owed (more), which
1602
+ // is no reason not to guess the step's own
1603
+ const finish = (more) => {
1604
+ if (!counted && !usage.output) usage.output = Math.floor((said + 3) / 4)
1356
1605
  // a guess only when Cursor counted nothing: a prompt read whole from
1357
- // the cache leaves no uncached rest, and is no reason to guess
1358
- if (!usage.input && !usage.cacheRead && !usage.cacheWrite) usage.input = estimate
1606
+ // the cache leaves no uncached rest, and is no reason to guess; nor is
1607
+ // a prompt counted from the events
1608
+ if (!counted && !usage.input && !usage.cacheRead && !usage.cacheWrite) usage.input = estimate
1609
+ // the events' output is their steps', counted here as their prompt is
1610
+ if (more) for (const k of ["input", "output", "cacheRead", "cacheWrite"]) usage[k] += more[k]
1359
1611
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
1360
1612
  return {
1361
1613
  stop: calls > 0 ? "tool_calls" : "stop",
@@ -1368,6 +1620,19 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1368
1620
  },
1369
1621
  }
1370
1622
  }
1623
+ // settled is finish once a step that called tools has its usage: the
1624
+ // dashboard's count, the Run closed first, when the turn didn't end (or
1625
+ // owed); and with what the conversation's earlier steps owed, which a
1626
+ // step that ends the turn waits for
1627
+ const settled = async () => {
1628
+ if (calls > 0 && !ended && stepUsage) {
1629
+ close?.()
1630
+ const u = await stepUsage().catch(() => null)
1631
+ if (u) usage = u
1632
+ counted = u === false || !!u
1633
+ }
1634
+ return finish(owedUsage ? await owedUsage(calls === 0).catch(() => null) : null)
1635
+ }
1371
1636
  const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1372
1637
  try {
1373
1638
  for await (const f of stream) {
@@ -1375,7 +1640,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1375
1640
  const e = failure(200, f.data)
1376
1641
  if (e.status < 200 || e.status > 299) yield { error: e }
1377
1642
  else if (wrote === 0 && calls === 0) yield { error: { status: 502, message: "an empty reply" } }
1378
- else yield finish()
1643
+ else yield await settled()
1379
1644
  return
1380
1645
  }
1381
1646
  for (const m of fields(f.data)) {
@@ -1405,17 +1670,18 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1405
1670
  }
1406
1671
  case 14: // turn ended, with what it used
1407
1672
  usage = usageOf(uf)
1673
+ ended = true
1408
1674
  if (calls === 0) {
1409
1675
  // nothing written and nothing called: an empty reply,
1410
1676
  // which the client asks again, not a finished turn
1411
- yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : finish()
1677
+ yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : await settled()
1412
1678
  return
1413
1679
  }
1414
1680
  break
1415
1681
  case 27: // how many tool calls the step makes, sent before them
1416
1682
  listed = pbNum(uf, 1)
1417
1683
  if (listed > 0 && calls >= listed) {
1418
- yield finish()
1684
+ yield await settled()
1419
1685
  return
1420
1686
  }
1421
1687
  break
@@ -1431,7 +1697,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1431
1697
  }
1432
1698
  if (listed > 0 && calls >= listed) {
1433
1699
  // every call made: the caller runs them
1434
- yield finish()
1700
+ yield await settled()
1435
1701
  return
1436
1702
  }
1437
1703
  break
@@ -1457,13 +1723,13 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1457
1723
  }
1458
1724
  }
1459
1725
  if (calls > 0) {
1460
- yield finish()
1726
+ yield await settled()
1461
1727
  return
1462
1728
  }
1463
1729
  yield { error: { status: 502, message: "the reply broke off: EOF" } }
1464
1730
  } catch (e) {
1465
1731
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
1466
- yield finish()
1732
+ yield await settled()
1467
1733
  return
1468
1734
  }
1469
1735
  yield { error: { status: 502, message: "the reply broke off: " + (e?.message ?? e) } }
@@ -1733,4 +1999,4 @@ export async function CursorAuthPlugin() {
1733
1999
  }
1734
2000
 
1735
2001
  // for tests
1736
- export const _internal = { 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 }
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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",