@magpie-community/opencode-cursor-auth 0.2.26 → 0.2.28

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 +10 -2
  2. package/index.mjs +87 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,8 +50,11 @@ plugin's `fetch` answers them on Cursor's agent API:
50
50
  after it go straight to HTTP/1.1 for 10 minutes; a failed connection
51
51
  (refused, no network) or a stream the server refused leaves the next Run
52
52
  to try HTTP/2 again. A region error over HTTP/1.1 sends the Run back to
53
- HTTP/2 at the region's agent host. A BidiAppend with no answer in 60 s
54
- (more for a large one) ends the Run, as cursor-agent does.
53
+ HTTP/2 at the region's agent host. BidiAppends go up numbered in order,
54
+ up to 16 at a time as cursor-agent sends them (one at a time for a
55
+ native session's Run, whose tool results must be certain). A BidiAppend
56
+ with no answer in 60 s (more for a large one) ends the Run, as
57
+ cursor-agent does.
55
58
  - A new Run imports the whole conversation as AI SDK messages kept as blobs
56
59
  the server asks for. The caller's tools are MCP tools, listed in the
57
60
  system prompt; the model calls them through Cursor's `CallDynamicTool`,
@@ -227,6 +230,11 @@ minutes), the way pi-cursor-sdk lists Cursor's catalog:
227
230
  and `service_tier` counting as for a listed model. The list doesn't show
228
231
  these ids: pick the `@<size>` model for a window that is the model's.
229
232
 
233
+ A chat request picks its variant from the same list without waiting for it
234
+ when it needn't: a list past its 10 minutes is used while a new one is
235
+ asked for, requests at once share one ask, and after an ask fails requests
236
+ go without the list for 5 minutes instead of each waiting for it again.
237
+
230
238
  ## Usage
231
239
 
232
240
  magpie shows Cursor Models, Other Models and Total for the billing period.
package/index.mjs CHANGED
@@ -981,22 +981,66 @@ function fitEffort(want, levels) {
981
981
  // can't give is a failure: magpie keeps the list it had. A usable list it
982
982
  // can't give leaves the picker as it is.
983
983
  const lists = new Map()
984
+ // listings are the lists being asked for now, by token: callers at once
985
+ // share one ask; listFailed when a token's last ask failed
986
+ const listings = new Map()
987
+ const listFailed = new Map()
984
988
  async function usable(tok) {
985
989
  const h = createHash("sha256").update(tok).digest("hex")
986
990
  const had = lists.get(h)
987
991
  if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
988
- const [j, listed] = await Promise.all([
989
- unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
990
- unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
991
- (u) => (Array.isArray(u?.models) ? u.models : []),
992
- () => [],
993
- ),
994
- ])
995
- const ids = new Set(listed.flatMap((m) => [m?.modelId, m?.displayModelId]).filter(Boolean))
996
- const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids.size ? ids : null, listed)
997
- if (!raw.length) throw new Error("Cursor listed no models")
998
- lists.set(h, { at: Date.now(), raw })
999
- return raw
992
+ return listing(tok, h)
993
+ }
994
+ function listing(tok, h) {
995
+ let p = listings.get(h)
996
+ if (p) return p
997
+ p = (async () => {
998
+ const [j, listed] = await Promise.all([
999
+ unary(API, "aiserver.v1.AiService/AvailableModels", tok, { useModelParameters: true, doNotUseMarkdown: true }, 30_000),
1000
+ unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 8_000).then(
1001
+ (u) => (Array.isArray(u?.models) ? u.models : []),
1002
+ () => [],
1003
+ ),
1004
+ ])
1005
+ const ids = new Set(listed.flatMap((m) => [m?.modelId, m?.displayModelId]).filter(Boolean))
1006
+ const raw = modelsOf(Array.isArray(j?.models) ? j.models : [], ids.size ? ids : null, listed)
1007
+ if (!raw.length) throw new Error("Cursor listed no models")
1008
+ lists.set(h, { at: Date.now(), raw })
1009
+ listFailed.delete(h)
1010
+ return raw
1011
+ })()
1012
+ .catch((e) => {
1013
+ listFailed.set(h, Date.now())
1014
+ throw e
1015
+ })
1016
+ .finally(() => listings.delete(h))
1017
+ listings.set(h, p)
1018
+ return p
1019
+ }
1020
+
1021
+ // LIST_RETRY is how long a request goes on without the list after Cursor
1022
+ // couldn't give it, before it waits for an ask again.
1023
+ const LIST_RETRY = 5 * 60_000
1024
+
1025
+ // listFor is the list a chat request picks its variant from. It is asked
1026
+ // before the Run, so a request never waits on it when it needn't: a list
1027
+ // past MODELS_KEEP is used as it is while a new one is asked for, and
1028
+ // after a failed ask the requests go without one for LIST_RETRY, as the
1029
+ // failed ask left them, instead of each waiting up to AvailableModels'
1030
+ // 30 s again before its Run (ysm119 on Discord: 33–36 s to the first
1031
+ // token on every Opus request). Only a token with no list at all, and no
1032
+ // failure lately, waits for it.
1033
+ async function listFor(tok) {
1034
+ const h = createHash("sha256").update(tok).digest("hex")
1035
+ const had = lists.get(h)
1036
+ if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
1037
+ if (had) {
1038
+ listing(tok, h).catch(() => {})
1039
+ return had.raw
1040
+ }
1041
+ const failed = listFailed.get(h)
1042
+ if (failed && Date.now() - failed < LIST_RETRY) return []
1043
+ return listing(tok, h).catch(() => [])
1000
1044
  }
