@magpie-community/opencode-qoder-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 +25 -4
  2. package/index.mjs +201 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -139,7 +139,7 @@ itself.
139
139
  |---|---|
140
140
  | A refused sign-in (no code, or code 105) | 401 |
141
141
  | A quota (codes 110, 112–119, 122) | 429 |
142
- | Busy or queued for the model (code 10605) | 503, with `Retry-After` from Qoder's `retryAfterSeconds` (30s when it gives none); the sign-in is untouched |
142
+ | Busy or queued for the model (code 10605) | Since 0.2.28 the request waits in Qoder's queue as Qoder's client does (below), then is sent again; past the wait, 503 with `Retry-After` from Qoder's `retryAfterSeconds` (30s when it gives none). The sign-in is untouched |
143
143
  | A refusal of the request itself (codes 406, 416, 430, 80411, 80412, 100400–100403) | 400, with Qoder's words |
144
144
  | "Error in upstream response", the model's own vendor behind Qoder failing, which Qoder sends as a 400 (since 0.2.24) | 502, so magpie asks again or another account; the sign-in is untouched |
145
145
  | A failure before the answer starts | Qoder's status |
@@ -277,9 +277,16 @@ the account's credentials.
277
277
  - For another installation location, set `QODER_RUNTIME_INFO` to the
278
278
  absolute path of Qoder's installed `runtime-info` executable
279
279
  (`runtime-info.exe` on Windows), with its accompanying SDK files.
