@magpie-community/opencode-cursor-auth 0.1.1 → 0.1.3

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 (2) hide show
  1. package/index.mjs +48 -25
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -207,6 +207,23 @@ async function whoIs(tok) {
207
207
  }
208
208
  }
209
209
 
210
+ // plans are the plan each token's account is on, as `cursor-agent about`
211
+ // names it (GetPlanInfo's planName, magpie's card title), asked again after
212
+ // an hour.
213
+ const plans = new Map()
214
+ const PLAN_TTL = 3600_000
215
+ async function planOf(tok) {
216
+ const had = plans.get(tok)
217
+ if (had && Date.now() - had.at < PLAN_TTL) return had.plan
218
+ try {
219
+ const plan = String((await unary(API, "aiserver.v1.DashboardService/GetPlanInfo", tok))?.planInfo?.planName ?? "").trim()
220
+ if (plan) plans.set(tok, { plan, at: Date.now() })
221
+ return plan || had?.plan || ""
222
+ } catch {
223
+ return had?.plan ?? ""
224
+ }
225
+ }
226
+
210
227
  // exchanged are API keys' tokens, by the key's hash.
211
228
  const exchanged = new Map()
212
229
 
@@ -222,9 +239,9 @@ async function keyToken(key) {
222
239
  body: "{}",
223
240
  signal: AbortSignal.timeout(15_000),
224
241
  }).catch((e) => {
225
- throw Object.assign(new Error("Cursor: couldn't reach the API: " + e.message), { status: 502 })
242
+ throw Object.assign(new Error("couldn't reach Cursor's API: " + e.message), { status: 502 })
226
243
  })
227
- if (res.status >= 500) throw Object.assign(new Error(`Cursor: exchange_user_api_key: HTTP ${res.status}`), { status: 502 })
244
+ if (res.status >= 500) throw Object.assign(new Error(`exchange_user_api_key: HTTP ${res.status}`), { status: 502 })
228
245
  const j = res.ok ? await res.json().catch(() => null) : null
229
246
  if (!j?.accessToken) throw new AuthError("Cursor didn't take this API key")
230
247
  exchanged.set(h, { tok: j.accessToken, exp: expiry(j.accessToken) })
@@ -268,7 +285,7 @@ async function browserSignIn() {
268
285
  try {
269
286
  res = await fetch(`${API}/auth/poll?uuid=${uuid}&verifier=${verifier}`, { headers, signal: AbortSignal.timeout(15_000) })
270
287
  } catch {
271
- if (++errors >= 3) return { type: "failed" }
288
+ if (++errors >= 3) return { type: "failed", error: "couldn't reach Cursor" }
272
289
  await sleep(wait)
273
290
  continue
274
291
  }
@@ -278,12 +295,12 @@ async function browserSignIn() {
278
295
  continue
279
296
  }
280
297
  if (!res.ok) {
281
- if (res.status === 403 || ++errors >= 3) return { type: "failed" }
298
+ if (res.status === 403 || ++errors >= 3) return { type: "failed", error: res.status === 403 ? "Cursor refused the sign-in (403)" : `Cursor's sign-in answered HTTP ${res.status}` }
282
299
  await sleep(wait)
283
300
  continue
284
301
  }
285
302
  const j = await res.json().catch(() => null)
286
- if (!j?.accessToken || !("refreshToken" in j)) return { type: "failed" }
303
+ if (!j?.accessToken || !("refreshToken" in j)) return { type: "failed", error: "Cursor sent back no token" }
287
304
  const who = await whoIs(j.accessToken)
288
305
  return {
289
306
  type: "success",
@@ -294,7 +311,7 @@ async function browserSignIn() {
294
311
  ...(who.plan ? { plan: who.plan } : {}),
295
312
  }
296
313
  }
297
- return { type: "failed" }
314
+ return { type: "failed", error: "the sign-in timed out" }
298
315
  },
299
316
  }
300
317
  }
@@ -309,9 +326,10 @@ async function cliSignIn() {
309
326
  async callback() {
310
327
  const tok = await cliToken()
311
328
  const exp = expiry(tok)
312
- if (!tok || (exp && exp <= Date.now())) return { type: "failed" }
329
+ if (!tok) return { type: "failed", error: "cursor-agent isn't signed in: run `cursor-agent login`" }
330
+ if (exp && exp <= Date.now()) return { type: "failed", error: "cursor-agent's sign-in has run out: run `cursor-agent login` again" }
313
331
  const who = await whoIs(tok)
314
- if (!who.email) return { type: "failed" }
332
+ if (!who.email) return { type: "failed", error: "Cursor couldn't say which account is signed in" }
315
333
  return { type: "success", access: "", refresh: CLI_MARK, expires: 0, accountId: who.email, ...(who.plan ? { plan: who.plan } : {}) }
316
334
  },
317
335
  }
@@ -829,15 +847,21 @@ function failure(status, text) {
829
847
  return statusOf(status, f.code ?? "", msg)
830
848
  }
831
849
 
850
+ // signInExpired is a message of a token or session run out; a trial, a
851
+ // plan or a link run out is no lapsed sign-in, and magpie marks the
852
+ // account lapsed on a 401.
853
+ const signInExpired = (low) => low.includes("expired") && /token|session|sign[- ]?in|log[- ]?in|auth|credential/.test(low)
854
+
832
855
  // statusOf is the status and words for Cursor's error of this code and
833
856
  // message. A region the team isn't served in says so, not to sign in.
