@magpie-community/opencode-qoder-auth 0.2.13 → 0.2.15
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 +4 -2
- package/index.mjs +165 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,8 +83,10 @@ itself.
|
|
|
83
83
|
|
|
84
84
|
| What Qoder says | What the request returns |
|
|
85
85
|
|---|---|
|
|
86
|
-
| A refused sign-in | 401 |
|
|
87
|
-
| A quota | 429 |
|
|
86
|
+
| A refused sign-in (no code, or code 105) | 401 |
|
|
87
|
+
| A quota (codes 110, 112–119, 122) | 429 |
|
|
88
|
+
| 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 |
|
|
89
|
+
| A refusal of the request itself (codes 406, 416, 430, 80411, 80412, 100400–100403) | 400, with Qoder's words |
|
|
88
90
|
| A failure before the answer starts | Qoder's status |
|
|
89
91
|
| A failure after the answer has started | The stream ends with an error |
|
|
90
92
|
|
package/index.mjs
CHANGED
|
@@ -311,6 +311,64 @@ async function deviceSignIn(site) {
|
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
// exchangeExpiry is when a job token from /api/v1/jobToken/exchange runs
|
|
315
|
+
// out, as Qoder's own client reads it: expire_time or expires_at (a date)
|
|
316
|
+
// first, then expires_in (milliseconds), then a day.
|
|
317
|
+
function exchangeExpiry(jt) {
|
|
318
|
+
for (const k of ["expire_time", "expires_at"]) {
|
|
319
|
+
const at = Date.parse(String(jt?.[k] ?? "").trim())
|
|
320
|
+
if (!isNaN(at)) return at
|
|
321
|
+
}
|
|
322
|
+
if (jt?.expires_in > 0) return Date.now() + jt.expires_in
|
|
323
|
+
return Date.now() + DAY
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// exchangeToken is the job token from an exchange response, as Qoder's
|
|
327
|
+
// client reads it: token, device_token or access_token.
|
|
328
|
+
function exchangeToken(jt) {
|
|
329
|
+
return String(jt?.token ?? jt?.device_token ?? jt?.access_token ?? "").trim()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// patSignIn exchanges a Personal Access Token for a job token, bypassing
|
|
333
|
+
// the OAuth device flow. PATs are long-lived (up to 1 year): the PAT is
|
|
334
|
+
// kept on the credential, and when the job token's refresh lapses the PAT
|
|
335
|
+
// is exchanged again rather than asking the user to sign in once more.
|
|
336
|
+
async function patSignIn(site, pat) {
|
|
337
|
+
const machineId = randomUUID()
|
|
338
|
+
const r = await openapi(site, "/api/v1/jobToken/exchange", { method: "POST", body: { personal_token: pat } })
|
|
339
|
+
if (r.status === 400 || r.status === 401 || r.status === 403)
|
|
340
|
+
throw Object.assign(new Error(`${site.name} refused the PAT — check it is still valid`), { status: r.status })
|
|
341
|
+
if (r.status !== 200) throw new Error(`${site.name} PAT exchange: status ${r.status}`)
|
|
342
|
+
const token = exchangeToken(r.json)
|
|
343
|
+
if (!token) throw new Error(`${site.name} PAT exchange: empty token in response`)
|
|
344
|
+
const refresh = String(r.json?.refresh_token ?? r.json?.refreshToken ?? "").trim()
|
|
345
|
+
const chat = { access: token, refresh, expires: exchangeExpiry(r.json) }
|
|
346
|
+
let email = ""
|
|
347
|
+
let name = ""
|
|
348
|
+
let uid = ""
|
|
349
|
+
try {
|
|
350
|
+
const ui = await openapi(site, "/api/v1/userinfo", { token })
|
|
351
|
+
if (ui.status >= 200 && ui.status < 300) {
|
|
352
|
+
;[email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
|
|
353
|
+
uid = String(ui.json?.id ?? ui.json?.user_id ?? ui.json?.uid ?? "")
|
|
354
|
+
}
|
|
355
|
+
} catch {}
|
|
356
|
+
if (!uid) throw new Error(`${site.name} PAT sign-in: could not determine user id`)
|
|
357
|
+
return {
|
|
358
|
+
type: "success",
|
|
359
|
+
...chat,
|
|
360
|
+
accountId: email || uid,
|
|
361
|
+
uid,
|
|
362
|
+
email,
|
|
363
|
+
name: name || "",
|
|
364
|
+
machineId,
|
|
365
|
+
deviceToken: "",
|
|
366
|
+
deviceRefresh: "",
|
|
367
|
+
patAuth: true,
|
|
368
|
+
pat,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
314
372
|
// SignInGone is a sign-in that can't sign a request: a 401. Only Qoder
|
|
315
373
|
// refusing the refresh token marks the account lapsed (expired), as the
|
|
316
374
|
// built-in's qoderRefreshFailed did; no refresh token, or no account,
|
|
@@ -720,19 +778,55 @@ function qoderBody(chat, m) {
|
|
|
720
778
|
return body
|
|
721
779
|
}
|
|
722
780
|
|
|
723
|
-
// failure is the status and message for a Qoder failure
|
|
724
|
-
//
|
|
781
|
+
// failure is the status and message for a Qoder failure. Qoder's own code
|
|
782
|
+
// says what it is, read as Qoder's client reads it (QoderErrorCode in the
|
|
783
|
+
// app's qoder-agent-sdk worker): only 105 (loginExpired) is a lapsed
|
|
784
|
+
// sign-in there, so a 401/403 that carries another code isn't one here
|
|
785
|
+
// either (#98: a queued 10605 came as a 403 and read as "sign in again").
|
|
786
|
+
// A refused chat with no code keeps the built-in's 401, and a quota is a
|
|
787
|
+
// 429.
|
|
788
|
+
const QUEUED = "10605"
|
|
789
|
+
// the codes Qoder's client counts as a plan's usage or credits used up
|
|
790
|
+
const QUOTA_CODES = new Set(["110", "112", "113", "114", "115", "116", "117", "118", "119", "122"])
|
|
791
|
+
// refusals of this request, not of the account: sensitive content, not
|
|
792
|
+
// supported, input too long, too many media, a custom model's service
|
|
793
|
+
const REQUEST_CODES = new Set(["406", "416", "430", "80411", "80412", "100400", "100401", "100403"])
|
|
794
|
+
const BUSY_WAIT = 30
|
|
795
|
+
|
|
725
796
|
function failure(status, text) {
|
|
726
797
|
if (status < 400 || status > 599) status = 502
|
|
727
798
|
let msg = String(text ?? "").trim()
|
|
799
|
+
let code = ""
|
|
800
|
+
let queue
|
|
728
801
|
try {
|
|
729
802
|
const j = JSON.parse(text)
|
|
730
|
-
|
|
803
|
+
code = j?.code == null ? "" : String(j.code)
|
|
804
|
+
if (j?.message) msg = String(j.message)
|
|
731
805
|
// details is a JSON string, or the object itself (gjson reads either)
|
|
732
806
|
const d = typeof j?.details === "string" ? JSON.parse(j.details) : j?.details
|
|
733
807
|
if (d?.error?.message) msg += ": " + d.error.message
|
|
808
|
+
// the queue's state: the message itself, as a JSON string, or a field
|
|
809
|
+
queue = j?.queue && typeof j.queue === "object" ? j.queue : undefined
|
|
810
|
+
if (!queue && typeof j?.message === "string" && j.message.trim().startsWith("{")) {
|
|
811
|
+
const q = JSON.parse(j.message)
|
|
812
|
+
if (q && typeof q === "object" && ("isQueued" in q || "serviceAvailable" in q || "retryAfterSeconds" in q)) queue = q
|
|
813
|
+
}
|
|
734
814
|
} catch {}
|
|
735
815
|
msg ||= STATUS_CODES[status] ?? `HTTP ${status}`
|
|
816
|
+
// Qoder's queue for the model: busy for now, with when to ask again. The
|
|
817
|
+
// sign-in is fine (the same token reads the models and the usage), so it
|
|
818
|
+
// is a 503 with Retry-After, which magpie asks again after, and never
|
|
819
|
+
// touches the account's sign-in.
|
|
820
|
+
if (code === QUEUED || queue?.isQueued === true || queue?.serviceAvailable === false) {
|
|
821
|
+
const n = Number(queue?.retryAfterSeconds)
|
|
822
|
+
const wait = Number.isFinite(n) && n > 0 ? Math.ceil(n) : BUSY_WAIT
|
|
823
|
+
const model = queue?.modelKey ? ` for ${queue.modelKey}` : ""
|
|
824
|
+
const tier = queue?.queueType ? ` (this plan's queue: ${queue.queueType})` : ""
|
|
825
|
+
const what = queue?.isQueued === true ? "queued the request" : "is busy"
|
|
826
|
+
return { status: 503, message: `Qoder ${what}${model}${tier}; try again in ${wait}s`, retryAfter: wait, signIn: "" }
|
|
827
|
+
}
|
|
828
|
+
if (QUOTA_CODES.has(code)) return { status: 429, message: "usage limit reached: " + msg, signIn: "" }
|
|
829
|
+
if (REQUEST_CODES.has(code)) return { status: 400, message: `Qoder refused the request (${code}): ${msg}`, signIn: "" }
|
|
736
830
|
// a refused chat is the built-in's 401, which never marked the account
|
|
737
831
|
// lapsed: only a refused refresh did (errorResponse says kept). Qoder's
|
|
738
832
|
// own words go with it: a 401 right after signing in isn't a lapsed
|
|
@@ -747,11 +841,16 @@ function failure(status, text) {
|
|
|
747
841
|
|
|
748
842
|
// errorResponse is a failure as OpenAI's API gives it. signIn is what it
|
|
749
843
|
// means for the account (magpie's X-Magpie-Sign-In): a 401 keeps it unless
|
|
750
|
-
// told expired, since the built-in marked only a refused refresh.
|
|
751
|
-
|
|
844
|
+
// told expired, since the built-in marked only a refused refresh. retryAfter
|
|
845
|
+
// (seconds) is sent as Retry-After.
|
|
846
|
+
const errorResponse = ({ status, message, signIn = status === 401 ? "kept" : undefined, retryAfter }) =>
|
|
752
847
|
new Response(JSON.stringify({ error: { message, type: "qoder_error", code: status } }), {
|
|
753
848
|
status,
|
|
754
|
-
headers: {
|
|
849
|
+
headers: {
|
|
850
|
+
"Content-Type": "application/json",
|
|
851
|
+
...(signIn ? { "X-Magpie-Sign-In": signIn } : {}),
|
|
852
|
+
...(retryAfter > 0 ? { "Retry-After": String(retryAfter) } : {}),
|
|
853
|
+
},
|
|
755
854
|
})
|
|
756
855
|
|
|
757
856
|
// ---- the reply ----------------------------------------------------------------
|
|
@@ -1331,7 +1430,8 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1331
1430
|
return got
|
|
1332
1431
|
}
|
|
1333
1432
|
// renew spends a's chat refresh token, under the lock: the fields it
|
|
1334
|
-
// changes, recorded in spent.
|
|
1433
|
+
// changes, recorded in spent. A PAT account whose job refresh has lapsed
|
|
1434
|
+
// exchanges the PAT again: the PAT lasts a year, the refresh two days.
|
|
1335
1435
|
const renew = async (a) => {
|
|
1336
1436
|
const s = siteOf(site, a)
|
|
1337
1437
|
if (a.deviceChat) {
|
|
@@ -1340,7 +1440,22 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1340
1440
|
const dt = await refreshDevice(s, a.refresh, true)
|
|
1341
1441
|
return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
|
|
1342
1442
|
}
|
|
1343
|
-
|
|
1443
|
+
let jt
|
|
1444
|
+
try {
|
|
1445
|
+
jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
if (!(e instanceof SignInGone) || !a.patAuth || !a.pat) throw e
|
|
1448
|
+
let ex
|
|
1449
|
+
try {
|
|
1450
|
+
ex = await patSignIn(s, a.pat)
|
|
1451
|
+
} catch (p) {
|
|
1452
|
+
// a PAT lapses too — out of date, or revoked: Qoder's refusal ends
|
|
1453
|
+
// the sign-in as a lapsed device one does, not as a server fault
|
|
1454
|
+
if (!p?.status) throw p
|
|
1455
|
+
throw new SignInGone(`${site.name} refused the PAT — sign in again with a new one`, true)
|
|
1456
|
+
}
|
|
1457
|
+
return spend(a.refresh, { access: ex.access, refresh: ex.refresh, expires: ex.expires })
|
|
1458
|
+
}
|
|
1344
1459
|
return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
|
|
1345
1460
|
}
|
|
1346
1461
|
// each account's listing, the model configs a request carries
|
|
@@ -1409,6 +1524,14 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1409
1524
|
try {
|
|
1410
1525
|
const cred = await fresh(getAuth)
|
|
1411
1526
|
if (renewed(cred)) signIn = "renewed"
|
|
1527
|
+
// A PAT account has no device token: the usage endpoint takes the job
|
|
1528
|
+
// token the exchange gave — a live PAT account answered 200 with a
|
|
1529
|
+
// qoder envelope. Its errors surface as the device path's do; there is
|
|
1530
|
+
// no second token here to rotate and retry with.
|
|
1531
|
+
if (cred.patAuth && !cred.deviceToken) {
|
|
1532
|
+
const env = await fetchUsage(siteOf(site, cred), cred.access)
|
|
1533
|
+
return { ...parseUsage(env), signIn }
|
|
1534
|
+
}
|
|
1412
1535
|
let env
|
|
1413
1536
|
try {
|
|
1414
1537
|
env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
|
|
@@ -1542,6 +1665,40 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1542
1665
|
},
|
|
1543
1666
|
methods: [
|
|
1544
1667
|
{ type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
|
|
1668
|
+
// PAT sign-in: exchange a Personal Access Token for a job token,
|
|
1669
|
+
// bypassing the OAuth device flow. PATs last up to 1 year.
|
|
1670
|
+
// Not offered on Qoder CN: its CLI uses device chat directly and
|
|
1671
|
+
// never makes a job token, so a PAT exchange may not produce one
|
|
1672
|
+
// the chat endpoint accepts.
|
|
1673
|
+
...(site.id !== "qoder-cn"
|
|
1674
|
+
? [
|
|
1675
|
+
{
|
|
1676
|
+
type: "oauth",
|
|
1677
|
+
label: `Sign in with ${site.name} PAT`,
|
|
1678
|
+
prompts: [
|
|
1679
|
+
{
|
|
1680
|
+
type: "password",
|
|
1681
|
+
key: "pat",
|
|
1682
|
+
message: "Your Qoder Personal Access Token (from qoder.com/account/integrations)",
|
|
1683
|
+
placeholder: "pt-...",
|
|
1684
|
+
validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
|
|
1685
|
+
},
|
|
1686
|
+
],
|
|
1687
|
+
authorize: (inputs) => {
|
|
1688
|
+
const pat = String(inputs?.pat ?? "").trim()
|
|
1689
|
+
if (!pat) throw new Error("enter your Personal Access Token")
|
|
1690
|
+
return {
|
|
1691
|
+
url: `${site.web}/account/integrations`,
|
|
1692
|
+
instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
|
|
1693
|
+
method: "auto",
|
|
1694
|
+
async callback() {
|
|
1695
|
+
return patSignIn(site, pat)
|
|
1696
|
+
},
|
|
1697
|
+
}
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
]
|
|
1701
|
+
: []),
|
|
1545
1702
|
// an enterprise VPC instance (Qoder CN), a method of its own so
|
|
1546
1703
|
// that the sign-in above asks nothing new
|
|
1547
1704
|
...(site.vpcDomain
|
package/package.json
CHANGED