@magpie-community/opencode-cursor-auth 0.2.1 → 0.2.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 (3) hide show
  1. package/README.md +15 -1
  2. package/index.mjs +179 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,18 @@ plugin's `fetch` answers them on Cursor's agent API:
38
38
  - Each request is one `agent.v1.AgentService/Run`, a Connect stream both
39
39
  ways over HTTP/2, with the CLI's headers (`x-cursor-client-type: cli`,
40
40
  its version, privacy mode on).
41
+ - Where HTTP/2 can't open a Run (an error before its response, or none in
42
+ 15 s: a proxy or network that blocks HTTP/2), it goes as Cursor's clients
43
+ run it without HTTP/2: `agent.v1.AgentService/RunSSE` down and a
44
+ `aiserver.v1.BidiService/BidiAppend` for each client message up, over
45
+ HTTP/1.1 on `api2.cursor.sh`, through the proxy magpie gives the request.
46
+ When the failure says HTTP/2 itself can't be had (no head in 15 s,
47
+ "h2 is not supported", no HTTP/2 in ALPN, a protocol error), the Runs
48
+ after it go straight to HTTP/1.1 for 10 minutes; a failed connection
49
+ (refused, no network) or a stream the server refused leaves the next Run
50
+ to try HTTP/2 again. A region error over HTTP/1.1 sends the Run back to
51
+ HTTP/2 at the region's agent host. A BidiAppend with no answer in 60 s
52
+ (more for a large one) ends the Run, as cursor-agent does.
41
53
  - The whole conversation goes each time, as AI SDK messages kept as blobs
42
54
  the server asks for. The caller's tools are MCP tools, listed in the
43
55
  system prompt; the model calls them through Cursor's `CallDynamicTool`,
@@ -87,7 +99,9 @@ minutes), the way pi-cursor-sdk lists Cursor's catalog:
87
99
  sent as the model's effort parameter. `none` turns a Claude's thinking off.
88
100
  - Fast is not a model of its own either: a `service_tier` of `priority`
89
101
  or `fast` sends `fast=true`, anything else `fast=false` (it costs more,
90
- so only when asked).
102
+ so only when asked). A model listed at a size Cursor has a fast variant
103
+ of (served to the account) says `fast: true`, which magpie offers its
104
+ Fast switch for (yetone/magpie#1360).
91
105
  - A request goes as the picker's variant those parameters pick, in Max Mode
92
106
  when that variant is Max Mode's (a 1M size, say), as the CLI turns it on.
93
107
  A size with no variant goes in Max Mode when it is over Cursor's limit
package/index.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  // CURSOR_API_KEY.
12
12
  //
13
13
  // Requests go to AgentService/Run, a Connect stream both ways over HTTP/2.
14
+ // Where HTTP/2 can't open one (a proxy or network that blocks it), the Run
15
+ // goes as Cursor's clients run it without HTTP/2: its replies down one
16
+ // server stream, AgentService/RunSSE, and each client message up as a call
17
+ // of its own, BidiService/BidiAppend, both over HTTP/1.1 on api2.
14
18
  // Cursor keeps a conversation on its client: each message of the prompt is
15
19
  // an AI SDK message in JSON, a blob named by its sha256, which the server
16
20
  // asks the client for as it reads them. So each request goes whole, as such
@@ -27,6 +31,7 @@
27
31
  import http2 from "node:http2"
28
32
  import { STATUS_CODES } from "node:http"
29
33
  import { gunzipSync } from "node:zlib"
34
+ import { Duplex } from "node:stream"
30
35
  import { createHash, randomBytes, randomUUID } from "node:crypto"
31
36
  import { execFile } from "node:child_process"
32
37
  import { readFile, stat, readdir, realpath } from "node:fs/promises"
@@ -542,17 +547,26 @@ function modelsOf(picker, ids) {
542
547
  const id = m.name + "@" + c
543
548
  if (used.has(id)) continue
544
549
  used.add(id)
545
- out.push({ ...base, id, name: base.title + " @ " + c, context: tokensOf(c) || DEFAULT_CONTEXT, size: c })
550
+ out.push({ ...base, id, name: base.title + " @ " + c, context: tokensOf(c) || DEFAULT_CONTEXT, size: c, offersFast: fastAt(variants, c) })
546
551
  }
547
552
  } else if (!used.has(m.name)) {
548
553
  used.add(m.name)
549
554
  const limit = needsMax(m, def) ? m.contextTokenLimitForMaxMode || m.contextTokenLimit : m.contextTokenLimit
550
- out.push({ ...base, id: m.name === "default" ? "auto" : m.name, name: base.title, context: limit > 0 ? limit : DEFAULT_CONTEXT, size: "" })
555
+ out.push({ ...base, id: m.name === "default" ? "auto" : m.name, name: base.title, context: limit > 0 ? limit : DEFAULT_CONTEXT, size: "", offersFast: fastAt(variants, "") })
551
556
  }
