@magpie-community/opencode-qoder-auth 0.2.20 → 0.2.22

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 +29 -0
  2. package/index.mjs +100 -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`.
@@ -56,6 +81,10 @@ itself.
56
81
  - Messages become text and image blocks.
57
82
  - Tool calls and results become OpenAI tool turns. Images a tool returned
58
83
  follow in a user turn.
84
+ - A result whose id the client lost answers its turn's call in order
85
+ (since 0.2.22, #277): Qoder refuses a whole request with a result that
86
+ answers no call before it. A result with no call left to answer goes
87
+ as text in the user's turn.
59
88
  - Your tools become Qoder's native function tools.
60
89
  - The model's own configuration from Qoder's list goes with it.
61
90
  - It picks the reasoning effort from the model's own levels:
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,
@@ -683,6 +687,17 @@ const textOf = (c) => (typeof c === "string" ? c : blocksOf(c).filter((b) => b.t
683
687
  // blocks, an assistant's tool calls as tool_calls, results as tool turns by
684
688
  // tool_call_id. A tool turn holds text only, so the images a tool returned
685
689
  // follow in a user turn — the start of the user's own when one comes next.
690
+ //
691
+ // Qoder turns the whole request away when a tool turn answers no call of the
692
+ // turn before it ("Messages with role 'tool' must be a response to a
693
+ // preceding message with 'tool_calls'"), where another API takes the same
694
+ // conversation. A client's history reaches here with such a turn because it
695
+ // dropped the id: either the call lost it, so the result names one no call
696
+ // has, or the result kept none. Both are filled in rather than dropped — a
697
+ // call without an id is given one, and the results after it take the ids it
698
+ // went out with in order, which is the pairing a dropped id means. Only a
699
+ // result with no call of its turn left to answer becomes text in the user's
700
+ // own turn, so the words the tool returned stay with the conversation.
686
701
  function qoderMessages(msgs) {
687
702
  const out = []
688
703
  const names = {}
@@ -691,31 +706,64 @@ function qoderMessages(msgs) {
691
706
  if (seen.length) out.push({ role: "user", content: seen })
692
707
  seen = []
693
708
  }
694
- for (const m of msgs) {
709
+ // turn is the assistant turn being answered: the calls no result has
710
+ // answered yet, in the order they were made, and the ones a result after
711
+ // it names (kept), which a result whose id was lost must not take.
712
+ let turn = null
713
+ const asText = (txt, ims) => {
714
+ const said = txt.trim()
715
+ if (!said && !ims.length) return
716
+ if (seen.length) seen.push({ type: "text", text: "\n\n" })
717
+ if (said) seen.push({ type: "text", text: said })
718
+ seen.push(...ims)
719
+ }
720
+ for (const [i, m] of msgs.entries()) {
695
721
  if (m.role === "system" || m.role === "developer") continue
722
+ if (m.role === "user") turn = null
696
723
  if (m.role !== "user" && m.role !== "tool") showSeen()
697
724
  if (m.role === "tool") {
698
725
  let txt = textOf(m.content)
699
726
  const ims = Array.isArray(m.content) ? m.content.filter((p) => p?.type === "image_url").map(imageBlock).filter(Boolean) : []
727
+ const named = String(m.tool_call_id ?? "").trim()
728
+ // A result naming a call of this turn answers it; one whose id the
729
+ // client lost — none, or one no call here has — answers the earliest
730
+ // call still open that no other result names, which is the order the
731
+ // calls were made in.
732
+ const at = named ? turn?.open.findIndex((id) => id === named) ?? -1 : -1
733
+ const pick = at >= 0 ? at : turn ? turn.open.findIndex((id) => !turn.kept.has(id)) : -1
734
+ if (pick < 0) {
735
+ asText(txt, ims)
736
+ continue
737
+ }
738
+ const answered = turn.open.splice(pick, 1)[0]
700
739
  if (ims.length) {
701
- let of = "tool call " + m.tool_call_id
702
- const name = names[m.tool_call_id] || m.name
740
+ let of = "tool call " + answered
741
+ const name = names[answered] || m.name
703
742
  if (name) of = `${name} (${of})`
704
743
  seen.push({ type: "text", text: `[From the result of ${of}:]` }, ...ims)
705
744
  const note = ims.length === 1 ? "[The tool returned an image; it follows in the next message.]" : `[The tool returned ${ims.length} images; they follow in the next message.]`
706
745
  txt = txt.trim() ? txt + "\n\n" + note : txt + note
707
746
  }
708
- out.push({ role: "tool", tool_call_id: m.tool_call_id, content: txt })
747
+ out.push({ role: "tool", tool_call_id: answered, content: txt })
709
748
  continue
710
749
  }
711
750
  if (m.role === "assistant" && m.tool_calls?.length) {
751
+ // a call the client sent without an id gets one, so its result can
752
+ // answer it instead of being turned into text
712
753
  const calls = m.tool_calls.map((c) => {
713
- const id = c.id || "call_" + hexID()
754
+ const id = String(c.id ?? "").trim() || "call_" + hexID()
714
755
  names[id] = c.function?.name
715
756
  const args = c.function?.arguments
716
757
  return { id, type: "function", function: { name: c.function?.name, arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}) } }
717
758
  })
718
759
  out.push({ role: "assistant", content: textOf(m.content), tool_calls: calls })
760
+ const open = calls.map((c) => c.id)
761
+ const kept = new Set()
762
+ for (let j = i + 1; j < msgs.length && msgs[j].role === "tool"; j++) {
763
+ const id = String(msgs[j].tool_call_id ?? "").trim()
764
+ if (open.includes(id)) kept.add(id)
765
+ }
766
+ turn = { open, kept }
719
767
  continue
720
768
  }
721
769
  let blocks = blocksOf(m.content)
@@ -723,6 +771,8 @@ function qoderMessages(msgs) {
723
771
  if (!blocks.length) continue
724
772
  if (m.role === "user" && seen.length) [blocks, seen] = [[...seen, ...blocks], []]
725
773
  out.push({ role: m.role, content: blocks })
774
+ // a result after the assistant's own words answers no call before it
775
+ turn = null
726
776
  }
727
777
  showSeen()
728
778
  return out
@@ -1636,8 +1686,14 @@ const makePlugin = (site) => async ({ client }) => {
1636
1686
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1637
1687
  try {
1638
1688
  const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1639
- let res = await campaignCall(url, method, body, cred.deviceToken, machine, init.signal)
1640
- if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1689
+ // A PAT account has no device token: its job token signs the campaigns
1690
+ // pages too, as it does the usage read. Nothing is rotated on that
1691
+ // path — the refusal is the answer.
1692
+ const patToken = cred.patAuth && !cred.deviceToken
1693
+ const token = patToken ? cred.access : cred.deviceToken
1694
+ let res = await campaignCall(url, method, body, token, machine, init.signal)
1695
+ if (!patToken && (res.status === 401 || res.status === 403))
1696
+ res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1641
1697
  return signed(res, renewed(cred))
1642
1698
  } catch (e) {
1643
1699
  if (e?.expired) return signedInError(e)
@@ -1659,8 +1715,15 @@ const makePlugin = (site) => async ({ client }) => {
1659
1715
  if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1660
1716
  try {
1661
1717
  const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1718
+ // A PAT account has no device token: the campaigns pages take the job
1719
+ // token the exchange gave, as the usage endpoint does. A live Qoder CN
1720
+ // PAT account answered 200 to the list and CLAIMED to a claim, so the
1721
+ // check-in works there as it does on a device-token account. That path
1722
+ // has no second token to rotate, so a refusal is the answer.
1723
+ const patToken = cred.patAuth && !cred.deviceToken ? cred.access : ""
1662
1724
  const call = async (url, method, body) => {
1663
- let res = await campaignCall(url, method, body, cred.deviceToken, machine)
1725
+ let res = await campaignCall(url, method, body, patToken || cred.deviceToken, machine)
1726
+ if (patToken) return res
1664
1727
  if (res.status === 401 || res.status === 403) {
1665
1728
  const token = await deviceToken(getAuth, cred.deviceToken)
1666
1729
  const current = latest(await getAuth())
@@ -1809,38 +1872,35 @@ const makePlugin = (site) => async ({ client }) => {
1809
1872
  { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1810
1873
  // PAT sign-in: exchange a Personal Access Token for a job token,
1811
1874
  // bypassing the OAuth device flow. PATs last up to 1 year.
1812
- // Not offered on Qoder CN: its CLI uses device chat directly and
1813
- // never makes a job token, so a PAT exchange may not produce one
1814
- // the chat endpoint accepts.
1815
- ...(site.id !== "qoder-cn"
1816
- ? [
1817
- {
1818
- type: "oauth",
1819
- label: `Sign in with ${site.name} PAT`,
1820
- prompts: [
1821
- {
1822
- type: "password",
1823
- key: "pat",
1824
- message: "Your Qoder Personal Access Token (from qoder.com/account/integrations)",
1825
- placeholder: "pt-...",
1826
- validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1827
- },
1828
- ],
1829
- authorize: (inputs) => {
1830
- const pat = String(inputs?.pat ?? "").trim()
1831
- if (!pat) throw new Error("enter your Personal Access Token")
1832
- return {
1833
- url: `${site.web}/account/integrations`,
1834
- instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1835
- method: "auto",
1836
- async callback() {
1837
- return patSignIn(site, pat)
1838
- },
1839
- }
1840
- },
1875
+ // Offered on both sites: Qoder CN's /api/v1/jobToken/exchange takes
1876
+ // a PAT too (verified: HTTP 200 and a jt- token), and that job token
1877
+ // signs the model list and chat calls with the COSY envelope as on
1878
+ // qoder.com, so the exchange gives a token the chat endpoint accepts.
1879
+ {
1880
+ type: "oauth",
1881
+ label: `Sign in with ${site.name} PAT`,
1882
+ prompts: [
1883
+ {
1884
+ type: "password",
1885
+ key: "pat",
1886
+ message: `Your ${site.name} Personal Access Token (from ${site.web}/account/integrations)`,
1887
+ placeholder: "pt-...",
1888
+ validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1889
+ },
1890
+ ],
1891
+ authorize: (inputs) => {
1892
+ const pat = String(inputs?.pat ?? "").trim()
1893
+ if (!pat) throw new Error("enter your Personal Access Token")
1894
+ return {
1895
+ url: `${site.web}/account/integrations`,
1896
+ instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1897
+ method: "auto",
1898
+ async callback() {
1899
+ return patSignIn(site, pat)
1841
1900
  },
1842
- ]
1843
- : []),
1901
+ }
1902
+ },
1903
+ },
1844
1904
  // an enterprise VPC instance (Qoder CN), a method of its own so
1845
1905
  // that the sign-in above asks nothing new
1846
1906
  ...(site.vpcDomain
@@ -1914,4 +1974,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1914
1974
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1915
1975
 
1916
1976
  // for tests
1917
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
1977
+ export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, SITES, decodeBody, events }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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",