@magpie-community/opencode-qoder-auth 0.2.27 → 0.2.30

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 +37 -3
  2. package/index.mjs +245 -42
  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 |
@@ -285,8 +285,28 @@ the account's credentials.
285
285
  international account gets no daily claim until Qoder is installed.
286
286
  - A helper that is there but can't be read, or gives an invalid identity,
287
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.
288
+ result.
289
+
290
+ Qoder CN (since 0.2.30) sends its own device identity the same way when
291
+ Qoder CN is installed: its desktop 0.4.9 and CLI (`qoderclicn`) both ask
292
+ Qoder CN's `runtime-info` for environment `0` (the international one is
293
+ `3`) and send it on the campaigns pages (yetone/magpie#2911, #304).
294
+
295
+ - The plugin finds Qoder CN desktop's `resources/umid` (`Qoder CN.app` on
296
+ macOS, `%LOCALAPPDATA%\Programs\Qoder CN` or `%ProgramFiles%\Qoder CN`
297
+ on Windows, `/opt/Qoder-CN` on Linux) or the runtime its CLI caches in
298
+ `~/.qoder-cn/.bin` (`QODERCN_CONFIG_DIR`, `QODERCN_CLI_HOME` and
299
+ `QODERCN_CONFIG_DIR_NAME` move it). `QODERCN_RUNTIME_INFO` names
300
+ another location; `QODER_RUNTIME_INFO` is only the international one's.
301
+ - Without it the requests go out as before, as Qoder CN's desktop does
302
+ when it can't read the identity, and a claim the list still offers is
303
+ made. When the list then offers no daily claim, the check-in is
304
+ `ineligible` for the day with a message to install Qoder CN desktop (or
305
+ sign in to its CLI); a helper that is there but can't be read makes it
306
+ `failed`, asked again later.
307
+ - Not tried with a real Qoder CN account: whether Qoder CN now hides the
308
+ daily claim from a request without the identity, as the international
309
+ site does, is inferred from its clients, not seen.
290
310
 
291
311
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
292
312
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
@@ -319,6 +339,20 @@ That parser fix does not remove the provider's limit or prove that every
319
339
  live 401 had the same cause. A timeout likewise does not establish an
320
340
  empty wallet or an expired login.
321
341
 
