@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.
- package/README.md +6 -0
- package/index.mjs +180 -15
- 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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
914
|
-
|
|
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
|
|
944
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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