@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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/index.mjs +165 -8
  3. 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: a refused sign-in
724
- // asks for another, a quota is a 429.
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
- if (j?.message) msg = j.message
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
- const errorResponse = ({ status, message, signIn = status === 401 ? "kept" : undefined }) =>
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: { "Content-Type": "application/json", ...(signIn ? { "X-Magpie-Sign-In": signIn } : {}) },
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
- const jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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",