@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.
- package/README.md +15 -1
- package/index.mjs +179 -11
- 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
|
-
|
|
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.
|
|
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",
|