552
557
  }
553
558
  return out
554
559
  }
555
560
 
561
+ // fastAt is whether Cursor has a fast variant the account is served at
562
+ // the context size (any, for a model without sizes): what magpie offers
563
+ // fast for, which 0.1.x said with a -fast model of its own.
564
+ const fastAt = (variants, size) =>
565
+ variants.some((v) => {
566
+ const have = valuesOf(v)
567
+ return have.fast === "true" && (!size || have.context === undefined || have.context === size)
568
+ })
569
+
556
570
  // nearest is the variant of vs most like the parameters wanted, one at
557
571
  // their context size before any other.
558
572
  function nearest(vs, want) {
@@ -734,6 +748,9 @@ function runtimeModel(m) {
734
748
  },
735
749
  release_date: "",
736
750
  variants: Object.fromEntries(efforts.map((e) => [e, { reasoningEffort: e }])),
751
+ // a request's service_tier priority (or fast) runs it as Cursor's fast
752
+ // variant: magpie offers Fast for the models that have one
753
+ ...(m.offersFast ? { fast: true } : {}),
737
754
  }
738
755
  }
739
756
 
@@ -1497,18 +1514,61 @@ async function agentURL(tok, fresh) {
1497
1514
  }
1498
1515
 
1499
1516
  // open starts a Run at base: the session, the stream, and the response's
