@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.
- package/README.md +25 -0
- package/index.mjs +71 -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`.
|
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
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
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[
|
|
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
|
-
|
|
1623
|
-
|
|
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
|
-
//
|
|
1796
|
-
//
|
|
1797
|
-
// the chat
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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