280
- - A missing helper or invalid identity is an error, rather than a false
281
- "no activity" result. Qoder CN keeps its existing requests and does
282
- not need this local helper.
280
+ - With no helper anywhere (a machine that never had Qoder desktop or
281
+ Qoder CLI), the check-in is `ineligible` for the day, with a message
282
+ saying to install one (or set `QODER_RUNTIME_INFO`); magpie asks again
283
+ the next day rather than every 30 minutes (#304). The identity comes
284
+ only from Qoder's own helper: the plugin never makes one up, so such an
285
+ international account gets no daily claim until Qoder is installed.
286
+ - A helper that is there but can't be read, or gives an invalid identity,
287
+ is `failed` and asked again later, rather than a false "no activity"
288
+ result. Qoder CN keeps its existing requests and does not need this
289
+ local helper.
283
290
 
284
291
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
285
292
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
@@ -312,6 +319,20 @@ That parser fix does not remove the provider's limit or prove that every
312
319
  live 401 had the same cause. A timeout likewise does not establish an
313
320
  empty wallet or an expired login.
314
321
 
322
+ Since **0.2.28** a queued request waits its turn as Qoder's own client
323
+ (Qoder.app 0.4.3's agent worker) waits it: it asks
324
+ `/api/v2/service/ask/queue/status` with the request's `requestSetId`, the
325
+ model and the plan's `queueType`, at once and then every
326
+ `retryAfterSeconds` (held to 0.5–30s, 30s when unsaid), and once
327
+ `isQueued` is false it waits that state's `retryAfterSeconds` and sends the
328
+ same request again (up to 10 times). Qoder's client waits up to an hour;
329
+ the plugin waits at most 4 minutes from the request's start, since nothing
330
+ reaches the agent meanwhile (magpie keeps a stream alive with comments for
331
+ 5 minutes of silence). Three failed status asks in a row, a 404, or a
332
+ cancelled request end the wait early. Qoder's client also reports the end
333
+ of its turn to `/api/v2/service/ask/finish`; the plugin doesn't, and
334
+ Qoder releases that lease on its own TTL. `queue.test.mjs` has the wait.
335
+
315
336
  For a bounded offline reproduction, run `packages/qoder/busy.test.mjs`
316
337
  from #185 in an isolated temporary HOME. It compares plain queue JSON,
317
338
  BOM-prefixed JSON and known message wrappers, keeping explicit login and
package/index.mjs CHANGED
@@ -956,7 +956,7 @@ function failure(status, text) {
956
956
  const model = queue?.modelKey ? ` for ${queue.modelKey}` : ""
957
957
  const tier = queue?.queueType ? ` (this plan's queue: ${queue.queueType})` : ""
958
958
  const what = queue?.isQueued === true ? "queued the request" : "is busy"
959
- return { status: 503, message: `Qoder ${what}${model}${tier}; try again in ${wait}s`, retryAfter: wait, signIn: "" }
959
+ return { status: 503, message: `Qoder ${what}${model}${tier}; try again in ${wait}s`, retryAfter: wait, signIn: "", queue: queue ?? {} }
960
960
  }
961
961
  if (QUOTA_CODES.has(code)) return { status: 429, message: "usage limit reached: " + msg, signIn: "" }
962
962
  if (REQUEST_CODES.has(code)) return { status: 400, message: `Qoder refused the request (${code}): ${msg}`, signIn: "" }
@@ -987,6 +987,148 @@ const errorResponse = ({ status, message, signIn = status === 401 ? "kept" : und
987
987
  },
988
988
  })
989
989
 
990
+ // ---- Qoder's queue -------------------------------------------------------------
991
+
992
+ // A chat Qoder answers with its queue code (10605) is waited out as Qoder's
993
+ // own client waits it (qoder-agent-sdk's worker in Qoder.app 0.4.3, its
994
+ // "[ModelQueue]" path): it asks GET /api/v2/service/ask/queue/status with
995
+ // the request's requestSetId, the model and the plan's queueType, at once
996
+ // and then every retryAfterSeconds (held to 0.5s..30s, 30s when unsaid),
997
+ // while the state says isQueued or serviceAvailable false. Once isQueued is
998
+ // false it waits that state's retryAfterSeconds, if any, and sends the same
999
+ // request again, request_set_id and all, up to 10 times. Three failed asks
1000
+ // in a row, a 404 (no queue there) or a state with neither field end the
1001
+ // wait. Qoder's client waits up to an hour; here the whole of it is held
1002
+ // under QUEUE.budget from the request's start, since nothing reaches the
1003
+ // agent meanwhile: magpie keeps a stream's agent alive with comments for 5
1004
+ // minutes of a vendor's silence, and then leaves it to the agent's own
1005
+ // timeout. Past the budget it is the 503 with Retry-After it was before, so
1006
+ // magpie may ask another account. Qoder's client also tells
1007
+ // /api/v2/service/ask/finish when its turn ends; that is left out, and its
1008
+ // own log says Qoder's TTL releases the lease when finish is not heard.
1009
+ const QUEUE_STATUS_PATH = "/algo/api/v2/service/ask/queue/status"
1010
+ const QUEUE = {
1011
+ budget: 4 * 60 * 1000, // the longest a request is held, its first answer's wait included
1012
+ shortest: 500,
1013
+ longest: 30_000,
1014
+ unsaid: 30_000, // a pause when the queue gives no retryAfterSeconds
1015
+ recoveries: 10, // the queue waited out this many times, at most, for one request
1016
+ failures: 3, // status asks failing in a row that end the wait
1017
+ ask: 30_000, // the longest one status ask may take
1018
+ now: () => Date.now(),
1019
+ pause: (ms, signal) =>
1020
+ new Promise((ok, no) => {
1021
+ if (signal?.aborted) return no(signal.reason ?? new DOMException("aborted", "AbortError"))
1022
+ const t = setTimeout(() => {
1023
+ signal?.removeEventListener("abort", stop)
1024
+ ok()
1025
+ }, ms)
1026
+ const stop = () => {
1027
+ clearTimeout(t)
1028
+ no(signal.reason ?? new DOMException("aborted", "AbortError"))
1029
+ }
1030
+ signal?.addEventListener("abort", stop, { once: true })
1031
+ }),
1032
+ }
1033
+
1034
+ // queuePause is how long to wait before asking the queue again: the
1035
+ // queue's retryAfterSeconds held to 0.5s..30s, or 30s when it said none.
1036
+ function queuePause(seconds) {
1037
+ const n = Number(seconds)
1038
+ const ms = Number.isFinite(n) ? n * 1000 : QUEUE.unsaid
1039
+ return Math.min(QUEUE.longest, Math.max(QUEUE.shortest, ms))
1040
+ }
1041
+
1042
+ class QueueEnd extends Error {}
1043
+
1044
+ // queueState is the queue's state in a status reply, found as Qoder's
1045
+ // client finds it: the object, or one under data/result/message/body, as
1046
+ // an object or a JSON string, that says isQueued or serviceAvailable.
1047
+ function queueState(text) {
1048
+ let root
1049
+ try {
1050
+ root = JSON.parse(text)
1051
+ } catch {
1052
+ return undefined
1053
+ }
1054
+ const todo = [root]
1055
+ const seen = new Set()
1056
+ while (todo.length) {
1057
+ const o = todo.shift()
1058
+ if (!o || typeof o !== "object" || Array.isArray(o) || seen.has(o)) continue
1059
+ seen.add(o)
1060
+ const flag = (v) => (typeof v === "boolean" ? v : v === "true" ? true : v === "false" ? false : undefined)
1061
+ const isQueued = flag(o.isQueued)
1062
+ const serviceAvailable = flag(o.serviceAvailable)
1063
+ if (isQueued !== undefined || serviceAvailable !== undefined) {
1064
+ const num = (v) => (v == null || v === "" || !Number.isFinite(Number(v)) ? undefined : Number(v))
1065
+ const str = (v) => (typeof v === "string" && v ? v : undefined)
1066
+ return { isQueued, serviceAvailable, modelKey: str(o.modelKey), queueType: str(o.queueType), retryAfterSeconds: num(o.retryAfterSeconds) }
1067
+ }
1068
+ for (const k of ["data", "result", "message", "body"]) {
1069
+ const v = o[k]
1070
+ if (v && typeof v === "object") todo.push(v)
1071
+ else if (typeof v === "string")
1072
+ try {
1073
+ todo.push(JSON.parse(v))
1074
+ } catch {}
1075
+ }
1076
+ }
1077
+ return undefined
1078
+ }
1079
+
1080
+ // queueStatus asks Qoder's queue where the request stands.
1081
+ async function queueStatus(api, cred, { requestSetId, modelKey, queueType, requestId, sessionId }, signal, deadline) {
1082
+ const q = new URLSearchParams({ requestSetId, modelKey })
1083
+ if (queueType) q.set("queueType", queueType)
1084
+ const url = `${api}${QUEUE_STATUS_PATH}?${q}`
1085
+ const headers = { ...cosyHeaders(url, cred, ""), "X-Request-ID": requestId, "X-Session-ID": sessionId }
1086
+ delete headers["Content-Type"]
1087
+ const left = Math.min(QUEUE.ask, deadline - QUEUE.now())
1088
+ if (left <= 0) throw new QueueEnd("the queue's wait ran out")
1089
+ const res = await fetch(url, { headers, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(left)]) : AbortSignal.timeout(left) })
1090
+ const text = await res.text()
1091
+ if (res.status === 404) throw new QueueEnd("Qoder has no queue status here")
1092
+ if (!res.ok) throw new Error(`queue status: HTTP ${res.status}`)
1093
+ const st = queueState(text)
1094
+ if (!st) throw new QueueEnd("the queue status said nothing of the queue")
1095
+ return st
1096
+ }
1097
+
1098
+ // queueWait waits in Qoder's queue for the request, from the state its
1099
+ // refusal gave, until the queue lets it through (true) or the wait ends
1100
+ // (false). A cancelled request throws.
1101
+ async function queueWait(api, cred, ask, first, signal, deadline) {
1102
+ let q = first ?? {}
1103
+ let asked = false
1104
+ let failed = 0
1105
+ for (;;) {
1106
+ if (signal?.aborted) throw signal.reason ?? new DOMException("aborted", "AbortError")
1107
+ if (QUEUE.now() >= deadline) return false
1108
+ if (asked) await QUEUE.pause(Math.min(queuePause(q.retryAfterSeconds), Math.max(0, deadline - QUEUE.now())), signal)
1109
+ asked = true
1110
+ if (QUEUE.now() >= deadline) return false
1111
+ let st
1112
+ try {
1113
+ st = await queueStatus(api, cred, { ...ask, modelKey: q.modelKey ?? ask.modelKey, queueType: q.queueType ?? ask.queueType }, signal, deadline)
1114
+ } catch (e) {
1115
+ if (signal?.aborted) throw signal.reason ?? e
1116
+ if (e instanceof QueueEnd) return false
1117
+ if (++failed >= QUEUE.failures) return false
1118
+ q = { ...q, retryAfterSeconds: undefined }
1119
+ continue
1120
+ }
1121
+ failed = 0
1122
+ q = { ...st, modelKey: st.modelKey ?? q.modelKey, queueType: st.queueType ?? q.queueType }
1123
+ if (st.isQueued === false) {
1124
+ if (st.retryAfterSeconds > 0) await QUEUE.pause(Math.min(queuePause(st.retryAfterSeconds), Math.max(0, deadline - QUEUE.now())), signal)
1125
+ return QUEUE.now() < deadline
1126
+ }
1127
+ if (st.serviceAvailable === false || st.isQueued === true) continue
1128
+ return false
1129
+ }
1130
+ }
1131
+
990
1132
  // ---- the reply ----------------------------------------------------------------