342
+ Since **0.2.28** a queued request waits its turn as Qoder's own client
343
+ (Qoder.app 0.4.3's agent worker) waits it: it asks
344
+ `/api/v2/service/ask/queue/status` with the request's `requestSetId`, the
345
+ model and the plan's `queueType`, at once and then every
346
+ `retryAfterSeconds` (held to 0.5–30s, 30s when unsaid), and once
347
+ `isQueued` is false it waits that state's `retryAfterSeconds` and sends the
348
+ same request again (up to 10 times). Qoder's client waits up to an hour;
349
+ the plugin waits at most 4 minutes from the request's start, since nothing
350
+ reaches the agent meanwhile (magpie keeps a stream alive with comments for
351
+ 5 minutes of silence). Three failed status asks in a row, a 404, or a
352
+ cancelled request end the wait early. Qoder's client also reports the end
353
+ of its turn to `/api/v2/service/ask/finish`; the plugin doesn't, and
354
+ Qoder releases that lease on its own TTL. `queue.test.mjs` has the wait.
355
+
322
356
  For a bounded offline reproduction, run `packages/qoder/busy.test.mjs`
323
357
  from #185 in an isolated temporary HOME. It compares plain queue JSON,
324
358
  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
 
@@ -1378,23 +1526,45 @@ const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its
1378
1526
  // (or .qoder): a file runtime-info-<platform>-<arch>-<hash> on macOS and
1379
1527
  // Linux, a folder umid-win32-<arch>-<hash> holding runtime-info.exe on
1380
1528
  // Windows.
1381
- async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt" } = {}) {
1382
- if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
1529
+ //
1530
+ // Qoder CN keeps its own (Qoder CN desktop 0.4.9's packages, qoderclicn
1531
+ // 1.1.77): Qoder CN.app on macOS (its installer's expectedBundleName),
1532
+ // /opt/Qoder-CN on Linux (the .deb), Qoder CN on Windows (the user
1533
+ // installer's "Uninstall Qoder CN.exe", electron-builder's
1534
+ // Programs\<productName>); its CLI's config is QODERCN_CONFIG_DIR, else
1535
+ // QODERCN_CLI_HOME (or home) joined with QODERCN_CONFIG_DIR_NAME (or
1536
+ // .qoder-cn). Both ask the helper for environment 0, the international
1537
+ // ones for 3 (the desktop's `regionCode === "global" ? 3 : 0`, the CLI's
1538
+ // UMID environment ONLINE = 0, US_ONLINE = 3).
1539
+ //
1540
+ // required: international Qoder lists the daily credits only on a request
1541
+ // carrying the identity. Qoder CN's desktop sends it when it can read it
1542
+ // and goes on without it when it can't, and a Qoder CN account was offered
1543
+ // and claimed the credits without one (0.2.21), so there it is sent when
1544
+ // Qoder CN is installed and left out when it isn't.
1545
+ const RUNTIMES = {
1546
+ qoder: { name: "Qoder", environment: "3", override: "QODER_RUNTIME_INFO", app: "Qoder", linux: "Qoder", cli: "QODER_", dir: ".qoder", required: true },
1547
+ "qoder-cn": { name: "Qoder CN", environment: "0", override: "QODERCN_RUNTIME_INFO", app: "Qoder CN", linux: "Qoder-CN", cli: "QODERCN_", dir: ".qoder-cn", required: false },
1548
+ }
1549
+
1550
+ async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt", runtime = RUNTIMES.qoder } = {}) {
1551
+ if (env[runtime.override]) return resolve(env[runtime.override])
1383
1552
  const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
1384
1553
  const isFile = async (path) => (await stat(path).catch(() => null))?.isFile() ?? false
1385
1554
  const paths = []
1386
1555
  if (platform === "win32") {
1387
1556
  const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1388
- paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1557
+ paths.push(join(local, "Programs", runtime.app, "resources", "umid", name))
1389
1558
  // the machine-wide installer, as VS Code's system setup places it
1390
- for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, "Qoder", "resources", "umid", name))
1559
+ for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, runtime.app, "resources", "umid", name))
1391
1560
  } else if (platform === "darwin") {
1392
- for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1561
+ for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, runtime.app + ".app", "Contents", "Resources", "umid", name))
1393
1562
  } else if (platform === "linux") {
1394
- paths.push(join(opt, "Qoder", "resources", "umid", name))
1563
+ paths.push(join(opt, runtime.linux, "resources", "umid", name))
1395
1564
  }
1396
1565
  for (const path of paths) if (await isFile(path)) return path
1397
- const config = env.QODER_CONFIG_DIR || join(env.QODER_CLI_HOME || env.GEMINI_CLI_HOME || home, env.QODER_CONFIG_DIR_NAME || ".qoder")
1566
+ const cli = (key) => env[runtime.cli + key]
1567
+ const config = cli("CONFIG_DIR") || join(cli("CLI_HOME") || env.GEMINI_CLI_HOME || home, cli("CONFIG_DIR_NAME") || runtime.dir)
1398
1568
  const cache = join(config, ".bin")
1399
1569
  const entries = await readdir(cache, { withFileTypes: true }).catch(() => [])
1400
1570
  const found = []
@@ -1408,7 +1578,7 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1408
1578
  if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1409
1579
  }
1410
1580
  if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1411
- throw new RuntimeMissing()
1581
+ throw new RuntimeMissing(runtime)
1412
1582
  }
1413
1583
 
1414
1584
  // RuntimeMissing: no Qoder desktop or CLI on this machine to read the
@@ -1418,19 +1588,21 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1418
1588
  // never makes up. Asking again won't change that until the user installs
1419
1589
  // Qoder, so the check-in says what to do once instead of "tries again later".
