@magpie-community/opencode-qoder-auth 0.2.19 → 0.2.21

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 +25 -0
  2. package/index.mjs +71 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,6 +22,31 @@ hosts; [its section](#qoder-cn) says what differs.
22
22
  3. The device token is traded for a job token, and the account's email and
23
23
  name are read.
24
24
 
25
+ **Sign in with Qoder PAT** (and **Sign in with Qoder CN PAT**) skips the
26
+ device flow: a Personal Access Token from `<site>/account/integrations` is
27
+ posted to `/api/v1/jobToken/exchange` (`personal_token`), which answers a
28
+ job token, its refresh token and its expiry. The PAT is kept on the
29
+ credential, so a lapsed job refresh is exchanged again instead of asking
30
+ for the sign-in; PATs last up to a year. The job token signs the model list
31
+ and chat calls with the same COSY envelope either sign-in's does.
32
+
33
+ On Qoder CN this is the plugin's own reading of the protocol rather than
34
+ the CLI's path: qoder.cn's CLI signs requests with its device token and
35
+ never asks for a job token. Verified against a live qoder.cn account — the
36
+ exchange answers 200 with a `jt-` token, the signed model list answers 200
37
+ with that account's own models, and a chat completion returns its text.
38
+
39
+ A PAT account keeps the name its browser sign-in takes (`email || uid`): magpie
40
+ replaces an account on a new sign-in when both the accountId and the uid
41
+ match, so a renewed PAT sign-in names the same account rather than adding a
42
+ row beside the lapsed one.
43
+
44
+ A PAT account has no device token, so its usage read, campaigns pages and
45
+ daily check-in go out on the job token instead. The check-in works on it: a
46
+ live PAT account answered 200 to the campaigns list and `CLAIMED` to a claim,
47
+ so it earns the daily credits the browser sign-in does. That path has no
48
+ second token to rotate, so a refused call is the answer rather than a retry.
49
+
25
50
  What is kept: the job token, its refresh token and expiry, the uid, the
26
51
  device token and a machine id made for this sign-in. OpenCode keeps them in
27
52
  `auth.json`; magpie keeps them in `plugin-auth.json`.
package/index.mjs CHANGED
@@ -357,6 +357,10 @@ async function patSignIn(site, pat) {
357
357
  return {
358
358
  type: "success",
359
359
  ...chat,
360
+ // The same name its browser sign-in takes: magpie's plugin host replaces
361
+ // an account on a new sign-in when both the accountId and the uid match,
362
+ // so a renewed PAT sign-in must keep naming the same account. A suffix
363
+ // here would add a second row beside the lapsed one instead.
360
364
  accountId: email || uid,
361
365
  uid,
362
366
  email,
@@ -1015,7 +1019,7 @@ async function* sseLines(body) {
1015
1019
  async function* events(body) {
1016
1020
  const a = new Splitter()
1017
1021
  let index = -1 // the tool call being given
1018
- let native = -1 // the native tool call being assembled
1022
+ const native = new Map() // upstream index -> call and its output index
1019
1023
  // one piece of the split text as an event: a call, text, or a call that
1020
1024
  // can't be read, which ends the turn as a 502
1021
1025
  const piece = (f) =>
@@ -1055,16 +1059,29 @@ async function* events(body) {
1055
1059
  // tool calls served the OpenAI way keep Qoder's id: a result answers it
1056
1060
  for (const tc of delta.tool_calls ?? []) {
1057
1061
  const i = Number(tc.index ?? 0)
1058
- if (i !== native) {
1059
- native = i
1062
+ const name = typeof tc.function?.name === "string" ? tc.function.name.trim() : ""
1063
+ const args = tc.function?.arguments ?? ""
1064
+ if (!tc.id && !name && !args) continue
1065
+ let call = native.get(i)
1066
+ if (!call) {
1067
+ call = { id: "", args: "" }
1068
+ native.set(i, call)
1069
+ }
1070
+ if (tc.id) call.id = tc.id
1071
+ if (call.index === undefined) {
1072
+ // An id or arguments can arrive before the name. Never emit an
1073
+ // unnamed tool: clients persist it and other providers reject it.
1074
+ call.args += args
1075
+ if (!name) continue
1060
1076
  for (const f of a.flush()) {
1061
1077
  const e = piece(f)
1062
1078
  yield e
1063
1079
  if (e.error) return
1064
1080
  }
1065
1081
  a.sawTool = true
1066
- yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
1067
- } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
1082
+ call.index = ++index
1083
+ yield { tool: { index: call.index, id: call.id || "call_" + hexID(), name, args: call.args } }
1084
+ } else if (args) yield { args: { index: call.index, text: args } }
1068
1085
  }
1069
1086
  fr = chunk?.choices?.[0]?.finish_reason || fr
1070
1087
  // the usage comes in a chunk of its own after the finish, with no
@@ -1085,6 +1102,10 @@ async function* events(body) {
1085
1102
  yield e
1086
1103
  if (e.error) return
1087
1104
  }
1105
+ if ([...native.values()].some((call) => call.index === undefined)) {
1106
+ yield { error: { status: 502, message: "the model's native tool call has no name; it is not sent on as a tool" } }
1107
+ return
1108
+ }
1088
1109
  yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
1089
1110
  }
1090
1111
 
@@ -1111,7 +1132,7 @@ async function answer(res, chat) {
1111
1132
  if (e.text) msg.content += e.text
1112
1133
  if (e.reasoning) reasoning += e.reasoning
1113
1134
  if (e.tool) calls.push({ id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } })
1114
- if (e.args) calls[calls.length - 1].function.arguments += e.args.text
1135
+ if (e.args) calls[e.args.index].function.arguments += e.args.text
1115
1136
  if (e.stop) [stop, usage] = [e.stop, e.usage]
1116
1137
  }
1117
1138
  if (reasoning) msg.reasoning_content = reasoning
@@ -1619,8 +1640,14 @@ const makePlugin = (site) => async ({ client }) => {
1619
1640
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1620
1641
  try {
1621
1642
  const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1622
- let res = await campaignCall(url, method, body, cred.deviceToken, machine, init.signal)
1623
- if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1643
+ // A PAT account has no device token: its job token signs the campaigns
1644
+ // pages too, as it does the usage read. Nothing is rotated on that
1645
+ // path — the refusal is the answer.
1646
+ const patToken = cred.patAuth && !cred.deviceToken
1647
+ const token = patToken ? cred.access : cred.deviceToken
1648
+ let res = await campaignCall(url, method, body, token, machine, init.signal)
1649
+ if (!patToken && (res.status === 401 || res.status === 403))
1650
+ res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1624
1651
  return signed(res, renewed(cred))
1625
1652
  } catch (e) {
1626
1653
  if (e?.expired) return signedInError(e)
@@ -1642,8 +1669,15 @@ const makePlugin = (site) => async ({ client }) => {
1642
1669
  if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1643
1670
  try {
1644
1671
  const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1672
+ // A PAT account has no device token: the campaigns pages take the job
1673
+ // token the exchange gave, as the usage endpoint does. A live Qoder CN
1674
+ // PAT account answered 200 to the list and CLAIMED to a claim, so the
1675
+ // check-in works there as it does on a device-token account. That path
1676
+ // has no second token to rotate, so a refusal is the answer.
1677
+ const patToken = cred.patAuth && !cred.deviceToken ? cred.access : ""
1645
1678
  const call = async (url, method, body) => {
1646
- let res = await campaignCall(url, method, body, cred.deviceToken, machine)
1679
+ let res = await campaignCall(url, method, body, patToken || cred.deviceToken, machine)
1680
+ if (patToken) return res
1647
1681
  if (res.status === 401 || res.status === 403) {
1648
1682
  const token = await deviceToken(getAuth, cred.deviceToken)
1649
1683
  const current = latest(await getAuth())
@@ -1792,38 +1826,35 @@ const makePlugin = (site) => async ({ client }) => {
1792
1826
  { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1793
1827
  // PAT sign-in: exchange a Personal Access Token for a job token,
1794
1828
  // bypassing the OAuth device flow. PATs last up to 1 year.
1795
- // Not offered on Qoder CN: its CLI uses device chat directly and
1796
- // never makes a job token, so a PAT exchange may not produce one
1797
- // the chat endpoint accepts.
1798
- ...(site.id !== "qoder-cn"
1799
- ? [
1800
- {
1801
- type: "oauth",
1802
- label: `Sign in with ${site.name} PAT`,
1803
- prompts: [
1804
- {
1805
- type: "password",
1806
- key: "pat",
1807
- message: "Your Qoder Personal Access Token (from qoder.com/account/integrations)",
1808
- placeholder: "pt-...",
1809
- validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1810
- },
1811
- ],
1812
- authorize: (inputs) => {
1813
- const pat = String(inputs?.pat ?? "").trim()
1814
- if (!pat) throw new Error("enter your Personal Access Token")
1815
- return {
1816
- url: `${site.web}/account/integrations`,
1817
- instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1818
- method: "auto",
1819
- async callback() {
1820
- return patSignIn(site, pat)
1821
- },
1822
- }
1823
- },
1829
+ // Offered on both sites: Qoder CN's /api/v1/jobToken/exchange takes
1830
+ // a PAT too (verified: HTTP 200 and a jt- token), and that job token
1831
+ // signs the model list and chat calls with the COSY envelope as on
1832
+ // qoder.com, so the exchange gives a token the chat endpoint accepts.
1833
+ {
1834
+ type: "oauth",
1835
+ label: `Sign in with ${site.name} PAT`,
1836
+ prompts: [
1837
+ {
1838
+ type: "password",
1839
+ key: "pat",
1840
+ message: `Your ${site.name} Personal Access Token (from ${site.web}/account/integrations)`,
1841
+ placeholder: "pt-...",
1842
+ validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1843
+ },
1844
+ ],
1845
+ authorize: (inputs) => {
1846
+ const pat = String(inputs?.pat ?? "").trim()
1847
+ if (!pat) throw new Error("enter your Personal Access Token")
1848
+ return {
1849
+ url: `${site.web}/account/integrations`,
1850
+ instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1851
+ method: "auto",
1852
+ async callback() {
1853
+ return patSignIn(site, pat)
1824
1854
  },
1825
- ]
1826
- : []),
1855
+ }
1856
+ },
1857
+ },
1827
1858
  // an enterprise VPC instance (Qoder CN), a method of its own so
1828
1859
  // that the sign-in above asks nothing new
1829
1860
  ...(site.vpcDomain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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",