991
1133
 
992
1134
  const CALL_OPEN = "\x3ctool_call\x3e"
@@ -1232,11 +1374,17 @@ async function* events(body) {
1232
1374
  // answer turns Qoder's reply into the chat completion OpenAI's API gives.
1233
1375
  // A failure before the answer keeps its status; one after it ends the
1234
1376
  // stream with an error.
1235
- async function answer(res, chat) {
1377
+ // opened is a chat reply's events and the first of them, read before
1378
+ // anything is answered: a refusal in the stream (Qoder's queue among them)
1379
+ // comes first.
1380
+ async function opened(res) {
1381
+ const it = events(res.body)
1382
+ return { it, first: await it.next() }
1383
+ }
1384
+
1385
+ async function answer({ it, first }, chat) {
1236
1386
  const id = "chatcmpl-" + hexID()
1237
1387
  const created = Math.floor(Date.now() / 1000)
1238
- const it = events(res.body)
1239
- const first = await it.next()
1240
1388
  if (first.value?.error) return errorResponse(first.value.error)
1241
1389
  if (first.done) return errorResponse({ status: 502, message: "Qoder ended without an answer" })
1242
1390
 
@@ -1408,7 +1556,21 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1408
1556
  if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1409
1557
  }
1410
1558
  if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1411
- throw new Error("Qoder check-in: device identity runtime not found; install Qoder desktop or sign in to Qoder CLI once, or set QODER_RUNTIME_INFO to its runtime-info executable")
1559
+ throw new RuntimeMissing()
1560
+ }
1561
+
1562
+ // RuntimeMissing: no Qoder desktop or CLI on this machine to read the
1563
+ // device identity from. Qoder offers the daily credits only on a request
1564
+ // carrying it (without it the list answers 200 with no CLAIM_BENEFIT), and
1565
+ // the identity is the output of Qoder's own signed helper, which the plugin
1566
+ // never makes up. Asking again won't change that until the user installs
1567
+ // Qoder, so the check-in says what to do once instead of "tries again later".
1568
+ class RuntimeMissing extends Error {
1569
+ constructor() {
1570
+ super(
1571
+ "Qoder check-in: device identity runtime not found. Qoder gives the daily credits only to a device its own app identifies, and this machine has neither Qoder desktop nor Qoder CLI to read that identity from. Install Qoder desktop (or install Qoder CLI and sign in to it once), or set QODER_RUNTIME_INFO to its runtime-info executable; magpie checks in again the next day",
1572
+ )
1573
+ }
1412
1574
  }
1413
1575
 
1414
1576
  // Read the SDK identity as Qoder does: environment 3 is international; on
@@ -1851,6 +2013,9 @@ const makePlugin = (site) => async ({ client }) => {
1851
2013
  }
1852
2014
  return { outcome: "failed", message: `claim: ${said || "unknown answer"}` }
1853
2015
  } catch (e) {
2016
+ // no Qoder on this machine: an answer for the day, not a failure
2017
+ // magpie would ask again every 30 minutes (plugins#304)
2018
+ if (e instanceof RuntimeMissing) return { outcome: "ineligible", message: e.message }
1854
2019
  return { outcome: "failed", message: e?.message ?? String(e), ...(e?.expired ? { signIn: "expired" } : {}) }
1855
2020
  }
