@magpie-community/opencode-trae-auth 0.2.8 → 0.2.10

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 +17 -0
  2. package/index.mjs +119 -28
  3. 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 = String(a?.api ?? "").replace(/\/+$/, "")
83
- return /mchost\.guru|trae-api-/i.test(h) ? h : SITES["trae-cn"].hosts.api
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 = String(a?.api ?? "").replace(/\/+$/, "")
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(h) || /api-us(?:[-.]|$)/i.test(h) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
172
- if (/coresg/i.test(h)) return h
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) => /trae\.ai/i.test(String(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
- if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site)
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
- hide_saas_login: "true",
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}`,
@@ -1067,6 +1140,15 @@ class TextTools {
1067
1140
  this.after = true
1068
1141
  }
1069
1142
  if (end) {
1143
+ // an opener the stream ended inside (<tool_call name): a call cut
1144
+ // short, not the answer (plugins#38)
1145
+ const h = this.buf.search(OPEN_HOLD)
1146
+ if (h >= 0 && !quoted(this.sent + text + this.buf.slice(0, h))) {
1147
+ text += this.buf.slice(0, h)
1148
+ this.buf = ""
1149
+ this.sent += text
1150
+ return { text, calls, reasoning, error: CUT_SHORT }
1151
+ }
1070
1152
  text += this.buf
1071
1153
  this.buf = ""
1072
1154
  } else {
@@ -1088,11 +1170,11 @@ class TextTools {
1088
1170
  // gave as <tool_call name="…">.
1089
1171
  block(body, named, end) {
1090
1172
  if (!end && !body.trim()) return { wait: true }
1091
- const close = body.indexOf(CLOSE)
1173
+ const [close, closeLen] = closeAt(body)
1092
1174
  if (named) {
1093
1175
  if (close < 0 && !end) return { wait: true }
1094
1176
  const region = close >= 0 ? body.slice(0, close) : body
1095
- const rest = close >= 0 ? body.slice(close + CLOSE.length) : ""
1177
+ const rest = close >= 0 ? body.slice(close + closeLen) : ""
1096
1178
  if (region.trimStart().startsWith("{")) {
1097
1179
  const v = repairedJSON(region.trim())
1098
1180
  if (!v) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
@@ -1116,7 +1198,7 @@ class TextTools {
1116
1198
  const region = body.slice(ph[0].length, close >= 0 ? close : body.length)
1117
1199
  const p = paramArgs(region, ph[1], this.tools)
1118
1200
  if (close < 0 && !p.closed) return { error: CUT_SHORT }
1119
- return { call: { name: ph[1], arguments: JSON.stringify(p.args) }, rest: close >= 0 ? body.slice(close + CLOSE.length) : "" }
1201
+ return { call: { name: ph[1], arguments: JSON.stringify(p.args) }, rest: close >= 0 ? body.slice(close + closeLen) : "" }
1120
1202
  }
1121
1203
  const o = objectEnd(body, 0)
1122
1204
  if (o > 0 && !this.bad.has(":" + o)) {
@@ -1128,10 +1210,10 @@ class TextTools {
1128
1210
  }
1129
1211
  // not whole, or not JSON as it stands: read up to a close (a file's
1130
1212
  // content may hold one), or at the end all of it
1131
- for (let j = close; j >= 0; j = body.indexOf(CLOSE, j + 1)) {
1213
+ for (let [j, n] = [close, closeLen]; j >= 0; [j, n] = closeAt(body, j + 1)) {
1132
1214
  if (this.bad.has("<" + j)) continue
1133
1215
  const v = callJSON(body.slice(0, j))
1134
- if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(j + CLOSE.length) }
1216
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(j + n) }
1135
1217
  this.bad.add("<" + j)
1136
1218
  }
1137
1219
  if (!end) return { wait: true }
@@ -1145,7 +1227,7 @@ class TextTools {
1145
1227
  const region = close >= 0 ? body.slice(0, close) : body
1146
1228
  const w = glmCall(region, this.tools)
1147
1229
  // GLM's call, cut short at the end, is still the call
1148
- if (w) return { call: { name: w.name, arguments: callArgs(w) }, rest: close >= 0 ? body.slice(close + CLOSE.length) : "" }
1230
+ if (w) return { call: { name: w.name, arguments: callArgs(w) }, rest: close >= 0 ? body.slice(close + closeLen) : "" }
1149
1231
  if (/^\s*[\w.:-]{1,128}\s*<arg_key>/.test(body)) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1150
1232
  return { text: true }
1151
1233
  }
@@ -1155,9 +1237,18 @@ const CUT_SHORT = "the model's tool call, written as text, was cut short; it is
1155
1237
  const UNREADABLE = "the model's tool call, written as text, can't be read; it is not sent on as the answer"
1156
1238
 
1157
1239
  // an opener: <tool_call>, one with attributes (<tool_call name="bash">),
1158
- // or <tool_call right before its JSON; and one still being written
1159
- const OPEN_AT = /<tool_call(?:(\s[^<>{}]*)?>|(?=\s*\{))/
1160
- const OPEN_HOLD = /<tool_call(?:\s[^<>{}\n]{0,200})?$/
1240
+ // or <tool_call right before its JSON; and one still being written. GLM
1241
+ // also writes it without the underscore, <toolcall> (plugins#38)
1242
+ const OPEN_AT = /<tool_?call(?:(\s[^<>{}]*)?>|(?=\s*\{))/
1243
+ const OPEN_HOLD = /<tool_?call(?:\s[^<>{}\n]{0,200})?$/
1244
+ // closeAt is where the next close is from i, </tool_call> or </toolcall>,
1245
+ // and its length: [-1, 0] when there is none
1246
+ function closeAt(s, i = 0) {
1247
+ const re = /<\/tool_?call>/g
1248
+ re.lastIndex = i
1249
+ const m = re.exec(s)
1250
+ return m ? [m.index, m[0].length] : [-1, 0]
1251
+ }
1161
1252
  // DeepSeek's half-and-half call: {"name":"x"} (or its brace or > left
1162
1253
  // out), then <parameter name="k">v</parameter> or Qwen's <parameter=k>
1163
1254
  const PARAM_HEAD = /^\s*\{\s*"name"\s*:\s*"([^"]+)"\s*\}?\s*>?\s*(?=<parameter\b)/
@@ -1303,7 +1394,7 @@ const GLM_ARG = /<arg_key>\s*([\s\S]*?)\s*<\/arg_key>\s*<arg_value>([\s\S]*?)<\/
1303
1394
  // a value that isn't a string. A name alone is a call only to a tool the
1304
1395
  // request has.
1305
1396
  function glmCall(s, tools = []) {
1306
- s = String(s ?? "").replace(/<\/tool_call>\s*$/, "")
1397
+ s = String(s ?? "").replace(/<\/tool_?call>\s*$/, "")
1307
1398
  const k = s.indexOf("<arg_key>")
1308
1399
  const name = (k < 0 ? s : s.slice(0, k)).trim()
1309
1400
  if (!/^[\w.:-]{1,128}$/.test(name)) return null
@@ -1373,9 +1464,9 @@ const DSML_CLOSE = new RegExp(String.raw`<\s*\/\s*[||][\s||]*DSML[\s||]*in
1373
1464
  const DSML_PARAM = new RegExp(String.raw`<\s*[||][\s||]*DSML[\s||]*parameter\s+name\s*=\s*"([^"]+)"((?:\s+\w+\s*=\s*"[^"]*")*)\s*>([\s\S]*?)` + DSML + String.raw`parameter\s*>`, "g")
1374
1465
  // what is left after a call: blanks, our close, DeepSeek's tags but the
1375
1466
  // next call's
1376
- const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_calls?>|<tool_calls>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
1467
+ const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_?calls?>|<tool_?calls>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
1377
1468
  // what text may be the start of, so held back
1378
- const MARKS = [OPEN, CLOSE, "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|", '{"reasoning_content"']
1469
+ const MARKS = [OPEN, CLOSE, "<toolcall>", "</toolcall>", "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|", '{"reasoning_content"']
1379
1470
  // reasoning a model wrote into its text as JSON
1380
1471
  const REASON_AT = /\{\s*"reasoning_content"\s*:/
1381
1472
  const MARK_MAX = Math.max(...MARKS.map((m) => m.length))
@@ -2329,4 +2420,4 @@ const makePlugin = (site) => async ({ client }) => {
2329
2420
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2330
2421
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2331
2422
 
2332
- 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 }
2423
+ 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.8",
3
+ "version": "0.2.10",
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",