@magpie-community/opencode-cursor-auth 0.1.0
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 +70 -0
- package/index.mjs +1417 -0
- package/package.json +11 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
// Cursor subscriptions as an OpenCode provider plugin, served through the
|
|
2
|
+
// API cursor-agent talks to. Ported from magpie's built-in Cursor account
|
|
3
|
+
// (internal/provider/cursor*.go, internal/gateway/cursor.go).
|
|
4
|
+
//
|
|
5
|
+
// An account is a Cursor access token, got one of three ways:
|
|
6
|
+
// - the sign-in cursor-agent does (cursor.com/loginDeepControl with a PKCE
|
|
7
|
+
// challenge, then /auth/poll until the browser has signed in);
|
|
8
|
+
// - the token cursor-agent itself keeps (the Mac keychain, else its
|
|
9
|
+
// auth.json), read each time and never written;
|
|
10
|
+
// - a Cursor API key, exchanged for a token as the CLI does with
|
|
11
|
+
// CURSOR_API_KEY.
|
|
12
|
+
//
|
|
13
|
+
// Requests go to AgentService/Run, a Connect stream both ways over HTTP/2.
|
|
14
|
+
// Cursor keeps a conversation on its client: each message of the prompt is
|
|
15
|
+
// an AI SDK message in JSON, a blob named by its sha256, which the server
|
|
16
|
+
// asks the client for as it reads them. So each request goes whole, as such
|
|
17
|
+
// a conversation, the caller's system prompt at its head; the Run only
|
|
18
|
+
// resumes it. The caller's tools are MCP tools, which the model calls
|
|
19
|
+
// through Cursor's CallDynamicTool: the server hands each call to the
|
|
20
|
+
// client to run, and once it has said how many a step made, the stream is
|
|
21
|
+
// closed and the calls go back to OpenCode as a chat completion's tool
|
|
22
|
+
// calls. Nothing else the server asks the client to run (a shell, a file
|
|
23
|
+
// read) is run.
|
|
24
|
+
//
|
|
25
|
+
// OpenCode speaks chat completions to it (@ai-sdk/openai-compatible); the
|
|
26
|
+
// fetch here answers them.
|
|
27
|
+
import http2 from "node:http2"
|
|
28
|
+
import { gunzipSync } from "node:zlib"
|
|
29
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto"
|
|
30
|
+
import { execFile } from "node:child_process"
|
|
31
|
+
import { readFile, stat, readdir, realpath } from "node:fs/promises"
|
|
32
|
+
import { homedir, platform, tmpdir } from "node:os"
|
|
33
|
+
import { basename, dirname, join } from "node:path"
|
|
34
|
+
|
|
35
|
+
const ID = "cursor"
|
|
36
|
+
const API = "https://api2.cursor.sh"
|
|
37
|
+
const WEBSITE = "https://cursor.com"
|
|
38
|
+
const AGENT = "https://agentn.global.api5.cursor.sh" // when the server config can't say
|
|
39
|
+
const CHAT = "@ai-sdk/openai-compatible"
|
|
40
|
+
const VERSION_FALLBACK = "2026.09.23-86fc751" // the CLI version said when no install names one
|
|
41
|
+
const DEFAULT_CONTEXT = 200_000 // what Cursor gives a model it doesn't name as a 1M one
|
|
42
|
+
const MODELS_KEEP = 10 * 60 * 1000
|
|
43
|
+
const CALL = "CallDynamicTool" // how the model calls an MCP tool
|
|
44
|
+
const NO_RESULT = "Tool use was interrupted and did not produce a result."
|
|
45
|
+
const CLI_MARK = "cursor-agent" // an oauth sign-in's refresh when the token is cursor-agent's own
|
|
46
|
+
|
|
47
|
+
const GOOS = { win32: "windows" }[platform()] ?? platform()
|
|
48
|
+
const EMPTY = Buffer.alloc(0)
|
|
49
|
+
const enc = new TextEncoder()
|
|
50
|
+
|
|
51
|
+
// ---- cursor-agent ---------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const exists = (p) => stat(p).then((s) => !s.isDirectory(), () => false)
|
|
54
|
+
|
|
55
|
+
function run(file, args, { timeout = 10_000, env } = {}) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
execFile(file, args, { timeout, env: env ?? process.env, maxBuffer: 8 << 20 }, (err, stdout) =>
|
|
58
|
+
resolve({ ok: !err, out: String(stdout ?? "") }))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// cursorExecutable finds the cursor-agent CLI, or Cursor's `agent` by that
|
|
63
|
+
// name.
|
|
64
|
+
async function cursorExecutable() {
|
|
65
|
+
const dirs = (process.env.PATH ?? "").split(GOOS === "windows" ? ";" : ":").filter(Boolean)
|
|
66
|
+
for (const name of ["cursor-agent", "agent"]) {
|
|
67
|
+
for (const d of dirs) {
|
|
68
|
+
const p = join(d, GOOS === "windows" ? name + ".exe" : name)
|
|
69
|
+
if (!(await exists(p))) continue
|
|
70
|
+
if (name === "cursor-agent") return p
|
|
71
|
+
if ((await realpath(p).catch(() => "")).includes("cursor-agent")) return p
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const home = homedir()
|
|
75
|
+
for (const p of [join(home, ".local", "bin", "cursor-agent"), "/usr/local/bin/cursor-agent", "/opt/homebrew/bin/cursor-agent"])
|
|
76
|
+
if (await exists(p)) return p
|
|
77
|
+
return ""
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const VERSION_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/
|
|
81
|
+
|
|
82
|
+
// clientVersion is the cursor-agent the API is told it is talking to,
|
|
83
|
+
// "cli-<version>": the installed one, as the API turns away a version it no
|
|
84
|
+
// longer supports.
|
|
85
|
+
let versionSaid = ""
|
|
86
|
+
async function clientVersion() {
|
|
87
|
+
if (versionSaid) return versionSaid
|
|
88
|
+
let v = ""
|
|
89
|
+
const exe = await cursorExecutable()
|
|
90
|
+
if (exe) {
|
|
91
|
+
const d = basename(dirname(await realpath(exe).catch(() => exe)))
|
|
92
|
+
if (VERSION_RE.test(d)) v = d
|
|
93
|
+
}
|
|
94
|
+
if (!v) {
|
|
95
|
+
const dirs = [join(homedir(), ".local", "share", "cursor-agent", "versions")]
|
|
96
|
+
if (GOOS === "windows") dirs.push(join(process.env.LOCALAPPDATA ?? "", "cursor-agent", "versions"))
|
|
97
|
+
for (const dir of dirs)
|
|
98
|
+
for (const n of await readdir(dir).catch(() => [])) if (VERSION_RE.test(n) && n > v) v = n
|
|
99
|
+
}
|
|
100
|
+
versionSaid = "cli-" + (v || VERSION_FALLBACK)
|
|
101
|
+
return versionSaid
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// authFile is where cursor-agent keeps its sign-in off a Mac's keychain.
|
|
105
|
+
function authFile() {
|
|
106
|
+
const home = homedir()
|
|
107
|
+
if (GOOS === "windows") return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "Cursor", "auth.json")
|
|
108
|
+
if (GOOS === "darwin") return join(home, ".cursor", "auth.json")
|
|
109
|
+
return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// cliToken is the access token cursor-agent signed in with; read, never
|
|
113
|
+
// written.
|
|
114
|
+
async function cliToken() {
|
|
115
|
+
if (GOOS === "darwin") {
|
|
116
|
+
const r = await run("security", ["find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w"])
|
|
117
|
+
if (r.ok && r.out.trim()) return r.out.trim()
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(await readFile(authFile(), "utf8"))?.accessToken ?? ""
|
|
121
|
+
} catch {
|
|
122
|
+
return ""
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// expiry is when a JWT runs out, in ms; 0 when it doesn't say.
|
|
127
|
+
function expiry(tok) {
|
|
128
|
+
const parts = String(tok ?? "").split(".")
|
|
129
|
+
if (parts.length !== 3) return 0
|
|
130
|
+
try {
|
|
131
|
+
const exp = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))?.exp
|
|
132
|
+
return typeof exp === "number" && exp > 0 ? exp * 1000 : 0
|
|
133
|
+
} catch {
|
|
134
|
+
return 0
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SOON = 5 * 60 * 1000
|
|
139
|
+
|
|
140
|
+
class AuthError extends Error {
|
|
141
|
+
constructor(msg) {
|
|
142
|
+
super(msg)
|
|
143
|
+
this.status = 401
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// cliTokenNow is cursor-agent's token; one about to run out is renewed by
|
|
148
|
+
// cursor-agent, which does that whenever it runs.
|
|
149
|
+
let cliRefresh = null
|
|
150
|
+
async function cliTokenNow() {
|
|
151
|
+
let tok = await cliToken()
|
|
152
|
+
const exp = expiry(tok)
|
|
153
|
+
if (tok && (!exp || exp - Date.now() > SOON)) return tok
|
|
154
|
+
const exe = await cursorExecutable()
|
|
155
|
+
if (exe) {
|
|
156
|
+
cliRefresh ??= run(exe, ["status"], { timeout: 30_000 }).finally(() => (cliRefresh = null))
|
|
157
|
+
await cliRefresh
|
|
158
|
+
tok = await cliToken()
|
|
159
|
+
}
|
|
160
|
+
if (!tok) throw new AuthError("Cursor isn't signed in: run `cursor-agent login`, or sign in again")
|
|
161
|
+
const e = expiry(tok)
|
|
162
|
+
if (e && e <= Date.now()) throw new AuthError("Cursor's sign-in has run out: run `cursor-agent login`, or sign in again")
|
|
163
|
+
return tok
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- the account -----------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
async function headersFor(tok) {
|
|
169
|
+
return {
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
"Connect-Protocol-Version": "1",
|
|
172
|
+
...(tok ? { Authorization: `Bearer ${tok}` } : {}),
|
|
173
|
+
"x-cursor-client-version": await clientVersion(),
|
|
174
|
+
"x-cursor-client-type": "cli",
|
|
175
|
+
"x-ghost-mode": "true",
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// unary is a Connect call in JSON.
|
|
180
|
+
async function unary(base, path, tok, body = {}, timeout = 15_000) {
|
|
181
|
+
const res = await fetch(base + "/" + path, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: await headersFor(tok),
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
signal: AbortSignal.timeout(timeout),
|
|
186
|
+
})
|
|
187
|
+
const text = await res.text()
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const f = failure(res.status, text)
|
|
190
|
+
const e = new Error(f.message)
|
|
191
|
+
e.status = f.status
|
|
192
|
+
throw e
|
|
193
|
+
}
|
|
194
|
+
return JSON.parse(text || "{}")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// whoIs is the account's email and plan, as `cursor-agent about` says them.
|
|
198
|
+
async function whoIs(tok) {
|
|
199
|
+
const [me, plan] = await Promise.allSettled([
|
|
200
|
+
unary(API, "aiserver.v1.DashboardService/GetMe", tok),
|
|
201
|
+
unary(API, "aiserver.v1.DashboardService/GetPlanInfo", tok),
|
|
202
|
+
])
|
|
203
|
+
return {
|
|
204
|
+
email: me.status === "fulfilled" ? String(me.value?.email ?? "").trim() : "",
|
|
205
|
+
plan: plan.status === "fulfilled" ? String(plan.value?.planInfo?.planName ?? "").trim() : "",
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// exchanged are API keys' tokens, by the key's hash.
|
|
210
|
+
const exchanged = new Map()
|
|
211
|
+
|
|
212
|
+
// keyToken is the access token for a Cursor API key, as the CLI gets it
|
|
213
|
+
// for CURSOR_API_KEY: /auth/exchange_user_api_key, again when it runs out.
|
|
214
|
+
async function keyToken(key) {
|
|
215
|
+
const h = createHash("sha256").update(key).digest("hex")
|
|
216
|
+
const had = exchanged.get(h)
|
|
217
|
+
if (had && (!had.exp || had.exp - Date.now() > SOON)) return had.tok
|
|
218
|
+
const res = await fetch(API + "/auth/exchange_user_api_key", {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { ...(await headersFor(key)), "Connect-Protocol-Version": undefined },
|
|
221
|
+
body: "{}",
|
|
222
|
+
signal: AbortSignal.timeout(15_000),
|
|
223
|
+
}).catch((e) => {
|
|
224
|
+
throw Object.assign(new Error("Cursor: couldn't reach the API: " + e.message), { status: 502 })
|
|
225
|
+
})
|
|
226
|
+
if (res.status >= 500) throw Object.assign(new Error(`Cursor: exchange_user_api_key: HTTP ${res.status}`), { status: 502 })
|
|
227
|
+
const j = res.ok ? await res.json().catch(() => null) : null
|
|
228
|
+
if (!j?.accessToken) throw new AuthError("Cursor didn't take this API key")
|
|
229
|
+
exchanged.set(h, { tok: j.accessToken, exp: expiry(j.accessToken) })
|
|
230
|
+
return j.accessToken
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// tokenOf is the token to call Cursor's API with, for a sign-in.
|
|
234
|
+
async function tokenOf(auth) {
|
|
235
|
+
if (auth?.type === "api" && auth.key) return keyToken(auth.key)
|
|
236
|
+
if (auth?.type !== "oauth") throw new AuthError("Cursor isn't signed in")
|
|
237
|
+
if (auth.refresh === CLI_MARK) return cliTokenNow()
|
|
238
|
+
const exp = auth.expires || expiry(auth.access)
|
|
239
|
+
if (!auth.access) throw new AuthError("Cursor isn't signed in")
|
|
240
|
+
// Cursor's sign-in has nothing to renew it with: cursor-agent itself
|
|
241
|
+
// signs in again when it runs out (about two months)
|
|
242
|
+
if (exp && exp <= Date.now()) throw new AuthError("Cursor's sign-in has run out; sign in to Cursor again")
|
|
243
|
+
return auth.access
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// browserSignIn is cursor-agent's `login`: the page signs in, and
|
|
247
|
+
// /auth/poll hands the tokens over once it has.
|
|
248
|
+
async function browserSignIn() {
|
|
249
|
+
const verifier = randomBytes(32).toString("base64url")
|
|
250
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url")
|
|
251
|
+
const uuid = randomUUID()
|
|
252
|
+
const url = new URL("/loginDeepControl", WEBSITE)
|
|
253
|
+
url.searchParams.set("challenge", challenge)
|
|
254
|
+
url.searchParams.set("uuid", uuid)
|
|
255
|
+
url.searchParams.set("mode", "login")
|
|
256
|
+
url.searchParams.set("redirectTarget", "cli")
|
|
257
|
+
return {
|
|
258
|
+
url: url.toString(),
|
|
259
|
+
instructions: "Sign in to Cursor in the browser; this finishes by itself.",
|
|
260
|
+
method: "auto",
|
|
261
|
+
async callback() {
|
|
262
|
+
const headers = { "Content-Type": "application/json", "x-cursor-client-version": await clientVersion(), "x-cursor-client-type": "cli" }
|
|
263
|
+
let errors = 0
|
|
264
|
+
for (let r = 0; r < 150; r++) {
|
|
265
|
+
const wait = Math.min(1000 * 1.2 ** r, 10_000)
|
|
266
|
+
let res
|
|
267
|
+
try {
|
|
268
|
+
res = await fetch(`${API}/auth/poll?uuid=${uuid}&verifier=${verifier}`, { headers, signal: AbortSignal.timeout(15_000) })
|
|
269
|
+
} catch {
|
|
270
|
+
if (++errors >= 3) return { type: "failed" }
|
|
271
|
+
await sleep(wait)
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (res.status === 404) {
|
|
275
|
+
errors = 0
|
|
276
|
+
await sleep(wait)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
if (!res.ok) {
|
|
280
|
+
if (res.status === 403 || ++errors >= 3) return { type: "failed" }
|
|
281
|
+
await sleep(wait)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
const j = await res.json().catch(() => null)
|
|
285
|
+
if (!j?.accessToken || !("refreshToken" in j)) return { type: "failed" }
|
|
286
|
+
const who = await whoIs(j.accessToken)
|
|
287
|
+
return {
|
|
288
|
+
type: "success",
|
|
289
|
+
access: j.accessToken,
|
|
290
|
+
refresh: j.refreshToken || "none",
|
|
291
|
+
expires: expiry(j.accessToken) || Date.now() + 30 * 24 * 3600 * 1000,
|
|
292
|
+
...(who.email ? { accountId: who.email } : {}),
|
|
293
|
+
...(who.plan ? { plan: who.plan } : {}),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { type: "failed" }
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// cliSignIn takes the account cursor-agent is signed in to; its token is
|
|
302
|
+
// read each time from where cursor-agent keeps it.
|
|
303
|
+
async function cliSignIn() {
|
|
304
|
+
return {
|
|
305
|
+
url: "",
|
|
306
|
+
instructions: "Uses the account `cursor-agent login` signed in to.",
|
|
307
|
+
method: "auto",
|
|
308
|
+
async callback() {
|
|
309
|
+
const tok = await cliToken()
|
|
310
|
+
const exp = expiry(tok)
|
|
311
|
+
if (!tok || (exp && exp <= Date.now())) return { type: "failed" }
|
|
312
|
+
const who = await whoIs(tok)
|
|
313
|
+
if (!who.email) return { type: "failed" }
|
|
314
|
+
return { type: "success", access: "", refresh: CLI_MARK, expires: 0, accountId: who.email, ...(who.plan ? { plan: who.plan } : {}) }
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
320
|
+
|
|
321
|
+
// ---- models ----------------------------------------------------------------------
|
|
322
|
+
//
|
|
323
|
+
// Cursor lists a model once for each effort and speed it serves it at
|
|
324
|
+
// ("grok-4.7-low", "grok-4.7-low-fast", … "grok-4.7-xhigh-fast"), and a
|
|
325
|
+
// Claude with thinking apart from one without. One model a family is
|
|
326
|
+
// offered, with the efforts there are, and the request asks for the id the
|
|
327
|
+
// effort picks. Fast stays a model of its own.
|
|
328
|
+
|
|
329
|
+
// the effort words of Cursor's ids, the levels they are and the words
|
|
330
|
+
// Cursor's names say them in; extra-high before high
|
|
331
|
+
const EFFORTS = [
|
|
332
|
+
["extra-high", "xhigh", "Extra High"], ["xhigh", "xhigh", "Extra High"], ["minimal", "minimal", "Minimal"],
|
|
333
|
+
["none", "none", "None"], ["low", "low", "Low"], ["medium", "medium", "Medium"], ["high", "high", "High"], ["max", "max", "Max"],
|
|
334
|
+
]
|
|
335
|
+
const LEVEL_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
336
|
+
const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
|
337
|
+
|
|
338
|
+
const cut = (s, suffix) => (s.endsWith(suffix) && s.length > suffix.length ? s.slice(0, -suffix.length) : null)
|
|
339
|
+
|
|
340
|
+
// splitID is a Cursor id taken apart: its family, and the effort ("" for
|
|
341
|
+
// none named) it is at. Thinking comes before the effort or after it; in
|
|
342
|
+
// the family it goes after, as does fast.
|
|
343
|
+
function splitID(id) {
|
|
344
|
+
let s = id
|
|
345
|
+
let fast = false
|
|
346
|
+
let thinking = false
|
|
347
|
+
let effort = ""
|
|
348
|
+
let b
|
|
349
|
+
if ((b = cut(s, "-fast")) !== null) [s, fast] = [b, true]
|
|
350
|
+
if ((b = cut(s, "-thinking")) !== null) [s, thinking] = [b, true]
|
|
351
|
+
for (const [word, level] of EFFORTS) {
|
|
352
|
+
if ((b = cut(s, "-" + word)) !== null) {
|
|
353
|
+
;[s, effort] = [b, level]
|
|
354
|
+
break
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!thinking && (b = cut(s, "-thinking")) !== null) [s, thinking] = [b, true]
|
|
358
|
+
if (thinking) s += "-thinking"
|
|
359
|
+
if (fast) s += "-fast"
|
|
360
|
+
return [s, effort]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const labelOf = (level) => EFFORTS.find((e) => e[1] === level)?.[2] ?? ""
|
|
364
|
+
|
|
365
|
+
// withoutWords is name with the first run of these words taken out, and
|
|
366
|
+
// whether it had them.
|
|
367
|
+
function withoutWords(name, words) {
|
|
368
|
+
const ns = name.split(/\s+/).filter(Boolean)
|
|
369
|
+
const ws = words.split(/\s+/).filter(Boolean)
|
|
370
|
+
if (!ws.length) return [name, false]
|
|
371
|
+
for (let i = 0; i + ws.length <= ns.length; i++)
|
|
372
|
+
if (ws.every((w, j) => ns[i + j] === w)) return [[...ns.slice(0, i), ...ns.slice(i + ws.length)].join(" "), true]
|
|
373
|
+
return [name, false]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function families(raw) {
|
|
377
|
+
const out = []
|
|
378
|
+
const by = new Map()
|
|
379
|
+
for (const m of raw) {
|
|
380
|
+
const [id, effort] = splitID(m.id)
|
|
381
|
+
let f = by.get(id)
|
|
382
|
+
if (!f) {
|
|
383
|
+
f = { id, variants: [], efforts: [] }
|
|
384
|
+
by.set(id, f)
|
|
385
|
+
out.push(f)
|
|
386
|
+
}
|
|
387
|
+
f.variants.push(m)
|
|
388
|
+
f.efforts.push(effort)
|
|
389
|
+
}
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// byEffort is the id for each effort, "" the one Cursor picks by default:
|
|
394
|
+
// the one without an effort in its id, else the one whose name doesn't say
|
|
395
|
+
// its effort, else medium, else the first.
|
|
396
|
+
function byEffort(f) {
|
|
397
|
+
const out = {}
|
|
398
|
+
let def = f.efforts.indexOf("")
|
|
399
|
+
f.variants.forEach((v, i) => {
|
|
400
|
+
const e = f.efforts[i]
|
|
401
|
+
if (!(e in out)) out[e] = v.id
|
|
402
|
+
if (def < 0 && e !== "" && !withoutWords(v.name, labelOf(e))[1]) def = i
|
|
403
|
+
})
|
|
404
|
+
if (def < 0) def = Math.max(0, f.efforts.indexOf("medium"))
|
|
405
|
+
out[""] = f.variants[def].id
|
|
406
|
+
return out
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function familyModel(f) {
|
|
410
|
+
const def = byEffort(f)[""]
|
|
411
|
+
const m = { id: f.id, name: f.id, context: 0, efforts: [] }
|
|
412
|
+
f.variants.forEach((v, i) => {
|
|
413
|
+
if (v.id === def) m.name = withoutWords(v.name, labelOf(f.efforts[i]))[0]
|
|
414
|
+
if (v.context > 0 && (!m.context || v.context < m.context)) m.context = v.context
|
|
415
|
+
})
|
|
416
|
+
for (const l of LEVEL_RANK) if (f.efforts.includes(l)) m.efforts.push(l)
|
|
417
|
+
return m
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// contextOf is how much of a conversation Cursor lets a model hold: what
|
|
421
|
+
// the name says ("Claude Opus 5.5 1M"), else Cursor's default.
|
|
422
|
+
function contextOf(name) {
|
|
423
|
+
const m = /\b(\d+)M\b/.exec(name)
|
|
424
|
+
return m ? Number(m[1]) * 1_000_000 : DEFAULT_CONTEXT
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const CAPACITY = /\(\s*\d+M\s*\)|\b\d+M\b/g
|
|
428
|
+
|
|
429
|
+
// withoutCapacity takes the context out of each name, which is shown apart;
|
|
430
|
+
// a name that would then be another model's keeps it.
|
|
431
|
+
function withoutCapacity(ms) {
|
|
432
|
+
const strip = (n) => n.replace(CAPACITY, " ").split(/\s+/).filter(Boolean).join(" ")
|
|
433
|
+
const names = new Map()
|
|
434
|
+
const add = (n) => names.set(n.toLowerCase(), (names.get(n.toLowerCase()) ?? 0) + 1)
|
|
435
|
+
for (const m of ms) {
|
|
436
|
+
add(m.name)
|
|
437
|
+
const s = strip(m.name)
|
|
438
|
+
if (s !== m.name) add(s)
|
|
439
|
+
}
|
|
440
|
+
return ms.map((m) => {
|
|
441
|
+
const s = strip(m.name)
|
|
442
|
+
return s === m.name || !s || names.get(s.toLowerCase()) > 1 ? m : { ...m, name: s }
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// offered is Cursor's list as it is offered: each family one model.
|
|
447
|
+
function offered(raw) {
|
|
448
|
+
return withoutCapacity(families(raw).map((f) => (f.variants.length === 1 ? { ...f.variants[0], efforts: [] } : familyModel(f))))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function variantsIn(raw, model) {
|
|
452
|
+
const f = families(raw).find((f) => f.id === model && f.variants.length > 1)
|
|
453
|
+
return f ? byEffort(f) : null
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function baseIn(raw, id) {
|
|
457
|
+
const [family, effort] = splitID(id)
|
|
458
|
+
if (family === id) return null
|
|
459
|
+
const f = families(raw).find((f) => f.id === family && f.variants.length > 1 && f.variants.some((m) => m.id === id))
|
|
460
|
+
return f ? [family, effort] : null
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function fitEffort(want, levels) {
|
|
464
|
+
if (want === "ultra" && !levels.includes(want)) want = "max"
|
|
465
|
+
if (!levels.length || levels.includes(want)) return want
|
|
466
|
+
const at = EFFORT_RANK.indexOf(want)
|
|
467
|
+
if (at < 0) return want
|
|
468
|
+
let best = want
|
|
469
|
+
let dist = EFFORT_RANK.length
|
|
470
|
+
for (const l of levels) {
|
|
471
|
+
const i = EFFORT_RANK.indexOf(l)
|
|
472
|
+
if (i < 0 || l === "none") continue
|
|
473
|
+
const d = Math.abs(i - at)
|
|
474
|
+
if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
|
|
475
|
+
}
|
|
476
|
+
return best
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// modelID is Cursor's id for a model offered, at the effort asked for: the
|
|
480
|
+
// family's variant at it; else, for an effort between the ones it has, the
|
|
481
|
+
// one Cursor picks by default; else the nearest. Fast, when asked, is from
|
|
482
|
+
// the family's fast one. An id of Cursor's own at an effort is at the
|
|
483
|
+
// effort asked for where Cursor has that id; else it goes as it is.
|
|
484
|
+
function modelID(raw, model, effort, fast) {
|
|
485
|
+
const base = baseIn(raw, model)
|
|
486
|
+
if (base) {
|
|
487
|
+
let [b, at] = base
|
|
488
|
+
effort ||= at
|
|
489
|
+
if (fast && !b.endsWith("-fast") && variantsIn(raw, b + "-fast")) b += "-fast"
|
|
490
|
+
const vs = variantsIn(raw, b)
|
|
491
|
+
return vs && effort && vs[effort] ? vs[effort] : model
|
|
492
|
+
}
|
|
493
|
+
if (fast && !model.endsWith("-fast") && variantsIn(raw, model + "-fast")) model += "-fast"
|
|
494
|
+
const vs = variantsIn(raw, model)
|
|
495
|
+
if (!vs) return model
|
|
496
|
+
if (!effort) return vs[""]
|
|
497
|
+
if (vs[effort]) return vs[effort]
|
|
498
|
+
const levels = []
|
|
499
|
+
let unnamed = true // the default has no effort of its own
|
|
500
|
+
for (const l of EFFORT_RANK) {
|
|
501
|
+
if (vs[l]) {
|
|
502
|
+
levels.push(l)
|
|
503
|
+
unnamed &&= vs[l] !== vs[""]
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!levels.length) return vs[""]
|
|
507
|
+
const at = EFFORT_RANK.indexOf(effort)
|
|
508
|
+
if (unnamed && at > EFFORT_RANK.indexOf(levels[0]) && at < EFFORT_RANK.indexOf(levels.at(-1))) return vs[""]
|
|
509
|
+
return vs[fitEffort(effort, levels)]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// usable is the account's list, as `cursor-agent models` has it: its ids,
|
|
513
|
+
// names and the id each is run by; kept a while, by token.
|
|
514
|
+
const lists = new Map()
|
|
515
|
+
async function usable(tok) {
|
|
516
|
+
const h = createHash("sha256").update(tok).digest("hex")
|
|
517
|
+
const had = lists.get(h)
|
|
518
|
+
if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
|
|
519
|
+
const j = await unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 30_000)
|
|
520
|
+
const raw = []
|
|
521
|
+
for (const m of j?.models ?? []) {
|
|
522
|
+
const id = m.displayModelId || m.modelId
|
|
523
|
+
if (!id) continue
|
|
524
|
+
// some names come with zero-width spaces and doubled ones
|
|
525
|
+
let name = String(m.displayName || id).replaceAll("", "").split(/\s+/).filter(Boolean).join(" ")
|
|
526
|
+
name = name.replace(/\(default\)$/, "").trim().replace(/\(current\)$/, "").trim()
|
|
527
|
+
raw.push({ id, name, context: contextOf(name), run: m.modelId || id })
|
|
528
|
+
}
|
|
529
|
+
if (!raw.length) throw new Error("Cursor listed no models")
|
|
530
|
+
lists.set(h, { at: Date.now(), raw })
|
|
531
|
+
return raw
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function runtimeModel(m) {
|
|
535
|
+
return {
|
|
536
|
+
id: m.id,
|
|
537
|
+
providerID: ID,
|
|
538
|
+
name: m.name,
|
|
539
|
+
api: { id: m.id, url: AGENT, npm: CHAT },
|
|
540
|
+
status: "active",
|
|
541
|
+
headers: {},
|
|
542
|
+
options: {},
|
|
543
|
+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
544
|
+
limit: { context: m.context || DEFAULT_CONTEXT, output: 0 },
|
|
545
|
+
capabilities: {
|
|
546
|
+
temperature: false,
|
|
547
|
+
reasoning: m.efforts.length > 0,
|
|
548
|
+
attachment: true,
|
|
549
|
+
toolcall: true,
|
|
550
|
+
input: { text: true, image: true, audio: false, video: false, pdf: false },
|
|
551
|
+
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
552
|
+
interleaved: false,
|
|
553
|
+
},
|
|
554
|
+
release_date: "",
|
|
555
|
+
variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])),
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---- protobuf, by hand ------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
class PB {
|
|
562
|
+
parts = []
|
|
563
|
+
push(b) {
|
|
564
|
+
this.parts.push(b)
|
|
565
|
+
return this
|
|
566
|
+
}
|
|
567
|
+
uv(v) {
|
|
568
|
+
const out = []
|
|
569
|
+
while (v >= 0x80) {
|
|
570
|
+
out.push((v % 128) | 0x80)
|
|
571
|
+
v = Math.floor(v / 128)
|
|
572
|
+
}
|
|
573
|
+
out.push(v)
|
|
574
|
+
return this.push(Uint8Array.from(out))
|
|
575
|
+
}
|
|
576
|
+
tag(num, wire) {
|
|
577
|
+
return this.uv(num * 8 + wire)
|
|
578
|
+
}
|
|
579
|
+
varint(num, v) {
|
|
580
|
+
return this.tag(num, 0).uv(v)
|
|
581
|
+
}
|
|
582
|
+
bytes(num, v) {
|
|
583
|
+
const b = v instanceof PB ? v.done() : v ?? EMPTY
|
|
584
|
+
return this.tag(num, 2).uv(b.length).push(b)
|
|
585
|
+
}
|
|
586
|
+
str(num, s) {
|
|
587
|
+
return this.bytes(num, Buffer.from(s, "utf8"))
|
|
588
|
+
}
|
|
589
|
+
double(num, v) {
|
|
590
|
+
const b = Buffer.alloc(8)
|
|
591
|
+
b.writeDoubleLE(v)
|
|
592
|
+
return this.tag(num, 1).push(b)
|
|
593
|
+
}
|
|
594
|
+
done() {
|
|
595
|
+
return Buffer.concat(this.parts)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const pb = () => new PB()
|
|
599
|
+
|
|
600
|
+
// fields reads a message's fields; a malformed tail is dropped.
|
|
601
|
+
function fields(b) {
|
|
602
|
+
b = Buffer.isBuffer(b) ? b : Buffer.from(b ?? EMPTY)
|
|
603
|
+
const out = []
|
|
604
|
+
let i = 0
|
|
605
|
+
const uv = () => {
|
|
606
|
+
let v = 0
|
|
607
|
+
let m = 1
|
|
608
|
+
while (i < b.length) {
|
|
609
|
+
const c = b[i++]
|
|
610
|
+
v += (c & 0x7f) * m
|
|
611
|
+
if (c < 0x80) return v
|
|
612
|
+
m *= 128
|
|
613
|
+
}
|
|
614
|
+
return -1
|
|
615
|
+
}
|
|
616
|
+
while (i < b.length) {
|
|
617
|
+
const key = uv()
|
|
618
|
+
if (key < 0) break
|
|
619
|
+
const f = { num: Math.floor(key / 8), wire: key % 8, n: 0, data: EMPTY }
|
|
620
|
+
switch (f.wire) {
|
|
621
|
+
case 0: {
|
|
622
|
+
const v = uv()
|
|
623
|
+
if (v < 0) return out
|
|
624
|
+
f.n = v
|
|
625
|
+
break
|
|
626
|
+
}
|
|
627
|
+
case 1:
|
|
628
|
+
if (i + 8 > b.length) return out
|
|
629
|
+
f.data = b.subarray(i, i + 8)
|
|
630
|
+
f.n = Number(b.readBigUInt64LE(i))
|
|
631
|
+
i += 8
|
|
632
|
+
break
|
|
633
|
+
case 2: {
|
|
634
|
+
const l = uv()
|
|
635
|
+
if (l < 0 || i + l > b.length) return out
|
|
636
|
+
f.data = b.subarray(i, i + l)
|
|
637
|
+
i += l
|
|
638
|
+
break
|
|
639
|
+
}
|
|
640
|
+
case 5:
|
|
641
|
+
if (i + 4 > b.length) return out
|
|
642
|
+
f.n = b.readUInt32LE(i)
|
|
643
|
+
i += 4
|
|
644
|
+
break
|
|
645
|
+
default:
|
|
646
|
+
return out
|
|
647
|
+
}
|
|
648
|
+
out.push(f)
|
|
649
|
+
}
|
|
650
|
+
return out
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const field = (fs, num) => fs.find((f) => f.num === num)
|
|
654
|
+
const pbStr = (fs, num) => field(fs, num)?.data.toString("utf8") ?? ""
|
|
655
|
+
const pbNum = (fs, num) => field(fs, num)?.n ?? 0
|
|
656
|
+
|
|
657
|
+
// pbValue is a google.protobuf.Value.
|
|
658
|
+
function pbValue(v) {
|
|
659
|
+
if (v === null || v === undefined) return pb().varint(1, 0)
|
|
660
|
+
if (typeof v === "number") return pb().double(2, v)
|
|
661
|
+
if (typeof v === "string") return pb().str(3, v)
|
|
662
|
+
if (typeof v === "boolean") return pb().varint(4, v ? 1 : 0)
|
|
663
|
+
if (Array.isArray(v)) {
|
|
664
|
+
const l = pb()
|
|
665
|
+
for (const e of v) l.bytes(1, pbValue(e))
|
|
666
|
+
return pb().bytes(6, l)
|
|
667
|
+
}
|
|
668
|
+
if (typeof v === "object") {
|
|
669
|
+
const st = pb()
|
|
670
|
+
for (const k of Object.keys(v).sort()) st.bytes(1, pb().str(1, k).bytes(2, pbValue(v[k])))
|
|
671
|
+
return pb().bytes(5, st)
|
|
672
|
+
}
|
|
673
|
+
return pb().varint(1, 0)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// pbAny reads a google.protobuf.Value.
|
|
677
|
+
function pbAny(b) {
|
|
678
|
+
for (const f of fields(b)) {
|
|
679
|
+
switch (f.num) {
|
|
680
|
+
case 1:
|
|
681
|
+
return null
|
|
682
|
+
case 2:
|
|
683
|
+
return f.data.length === 8 ? f.data.readDoubleLE(0) : 0
|
|
684
|
+
case 3:
|
|
685
|
+
return f.data.toString("utf8")
|
|
686
|
+
case 4:
|
|
687
|
+
return f.n !== 0
|
|
688
|
+
case 5: {
|
|
689
|
+
const m = {}
|
|
690
|
+
for (const e of fields(f.data)) {
|
|
691
|
+
if (e.num !== 1) continue
|
|
692
|
+
const kv = fields(e.data)
|
|
693
|
+
const v = field(kv, 2)
|
|
694
|
+
m[pbStr(kv, 1)] = v ? pbAny(v.data) : null
|
|
695
|
+
}
|
|
696
|
+
return m
|
|
697
|
+
}
|
|
698
|
+
case 6:
|
|
699
|
+
return fields(f.data).filter((e) => e.num === 1).map((e) => pbAny(e.data))
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return null
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// frame wraps a message as one frame of a Connect stream.
|
|
706
|
+
function frame(msg) {
|
|
707
|
+
const head = Buffer.alloc(5)
|
|
708
|
+
head.writeUInt32BE(msg.length, 1)
|
|
709
|
+
return Buffer.concat([head, msg])
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// frames reads a Connect stream's frames: a message, or the stream's end
|
|
713
|
+
// with its JSON.
|
|
714
|
+
async function* frames(body) {
|
|
715
|
+
let buf = EMPTY
|
|
716
|
+
for await (const chunk of body) {
|
|
717
|
+
buf = buf.length ? Buffer.concat([buf, chunk]) : Buffer.from(chunk)
|
|
718
|
+
while (buf.length >= 5) {
|
|
719
|
+
const n = buf.readUInt32BE(1)
|
|
720
|
+
if (n > 64 << 20) throw new Error("a malformed stream")
|
|
721
|
+
if (buf.length < 5 + n) break
|
|
722
|
+
const flags = buf[0]
|
|
723
|
+
let data = buf.subarray(5, 5 + n)
|
|
724
|
+
buf = buf.subarray(5 + n)
|
|
725
|
+
if (flags & 1) data = gunzipSync(data)
|
|
726
|
+
yield { end: (flags & 2) !== 0, data }
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (buf.length) throw new Error("unexpected EOF")
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---- failures -----------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
const regional = (msg) => msg.toLowerCase().includes("region")
|
|
735
|
+
|
|
736
|
+
// failure is the status and message for Cursor's Connect error, whose
|
|
737
|
+
// details say it best: {"code", "message", "details": [{"debug": {"error",
|
|
738
|
+
// "details": {"title", "detail"}}}]}, or the same under "error" at a
|
|
739
|
+
// stream's end.
|
|
740
|
+
function failure(status, text) {
|
|
741
|
+
let e
|
|
742
|
+
try {
|
|
743
|
+
e = JSON.parse(typeof text === "string" ? text : Buffer.from(text).toString("utf8"))
|
|
744
|
+
} catch {
|
|
745
|
+
return statusOf(status, "", String(text ?? "").trim())
|
|
746
|
+
}
|
|
747
|
+
const f = e?.error && typeof e.error === "object" ? e.error : e ?? {}
|
|
748
|
+
let msg = f.message ?? ""
|
|
749
|
+
for (const d of f.details ?? []) {
|
|
750
|
+
const t = `${d?.debug?.details?.title ?? ""}: ${d?.debug?.details?.detail ?? ""}`.trim()
|
|
751
|
+
if (t !== ":") msg = t.replace(/^[:\s]+|[:\s]+$/g, "")
|
|
752
|
+
}
|
|
753
|
+
if (!msg || msg === "Error") msg = f.code ?? ""
|
|
754
|
+
if (!f.code && status >= 200 && status < 300) return { status, message: msg } // a stream that ended well
|
|
755
|
+
return statusOf(status, f.code ?? "", msg)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// statusOf is the status and words for Cursor's error of this code and
|
|
759
|
+
// message. A region the team isn't served in says so, not to sign in.
|
|
760
|
+
function statusOf(status, code, msg) {
|
|
761
|
+
msg ||= `HTTP ${status}`
|
|
762
|
+
const low = msg.toLowerCase()
|
|
763
|
+
const out = (status, message) => ({ status, message: "Cursor: " + message })
|
|
764
|
+
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")
|
|
765
|
+
if (code === "permission_denied") return out(403, msg)
|
|
766
|
+
if (code === "unauthenticated" || status === 401 || low.includes("expired")) return out(401, msg + " — sign in to Cursor again")
|
|
767
|
+
if (code === "resource_exhausted" || low.includes("quota") || low.includes("rate limit") || low.includes("usage limit"))
|
|
768
|
+
return out(429, "usage limit reached: " + msg)
|
|
769
|
+
if (low.includes("too long") || low.includes("context length") || low.includes("too many tokens"))
|
|
770
|
+
return out(400, "input is too long for the model's context: " + msg)
|
|
771
|
+
if (code === "invalid_argument") return out(400, msg)
|
|
772
|
+
if (code === "unavailable") return out(503, msg)
|
|
773
|
+
if (code) return out(502, msg)
|
|
774
|
+
return out(status, msg)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---- the conversation -------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
const textOf = (c) =>
|
|
780
|
+
typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : ""
|
|
781
|
+
|
|
782
|
+
function parseArgs(s) {
|
|
783
|
+
if (s && typeof s === "object") return s
|
|
784
|
+
try {
|
|
785
|
+
const v = JSON.parse(s || "{}")
|
|
786
|
+
return v && typeof v === "object" ? v : {}
|
|
787
|
+
} catch {
|
|
788
|
+
return {}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// tools are the caller's tools the model may call: none for tool_choice
|
|
793
|
+
// "none", the one named when one is.
|
|
794
|
+
function toolsOf(chat) {
|
|
795
|
+
const choice = chat.tool_choice
|
|
796
|
+
const only = typeof choice === "object" ? choice?.function?.name : ""
|
|
797
|
+
if (choice === "none") return []
|
|
798
|
+
return (chat.tools ?? [])
|
|
799
|
+
.filter((t) => t?.type === "function" && t.function?.name && (!only || t.function.name === only))
|
|
800
|
+
.map((t) => {
|
|
801
|
+
const schema = t.function.parameters ?? { type: "object", properties: {} }
|
|
802
|
+
return { name: t.function.name, description: t.function.description ?? "", schema, schemaText: JSON.stringify(schema) }
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// catalog lists the caller's tools for the model. It sees MCP tools only
|
|
807
|
+
// through Cursor's GetDynamicTools and CallDynamicTool, and Cursor names
|
|
808
|
+
// them in a prompt of its own, which this conversation goes without:
|
|
809
|
+
// without the list the model says it has no such tool.
|
|
810
|
+
function catalog(tools) {
|
|
811
|
+
if (!tools.length) return ""
|
|
812
|
+
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`
|
|
813
|
+
for (const t of tools) s += `<tool name="${t.name}">\n${t.description}\ninput schema: ${t.schemaText}\n</tool>\n`
|
|
814
|
+
return s + "</dynamic_tool_catalog>"
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// callID is a call's id as Cursor gave it, before the line in it was sent
|
|
818
|
+
// to the caller as __.
|
|
819
|
+
const callID = (id) => (id.startsWith("call_") ? id.replace("__fc_", "\nfc_") : id)
|
|
820
|
+
|
|
821
|
+
function result(id, text, isError) {
|
|
822
|
+
let res = text
|
|
823
|
+
try {
|
|
824
|
+
res = JSON.parse(text)
|
|
825
|
+
} catch {}
|
|
826
|
+
return {
|
|
827
|
+
type: "tool-result",
|
|
828
|
+
toolCallId: callID(id),
|
|
829
|
+
toolName: CALL,
|
|
830
|
+
result: res,
|
|
831
|
+
experimental_content: [{ type: "text", text }],
|
|
832
|
+
...(isError ? { isError: true } : {}),
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// messages is the conversation as AI SDK messages, in JSON: the system
|
|
837
|
+
// prompt with the caller's tools listed, then each turn. A tool result
|
|
838
|
+
// answers a call of the reply before it; a call left unanswered is
|
|
839
|
+
// answered for, as the API wants every call to have its result. With it,
|
|
840
|
+
// the user's last words and a guess at the prompt's size.
|
|
841
|
+
function conversation(chat, tools) {
|
|
842
|
+
const out = []
|
|
843
|
+
const add = (m) => out.push(Buffer.from(JSON.stringify(m)))
|
|
844
|
+
let size = 0
|
|
845
|
+
const system = []
|
|
846
|
+
let last = "."
|
|
847
|
+
for (const m of chat.messages ?? [])
|
|
848
|
+
if (m.role === "system" || m.role === "developer") system.push(textOf(m.content))
|
|
849
|
+
const sys = system.filter(Boolean).join("\n\n") + catalog(tools)
|
|
850
|
+
size += sys.length
|
|
851
|
+
if (sys) add({ role: "system", content: sys })
|
|
852
|
+
let pending = []
|
|
853
|
+
let results = []
|
|
854
|
+
const answer = () => {
|
|
855
|
+
for (const id of pending) results.push(result(id, NO_RESULT, true))
|
|
856
|
+
pending = []
|
|
857
|
+
if (results.length) add({ role: "tool", content: results })
|
|
858
|
+
results = []
|
|
859
|
+
}
|
|
860
|
+
for (const m of chat.messages ?? []) {
|
|
861
|
+
if (m.role === "system" || m.role === "developer") continue
|
|
862
|
+
if (m.role === "tool") {
|
|
863
|
+
const id = m.tool_call_id ?? ""
|
|
864
|
+
const i = pending.indexOf(id)
|
|
865
|
+
if (i >= 0) {
|
|
866
|
+
pending.splice(i, 1)
|
|
867
|
+
const text = textOf(m.content)
|
|
868
|
+
size += text.length
|
|
869
|
+
results.push(result(id, text, false))
|
|
870
|
+
}
|
|
871
|
+
continue
|
|
872
|
+
}
|
|
873
|
+
if (m.role === "assistant") {
|
|
874
|
+
answer()
|
|
875
|
+
const content = []
|
|
876
|
+
const text = textOf(m.content)
|
|
877
|
+
if (text) content.push({ type: "text", text })
|
|
878
|
+
size += text.length
|
|
879
|
+
for (const c of m.tool_calls ?? []) {
|
|
880
|
+
if (c?.type && c.type !== "function") continue
|
|
881
|
+
const args = parseArgs(c.function?.arguments)
|
|
882
|
+
size += (c.function?.arguments ?? "").length + (c.function?.name ?? "").length
|
|
883
|
+
content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace: "magpie", toolName: c.function?.name, arguments: args } })
|
|
884
|
+
pending.push(c.id)
|
|
885
|
+
}
|
|
886
|
+
if (content.length) add({ role: "assistant", content })
|
|
887
|
+
continue
|
|
888
|
+
}
|
|
889
|
+
// the user's
|
|
890
|
+
const content = []
|
|
891
|
+
const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
|
|
892
|
+
let first = ""
|
|
893
|
+
for (const p of parts) {
|
|
894
|
+
if (p?.type === "text" && p.text) {
|
|
895
|
+
content.push({ type: "text", text: p.text })
|
|
896
|
+
size += p.text.length
|
|
897
|
+
first ||= p.text
|
|
898
|
+
} else if (p?.type === "image_url") {
|
|
899
|
+
const u = typeof p.image_url === "string" ? p.image_url : p.image_url?.url ?? ""
|
|
900
|
+
const d = /^data:([^;,]+);base64,(.*)$/s.exec(u)
|
|
901
|
+
if (d) content.push({ type: "image", mimeType: d[1], image: { __type: "Uint8Array", hex: Buffer.from(d[2], "base64").toString("hex") } })
|
|
902
|
+
else content.push({ type: "text", text: `[attachment image: ${u}]` })
|
|
903
|
+
} else if (p?.type === "file") {
|
|
904
|
+
content.push({ type: "text", text: `[attachment ${p.file?.filename ?? "file"}]` })
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
answer()
|
|
908
|
+
if (first) last = first
|
|
909
|
+
if (content.length) add({ role: "user", content })
|
|
910
|
+
}
|
|
911
|
+
answer()
|
|
912
|
+
for (const t of tools) size += t.name.length + t.description.length + t.schemaText.length
|
|
913
|
+
return { msgs: out, last, estimate: Math.floor(size / 4) }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ---- the request ---------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
// toolDef is an McpToolDefinition.
|
|
919
|
+
function toolDef(t) {
|
|
920
|
+
const d = pb().str(1, t.name)
|
|
921
|
+
if (t.description) d.str(2, t.description)
|
|
922
|
+
d.bytes(3, pbValue(t.schema))
|
|
923
|
+
return d.str(4, "magpie").str(5, t.name).str(6, t.schemaText).done()
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const env = () => pb().str(1, GOOS).str(2, tmpdir()).str(10, "UTC")
|
|
927
|
+
|
|
928
|
+
// buildRun is the Run's first message, an AgentClientMessage with its
|
|
929
|
+
// run_request, and the blobs it names. The conversation state is the
|
|
930
|
+
// messages and one turn, which the server wants there to sample at all.
|
|
931
|
+
function buildRun(msgs, lastUser, tools, model) {
|
|
932
|
+
const blobs = new Map()
|
|
933
|
+
const put = (b) => {
|
|
934
|
+
const id = createHash("sha256").update(b).digest()
|
|
935
|
+
blobs.set(id.toString("hex"), b)
|
|
936
|
+
return id
|
|
937
|
+
}
|
|
938
|
+
const state = pb()
|
|
939
|
+
for (const m of msgs) state.bytes(1, put(m))
|
|
940
|
+
const mid = randomUUID()
|
|
941
|
+
const user = pb().str(1, lastUser).str(2, mid).varint(4, 1).done() // mode: agent
|
|
942
|
+
const turn = pb().bytes(1, pb().bytes(1, put(user)).str(10, mid)).done()
|
|
943
|
+
state.bytes(8, put(turn)).varint(10, 1).str(22, "cli")
|
|
944
|
+
const defs = tools.map(toolDef)
|
|
945
|
+
const rc = pb().bytes(4, env())
|
|
946
|
+
const mcp = pb()
|
|
947
|
+
for (const d of defs) {
|
|
948
|
+
rc.bytes(7, d)
|
|
949
|
+
mcp.bytes(1, d)
|
|
950
|
+
}
|
|
951
|
+
const action = pb().bytes(2, pb().bytes(2, rc)) // resume_action
|
|
952
|
+
const rr = pb()
|
|
953
|
+
.bytes(1, state)
|
|
954
|
+
.bytes(2, action)
|
|
955
|
+
.bytes(3, pb().str(1, model).str(3, model).str(4, model))
|
|
956
|
+
.bytes(4, mcp)
|
|
957
|
+
.str(5, randomUUID())
|
|
958
|
+
.bytes(9, pb().str(1, model))
|
|
959
|
+
.varint(19, 1) // inline images
|
|
960
|
+
return { first: pb().bytes(1, rr).done(), blobs }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ---- the agent API ---------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
// endpoints are the agent API picked for each token (by its hash). As
|
|
966
|
+
// cursor-agent does, it is what ServerConfigService/GetServerConfig names —
|
|
967
|
+
// a team may be served in one region only, and the global API turns it
|
|
968
|
+
// away — asked once a token, and again when fresh is set; the global one
|
|
969
|
+
// while it can't be had.
|
|
970
|
+
const endpoints = new Map()
|
|
971
|
+
async function agentURL(tok, fresh) {
|
|
972
|
+
const key = createHash("sha256").update(tok).digest("hex")
|
|
973
|
+
const had = endpoints.get(key)
|
|
974
|
+
if (!fresh && had && (had.listed || Date.now() - had.at < 60_000)) return had.url
|
|
975
|
+
let url = AGENT
|
|
976
|
+
let listed = false
|
|
977
|
+
try {
|
|
978
|
+
const cfg = await unary(API, "aiserver.v1.ServerConfigService/GetServerConfig", tok, {}, 10_000)
|
|
979
|
+
for (const raw of [cfg?.agentUrlConfig?.agentUrl, cfg?.agentUrlConfig?.agentnUrl]) {
|
|
980
|
+
try {
|
|
981
|
+
const u = new URL(raw)
|
|
982
|
+
if ((u.protocol === "https:" || u.protocol === "http:") && u.host) {
|
|
983
|
+
url = raw.replace(/\/+$/, "")
|
|
984
|
+
listed = true
|
|
985
|
+
break
|
|
986
|
+
}
|
|
987
|
+
} catch {}
|
|
988
|
+
}
|
|
989
|
+
} catch {}
|
|
990
|
+
endpoints.set(key, { url, at: Date.now(), listed })
|
|
991
|
+
return url
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// open starts a Run at base: the session, the stream, and the response's
|
|
995
|
+
// status, or an error the request never got past.
|
|
996
|
+
function open(base, headers, signal) {
|
|
997
|
+
return new Promise((resolve) => {
|
|
998
|
+
const u = new URL(base)
|
|
999
|
+
const session = http2.connect(u.origin)
|
|
1000
|
+
let done = false
|
|
1001
|
+
const fail = (e) => {
|
|
1002
|
+
if (done) return
|
|
1003
|
+
done = true
|
|
1004
|
+
session.destroy()
|
|
1005
|
+
resolve({ error: { status: 502, message: "Cursor: " + (e?.message ?? String(e)) } })
|
|
1006
|
+
}
|
|
1007
|
+
session.on("error", fail)
|
|
1008
|
+
const req = session.request({
|
|
1009
|
+
":method": "POST",
|
|
1010
|
+
":path": u.pathname.replace(/\/+$/, "") + "/agent.v1.AgentService/Run",
|
|
1011
|
+
...headers,
|
|
1012
|
+
})
|
|
1013
|
+
req.on("error", fail)
|
|
1014
|
+
req.once("response", (h) => {
|
|
1015
|
+
if (done) return
|
|
1016
|
+
done = true
|
|
1017
|
+
resolve({ session, req, status: Number(h[":status"]) })
|
|
1018
|
+
})
|
|
1019
|
+
signal?.addEventListener("abort", () => fail(new Error("the request was cancelled")), { once: true })
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// runOnce is one Run of the chat on the agent API at base: an async
|
|
1024
|
+
// iterator of the answer's pieces, or the error it failed with before any.
|
|
1025
|
+
async function runOnce({ tok, base, id, tools, conv, signal }) {
|
|
1026
|
+
const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id)
|
|
1027
|
+
const o = await open(
|
|
1028
|
+
base,
|
|
1029
|
+
{
|
|
1030
|
+
"content-type": "application/connect+proto",
|
|
1031
|
+
"connect-protocol-version": "1",
|
|
1032
|
+
authorization: `Bearer ${tok}`,
|
|
1033
|
+
"x-cursor-client-version": await clientVersion(),
|
|
1034
|
+
"x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
|
|
1035
|
+
"x-ghost-mode": "true", // privacy mode: nothing kept for training
|
|
1036
|
+
"x-request-id": randomUUID(),
|
|
1037
|
+
// none of Cursor's own tools, only the caller's
|
|
1038
|
+
"x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
|
|
1039
|
+
},
|
|
1040
|
+
signal,
|
|
1041
|
+
)
|
|
1042
|
+
if (o.error) return o
|
|
1043
|
+
const { session, req } = o
|
|
1044
|
+
let closed = false
|
|
1045
|
+
const send = (msg) => {
|
|
1046
|
+
if (!closed && !req.destroyed && req.writable) req.write(frame(msg))
|
|
1047
|
+
}
|
|
1048
|
+
const heartbeat = setInterval(() => send(pb().bytes(7, EMPTY).done()), 5000) // the client is still there, as the CLI says
|
|
1049
|
+
const close = () => {
|
|
1050
|
+
if (closed) return
|
|
1051
|
+
closed = true
|
|
1052
|
+
clearInterval(heartbeat)
|
|
1053
|
+
try {
|
|
1054
|
+
req.close(http2.constants.NGHTTP2_CANCEL)
|
|
1055
|
+
} catch {}
|
|
1056
|
+
session.close()
|
|
1057
|
+
setTimeout(() => session.destroy(), 1000).unref?.()
|
|
1058
|
+
}
|
|
1059
|
+
signal?.addEventListener("abort", close, { once: true })
|
|
1060
|
+
send(first)
|
|
1061
|
+
if (o.status < 200 || o.status > 299) {
|
|
1062
|
+
const chunks = []
|
|
1063
|
+
try {
|
|
1064
|
+
for await (const c of req) chunks.push(c)
|
|
1065
|
+
} catch {}
|
|
1066
|
+
close()
|
|
1067
|
+
return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
|
|
1068
|
+
}
|
|
1069
|
+
const it = decode(frames(req), { send, blobs, tools, estimate: conv.estimate })
|
|
1070
|
+
const wrapped = (async function* () {
|
|
1071
|
+
try {
|
|
1072
|
+
yield* it
|
|
1073
|
+
} finally {
|
|
1074
|
+
close()
|
|
1075
|
+
}
|
|
1076
|
+
})()
|
|
1077
|
+
// an error comes before anything of the answer: out of quota, a model the
|
|
1078
|
+
// plan doesn't have — answered with its own status
|
|
1079
|
+
const head = await wrapped.next()
|
|
1080
|
+
if (head.done) return { error: { status: 502, message: "Cursor: an empty reply" } }
|
|
1081
|
+
if (head.value.error) {
|
|
1082
|
+
await wrapped.return()
|
|
1083
|
+
return { error: head.value.error }
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
events: (async function* () {
|
|
1087
|
+
yield head.value
|
|
1088
|
+
yield* wrapped
|
|
1089
|
+
})(),
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// decode follows the server's messages into the answer's pieces until the
|
|
1094
|
+
// turn ends, or the model has made its tool calls.
|
|
1095
|
+
async function* decode(stream, { send, blobs, tools, estimate }) {
|
|
1096
|
+
let calls = 0
|
|
1097
|
+
let listed = 0
|
|
1098
|
+
let said = 0
|
|
1099
|
+
let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
1100
|
+
const finish = () => {
|
|
1101
|
+
if (!usage.output) usage.output = Math.floor((said + 3) / 4)
|
|
1102
|
+
if (!usage.input) usage.input = estimate
|
|
1103
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite
|
|
1104
|
+
return {
|
|
1105
|
+
stop: calls > 0 ? "tool_calls" : "stop",
|
|
1106
|
+
usage: {
|
|
1107
|
+
prompt_tokens: prompt,
|
|
1108
|
+
completion_tokens: usage.output,
|
|
1109
|
+
total_tokens: prompt + usage.output,
|
|
1110
|
+
prompt_tokens_details: { cached_tokens: usage.cacheRead, cache_write_tokens: usage.cacheWrite },
|
|
1111
|
+
},
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
|
|
1115
|
+
try {
|
|
1116
|
+
for await (const f of stream) {
|
|
1117
|
+
if (f.end) {
|
|
1118
|
+
const e = failure(200, f.data)
|
|
1119
|
+
if (e.status < 200 || e.status > 299) yield { error: e }
|
|
1120
|
+
else if (said === 0 && calls === 0) yield { error: { status: 502, message: "Cursor: an empty reply" } }
|
|
1121
|
+
else yield finish()
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
for (const m of fields(f.data)) {
|
|
1125
|
+
switch (m.num) {
|
|
1126
|
+
case 1: // interaction_update
|
|
1127
|
+
for (const u of fields(m.data)) {
|
|
1128
|
+
const uf = fields(u.data)
|
|
1129
|
+
switch (u.num) {
|
|
1130
|
+
case 1: {
|
|
1131
|
+
// text
|
|
1132
|
+
const t = pbStr(uf, 1)
|
|
1133
|
+
if (t) {
|
|
1134
|
+
said += t.length
|
|
1135
|
+
yield { text: t }
|
|
1136
|
+
}
|
|
1137
|
+
break
|
|
1138
|
+
}
|
|
1139
|
+
case 4: {
|
|
1140
|
+
// thinking
|
|
1141
|
+
const t = pbStr(uf, 1)
|
|
1142
|
+
if (t) {
|
|
1143
|
+
said += t.length
|
|
1144
|
+
yield { reasoning: t }
|
|
1145
|
+
}
|
|
1146
|
+
break
|
|
1147
|
+
}
|
|
1148
|
+
case 14: // turn ended, with what it used
|
|
1149
|
+
usage = { input: pbNum(uf, 1), output: pbNum(uf, 2), cacheRead: pbNum(uf, 3), cacheWrite: pbNum(uf, 4) }
|
|
1150
|
+
if (calls === 0) {
|
|
1151
|
+
yield finish()
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
break
|
|
1155
|
+
case 27: // how many tool calls the step makes, sent before them
|
|
1156
|
+
listed = pbNum(uf, 1)
|
|
1157
|
+
if (listed > 0 && calls >= listed) {
|
|
1158
|
+
yield finish()
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
break
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
break
|
|
1165
|
+
case 2: {
|
|
1166
|
+
// exec_server_message: the server asks the client to run something
|
|
1167
|
+
const call = exec(m.data, tools, send, closeExec)
|
|
1168
|
+
if (call) {
|
|
1169
|
+
said += call.args.length
|
|
1170
|
+
yield { tool: { index: calls++, ...call } }
|
|
1171
|
+
}
|
|
1172
|
+
if (listed > 0 && calls >= listed) {
|
|
1173
|
+
// every call made: the caller runs them
|
|
1174
|
+
yield finish()
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
break
|
|
1178
|
+
}
|
|
1179
|
+
case 4: {
|
|
1180
|
+
// kv_server_message: a blob wanted, or one to keep
|
|
1181
|
+
const kv = fields(m.data)
|
|
1182
|
+
const id = pbNum(kv, 1)
|
|
1183
|
+
for (const k of kv) {
|
|
1184
|
+
if (k.num === 2) {
|
|
1185
|
+
const want = field(fields(k.data), 1)?.data.toString("hex") ?? ""
|
|
1186
|
+
const b = blobs.get(want)
|
|
1187
|
+
const res = b ? pb().bytes(1, b) : pb().bytes(2, pb().str(1, "blob not found"))
|
|
1188
|
+
send(pb().bytes(3, pb().varint(1, id).bytes(2, res)).done())
|
|
1189
|
+
} else if (k.num === 3) {
|
|
1190
|
+
// the server's own record of the turn, not needed
|
|
1191
|
+
send(pb().bytes(3, pb().varint(1, id).bytes(3, EMPTY)).done())
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
break
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (calls > 0) {
|
|
1200
|
+
yield finish()
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
yield { error: { status: 502, message: "Cursor: the reply broke off: EOF" } }
|
|
1204
|
+
} catch (e) {
|
|
1205
|
+
if (calls > 0 && /EOF/.test(e?.message ?? "")) {
|
|
1206
|
+
yield finish()
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
yield { error: { status: 502, message: "Cursor: the reply broke off: " + (e?.message ?? e) } }
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// exec answers what the server asks the client to run: a call of the
|
|
1214
|
+
// caller's tools is returned, the list of them is given, and anything else
|
|
1215
|
+
// is refused.
|
|
1216
|
+
function exec(msg, tools, send, closeExec) {
|
|
1217
|
+
const es = fields(msg)
|
|
1218
|
+
const id = pbNum(es, 1)
|
|
1219
|
+
const execID = pbStr(es, 15)
|
|
1220
|
+
const answer = (num, res) => {
|
|
1221
|
+
send(pb().bytes(2, pb().varint(1, id).str(15, execID).bytes(num, res)).done())
|
|
1222
|
+
closeExec(id)
|
|
1223
|
+
}
|
|
1224
|
+
for (const e of es) {
|
|
1225
|
+
switch (e.num) {
|
|
1226
|
+
case 11: {
|
|
1227
|
+
// mcp_args: a call of the caller's tools
|
|
1228
|
+
const a = fields(e.data)
|
|
1229
|
+
const args = {}
|
|
1230
|
+
for (const kv of a) {
|
|
1231
|
+
if (kv.num !== 2) continue
|
|
1232
|
+
const ef = fields(kv.data)
|
|
1233
|
+
const v = field(ef, 2)
|
|
1234
|
+
args[pbStr(ef, 1)] = v ? pbAny(v.data) : null
|
|
1235
|
+
}
|
|
1236
|
+
const name = pbStr(a, 5) || pbStr(a, 1).replace(/^magpie-/, "")
|
|
1237
|
+
// an OpenAI model's id is its call's and its item's, a line apart,
|
|
1238
|
+
// which no caller would take as an id: the line goes as __
|
|
1239
|
+
const cid = pbStr(a, 3).replaceAll("\n", "__") || "call_" + randomBytes(12).toString("hex")
|
|
1240
|
+
return { id: cid, name, args: JSON.stringify(args) }
|
|
1241
|
+
}
|
|
1242
|
+
case 36: {
|
|
1243
|
+
// mcp_state_exec_args: the tools there are
|
|
1244
|
+
const srv = pb().str(1, "magpie").str(2, "magpie").str(7, "connected")
|
|
1245
|
+
for (const t of tools) srv.bytes(5, toolDef(t))
|
|
1246
|
+
answer(36, pb().bytes(1, pb().bytes(1, srv)))
|
|
1247
|
+
return null
|
|
1248
|
+
}
|
|
1249
|
+
case 10: // request_context_args
|
|
1250
|
+
answer(10, pb().bytes(1, pb().bytes(1, pb().bytes(4, env()))))
|
|
1251
|
+
return null
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
// a shell, a file read or an edit of Cursor's own: never run
|
|
1255
|
+
send(pb().bytes(5, pb().bytes(2, pb().varint(1, id).str(2, "not available"))).done())
|
|
1256
|
+
closeExec(id)
|
|
1257
|
+
return null
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ---- chat completions ------------------------------------------------------------------
|
|
1261
|
+
|
|
1262
|
+
const errorResponse = ({ status, message }) =>
|
|
1263
|
+
new Response(JSON.stringify({ error: { message, type: "cursor_error", code: status } }), {
|
|
1264
|
+
status,
|
|
1265
|
+
headers: { "Content-Type": "application/json" },
|
|
1266
|
+
})
|
|
1267
|
+
|
|
1268
|
+
// answer runs a chat completion on Cursor and answers it as one, streamed
|
|
1269
|
+
// or not.
|
|
1270
|
+
async function answer(auth, chat, signal) {
|
|
1271
|
+
let tok
|
|
1272
|
+
try {
|
|
1273
|
+
tok = await tokenOf(auth)
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
return errorResponse({ status: e.status ?? 401, message: e.message.startsWith("Cursor") ? e.message : "Cursor: " + e.message })
|
|
1276
|
+
}
|
|
1277
|
+
const model = String(chat.model ?? "")
|
|
1278
|
+
let raw = []
|
|
1279
|
+
try {
|
|
1280
|
+
raw = await usable(tok)
|
|
1281
|
+
} catch {}
|
|
1282
|
+
const fast = chat.service_tier === "priority" || chat.service_tier === "fast"
|
|
1283
|
+
let id = modelID(raw, model, chat.reasoning_effort ?? "", fast)
|
|
1284
|
+
id = raw.find((m) => m.id === id)?.run ?? id
|
|
1285
|
+
if (id === "auto") id = "default" // Cursor's pick, which its API calls default
|
|
1286
|
+
const tools = toolsOf(chat)
|
|
1287
|
+
const conv = conversation(chat, tools)
|
|
1288
|
+
const base = await agentURL(tok, false)
|
|
1289
|
+
let r = await runOnce({ tok, base, id, tools, conv, signal })
|
|
1290
|
+
if (r.error && regional(r.error.message)) {
|
|
1291
|
+
// the team moved, or the config was kept from before: once more with
|
|
1292
|
+
// what the config says now
|
|
1293
|
+
const fresh = await agentURL(tok, true)
|
|
1294
|
+
if (fresh !== base) r = await runOnce({ tok, base: fresh, id, tools, conv, signal })
|
|
1295
|
+
}
|
|
1296
|
+
if (r.error) return errorResponse(r.error)
|
|
1297
|
+
const it = r.events
|
|
1298
|
+
const cid = "chatcmpl-" + randomBytes(12).toString("hex")
|
|
1299
|
+
const created = Math.floor(Date.now() / 1000)
|
|
1300
|
+
|
|
1301
|
+
if (!chat.stream) {
|
|
1302
|
+
const msg = { role: "assistant", content: "" }
|
|
1303
|
+
let reasoning = ""
|
|
1304
|
+
const calls = []
|
|
1305
|
+
let stop = "stop"
|
|
1306
|
+
let usage
|
|
1307
|
+
for await (const e of it) {
|
|
1308
|
+
if (e.error) return errorResponse(e.error)
|
|
1309
|
+
if (e.text) msg.content += e.text
|
|
1310
|
+
if (e.reasoning) reasoning += e.reasoning
|
|
1311
|
+
if (e.tool) calls.push({ id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } })
|
|
1312
|
+
if (e.stop) [stop, usage] = [e.stop, e.usage]
|
|
1313
|
+
}
|
|
1314
|
+
if (reasoning) msg.reasoning_content = reasoning
|
|
1315
|
+
if (calls.length) msg.tool_calls = calls
|
|
1316
|
+
return Response.json({ id: cid, object: "chat.completion", created, model, choices: [{ index: 0, message: msg, finish_reason: stop }], ...(usage ? { usage } : {}) })
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const chunk = (delta, finish_reason = null, extra = {}) =>
|
|
1320
|
+
enc.encode(`data: ${JSON.stringify({ id: cid, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`)
|
|
1321
|
+
let began = false
|
|
1322
|
+
const stream = new ReadableStream({
|
|
1323
|
+
async pull(ctl) {
|
|
1324
|
+
const r = await it.next()
|
|
1325
|
+
if (!began) {
|
|
1326
|
+
began = true
|
|
1327
|
+
ctl.enqueue(chunk({ role: "assistant", content: "" }))
|
|
1328
|
+
}
|
|
1329
|
+
if (r.done) {
|
|
1330
|
+
ctl.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
1331
|
+
return ctl.close()
|
|
1332
|
+
}
|
|
1333
|
+
const e = r.value
|
|
1334
|
+
if (e.text) ctl.enqueue(chunk({ content: e.text }))
|
|
1335
|
+
else if (e.reasoning) ctl.enqueue(chunk({ reasoning_content: e.reasoning }))
|
|
1336
|
+
else if (e.tool) ctl.enqueue(chunk({ tool_calls: [{ index: e.tool.index, id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } }] }))
|
|
1337
|
+
else if (e.stop) ctl.enqueue(chunk({}, e.stop, e.usage ? { usage: e.usage } : {}))
|
|
1338
|
+
else if (e.error) {
|
|
1339
|
+
ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: e.error.message, code: e.error.status } })}\n\n`))
|
|
1340
|
+
ctl.close()
|
|
1341
|
+
}
|
|
1342
|
+
},
|
|
1343
|
+
cancel() {
|
|
1344
|
+
it.return?.()
|
|
1345
|
+
},
|
|
1346
|
+
})
|
|
1347
|
+
return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ---- the plugin ---------------------------------------------------------------------------
|
|
1351
|
+
|
|
1352
|
+
export async function CursorAuthPlugin() {
|
|
1353
|
+
return {
|
|
1354
|
+
auth: {
|
|
1355
|
+
provider: ID,
|
|
1356
|
+
async loader(getAuth) {
|
|
1357
|
+
const auth = await getAuth()
|
|
1358
|
+
if (!auth || (auth.type !== "oauth" && auth.type !== "api")) return {}
|
|
1359
|
+
return {
|
|
1360
|
+
baseURL: AGENT,
|
|
1361
|
+
apiKey: "cursor",
|
|
1362
|
+
// every chat completion run on Cursor's agent API, with the
|
|
1363
|
+
// sign-in as it is now
|
|
1364
|
+
async fetch(input, init = {}) {
|
|
1365
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
1366
|
+
if (!/\/chat\/completions$/.test(new URL(url).pathname))
|
|
1367
|
+
return errorResponse({ status: 404, message: "Cursor: only chat completions are served" })
|
|
1368
|
+
let chat
|
|
1369
|
+
try {
|
|
1370
|
+
const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
|
|
1371
|
+
chat = JSON.parse(typeof b === "string" ? b : new TextDecoder().decode(b))
|
|
1372
|
+
} catch {
|
|
1373
|
+
return errorResponse({ status: 400, message: "Cursor: a request that isn't JSON" })
|
|
1374
|
+
}
|
|
1375
|
+
return answer((await getAuth()) ?? auth, chat, init.signal ?? (input instanceof Request ? input.signal : undefined))
|
|
1376
|
+
},
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
methods: [
|
|
1380
|
+
{ type: "oauth", label: "Cursor (browser)", authorize: browserSignIn },
|
|
1381
|
+
{ type: "oauth", label: "cursor-agent's sign-in", authorize: cliSignIn },
|
|
1382
|
+
{ type: "api", label: "Cursor API key (cursor.com/dashboard → Integrations)" },
|
|
1383
|
+
],
|
|
1384
|
+
},
|
|
1385
|
+
async config(config) {
|
|
1386
|
+
config.provider ??= {}
|
|
1387
|
+
const was = config.provider[ID] ?? {}
|
|
1388
|
+
config.provider[ID] = {
|
|
1389
|
+
name: "Cursor",
|
|
1390
|
+
npm: CHAT,
|
|
1391
|
+
api: AGENT,
|
|
1392
|
+
...was,
|
|
1393
|
+
models: {
|
|
1394
|
+
auto: { name: "Auto", limit: { context: DEFAULT_CONTEXT, output: 0 }, attachment: true, tool_call: true },
|
|
1395
|
+
...(was.models ?? {}),
|
|
1396
|
+
},
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
// the account's list, each family of Cursor's ids one model with the
|
|
1400
|
+
// efforts there are
|
|
1401
|
+
provider: {
|
|
1402
|
+
id: ID,
|
|
1403
|
+
async models(provider, { auth } = {}) {
|
|
1404
|
+
if (!auth) return provider.models
|
|
1405
|
+
try {
|
|
1406
|
+
const tok = await tokenOf(auth)
|
|
1407
|
+
return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
|
|
1408
|
+
} catch {
|
|
1409
|
+
return provider.models
|
|
1410
|
+
}
|
|
1411
|
+
},
|
|
1412
|
+
},
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// for tests
|
|
1417
|
+
export const _internal = { splitID, families, byEffort, offered, modelID, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
|