1856
2021
  }
@@ -1872,23 +2037,37 @@ const makePlugin = (site) => async ({ client }) => {
1872
2037
  return errorResponse({ status: 502, message: e?.message ?? String(e) })
1873
2038
  }
1874
2039
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
1875
- const CHAT_URL = siteOf(site, cred).api + CHAT_PATH
1876
- const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
1877
- const headers = {
1878
- ...cosyHeaders(CHAT_URL, cred, wire),
1879
- Accept: "text/event-stream",
1880
- "Cache-Control": "no-cache",
1881
- "X-Model-Key": m.key,
1882
- "X-Model-Source": m.source,
1883
- }
1884
- let res
1885
- try {
1886
- res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
1887
- } catch (e) {
1888
- return errorResponse({ status: 502, message: e.message })
2040
+ const api = siteOf(site, cred).api
2041
+ const CHAT_URL = api + CHAT_PATH
2042
+ const body = qoderBody(chat, m)
2043
+ const wire = encodeBody(JSON.stringify(body))
2044
+ // Qoder's queue is waited out (queueWait) and the same request, its
2045
+ // request_set_id the one the queue knows, sent again
2046
+ const deadline = QUEUE.now() + QUEUE.budget
2047
+ const ask = { requestSetId: body.request_set_id, requestId: body.request_id, sessionId: body.session_id, modelKey: m.key }
2048
+ for (let waited = 0; ; waited++) {
2049
+ const headers = {
2050
+ ...cosyHeaders(CHAT_URL, cred, wire),
2051
+ Accept: "text/event-stream",
2052
+ "Cache-Control": "no-cache",
2053
+ "X-Model-Key": m.key,
2054
+ "X-Model-Source": m.source,
2055
+ }
2056
+ let res, open
2057
+ try {
2058
+ res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
2059
+ if (res.ok) open = await opened(res)
2060
+ } catch (e) {
2061
+ return errorResponse({ status: 502, message: e.message })
2062
+ }
2063
+ const f = !res.ok ? failure(res.status, (await res.text()).slice(0, 1 << 20)) : open.first.value?.error
2064
+ if (!f?.queue || waited >= QUEUE.recoveries) return f && !res.ok ? errorResponse(f) : answer(open, chat)
2065
+ try {
2066
+ if (!(await queueWait(api, cred, ask, f.queue, init.signal, deadline))) return errorResponse(f)
2067
+ } catch (e) {
2068
+ return errorResponse({ status: 502, message: e?.message ?? String(e) })
2069
+ }
1889
2070
  }
1890
- if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
1891
- return answer(res, chat)
1892
2071
  }
1893
2072
 
1894
2073
  return {
@@ -2054,4 +2233,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
2054
2233
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
2055
2234
 
2056
2235
  // for tests
2057
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events }
2236
+ export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events, QUEUE, queueState }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",