1001
1045
 
1002
1046
  // named is the model and variant an id the list doesn't have stands for,
@@ -1993,10 +2037,11 @@ async function agentURL(tok, fresh) {
1993
2037
  // had here (h2Blocked), the Runs after it go straight to HTTP/1.1 for
1994
2038
  // H2.retryAfter, or until a region error sends one back (answerOf); a
1995
2039
  // connection that failed (refused, no network) or a stream the server
1996
- // refused leaves the next Run to try HTTP/2 again.
2040
+ // refused leaves the next Run to try HTTP/2 again. atOnce is how many
2041
+ // BidiAppends openH1 may have on their way at once.
1997
2042
  let h1Until = 0
1998
2043
  const cancelledOpen = () => ({ error: { status: 410, message: "the request was cancelled" }, h2Failed: false })
1999
- async function open(base, headers, signal) {
2044
+ async function open(base, headers, signal, atOnce = H1.atOnce) {
2000
2045
  if (signal?.aborted) return cancelledOpen()
2001
2046
  if (Date.now() >= h1Until) {
2002
2047
  const o = await openH2(base, headers, signal)
@@ -2010,7 +2055,7 @@ async function open(base, headers, signal) {
2010
2055
  if (o.blocked) h1Until = Date.now() + H2.retryAfter
2011
2056
  }
2012
2057
  if (signal?.aborted) return cancelledOpen()
2013
- return openH1(headers, signal)
2058
+ return openH1(headers, signal, atOnce)
2014
2059
  }
2015
2060
 
2016
2061
  const H2 = {
@@ -2077,7 +2122,15 @@ function openH2(base, headers, signal) {
2077
2122
  })
2078
2123
  }
2079
2124
 
2080
- const H1 = { appendWait: 60_000 } // ms a BidiAppend may take before the Run fails, as cursor-agent's appendTimeoutMs
2125
+ const H1 = {
2126
+ appendWait: 60_000, // ms a BidiAppend may take before the Run fails, as cursor-agent's appendTimeoutMs
2127
+ // BidiAppends on their way at once, numbered in order, as cursor-agent
2128
+ // keeps them: one at a time, a new Run's history (a blob a message,
2129
+ // hundreds for a long conversation, all asked for at once) took a round
2130
+ // trip to Cursor each before the model could start. A native session's
2131
+ // Run keeps them one at a time (runNative).
2132
+ atOnce: 16,
2133
+ }
2081
2134
 
2082
2135
  // connectCode is Connect's code for an HTTP status that came with no
2083
2136
  // Connect error of its own
@@ -2092,7 +2145,7 @@ function connectCode(status) {
2092
2145
  // is a 200 at once; an error Cursor says later, or a call that fails,
2093
2146
  // ends the stream with Connect's end frame, which runOnce reads as the
2094
2147
  // Run's own.
2095
- function openH1(headers, signal) {
2148
+ function openH1(headers, signal, atOnce = H1.atOnce) {
2096
2149
  if (signal?.aborted) return Promise.resolve(cancelledOpen())
2097
2150
  const ac = new AbortController()
2098
2151
  const id = headers["x-request-id"]
@@ -2100,15 +2153,23 @@ function openH1(headers, signal) {
2100
2153
  let ended = false
2101
2154
  let seq = 0
2102
2155
  let reader
2156
+ const going = new Set()
2103
2157
  const req = new Duplex({
2104
2158
  read() {},
2105
2159
  write(chunk, _enc, cb) {
2106
2160
  // runOnce writes one whole frame each time
2107
2161
  const msg = Buffer.from(chunk).subarray(5)
2108
2162
  const n = seq++
2109
- // Keep the write buffered until its append finishes, so callbacks and
2110
- // drain reflect the pending upload rather than an unbounded promise chain.
2111
- append(msg, n).then(() => cb(), (e) => cb(e))
2163
+ const p = append(msg, n)
2164
+ going.add(p)
2165
+ const gone = () => going.delete(p)
2166
+ p.then(gone, gone)
2167
+ // Up to atOnce appends go at once; past that the write stays
2168
+ // buffered until one finishes, so callbacks and drain reflect the
2169
+ // pending upload rather than an unbounded promise chain. At one,
2170
+ // a write's callback is its own append answered.
2171
+ if (going.size < atOnce) return cb()
2172
+ Promise.race(going).then(() => cb(), (e) => cb(e))
2112
2173
  },
2113
2174
  })
2114
2175
  const cleanup = () => {
@@ -2135,7 +2196,7 @@ function openH1(headers, signal) {
2135
2196
  }
2136
2197
  // append sends one message up. One that gets no answer by its deadline
2137
2198
  // (more time for a large one) ends the Run, as cursor-agent's
2138
- // bidi_append_deadline_exceeded does: the messages after it wait on it.
2199
+ // bidi_append_deadline_exceeded does; so does one that fails.
2139
2200
  const append = async (msg, n) => {
2140
2201
  if (ended || ac.signal.aborted) return
2141
2202
  const body = pb().str(1, msg.toString("hex")).bytes(2, pb().str(1, id).done()).varint(3, n).done()
@@ -2371,6 +2432,9 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2371
2432
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2372
2433
  },
2373
2434
  native.ac.signal,
2435
+ // one at a time: a native session counts a write as done only when
2436
+ // Cursor answered its own append, so a tool result is never uncertain
2437
+ 1,
2374
2438
  )
2375
2439
  if (o.error) {
2376
2440
  native.fail(o.error)
@@ -2872,10 +2936,7 @@ async function answerOf(auth, chat, signal, session = "") {
2872
2936
  if (prepared.owner) r = prepared.owner.resume({ ...prepared, estimate: conversation(chat, tools).estimate }, signal)
2873
2937
  const model = String(chat.model ?? "")
2874
2938
  if (!r) {
2875
- let raw = []
2876
- try {
2877
- raw = await usable(tok)
2878
- } catch {}
2939
+ const raw = await listFor(tok)
2879
2940
  // fast only when asked: a service_tier of priority or fast is on; any
2880
2941
  // other tier, or none, is off, unless the id is a fast one of Cursor's own
2881
2942
  const tier = chat.service_tier
@@ -3106,4 +3167,4 @@ export async function CursorAuthPlugin() {
3106
3167
  }
3107
3168
 
3108
3169
  // for tests
3109
- export const _internal = { keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, 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 }
3170
+ export const _internal = { listFor, lists, keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, 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.26",
3
+ "version": "0.2.28",
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",