@magpie-community/opencode-cursor-auth 0.1.9 → 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 +9 -1
  2. package/index.mjs +216 -27
  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
@@ -1174,47 +1174,218 @@ function usageOf(uf) {
1174
1174
  // was read from the cache and written to it, as TurnEndedUpdate does; one
1175
1175
  // shows about 2.5 s after the Run is closed. STEP_WAIT is how long a step's
1176
1176
  // 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 }
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 }
1179
1195
 
1180
1196
  // claimed are the usage events already counted, by conversation and time,
1181
1197
  // so that two steps of one conversation never count the same one.
1182
1198
  const claimed = new Map()
1183
1199
 
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) {
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 } = {}) {
1187
1250
  const end = Date.now() + STEP_WAIT.until
1188
1251
  const wait = (ms) => new Promise((r) => {
1189
1252
  const t = setTimeout(r, ms)
1190
1253
  signal?.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
1191
1254
  })
1192
- for (const [k, at] of claimed) if (Date.now() - at > 600_000) claimed.delete(k)
1193
- await wait(STEP_WAIT.first)
1255
+ for (const [k, at] of claimed) if (Date.now() - at > 2 * STEP_WAIT.owedFor) claimed.delete(k)
1194
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)
1195
1260
  while (!signal?.aborted) {
1196
1261
  try {
1197
1262
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1198
1263
  method: "POST",
1199
1264
  headers: await headersFor(tok),
1200
1265
  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),
1266
+ signal: AbortSignal.timeout(STEP_WAIT.ask),
1202
1267
  })
1203
- if (res.status >= 400 && res.status < 500 && res.status !== 429) return null // not this account's to read
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
+ }
1204
1273
  const evs = res.ok ? ((await res.json())?.usageEventsDisplay ?? []) : []
1205
- const ev = evs
1274
+ const found = evs
1206
1275
  .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) {
1276
+ .sort((a, b) => num(a.timestamp) - num(b.timestamp))
1277
+ .slice(0, want - got)
1278
+ for (const ev of found) {
1209
1279
  claimed.set(conv + "\0" + ev.timestamp, Date.now())
1210
1280
  const t = ev.tokenUsage
1211
- return { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens), reasoning: 0 }
1281
+ usage = addUsage(usage ?? ZERO(), { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens) })
1282
+ got++
1212
1283
  }
1284
+ if (got >= want) break
1213
1285
  } catch {}
1214
- if (Date.now() + STEP_WAIT.every > end) return null
1286
+ if (!poll || Date.now() + STEP_WAIT.every > end) break
1215
1287
  await wait(STEP_WAIT.every)
1216
1288
  }
1217
- return null
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
+ }
1218
1389
  }
1219
1390
 
1220
1391
  // buildRun is the Run's first message, an AgentClientMessage with its
@@ -1332,6 +1503,10 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1332
1503
  // its usage event is found by it
1333
1504
  const cid = convID || randomUUID()
1334
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)
1335
1510
  const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, cid, { maxMode, params })
1336
1511
  const o = await open(
1337
1512
  base,
@@ -1348,7 +1523,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1348
1523
  },
1349
1524
  signal,
1350
1525
  )
1351
- if (o.error) return o
1526
+ if (o.error) return book.done(), o
1352
1527
  const { session, req } = o
1353
1528
  let closed = false
1354
1529
  const send = (msg) => {
@@ -1373,6 +1548,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1373
1548
  for await (const c of req) chunks.push(c)
1374
1549
  } catch {}
1375
1550
  close()
1551
+ book.done()
1376
1552
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1377
1553
  }
1378
1554
  const it = decode(frames(req), {
@@ -1381,19 +1557,21 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1381
1557
  tools,
1382
1558
  estimate: conv.estimate,
1383
1559
  close,
1384
- stepUsage: () => stepUsage(tok, cid, since, signal),
1560
+ stepUsage: book.own,
1561
+ owedUsage: book.owed,
1385
1562
  })
1386
1563
  const wrapped = (async function* () {
1387
1564
  try {
1388
1565
  yield* it
1389
1566
  } finally {
1390
1567
  close()
1568
+ book.done()
1391
1569
  }
1392
1570
  })()
1393
1571
  // an error comes before anything of the answer: out of quota, a model the
1394
1572
  // plan doesn't have — answered with its own status
1395
1573
  const head = await wrapped.next()
1396
- 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" } }
1397
1575
  if (head.value.error) {
1398
1576
  await wrapped.return()
1399
1577
  return { error: head.value.error }
@@ -1408,7 +1586,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1408
1586
 
1409
1587
  // decode follows the server's messages into the answer's pieces until the
1410
1588
  // turn ends, or the model has made its tool calls.
1411
- async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage }) {
1589
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
1412
1590
  let calls = 0
1413
1591
  // ended: Cursor said what the turn used
1414
1592
  let ended = false
@@ -1417,11 +1595,19 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1417
1595
  // wrote: the reply's text alone; a turn of thinking only says nothing
1418
1596
  let wrote = 0
1419
1597
  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)
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)
1422
1605
  // 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
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]
1425
1611
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
1426
1612
  return {
1427
1613
  stop: calls > 0 ? "tool_calls" : "stop",
@@ -1435,14 +1621,17 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1435
1621
  }
1436
1622
  }
1437
1623
  // 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
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
1439
1627
  const settled = async () => {
1440
1628
  if (calls > 0 && !ended && stepUsage) {
1441
1629
  close?.()
1442
1630
  const u = await stepUsage().catch(() => null)
1443
1631
  if (u) usage = u
1632
+ counted = u === false || !!u
1444
1633
  }
1445
- return finish()
1634
+ return finish(owedUsage ? await owedUsage(calls === 0).catch(() => null) : null)
1446
1635
  }
1447
1636
  const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1448
1637
  try {
@@ -1485,7 +1674,7 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage
1485
1674
  if (calls === 0) {
1486
1675
  // nothing written and nothing called: an empty reply,
1487
1676
  // which the client asks again, not a finished turn
1488
- yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : finish()
1677
+ yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : await settled()
1489
1678
  return
1490
1679
  }
1491
1680
  break
@@ -1810,4 +1999,4 @@ export async function CursorAuthPlugin() {
1810
1999
  }
1811
2000
 
1812
2001
  // 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 }
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.9",
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",