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

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 +6 -1
  2. package/index.mjs +86 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,7 +54,12 @@ 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, after which it is estimated as before.
62
+ Failures keep their statuses: 401 to sign in again, 429 at the
58
63
  usage limit, 400 for a prompt too long, 403 for a region refusal.
59
64
 
60
65
  ## Models
package/index.mjs CHANGED
@@ -1164,6 +1164,59 @@ 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, after which it is guessed as before.
1178
+ const STEP_WAIT = { first: 1500, every: 500, until: 6000 }
1179
+
1180
+ // claimed are the usage events already counted, by conversation and time,
1181
+ // so that two steps of one conversation never count the same one.
1182
+ const claimed = new Map()
1183
+
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) {
1187
+ const end = Date.now() + STEP_WAIT.until
1188
+ const wait = (ms) => new Promise((r) => {
1189
+ const t = setTimeout(r, ms)
1190
+ signal?.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
1191
+ })
1192
+ for (const [k, at] of claimed) if (Date.now() - at > 600_000) claimed.delete(k)
1193
+ await wait(STEP_WAIT.first)
1194
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
1195
+ while (!signal?.aborted) {
1196
+ try {
1197
+ const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1198
+ method: "POST",
1199
+ headers: await headersFor(tok),
1200
+ 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),
1202
+ })
1203
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) return null // not this account's to read
1204
+ const evs = res.ok ? ((await res.json())?.usageEventsDisplay ?? []) : []
1205
+ const ev = evs
1206
+ .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) {
1209
+ claimed.set(conv + "\0" + ev.timestamp, Date.now())
1210
+ const t = ev.tokenUsage
1211
+ return { input: num(t.inputTokens), output: num(t.outputTokens), cacheRead: num(t.cacheReadTokens), cacheWrite: num(t.cacheWriteTokens), reasoning: 0 }
1212
+ }
1213
+ } catch {}
1214
+ if (Date.now() + STEP_WAIT.every > end) return null
1215
+ await wait(STEP_WAIT.every)
1216
+ }
1217
+ return null
1218
+ }
1219
+
1167
1220
  // buildRun is the Run's first message, an AgentClientMessage with its
1168
1221
  // run_request, and the blobs it names. The conversation state is the
1169
1222
  // messages and one turn, which the server wants there to sample at all.
@@ -1275,7 +1328,11 @@ function open(base, headers, signal) {
1275
1328
  // runOnce is one Run of the chat on the agent API at base: an async
1276
1329
  // iterator of the answer's pieces, or the error it failed with before any.
1277
1330
  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 })
1331
+ // the Run's conversation_id, a new one when nothing names the session:
1332
+ // its usage event is found by it
1333
+ const cid = convID || randomUUID()
1334
+ const since = Date.now()
1335
+ const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id, cid, { maxMode, params })
1279
1336
  const o = await open(
1280
1337
  base,
1281
1338
  {
@@ -1318,7 +1375,14 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1318
1375
  close()
1319
1376
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1320
1377
  }
1321
- const it = decode(frames(req), { send, blobs, tools, estimate: conv.estimate })
1378
+ const it = decode(frames(req), {
1379
+ send,
1380
+ blobs,
1381
+ tools,
1382
+ estimate: conv.estimate,
1383
+ close,
1384
+ stepUsage: () => stepUsage(tok, cid, since, signal),
1385
+ })
1322
1386
  const wrapped = (async function* () {
1323
1387
  try {
1324
1388
  yield* it
@@ -1344,8 +1408,10 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1344
1408
 
1345
1409
  // decode follows the server's messages into the answer's pieces until the
1346
1410
  // turn ends, or the model has made its tool calls.
1347
- async function* decode(stream, { send, blobs, tools, estimate }) {
1411
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage }) {
1348
1412
  let calls = 0
1413
+ // ended: Cursor said what the turn used
1414
+ let ended = false
1349
1415
  let listed = 0
1350
1416
  let said = 0
1351
1417
  // wrote: the reply's text alone; a turn of thinking only says nothing
@@ -1368,6 +1434,16 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1368
1434
  },
1369
1435
  }
1370
1436
  }
1437
+ // 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
1439
+ const settled = async () => {
1440
+ if (calls > 0 && !ended && stepUsage) {
1441
+ close?.()
1442
+ const u = await stepUsage().catch(() => null)
1443
+ if (u) usage = u
1444
+ }
1445
+ return finish()
1446
+ }
1371
1447
  const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1372
1448
  try {
1373
1449
  for await (const f of stream) {
@@ -1375,7 +1451,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1375
1451
  const e = failure(200, f.data)
1376
1452
  if (e.status < 200 || e.status > 299) yield { error: e }
1377
1453
  else if (wrote === 0 && calls === 0) yield { error: { status: 502, message: "an empty reply" } }
1378
- else yield finish()
1454
+ else yield await settled()
1379
1455
  return
1380
1456
  }
1381
1457
  for (const m of fields(f.data)) {
@@ -1405,6 +1481,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1405
1481
  }
1406
1482
  case 14: // turn ended, with what it used
1407
1483
  usage = usageOf(uf)
1484
+ ended = true
1408
1485
  if (calls === 0) {
1409
1486
  // nothing written and nothing called: an empty reply,
1410
1487
  // which the client asks again, not a finished turn
@@ -1415,7 +1492,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1415
1492
  case 27: // how many tool calls the step makes, sent before them
1416
1493
  listed = pbNum(uf, 1)
1417
1494
  if (listed > 0 && calls >= listed) {
1418
- yield finish()
1495
+ yield await settled()
1419
1496
  return
1420
1497
  }
1421
1498
  break
@@ -1431,7 +1508,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1431
1508
  }
1432
1509
  if (listed > 0 && calls >= listed) {
1433
1510
  // every call made: the caller runs them
1434
- yield finish()
1511
+ yield await settled()
1435
1512
  return
1436
1513
  }
1437
1514
  break
@@ -1457,13 +1534,13 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1457
1534
  }
1458
1535
  }
1459
1536
  if (calls > 0) {
1460
- yield finish()
1537
+ yield await settled()
1461
1538
  return
1462
1539
  }
1463
1540
  yield { error: { status: 502, message: "the reply broke off: EOF" } }
1464
1541
  } catch (e) {
1465
1542
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
1466
- yield finish()
1543
+ yield await settled()
1467
1544
  return
1468
1545
  }
1469
1546
  yield { error: { status: 502, message: "the reply broke off: " + (e?.message ?? e) } }
@@ -1733,4 +1810,4 @@ export async function CursorAuthPlugin() {
1733
1810
  }
1734
1811
 
1735
1812
  // 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 }
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 }
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.9",
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",