1420
1590
  class RuntimeMissing extends Error {
1421
- constructor() {
1591
+ constructor(runtime = RUNTIMES.qoder) {
1592
+ const { name, override } = runtime
1422
1593
  super(
1423
- "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",
1594
+ `${name} check-in: device identity runtime not found. ${name} gives the daily credits only to a device its own app identifies, and this machine has neither ${name} desktop nor ${name} CLI to read that identity from. Install ${name} desktop (or install ${name} CLI and sign in to it once), or set ${override} to its runtime-info executable; magpie checks in again the next day`,
1424
1595
  )
1425
1596
  }
1426
1597
  }
1427
1598
 
1428
- // Read the SDK identity as Qoder does: environment 3 is international; on
1429
- // Windows and macOS the uid goes over stdin, never into the command line.
1430
- async function campaignMachine(uid) {
1599
+ // Read the SDK identity as Qoder does: environment 3 is international, 0
1600
+ // Qoder CN; on Windows and macOS the uid goes over stdin, never into the
1601
+ // command line.
1602
+ async function campaignMachine(uid, runtime = RUNTIMES.qoder) {
1431
1603
  if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
1432
- const file = await campaignRuntime()
1433
- const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
1604
+ const file = await campaignRuntime({ runtime })
1605
+ const args = process.platform === "linux" ? [runtime.environment] : [runtime.environment, "--account-stdin"]
1434
1606
  const text = await new Promise((resolve, reject) => {
1435
1607
  const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
1436
1608
  if (err) reject(err)
@@ -1439,7 +1611,7 @@ async function campaignMachine(uid) {
1439
1611
  child.stdin?.on("error", () => {})
1440
1612
  child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
1441
1613
  }).catch(() => {
1442
- throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
1614
+ throw new Error(`${runtime.name} check-in: could not read device identity; restart or update ${runtime.name}, or check ${runtime.override}`)
1443
1615
  })
1444
1616
  try {
1445
1617
  const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
@@ -1451,7 +1623,7 @@ async function campaignMachine(uid) {
1451
1623
  }
1452
1624
  return headers
1453
1625
  } catch {
1454
- throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
1626
+ throw new Error(`${runtime.name} check-in: invalid device identity from ${runtime.name}; restart or update ${runtime.name}`)
1455
1627
  }
1456
1628
  }
1457
1629
 
@@ -1606,12 +1778,13 @@ const makePlugin = (site) => async ({ client }) => {
1606
1778
  const ID = site.id
1607
1779
  // The SDK identity is account-specific. Share an in-flight read, refresh
1608
1780
  // after an hour, and leave a failed read available for the next attempt.
1781
+ const runtime = RUNTIMES[ID]
1609
1782
  const campaignMachines = new Map()
1610
1783
  const machineForCampaign = async (uid) => {
1611
1784
  const now = Date.now()
1612
1785
  let entry = campaignMachines.get(uid)
1613
1786
  if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
1614
- entry = { at: now, promise: campaignMachine(uid) }
1787
+ entry = { at: now, promise: campaignMachine(uid, runtime) }
1615
1788
  campaignMachines.set(uid, entry)
1616
1789
  }
1617
1790
  try {
@@ -1621,6 +1794,16 @@ const makePlugin = (site) => async ({ client }) => {
1621
1794
  throw e
1622
1795
  }
1623
1796
  }
1797
+ // campaignIdentity: the headers a campaigns request carries, and why
1798
+ // there are none where the site goes on without them (Qoder CN).
1799
+ const campaignIdentity = async (uid) => {
1800
+ if (runtime.required) return { machine: await machineForCampaign(uid) }
1801
+ try {
1802
+ return { machine: await machineForCampaign(uid) }
1803
+ } catch (e) {
1804
+ return { machine: {}, unread: e }
1805
+ }
1806
+ }
1624
1807
  // serializes checking, rotating and saving tokens: a refresh token is
1625
1808
  // spent once, so two refreshes would spend it twice
1626
1809
  let lock = Promise.resolve()
@@ -1779,7 +1962,7 @@ const makePlugin = (site) => async ({ client }) => {
1779
1962
  if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1780
1963
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1781
1964
  try {
1782
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1965
+ const { machine } = await campaignIdentity(cred.uid)
1783
1966
  // A PAT account has no device token: its job token signs the campaigns
1784
1967
  // pages too, as it does the usage read. Nothing is rotated on that
1785
1968
  // path — the refusal is the answer.
@@ -1808,7 +1991,7 @@ const makePlugin = (site) => async ({ client }) => {
1808
1991
  const base = campaignTarget(site, siteOf(site, cred), site.openapi + CAMPAIGNS)
1809
1992
  if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1810
1993
  try {
1811
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1994
+ const { machine, unread } = await campaignIdentity(cred.uid)
1812
1995
  // A PAT account has no device token: the campaigns pages take the job
1813
1996
  // token the exchange gave, as the usage endpoint does. A live Qoder CN
1814
1997
  // PAT account answered 200 to the list and CLAIMED to a claim, so the
@@ -1846,7 +2029,13 @@ const makePlugin = (site) => async ({ client }) => {
1846
2029
  const amount = Number(done.benefit?.amount)
1847
2030
  return { outcome: "done", ...(Number.isFinite(amount) && amount > 0 ? { credit: amount } : {}) }
1848
2031
  }
1849
- return campaigns.length ? { outcome: "failed", message: "campaigns: today's credits aren't offered yet" } : { outcome: "inactive" }
2032
+ if (campaigns.length) return { outcome: "failed", message: "campaigns: today's credits aren't offered yet" }
2033
+ // Asked without Qoder CN's identity and offered nothing: what an
2034
+ // international list says to a request without one, so say why
2035
+ // (no Qoder CN on this machine) or ask again (it didn't answer).
2036
+ if (unread instanceof RuntimeMissing) return { outcome: "ineligible", message: unread.message }
2037
+ if (unread) return { outcome: "failed", message: unread.message }
2038
+ return { outcome: "inactive" }
1850
2039
  }
1851
2040
  const claimRes = await call(base.replace(/\/$/, "") + "/" + encodeURIComponent(campaign.campaignId) + "/claim", "POST", "{}")
1852
2041
  let claim = {}
@@ -1889,23 +2078,37 @@ const makePlugin = (site) => async ({ client }) => {
1889
2078
  return errorResponse({ status: 502, message: e?.message ?? String(e) })
1890
2079
  }
1891
2080
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
1892
- const CHAT_URL = siteOf(site, cred).api + CHAT_PATH
1893
- const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
1894
- const headers = {
1895
- ...cosyHeaders(CHAT_URL, cred, wire),
1896
- Accept: "text/event-stream",
1897
- "Cache-Control": "no-cache",
1898
- "X-Model-Key": m.key,
1899
- "X-Model-Source": m.source,
1900
- }
1901
- let res
1902
- try {
1903
- res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
1904
- } catch (e) {
1905
- return errorResponse({ status: 502, message: e.message })
2081
+ const api = siteOf(site, cred).api
2082
+ const CHAT_URL = api + CHAT_PATH
2083
+ const body = qoderBody(chat, m)
2084
+ const wire = encodeBody(JSON.stringify(body))
2085
+ // Qoder's queue is waited out (queueWait) and the same request, its
2086
+ // request_set_id the one the queue knows, sent again
2087
+ const deadline = QUEUE.now() + QUEUE.budget
2088
+ const ask = { requestSetId: body.request_set_id, requestId: body.request_id, sessionId: body.session_id, modelKey: m.key }
2089
+ for (let waited = 0; ; waited++) {
2090
+ const headers = {
2091
+ ...cosyHeaders(CHAT_URL, cred, wire),
2092
+ Accept: "text/event-stream",
2093
+ "Cache-Control": "no-cache",
2094
+ "X-Model-Key": m.key,
2095
+ "X-Model-Source": m.source,
2096
+ }
2097
+ let res, open
2098
+ try {
2099
+ res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
2100
+ if (res.ok) open = await opened(res)
2101
+ } catch (e) {
2102
+ return errorResponse({ status: 502, message: e.message })
2103
+ }
2104
+ const f = !res.ok ? failure(res.status, (await res.text()).slice(0, 1 << 20)) : open.first.value?.error
2105
+ if (!f?.queue || waited >= QUEUE.recoveries) return f && !res.ok ? errorResponse(f) : answer(open, chat)
2106
+ try {
2107
+ if (!(await queueWait(api, cred, ask, f.queue, init.signal, deadline))) return errorResponse(f)
2108
+ } catch (e) {
2109
+ return errorResponse({ status: 502, message: e?.message ?? String(e) })
2110
+ }
1906
2111
  }
1907
- if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
1908
- return answer(res, chat)
1909
2112
  }
1910
2113
 
1911
2114
  return {
@@ -2071,4 +2274,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
2071
2274
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
2072
2275
 
2073
2276
  // for tests
2074
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events }
2277
+ export const _internal = { campaignRuntime, RUNTIMES, 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.27",
3
+ "version": "0.2.30",
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",