@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.
- package/README.md +25 -4
- package/index.mjs +201 -22
- 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) |
|
|
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
|
-
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1876
|
-
const
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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