@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.
- package/README.md +29 -0
- package/index.mjs +100 -40
- 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
|
-
|
|
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 " +
|
|
702
|
-
const name = names[
|
|
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:
|
|
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
|
-
|
|
1640
|
-
|
|
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
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
// the chat
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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