@magpie-community/opencode-trae-auth 0.2.9 → 0.2.11
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 +17 -0
- package/index.mjs +145 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,23 @@ authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
|
|
|
46
46
|
request then sends that same device. The relays saw requests dropped when the
|
|
47
47
|
device was new each time.
|
|
48
48
|
|
|
49
|
+
### Enterprise (SaaS) accounts
|
|
50
|
+
|
|
51
|
+
Since 0.2.10 the authorization page also offers Trae's enterprise (SaaS / SSO)
|
|
52
|
+
sign-in; before, the plugin asked the page to hide it (`hide_saas_login`), so
|
|
53
|
+
an enterprise subscription could only sign in as a personal account. A host
|
|
54
|
+
the sign-in names for the account is kept when it is an https host on Trae's
|
|
55
|
+
own domains (`trae.cn`, `trae.ai`, `mchost.guru`): a model host other than the
|
|
56
|
+
consumer site's own (`www`, `api`, `grow…`) takes the account's model
|
|
57
|
+
requests, and a token host the callback names (`authHost`) takes its
|
|
58
|
+
renewals. Any other host is ignored and the consumer hosts are used, as for a
|
|
59
|
+
personal account, which is unchanged. The account's tenant and login-type
|
|
60
|
+
fields are kept with the sign-in; nothing sends them.
|
|
61
|
+
|
|
62
|
+
**Not tried with a real enterprise account.** Which hosts and fields an
|
|
63
|
+
enterprise sign-in sends back is not known yet; if yours doesn't work, open an
|
|
64
|
+
issue with what the sign-in page showed.
|
|
65
|
+
|
|
49
66
|
### The international realm's region
|
|
50
67
|
|
|
51
68
|
Trae Global has two deployments. The sign-in's `AIRegion` (or a US auth host
|
package/index.mjs
CHANGED
|
@@ -79,11 +79,19 @@ const SITES = {
|
|
|
79
79
|
// the model host for an account: Trae CN's own hosts serve no models,
|
|
80
80
|
// so only a host that is one is taken, else the one gateway
|
|
81
81
|
apiOf: (a) => {
|
|
82
|
-
const h =
|
|
83
|
-
return
|
|
82
|
+
const h = traeHost(a?.api)
|
|
83
|
+
return h && SITES["trae-cn"].modelHost(h) ? h : SITES["trae-cn"].hosts.api
|
|
84
|
+
},
|
|
85
|
+
// whether a host named by a sign-in is one of the model hosts: an https
|
|
86
|
+
// host on mchost.guru or named trae-api-…, as the consumer gateway is,
|
|
87
|
+
// or another trae.cn host than the consumer site's own (www, api,
|
|
88
|
+
// grow…), which an enterprise (SaaS) sign-in may name for its tenant
|
|
89
|
+
modelHost: (h) => {
|
|
90
|
+
const t = traeHost(h)
|
|
91
|
+
if (!t) return false
|
|
92
|
+
const n = new URL(t).hostname
|
|
93
|
+
return /(^|\.)mchost\.guru$|^trae-api-/i.test(n) || (/\.trae\.cn$/i.test(n) && !/^(www|api|grow[^.]*)\./i.test(n))
|
|
84
94
|
},
|
|
85
|
-
// whether a host named by a sign-in is one of the model hosts
|
|
86
|
-
modelHost: (h) => /mchost\.guru|trae-api-/i.test(String(h ?? "")),
|
|
87
95
|
// ownPage: url is one of Trae CN's own JSON pages on api.trae.cn
|
|
88
96
|
// (/trae/api/…: the daily check-in, credits), which the account's fetch
|
|
89
97
|
// sends as the account rather than as a chat
|
|
@@ -166,15 +174,22 @@ const SITES = {
|
|
|
166
174
|
// the model host: the account's region (or a US host the sign-in named)
|
|
167
175
|
// selects the US deployment, else the SG one
|
|
168
176
|
apiOf: (a) => {
|
|
169
|
-
const h =
|
|
177
|
+
const h = traeHost(a?.api)
|
|
178
|
+
const n = h ? new URL(h).hostname : ""
|
|
170
179
|
const region = String(a?.region ?? "").toUpperCase()
|
|
171
|
-
if (/coreva/i.test(
|
|
172
|
-
if (/coresg/i.test(
|
|
180
|
+
if (/coreva/i.test(n) || /api-us(?:[-.]|$)/i.test(n) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
|
|
181
|
+
if (/coresg/i.test(n)) return h
|
|
182
|
+
// another trae.ai host than the site's own (www, api-…, grow…): one an
|
|
183
|
+
// enterprise (SaaS) sign-in named for its tenant
|
|
184
|
+
if (/\.trae\.ai$/i.test(n) && !/^(www\.|api[-.]|grow)/i.test(n)) return h
|
|
173
185
|
return SITES["trae-global"].hosts.api
|
|
174
186
|
},
|
|
175
187
|
// a sign-in names a host of Trae's, which can be an auth host
|
|
176
188
|
// (api-us-east), not the chat host
|
|
177
|
-
modelHost: (h) =>
|
|
189
|
+
modelHost: (h) => {
|
|
190
|
+
const t = traeHost(h)
|
|
191
|
+
return !!t && /(^|\.)trae\.ai$/i.test(new URL(t).hostname)
|
|
192
|
+
},
|
|
178
193
|
// Trae Global serves no own JSON pages through the account's fetch
|
|
179
194
|
ownPage: () => false,
|
|
180
195
|
// the account's region, from the sign-in's user info
|
|
@@ -213,6 +228,46 @@ const SITES = {
|
|
|
213
228
|
},
|
|
214
229
|
}
|
|
215
230
|
|
|
231
|
+
// traeHost is a host a sign-in named, when it is a well-formed https one
|
|
232
|
+
// on Trae's own domains (no port, no credentials), as origin plus any path;
|
|
233
|
+
// "" for anything else, which is then not taken
|
|
234
|
+
const TRAE_DOMAINS = ["trae.cn", "trae.ai", "mchost.guru"]
|
|
235
|
+
function traeHost(h) {
|
|
236
|
+
try {
|
|
237
|
+
const u = new URL(String(h ?? "").trim())
|
|
238
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port || u.search || u.hash) return ""
|
|
239
|
+
const n = u.hostname.toLowerCase()
|
|
240
|
+
if (!TRAE_DOMAINS.some((d) => n === d || n.endsWith("." + d))) return ""
|
|
241
|
+
return (u.origin + u.pathname).replace(/\/+$/, "")
|
|
242
|
+
} catch {
|
|
243
|
+
return ""
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// authHostOf is the host a sign-in named for its tokens, when it named one
|
|
248
|
+
// on the site's own domain (an enterprise's may differ from the consumer
|
|
249
|
+
// one); "" when it named none, and the site's own is used
|
|
250
|
+
function authHostOf(h, site) {
|
|
251
|
+
const t = traeHost(h)
|
|
252
|
+
if (!t) return ""
|
|
253
|
+
const n = new URL(t).hostname
|
|
254
|
+
return n === site.webHost || n.endsWith("." + site.webHost) ? t : ""
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// accountOf keeps the account fields of the sign-in's user info that say
|
|
258
|
+
// what kind of account it is (tenant, enterprise, login type), plain values
|
|
259
|
+
// only and never anything that looks like a secret, for later use; nothing
|
|
260
|
+
// sends them
|
|
261
|
+
function accountOf(info) {
|
|
262
|
+
const out = {}
|
|
263
|
+
for (const [k, v] of Object.entries(info ?? {})) {
|
|
264
|
+
if (!/tenant|enterprise|saas|login|org|team|sso|^ent|usertype|accounttype/i.test(k)) continue
|
|
265
|
+
if (/token|secret|key|password|cookie|session|jwt/i.test(k)) continue
|
|
266
|
+
if (v === null || ["string", "number", "boolean"].includes(typeof v)) out[k] = v
|
|
267
|
+
}
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
|
|
216
271
|
const SIGN_IN_TIMEOUT = 10 * 60 * 1000
|
|
217
272
|
const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
|
|
218
273
|
const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
|
|
@@ -301,6 +356,11 @@ function toAuth(s, site) {
|
|
|
301
356
|
// the model host the sign-in named, if it named one
|
|
302
357
|
...(site.modelHost(s.api) ? { api: s.api } : {}),
|
|
303
358
|
...(s.region ? { region: s.region } : {}),
|
|
359
|
+
// the host the sign-in named, its own token host if it named one, and
|
|
360
|
+
// what kind of account it is (an enterprise one's tenant), kept as given
|
|
361
|
+
...(traeHost(s.host) ? { host: traeHost(s.host) } : {}),
|
|
362
|
+
...(authHostOf(s.authHost, site) ? { authHost: authHostOf(s.authHost, site) } : {}),
|
|
363
|
+
...(s.account && Object.keys(s.account).length ? { account: s.account } : {}),
|
|
304
364
|
}
|
|
305
365
|
}
|
|
306
366
|
|
|
@@ -319,6 +379,9 @@ function fromAuth(auth, site) {
|
|
|
319
379
|
devicePrivateKey: auth.devicePrivateKey || "",
|
|
320
380
|
api: auth.api || "",
|
|
321
381
|
region: site.regionOf(auth),
|
|
382
|
+
host: auth.host || "",
|
|
383
|
+
authHost: authHostOf(auth.authHost, site),
|
|
384
|
+
account: auth.account && typeof auth.account === "object" ? auth.account : {},
|
|
322
385
|
}
|
|
323
386
|
}
|
|
324
387
|
|
|
@@ -405,6 +468,7 @@ async function exchange(refresh, clientId, site, host = site.hosts.auth) {
|
|
|
405
468
|
name: String(info.ScreenName ?? info.screenName ?? ""),
|
|
406
469
|
api: info.Host ?? "",
|
|
407
470
|
region: site.regionOf(info),
|
|
471
|
+
account: accountOf(info),
|
|
408
472
|
}
|
|
409
473
|
}
|
|
410
474
|
const e = errorOf(text)
|
|
@@ -495,12 +559,14 @@ async function boundExchange(a, site, host = site.hosts.auth) {
|
|
|
495
559
|
// before 0.2.3 (exchange). A device key made for the account goes with
|
|
496
560
|
// either, so the next renewal signs with the same one.
|
|
497
561
|
async function renewal(a, site) {
|
|
498
|
-
|
|
562
|
+
// the token host the sign-in named, else the site's
|
|
563
|
+
const host = authHostOf(a.authHost, site) || site.hosts.auth
|
|
564
|
+
if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site, host)
|
|
499
565
|
const keys = a.devicePrivateKey && a.devicePublicKey ? {} : newDeviceKey()
|
|
500
566
|
try {
|
|
501
|
-
return await boundExchange({ ...a, ...keys }, site)
|
|
567
|
+
return await boundExchange({ ...a, ...keys }, site, host)
|
|
502
568
|
} catch {}
|
|
503
|
-
return { ...(await exchange(a.refresh, a.clientId, site)), ...keys }
|
|
569
|
+
return { ...(await exchange(a.refresh, a.clientId, site, host)), ...keys }
|
|
504
570
|
}
|
|
505
571
|
|
|
506
572
|
// ---- the browser sign-in --------------------------------------------------------
|
|
@@ -527,10 +593,15 @@ async function signedIn(q, device, site) {
|
|
|
527
593
|
name: String(info.ScreenName ?? info.screenName ?? ""),
|
|
528
594
|
api: host,
|
|
529
595
|
region: site.regionOf(info),
|
|
596
|
+
host,
|
|
597
|
+
// a token host the callback named, if it named one
|
|
598
|
+
authHost: authHostOf(q.get("authHost") || q.get("auth_host") || info.AuthHost || info.authHost, site),
|
|
599
|
+
account: accountOf(info),
|
|
530
600
|
}
|
|
601
|
+
const authHost = s.authHost || site.hosts.auth
|
|
531
602
|
if (!s.token && s.refresh) {
|
|
532
|
-
const x = await exchange(s.refresh, s.clientId, site)
|
|
533
|
-
s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region }
|
|
603
|
+
const x = await exchange(s.refresh, s.clientId, site, authHost)
|
|
604
|
+
s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region, host: s.host || x.api, account: { ...x.account, ...s.account } }
|
|
534
605
|
}
|
|
535
606
|
if (!s.token) throw new Error(`${site.webHost} sent back no token`)
|
|
536
607
|
if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
|
|
@@ -539,7 +610,7 @@ async function signedIn(q, device, site) {
|
|
|
539
610
|
// one at once, so a check-in takes it; when Trae won't, it does as is
|
|
540
611
|
if (site.deviceExchange) {
|
|
541
612
|
try {
|
|
542
|
-
const x = await boundExchange(s, site)
|
|
613
|
+
const x = await boundExchange(s, site, authHost)
|
|
543
614
|
s = { ...s, token: x.token, refresh: x.refresh, expires: x.expires || s.expires }
|
|
544
615
|
} catch {}
|
|
545
616
|
}
|
|
@@ -616,7 +687,9 @@ async function browserSignIn(site) {
|
|
|
616
687
|
x_env: "",
|
|
617
688
|
x_app_version: site.ideVersion,
|
|
618
689
|
x_app_type: "stable",
|
|
619
|
-
|
|
690
|
+
// the enterprise (SaaS) sign-in is offered beside the personal one: an
|
|
691
|
+
// enterprise subscription signs in there (@shad70w0 on X)
|
|
692
|
+
hide_saas_login: "false",
|
|
620
693
|
})
|
|
621
694
|
return {
|
|
622
695
|
url: `${site.hosts.web}/authorization?${q}`,
|
|
@@ -949,6 +1022,7 @@ class TextTools {
|
|
|
949
1022
|
this.tools = tools
|
|
950
1023
|
this.sent = "" // the text given out so far, to tell a block in code from a call
|
|
951
1024
|
this.bad = new Set() // where the waiting block's JSON was tried and not read, not tried again
|
|
1025
|
+
this.mark = 0 // where in sent the text after the last call starts
|
|
952
1026
|
}
|
|
953
1027
|
push(s, end = false) {
|
|
954
1028
|
this.buf += s
|
|
@@ -971,6 +1045,27 @@ class TextTools {
|
|
|
971
1045
|
const i = om ? om.index : -1
|
|
972
1046
|
const d = this.buf.search(DSML_AT)
|
|
973
1047
|
const r = this.buf.search(REASON_AT)
|
|
1048
|
+
const sm = this.buf.match(STRAY_AT)
|
|
1049
|
+
const s = sm ? sm.index : -1
|
|
1050
|
+
if (s >= 0 && [i, d, r].every((x) => x < 0 || s < x)) {
|
|
1051
|
+
// a close with no call open, with what GLM left before it
|
|
1052
|
+
// (}}</tool_call>, magpie#1945): not the answer, dropped; in code,
|
|
1053
|
+
// shown
|
|
1054
|
+
const head = this.buf.slice(0, s)
|
|
1055
|
+
// only a tail: a close with nothing before it since the last call
|
|
1056
|
+
// (or the start), or with GLM's braces, quotes or > right before
|
|
1057
|
+
// it; a close a sentence names in passing stays
|
|
1058
|
+
const tail = /^[}\]"'>]/.test(sm[0]) || !(this.sent + text + head).slice(this.mark).trim()
|
|
1059
|
+
if (!tail || quoted(this.sent + text + head)) {
|
|
1060
|
+
text += this.buf.slice(0, s + sm[0].length)
|
|
1061
|
+
this.buf = this.buf.slice(s + sm[0].length)
|
|
1062
|
+
continue
|
|
1063
|
+
}
|
|
1064
|
+
text += head
|
|
1065
|
+
this.buf = this.buf.slice(s + sm[0].length)
|
|
1066
|
+
this.after = true
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
974
1069
|
if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
|
|
975
1070
|
const o = objectEnd(this.buf, r)
|
|
976
1071
|
if (o === -1 && !end) {
|
|
@@ -1022,6 +1117,7 @@ class TextTools {
|
|
|
1022
1117
|
stop = body.length
|
|
1023
1118
|
}
|
|
1024
1119
|
calls.push({ name: inv[1], arguments: dsmlArgs(body.slice(0, stop)) })
|
|
1120
|
+
this.mark = this.sent.length + text.length
|
|
1025
1121
|
this.buf = body.slice(c && c.index === stop ? stop + c[0].length : stop)
|
|
1026
1122
|
this.after = true
|
|
1027
1123
|
continue
|
|
@@ -1063,6 +1159,7 @@ class TextTools {
|
|
|
1063
1159
|
return { text, calls, reasoning, error: b.error }
|
|
1064
1160
|
}
|
|
1065
1161
|
calls.push(b.call)
|
|
1162
|
+
this.mark = this.sent.length + text.length
|
|
1066
1163
|
this.buf = b.rest
|
|
1067
1164
|
this.after = true
|
|
1068
1165
|
}
|
|
@@ -1083,6 +1180,9 @@ class TextTools {
|
|
|
1083
1180
|
const at = [this.buf.search(OPEN_AT), this.buf.search(OPEN_HOLD), this.buf.search(DSML_AT), this.buf.search(REASON_AT)].filter((x) => x >= 0)
|
|
1084
1181
|
let keep = at.length ? this.buf.length - Math.min(...at) : 0
|
|
1085
1182
|
if (!keep) for (let k = Math.min(MARK_MAX - 1, this.buf.length); k > 0; k--) if (MARKS.some((m) => m.startsWith(this.buf.slice(-k)))) { keep = k; break }
|
|
1183
|
+
// and braces, brackets, quotes or a > a close may still come after
|
|
1184
|
+
const sh = this.buf.slice(0, this.buf.length - keep).match(STRAY_HOLD)
|
|
1185
|
+
if (sh) keep = this.buf.length - sh.index
|
|
1086
1186
|
text += this.buf.slice(0, this.buf.length - keep)
|
|
1087
1187
|
this.buf = this.buf.slice(this.buf.length - keep)
|
|
1088
1188
|
}
|
|
@@ -1132,8 +1232,27 @@ class TextTools {
|
|
|
1132
1232
|
const v = callJSON(body.slice(0, o))
|
|
1133
1233
|
// a name alone may have its parameters still to come
|
|
1134
1234
|
if (v && !end && Object.keys(v).length === 1 && "<parameter".startsWith(body.slice(o).replace(/^\s*>?\s*/, ""))) return { wait: true }
|
|
1135
|
-
if (v)
|
|
1136
|
-
|
|
1235
|
+
if (v) {
|
|
1236
|
+
// what follows the object up to the close: GLM leaves braces,
|
|
1237
|
+
// brackets, quotes or a > of its own there (}}</tool_call>,
|
|
1238
|
+
// "></tool_call>, magpie#1945), dropped with the close; held
|
|
1239
|
+
// while only those have come
|
|
1240
|
+
const tail = body.slice(o)
|
|
1241
|
+
const junk = tail.match(TAIL_JUNK)[0]
|
|
1242
|
+
const next = tail.slice(junk.length)
|
|
1243
|
+
const call = { name: String(v.name), arguments: callArgs(v) }
|
|
1244
|
+
if (!end && (!next || ["</tool_call>", "</toolcall>"].some((k) => k.startsWith(next)))) return { wait: true }
|
|
1245
|
+
const [c, n] = closeAt(next)
|
|
1246
|
+
if (c === 0) return { call, rest: next.slice(n) }
|
|
1247
|
+
// after blanks alone, or with no close to come: the call, and
|
|
1248
|
+
// what follows read on as before. Other text before the close
|
|
1249
|
+
// right after the object means the object ended early, at a
|
|
1250
|
+
// quote inside a string left unescaped: that cut is wrong (a
|
|
1251
|
+
// heredoc's command cut short, magpie#1945), so it is read up to
|
|
1252
|
+
// the close below, or not at all
|
|
1253
|
+
if (!/\S/.test(junk) && !/^[^\s<]/.test(next)) return { call, rest: tail }
|
|
1254
|
+
if (close < 0 && end) return { call, rest: tail }
|
|
1255
|
+
} else this.bad.add(":" + o)
|
|
1137
1256
|
}
|
|
1138
1257
|
// not whole, or not JSON as it stands: read up to a close (a file's
|
|
1139
1258
|
// content may hold one), or at the end all of it
|
|
@@ -1168,6 +1287,14 @@ const UNREADABLE = "the model's tool call, written as text, can't be read; it is
|
|
|
1168
1287
|
// also writes it without the underscore, <toolcall> (plugins#38)
|
|
1169
1288
|
const OPEN_AT = /<tool_?call(?:(\s[^<>{}]*)?>|(?=\s*\{))/
|
|
1170
1289
|
const OPEN_HOLD = /<tool_?call(?:\s[^<>{}\n]{0,200})?$/
|
|
1290
|
+
// what GLM leaves between a call's JSON and its close: blanks, closing
|
|
1291
|
+
// braces and brackets, quotes, a > (magpie#1945)
|
|
1292
|
+
const TAIL_JUNK = /^[\s}\]"'>]*/
|
|
1293
|
+
// a close with no call open, and that leftover before it: the tail of a
|
|
1294
|
+
// call Trae's service read itself, or of one already read (magpie#1945)
|
|
1295
|
+
const STRAY_AT = /[}\]"'>]*\s*<\/tool_?calls?>/
|
|
1296
|
+
// the end of text that may be such a tail still being written
|
|
1297
|
+
const STRAY_HOLD = /[}\]"'>][\s}\]"'>]*$/
|
|
1171
1298
|
// closeAt is where the next close is from i, </tool_call> or </toolcall>,
|
|
1172
1299
|
// and its length: [-1, 0] when there is none
|
|
1173
1300
|
function closeAt(s, i = 0) {
|
|
@@ -2347,4 +2474,4 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
2347
2474
|
export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
|
|
2348
2475
|
export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
|
|
2349
2476
|
|
|
2350
|
-
export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
|
|
2477
|
+
export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-trae-auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|