@magpie-community/opencode-cursor-auth 0.1.7 → 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 +99 -14
  3. package/package.json +3 -2
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
@@ -981,11 +981,14 @@ function toolsOf(chat) {
981
981
  // catalog lists the caller's tools for the model. It sees MCP tools only
982
982
  // through Cursor's GetDynamicTools and CallDynamicTool, and Cursor names
983
983
  // them in a prompt of its own, which this conversation goes without:
984
- // without the list the model says it has no such tool.
984
+ // without the list the model says it has no such tool. Each entry is
985
+ // written as the CallDynamicTool call itself: listed as <tool name=...>,
986
+ // some models (GLM-5.3) called the names bare, as tools of their own, and
987
+ // those calls went nowhere (#11).
985
988
  function catalog(tools) {
986
989
  if (!tools.length) return ""
987
- let s = `\n\n<dynamic_tool_catalog>\nThe tools below are available in the MCP namespace "magpie". Call one with \`${CALL}\` (namespace "magpie", toolName, arguments). Their schemas are given here, so there is no need to call \`GetDynamicTools\` first.\n`
988
- for (const t of tools) s += `<tool name="${t.name}">\n${t.description}\ninput schema: ${t.schemaText}\n</tool>\n`
990
+ let s = `\n\n<dynamic_tool_catalog>\nThe MCP namespace "magpie" has the tools below. They are reached only through \`${CALL}\`, never as tools of their own. Each entry is the exact call to make; its arguments must match the schema given. The schemas are complete, so there is no need to call \`GetDynamicTools\` first.\n`
991
+ for (const t of tools) s += `<call>${CALL}({"namespace":"magpie","toolName":"${t.name}","arguments":{...}})\n${t.description}\narguments schema: ${t.schemaText}\n</call>\n`
989
992
  return s + "</dynamic_tool_catalog>"
990
993
  }
991
994
 
@@ -1161,6 +1164,59 @@ function usageOf(uf) {
1161
1164
  return { input: Math.max(inp - cr - cw, 0), output: pbNum(uf, 2), cacheRead: cr, cacheWrite: cw, reasoning: pbNum(uf, 5) }
1162
1165
  }
1163
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
+
1164
1220
  // buildRun is the Run's first message, an AgentClientMessage with its
1165
1221
  // run_request, and the blobs it names. The conversation state is the
1166
1222
  // messages and one turn, which the server wants there to sample at all.
@@ -1272,7 +1328,11 @@ function open(base, headers, signal) {
1272
1328
  // runOnce is one Run of the chat on the agent API at base: an async
1273
1329
  // iterator of the answer's pieces, or the error it failed with before any.
1274
1330
  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 })
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 })
1276
1336
  const o = await open(
1277
1337
  base,
1278
1338
  {
@@ -1315,7 +1375,14 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1315
1375
  close()
1316
1376
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1317
1377
  }
1318
- 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
+ })
1319
1386
  const wrapped = (async function* () {
1320
1387
  try {
1321
1388
  yield* it
@@ -1341,10 +1408,14 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1341
1408
 
1342
1409
  // decode follows the server's messages into the answer's pieces until the
1343
1410
  // turn ends, or the model has made its tool calls.
1344
- async function* decode(stream, { send, blobs, tools, estimate }) {
1411
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage }) {
1345
1412
  let calls = 0
1413
+ // ended: Cursor said what the turn used
1414
+ let ended = false
1346
1415
  let listed = 0
1347
1416
  let said = 0
1417
+ // wrote: the reply's text alone; a turn of thinking only says nothing
1418
+ let wrote = 0
1348
1419
  let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1349
1420
  const finish = () => {
1350
1421
  if (!usage.output) usage.output = Math.floor((said + 3) / 4)
@@ -1363,14 +1434,24 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1363
1434
  },
1364
1435
  }
1365
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
+ }
1366
1447
  const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1367
1448
  try {
1368
1449
  for await (const f of stream) {
1369
1450
  if (f.end) {
1370
1451
  const e = failure(200, f.data)
1371
1452
  if (e.status < 200 || e.status > 299) yield { error: e }
1372
- else if (said === 0 && calls === 0) yield { error: { status: 502, message: "an empty reply" } }
1373
- else yield finish()
1453
+ else if (wrote === 0 && calls === 0) yield { error: { status: 502, message: "an empty reply" } }
1454
+ else yield await settled()
1374
1455
  return
1375
1456
  }
1376
1457
  for (const m of fields(f.data)) {
@@ -1384,6 +1465,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1384
1465
  const t = pbStr(uf, 1)
1385
1466
  if (t) {
1386
1467
  said += t.length
1468
+ wrote += t.length
1387
1469
  yield { text: t }
1388
1470
  }
1389
1471
  break
@@ -1399,15 +1481,18 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1399
1481
  }
1400
1482
  case 14: // turn ended, with what it used
1401
1483
  usage = usageOf(uf)
1484
+ ended = true
1402
1485
  if (calls === 0) {
1403
- yield finish()
1486
+ // nothing written and nothing called: an empty reply,
1487
+ // which the client asks again, not a finished turn
1488
+ yield wrote === 0 ? { error: { status: 502, message: "an empty reply" } } : finish()
1404
1489
  return
1405
1490
  }
1406
1491
  break
1407
1492
  case 27: // how many tool calls the step makes, sent before them
1408
1493
  listed = pbNum(uf, 1)
1409
1494
  if (listed > 0 && calls >= listed) {
1410
- yield finish()
1495
+ yield await settled()
1411
1496
  return
1412
1497
  }
1413
1498
  break
@@ -1423,7 +1508,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1423
1508
  }
1424
1509
  if (listed > 0 && calls >= listed) {
1425
1510
  // every call made: the caller runs them
1426
- yield finish()
1511
+ yield await settled()
1427
1512
  return
1428
1513
  }
1429
1514
  break
@@ -1449,13 +1534,13 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1449
1534
  }
1450
1535
  }
1451
1536
  if (calls > 0) {
1452
- yield finish()
1537
+ yield await settled()
1453
1538
  return
1454
1539
  }
1455
1540
  yield { error: { status: 502, message: "the reply broke off: EOF" } }
1456
1541
  } catch (e) {
1457
1542
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
1458
- yield finish()
1543
+ yield await settled()
1459
1544
  return
1460
1545
  }
1461
1546
  yield { error: { status: 502, message: "the reply broke off: " + (e?.message ?? e) } }
@@ -1725,4 +1810,4 @@ export async function CursorAuthPlugin() {
1725
1810
  }
1726
1811
 
1727
1812
  // for tests
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 }
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,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.1.7",
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",
7
7
  "exports": "./index.mjs",
8
8
  "files": ["index.mjs", "README.md"],
9
9
  "keywords": ["opencode", "opencode-plugin", "magpie", "cursor"],
10
- "license": "MIT"
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/cursor" }
11
12
  }