@magpie-community/opencode-qoder-auth 0.2.14 → 0.2.16

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 +6 -0
  2. package/index.mjs +180 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -78,6 +78,12 @@ itself.
78
78
  reasoning and usage.
79
79
  - Tool calls Qoder writes as XML or JSON in its text become tool calls.
80
80
  Tool calls it sends the OpenAI way keep Qoder's id.
81
+ - A call written in the text that can't be read, or that the stream ends
82
+ inside, is never sent on as the answer (since 0.2.16, #132): the turn
83
+ ends as an error, a 502 or the stream's error frame with no finish, so
84
+ the agent or magpie can try again. A call whose JSON is whole when the
85
+ stream ends without its close is still the call; a tag in code or prose
86
+ stays text.
81
87
 
82
88
  **Errors**
83
89
 
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,
@@ -826,13 +884,37 @@ function parseCall(v) {
826
884
  return { name: m[1].trim(), args: JSON.stringify(args) }
827
885
  }
828
886
 
887
+ // quoted says text ends inside a code fence or an inline code span: a
888
+ // <tool_call> there is prose about one, not a call
889
+ function quoted(text) {
890
+ if ((text.match(/```/g)?.length ?? 0) % 2) return true
891
+ const line = text.slice(text.lastIndexOf("\n") + 1).replace(/```/g, "")
892
+ return (line.match(/`/g)?.length ?? 0) % 2 === 1
893
+ }
894
+
895
+ // callish says a block was on its way to being a call: its JSON began, or
896
+ // the XML function form
897
+ const callish = (v) => v.trimStart().startsWith("{") || v.includes("\x3cfunction=")
898
+
899
+ const CUT_SHORT = "the model's tool call, written as text, was cut short; it is not sent on as the answer"
900
+ const UNREADABLE = "the model's tool call, written as text, can't be read; it is not sent on as the answer"
901
+
829
902
  // Splitter holds text until a whole tool-call block has come, then gives
830
- // it as a call.
903
+ // it as a call. A block on its way to being a call that can't be read, or
904
+ // that the stream ended inside, is an error, never the answer (plugins#132,
905
+ // as trae's since plugins#38): the turn fails so it can be tried again. A
906
+ // block whose JSON is whole when the stream ends without its close is still
907
+ // the call; one that is no call at all, or one in code, stays text.
831
908
  class Splitter {
832
909
  textBuf = ""
833
910
  callBuf = ""
834
911
  inCall = false
835
912
  sawTool = false
913
+ sent = "" // the text given out so far, to tell a block in code from a call
914
+ text(out, t) {
915
+ this.sent += t
916
+ out.push({ text: t })
917
+ }
836
918
  feed(input) {
837
919
  const out = []
838
920
  while (input) {
@@ -847,11 +929,11 @@ class Splitter {
847
929
  keep = n
848
930
  break
849
931
  }
850
- if (keep < comb.length) out.push({ text: comb.slice(0, comb.length - keep) })
932
+ if (keep < comb.length) this.text(out, comb.slice(0, comb.length - keep))
851
933
  this.textBuf = comb.slice(comb.length - keep)
852
934
  break
853
935
  }
854
- if (i > 0) out.push({ text: comb.slice(0, i) })
936
+ if (i > 0) this.text(out, comb.slice(0, i))
855
937
  input = comb.slice(i + CALL_OPEN.length)
856
938
  this.inCall = true
857
939
  continue
@@ -863,11 +945,16 @@ class Splitter {
863
945
  this.callBuf = comb
864
946
  break
865
947
  }
866
- const c = parseCall(comb.slice(0, j))
948
+ const body = comb.slice(0, j)
949
+ const c = parseCall(body)
867
950
  if (c) {
868
951
  this.sawTool = true
869
952
  out.push({ call: c })
870
- } else out.push({ text: CALL_OPEN + comb.slice(0, j) + CALL_CLOSE })
953
+ } else if (callish(body) && !quoted(this.sent)) {
954
+ this.inCall = false
955
+ out.push({ error: UNREADABLE })
956
+ return out
957
+ } else this.text(out, CALL_OPEN + body + CALL_CLOSE)
871
958
  input = comb.slice(j + CALL_CLOSE.length)
872
959
  this.inCall = false
873
960
  }
@@ -875,14 +962,23 @@ class Splitter {
875
962
  }
876
963
  flush() {
877
964
  if (this.inCall) {
878
- const t = CALL_OPEN + this.callBuf
965
+ const body = this.callBuf
879
966
  this.inCall = false
880
967
  this.callBuf = ""
968
+ if (callish(body) && !quoted(this.sent)) {
969
+ const c = parseCall(body)
970
+ if (!c) return [{ error: CUT_SHORT }]
971
+ this.sawTool = true
972
+ return [{ call: c }]
973
+ }
974
+ const t = CALL_OPEN + body
975
+ this.sent += t
881
976
  return [{ text: t }]
882
977
  }
883
978
  if (this.textBuf) {
884
979
  const t = this.textBuf
885
980
  this.textBuf = ""
981
+ this.sent += t
886
982
  return [{ text: t }]
887
983
  }
888
984
  return []
@@ -910,9 +1006,10 @@ async function* events(body) {
910
1006
  const a = new Splitter()
911
1007
  let index = -1 // the tool call being given
912
1008
  let native = -1 // the native tool call being assembled
913
- const flushed = function* () {
914
- for (const f of a.flush()) if (f.text) yield { text: f.text }
915
- }
1009
+ // one piece of the split text as an event: a call, text, or a call that
1010
+ // can't be read, which ends the turn as a 502
1011
+ const piece = (f) =>
1012
+ f.call ? { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } } : f.error ? { error: { status: 502, message: f.error } } : { text: f.text }
916
1013
  let usage
917
1014
  let fr = ""
918
1015
  for await (const line of sseLines(body)) {
@@ -940,15 +1037,21 @@ async function* events(body) {
940
1037
  if (delta.reasoning_content) yield { reasoning: delta.reasoning_content }
941
1038
  if (typeof delta.content === "string")
942
1039
  for (const f of a.feed(delta.content)) {
943
- if (f.call) yield { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } }
944
- else if (f.text) yield { text: f.text }
1040
+ if (!f.call && !f.error && !f.text) continue
1041
+ const e = piece(f)
1042
+ yield e
1043
+ if (e.error) return
945
1044
  }
946
1045
  // tool calls served the OpenAI way keep Qoder's id: a result answers it
947
1046
  for (const tc of delta.tool_calls ?? []) {
948
1047
  const i = Number(tc.index ?? 0)
949
1048
  if (i !== native) {
950
1049
  native = i
951
- yield* flushed()
1050
+ for (const f of a.flush()) {
1051
+ const e = piece(f)
1052
+ yield e
1053
+ if (e.error) return
1054
+ }
952
1055
  a.sawTool = true
953
1056
  yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
954
1057
  } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
@@ -967,7 +1070,11 @@ async function* events(body) {
967
1070
  ...(u.completion_tokens_details?.reasoning_tokens ? { completion_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
968
1071
  }
969
1072
  }
970
- yield* flushed()
1073
+ for (const f of a.flush()) {
1074
+ const e = piece(f)
1075
+ yield e
1076
+ if (e.error) return
1077
+ }
971
1078
  yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
972
1079
  }
973
1080
 
@@ -1372,7 +1479,8 @@ const makePlugin = (site) => async ({ client }) => {
1372
1479
  return got
1373
1480
  }
1374
1481
  // renew spends a's chat refresh token, under the lock: the fields it
1375
- // changes, recorded in spent.
1482
+ // changes, recorded in spent. A PAT account whose job refresh has lapsed
1483
+ // exchanges the PAT again: the PAT lasts a year, the refresh two days.
1376
1484
  const renew = async (a) => {
1377
1485
  const s = siteOf(site, a)
1378
1486
  if (a.deviceChat) {
@@ -1381,7 +1489,22 @@ const makePlugin = (site) => async ({ client }) => {
1381
1489
  const dt = await refreshDevice(s, a.refresh, true)
1382
1490
  return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1383
1491
  }
1384
- const jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1492
+ let jt
1493
+ try {
1494
+ jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1495
+ } catch (e) {
1496
+ if (!(e instanceof SignInGone) || !a.patAuth || !a.pat) throw e
1497
+ let ex
1498
+ try {
1499
+ ex = await patSignIn(s, a.pat)
1500
+ } catch (p) {
1501
+ // a PAT lapses too — out of date, or revoked: Qoder's refusal ends
1502
+ // the sign-in as a lapsed device one does, not as a server fault
1503
+ if (!p?.status) throw p
1504
+ throw new SignInGone(`${site.name} refused the PAT — sign in again with a new one`, true)
1505
+ }
1506
+ return spend(a.refresh, { access: ex.access, refresh: ex.refresh, expires: ex.expires })
1507
+ }
1385
1508
  return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1386
1509
  }
1387
1510
  // each account's listing, the model configs a request carries
@@ -1450,6 +1573,14 @@ const makePlugin = (site) => async ({ client }) => {
1450
1573
  try {
1451
1574
  const cred = await fresh(getAuth)
1452
1575
  if (renewed(cred)) signIn = "renewed"
1576
+ // A PAT account has no device token: the usage endpoint takes the job
1577
+ // token the exchange gave — a live PAT account answered 200 with a
1578
+ // qoder envelope. Its errors surface as the device path's do; there is
1579
+ // no second token here to rotate and retry with.
1580
+ if (cred.patAuth && !cred.deviceToken) {
1581
+ const env = await fetchUsage(siteOf(site, cred), cred.access)
1582
+ return { ...parseUsage(env), signIn }
1583
+ }
1453
1584
  let env
1454
1585
  try {
1455
1586
  env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
@@ -1583,6 +1714,40 @@ const makePlugin = (site) => async ({ client }) => {
1583
1714
  },
1584
1715
  methods: [
1585
1716
  { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1717
+ // PAT sign-in: exchange a Personal Access Token for a job token,
1718
+ // bypassing the OAuth device flow. PATs last up to 1 year.
1719
+ // Not offered on Qoder CN: its CLI uses device chat directly and
1720
+ // never makes a job token, so a PAT exchange may not produce one
1721
+ // the chat endpoint accepts.
1722
+ ...(site.id !== "qoder-cn"
1723
+ ? [
1724
+ {
1725
+ type: "oauth",
1726
+ label: `Sign in with ${site.name} PAT`,
1727
+ prompts: [
1728
+ {
1729
+ type: "password",
1730
+ key: "pat",
1731
+ message: "Your Qoder Personal Access Token (from qoder.com/account/integrations)",
1732
+ placeholder: "pt-...",
1733
+ validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1734
+ },
1735
+ ],
1736
+ authorize: (inputs) => {
1737
+ const pat = String(inputs?.pat ?? "").trim()
1738
+ if (!pat) throw new Error("enter your Personal Access Token")
1739
+ return {
1740
+ url: `${site.web}/account/integrations`,
1741
+ instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1742
+ method: "auto",
1743
+ async callback() {
1744
+ return patSignIn(site, pat)
1745
+ },
1746
+ }
1747
+ },
1748
+ },
1749
+ ]
1750
+ : []),
1586
1751
  // an enterprise VPC instance (Qoder CN), a method of its own so
1587
1752
  // that the sign-in above asks nothing new
1588
1753
  ...(site.vpcDomain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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",