834
857
  function statusOf(status, code, msg) {
835
858
  msg ||= `HTTP ${status}`
836
859
  const low = msg.toLowerCase()
837
- const out = (status, message) => ({ status, message: "Cursor: " + message })
860
+ // magpie names the provider before the message itself
861
+ const out = (status, message) => ({ status, message })
838
862
  if (regional(msg)) return out(403, msg + " — Cursor serves your team only in some regions and turned this request away; signing in again won't change that")
839
863
  if (code === "permission_denied") return out(403, msg)
840
- if (code === "unauthenticated" || status === 401 || low.includes("expired")) return out(401, msg + " — sign in to Cursor again")
864
+ if (code === "unauthenticated" || status === 401 || signInExpired(low)) return out(401, msg + " — sign in to Cursor again")
841
865
  if (code === "resource_exhausted" || low.includes("quota") || low.includes("rate limit") || low.includes("usage limit"))
842
866
  return out(429, "usage limit reached: " + msg)
843
867
  if (low.includes("too long") || low.includes("context length") || low.includes("too many tokens"))
@@ -1076,7 +1100,7 @@ function open(base, headers, signal) {
1076
1100
  if (done) return
1077
1101
  done = true
1078
1102
  session.destroy()
1079
- resolve({ error: { status: 502, message: "Cursor: " + (e?.message ?? String(e)) } })
1103
+ resolve({ error: { status: 502, message: e?.message ?? String(e) } })
1080
1104
  }
1081
1105
  session.on("error", fail)
1082
1106
  const req = session.request({
@@ -1151,7 +1175,7 @@ async function runOnce({ tok, base, id, tools, conv, signal }) {
1151
1175
  // an error comes before anything of the answer: out of quota, a model the
1152
1176
  // plan doesn't have — answered with its own status
1153
1177
  const head = await wrapped.next()
1154
- if (head.done) return { error: { status: 502, message: "Cursor: an empty reply" } }
1178
+ if (head.done) return { error: { status: 502, message: "an empty reply" } }
1155
1179
  if (head.value.error) {
1156
1180
  await wrapped.return()
1157
1181
  return { error: head.value.error }
@@ -1191,7 +1215,7 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1191
1215
  if (f.end) {
1192
1216
  const e = failure(200, f.data)
1193
1217
  if (e.status < 200 || e.status > 299) yield { error: e }
1194
- else if (said === 0 && calls === 0) yield { error: { status: 502, message: "Cursor: an empty reply" } }
1218
+ else if (said === 0 && calls === 0) yield { error: { status: 502, message: "an empty reply" } }
1195
1219
  else yield finish()
1196
1220
  return
1197
1221
  }
@@ -1274,13 +1298,13 @@ async function* decode(stream, { send, blobs, tools, estimate }) {
1274
1298
  yield finish()
1275
1299
  return
1276
1300
  }
1277
- yield { error: { status: 502, message: "Cursor: the reply broke off: EOF" } }
1301
+ yield { error: { status: 502, message: "the reply broke off: EOF" } }
1278
1302
  } catch (e) {
1279
1303
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
1280
1304
  yield finish()
1281
1305
  return
1282
1306
  }
1283
- yield { error: { status: 502, message: "Cursor: the reply broke off: " + (e?.message ?? e) } }
1307
+ yield { error: { status: 502, message: "the reply broke off: " + (e?.message ?? e) } }
1284
1308
  }
1285
1309
  }
1286
1310
 
@@ -1346,7 +1370,7 @@ async function answer(auth, chat, signal) {
1346
1370
  try {
1347
1371
  tok = await tokenOf(auth)
1348
1372
  } catch (e) {
1349
- return errorResponse({ status: e.status ?? 401, message: e.message.startsWith("Cursor") ? e.message : "Cursor: " + e.message })
1373
+ return errorResponse({ status: e.status ?? 401, message: e.message })
1350
1374
  }
1351
1375
  const model = String(chat.model ?? "")
1352
1376
  let raw = []
@@ -1438,13 +1462,13 @@ export async function CursorAuthPlugin() {
1438
1462
  async fetch(input, init = {}) {
1439
1463
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1440
1464
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
1441
- return errorResponse({ status: 404, message: "Cursor: only chat completions are served" })
1465
+ return errorResponse({ status: 404, message: "only chat completions are served" })
1442
1466
  let chat
1443
1467
  try {
1444
1468
  const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
1445
1469
  chat = JSON.parse(typeof b === "string" ? b : new TextDecoder().decode(b))
1446
1470
  } catch {
1447
- return errorResponse({ status: 400, message: "Cursor: a request that isn't JSON" })
1471
+ return errorResponse({ status: 400, message: "a request that isn't JSON" })
1448
1472
  }
1449
1473
  return answer((await getAuth()) ?? auth, chat, init.signal ?? (input instanceof Request ? input.signal : undefined))
1450
1474
  },
@@ -1464,7 +1488,8 @@ export async function CursorAuthPlugin() {
1464
1488
  } catch (e) {
1465
1489
  return { error: e.message, windows: [] }
1466
1490
  }
1467
- return usage(tok, Object.keys(provider?.models ?? {}))
1491
+ const [u, plan] = await Promise.all([usage(tok, Object.keys(provider?.models ?? {})), planOf(tok)])
1492
+ return plan ? { ...u, plan } : u
1468
1493
  },
1469
1494
  },
1470
1495
  async config(config) {
@@ -1487,12 +1512,10 @@ export async function CursorAuthPlugin() {
1487
1512
  id: ID,
1488
1513
  async models(provider, { auth } = {}) {
1489
1514
  if (!auth) return provider.models
1490
- try {
1491
- const tok = await tokenOf(auth)
1492
- return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
1493
- } catch {
1494
- return provider.models
1495
- }
1515
+ // a list Cursor couldn't give is a failure, not the few models
1516
+ // configured: magpie keeps the list it had
1517
+ const tok = await tokenOf(auth)
1518
+ return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
1496
1519
  },
1497
1520
  },
1498
1521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",