1500
- // status, or an error the request never got past.
1501
- function open(base, headers, signal) {
1517
+ // status, or an error the request never got past. A Run HTTP/2 can't open
1518
+ // goes over HTTP/1.1 (openH1). When the failure says HTTP/2 itself can't be
1519
+ // had here (h2Blocked), the Runs after it go straight to HTTP/1.1 for
1520
+ // H2.retryAfter, or until a region error sends one back (answerOf); a
1521
+ // connection that failed (refused, no network) or a stream the server
1522
+ // refused leaves the next Run to try HTTP/2 again.
1523
+ let h1Until = 0
1524
+ async function open(base, headers, signal) {
1525
+ if (Date.now() >= h1Until) {
1526
+ const o = await openH2(base, headers, signal)
1527
+ if (!o.h2Failed) return o
1528
+ if (o.blocked) h1Until = Date.now() + H2.retryAfter
1529
+ }
1530
+ return openH1(headers, signal)
1531
+ }
1532
+
1533
+ const H2 = {
1534
+ wait: 15_000, // ms an HTTP/2 Run waits for its response's head
1535
+ retryAfter: 10 * 60_000, // ms the Runs after one found HTTP/2 blocked stay on HTTP/1.1
1536
+ }
1537
+
1538
+ // NET are the failures of a connection itself, which say nothing of HTTP/2
1539
+ const NET = ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "ECONNRESET", "ETIMEDOUT", "ECONNABORTED", "EPIPE"]
1540
+
1541
+ // h2Blocked is whether a failure to open an HTTP/2 Run says HTTP/2 itself
1542
+ // can't be had: no head in H2.wait, the "h2 is not supported" magpie's
1543
+ // host gives a tunnel that can't carry it, a server or proxy that won't
1544
+ // negotiate it (ALPN), an HTTP/2 protocol error. Anything else, a failed
1545
+ // connection above all, is not.
1546
+ function h2Blocked(e) {
1547
+ if (e?.h2Timeout) return true
1548
+ const code = String(e?.code ?? "")
1549
+ const text = `${code} ${e?.message ?? ""}`
1550
+ if (NET.some((c) => text.includes(c))) return false
1551
+ // a stream the server refused or reset: HTTP/2 itself got through
1552
+ if (code === "ERR_HTTP2_STREAM_ERROR") return false
1553
+ return code.startsWith("ERR_HTTP2") || /h2 is not supported|alpn|nghttp2|protocol error/i.test(text)
1554
+ }
1555
+
1556
+ // openH2 is open over HTTP/2. A Run that fails before its response, other
1557
+ // than by being cancelled, says h2Failed, and blocked when h2Blocked says
1558
+ // so of the failure.
1559
+ function openH2(base, headers, signal) {
1502
1560
  return new Promise((resolve) => {
1503
1561
  const u = new URL(base)
1504
1562
  const session = http2.connect(u.origin)
1505
1563
  let done = false
1506
- const fail = (e) => {
1564
+ const fail = (e, h2Failed = true) => {
1507
1565
  if (done) return
1508
1566
  done = true
1567
+ clearTimeout(timer)
1509
1568
  session.destroy()
1510
- resolve({ error: { status: 502, message: e?.message ?? String(e) } })
1569
+ resolve({ error: { status: 502, message: e?.message ?? String(e) }, h2Failed, blocked: h2Failed && h2Blocked(e) })
1511
1570
  }
1571
+ const timer = setTimeout(() => fail(Object.assign(new Error(`no answer over HTTP/2 in ${H2.wait / 1000}s`), { h2Timeout: true })), H2.wait)
1512
1572
  session.on("error", fail)
1513
1573
  const req = session.request({
1514
1574
  ":method": "POST",
@@ -1516,15 +1576,120 @@ function open(base, headers, signal) {
1516
1576
  ...headers,
1517
1577
  })
1518
1578
  req.on("error", fail)
1579
+ // a stream reset before its head (RST_STREAM, REFUSED_STREAM) may close
1580
+ // it with no error said
1581
+ req.once("close", () => fail(Object.assign(new Error(`the HTTP/2 stream closed before its response (code ${req.rstCode ?? "?"})`), { code: "ERR_HTTP2_STREAM_ERROR" })))
1519
1582
  req.once("response", (h) => {
1520
1583
  if (done) return
1521
1584
  done = true
1585
+ clearTimeout(timer)
1522
1586
  resolve({ session, req, status: Number(h[":status"]) })
1523
1587
  })
1524
- signal?.addEventListener("abort", () => fail(new Error("the request was cancelled")), { once: true })
1588
+ signal?.addEventListener("abort", () => fail(new Error("the request was cancelled"), false), { once: true })
1525
1589
  })
1526
1590
  }
1527
1591
 
1592
+ const H1 = { appendWait: 60_000 } // ms a BidiAppend may take before the Run fails, as cursor-agent's appendTimeoutMs
1593
+
1594
+ // connectCode is Connect's code for an HTTP status that came with no
1595
+ // Connect error of its own
1596
+ function connectCode(status) {
1597
+ return { 400: "invalid_argument", 401: "unauthenticated", 403: "permission_denied", 429: "resource_exhausted" }[status] ?? "unavailable"
1598
+ }
1599
+
1600
+ // openH1 is open over HTTP/1.1, as Cursor's clients run a Run without
1601
+ // HTTP/2: what runOnce writes (a Connect frame each time) goes up as
1602
+ // BidiAppend calls in turn, numbered from 0, and what RunSSE sends down is
1603
+ // the stream it reads, Connect's frames as the HTTP/2 Run's. Its response
1604
+ // is a 200 at once; an error Cursor says later, or a call that fails,
1605
+ // ends the stream with Connect's end frame, which runOnce reads as the
1606
+ // Run's own.
1607
+ function openH1(headers, signal) {
1608
+ const ac = new AbortController()
1609
+ signal?.addEventListener("abort", () => ac.abort(), { once: true })
1610
+ const id = headers["x-request-id"]
1611
+ const h = { ...headers, "accept-encoding": "identity", "x-cursor-streaming": "true" }
1612
+ let ended = false
1613
+ let seq = 0
1614
+ let chain = Promise.resolve()
1615
+ const req = new Duplex({
1616
+ read() {},
1617
+ write(chunk, _enc, cb) {
1618
+ // runOnce writes one whole frame each time
1619
+ const msg = Buffer.from(chunk).subarray(5)
1620
+ const n = seq++
1621
+ chain = chain.then(() => (ended ? undefined : append(msg, n)))
1622
+ cb()
1623
+ },
1624
+ })
1625
+ const end = (body) => {
1626
+ if (ended || req.destroyed) return
1627
+ ended = true
1628
+ const f = frame(Buffer.from(body))
1629
+ f[0] = 2
1630
+ req.push(f)
1631
+ req.push(null)
1632
+ ac.abort()
1633
+ }
1634
+ const failed = async (res) => {
1635
+ const text = await res.text().catch(() => "")
1636
+ let e
1637
+ try {
1638
+ e = JSON.parse(text)
1639
+ } catch {}
1640
+ end(JSON.stringify({ error: typeof e?.code === "string" ? e : { code: connectCode(res.status), message: text.trim() || `HTTP ${res.status}` } }))
1641
+ }
1642
+ // append sends one message up. One that gets no answer by its deadline
1643
+ // (more time for a large one) ends the Run, as cursor-agent's
1644
+ // bidi_append_deadline_exceeded does: the messages after it wait on it.
1645
+ const append = async (msg, n) => {
1646
+ const body = pb().str(1, msg.toString("hex")).bytes(2, pb().str(1, id).done()).varint(3, n).done()
1647
+ const ms = H1.appendWait + Math.ceil(body.length / 65536) * 1000
1648
+ const deadline = AbortSignal.timeout(ms)
1649
+ try {
1650
+ const res = await fetch(API + "/aiserver.v1.BidiService/BidiAppend", {
1651
+ method: "POST",
1652
+ headers: { ...h, "content-type": "application/proto", "connect-protocol-version": "1" },
1653
+ body,
1654
+ signal: AbortSignal.any([ac.signal, deadline]),
1655
+ })
1656
+ if (!res.ok) return failed(res)
1657
+ await res.arrayBuffer()
1658
+ } catch (e) {
1659
+ if (ac.signal.aborted) return
1660
+ if (deadline.aborted) return end(JSON.stringify({ error: { code: "deadline_exceeded", message: `BidiAppend got no answer in ${Math.round(ms / 1000)}s` } }))
1661
+ end(JSON.stringify({ error: { code: "unavailable", message: `BidiAppend: ${e?.message ?? e}` } }))
1662
+ }
1663
+ }
1664
+ ;(async () => {
1665
+ try {
1666
+ const res = await fetch(API + "/agent.v1.AgentService/RunSSE", {
1667
+ method: "POST",
1668
+ headers: { ...h, "content-type": "application/connect+proto", "connect-protocol-version": "1" },
1669
+ body: frame(pb().str(1, id).done()),
1670
+ signal: ac.signal,
1671
+ })
1672
+ if (!res.ok) return failed(res)
1673
+ for await (const c of res.body) {
1674
+ if (ended || req.destroyed) return
1675
+ req.push(Buffer.from(c))
1676
+ }
1677
+ if (!ended && !req.destroyed) {
1678
+ ended = true
1679
+ req.push(null)
1680
+ }
1681
+ } catch (e) {
1682
+ if (!ac.signal.aborted) end(JSON.stringify({ error: { code: "unavailable", message: `RunSSE: ${e?.message ?? e}` } }))
1683
+ }
1684
+ })()
1685
+ const close = () => {
1686
+ ended = true
1687
+ ac.abort()
1688
+ req.destroy()
1689
+ }
1690
+ return Promise.resolve({ session: { close, destroy: close }, req: Object.assign(req, { close }), status: 200, h1: true })
1691
+ }
1692
+
1528
1693
  // runOnce is one Run of the chat on the agent API at base: an async
1529
1694
  // iterator of the answer's pieces, or the error it failed with before any.
1530
1695
  async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
@@ -1603,7 +1768,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
1603
1768
  if (head.done) return book.done(), { error: { status: 502, message: "an empty reply" } }
1604
1769
  if (head.value.error) {
1605
1770
  await wrapped.return()
1606
- return { error: head.value.error }
1771
+ return { error: head.value.error, h1: !!o.h1 }
1607
1772
  }
1608
1773
  return {
1609
1774
  events: (async function* () {
@@ -1871,9 +2036,12 @@ async function answerOf(auth, chat, signal, session = "") {
1871
2036
  let r = await once()
1872
2037
  if (r.error && regional(r.error.message)) {
1873
2038
  // the team moved, or the config was kept from before: once more with
1874
- // what the config says now
2039
+ // what the config says now. A Run that went over HTTP/1.1 went to api2,
2040
+ // not the region's agent host: once more over HTTP/2 there.
2041
+ const viaH1 = !!r.h1
2042
+ h1Until = 0
1875
2043
  const fresh = await agentURL(tok, true)
1876
- if (fresh !== base) {
2044
+ if (fresh !== base || viaH1) {
1877
2045
  base = fresh
1878
2046
  r = await once()
1879
2047
  }
@@ -2029,4 +2197,4 @@ export async function CursorAuthPlugin() {
2029
2197
  }
2030
2198
 
2031
2199
  // for tests
2032
- export const _internal = { STEP_WAIT, stepUsage, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
2200
+ export const _internal = { resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, H2, H1, h2Blocked, open, openH1, STEP_WAIT, stepUsage, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, 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.2.1",
3
+ "version": "0.2.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",