@magpie-community/opencode-trae-auth 0.2.4 → 0.2.6

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 +20 -0
  2. package/index.mjs +332 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -69,6 +69,18 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
69
69
  `solo_work_lite`, SOLO's Work mode; `solo_agent`, the TRAE agent's;
70
70
  `solo_agent_lite`). If Trae answers 4001, 4023 or 1005, the plugin tries
71
71
  the others once and remembers which one worked.
72
+ - `session_id` is the conversation's, as the IDE keeps one per conversation:
73
+ the session magpie (or OpenCode) names the request by, else the chat's
74
+ first user message, which every later turn repeats, together with the
75
+ account the request is made as. Trae counts a conversation by it, so a
76
+ fresh one per request made one user send that takes several steps (a tool
77
+ result sent back, a retry, another function answering) count as a
78
+ conversation of its own — several rows in the usage detail, each under no
79
+ title. One conversation answered by two accounts (magpie failing over, or
80
+ rotating, inside it) is named twice then, as two conversations, so Trae
81
+ never reads one account's conversation as another's; that costs the two
82
+ halves of such a conversation their single row. `request_id` is the
83
+ request's own, fresh on every one.
72
84
  - **Headers:** TRAE SOLO CN 0.1.69's (`x-ide-version`/`x-app-version`
73
85
  0.1.69, version code 20260917; Trae offers a model only to clients new
74
86
  enough for it): `Authorization: Cloud-IDE-JWT …`, `X-Cloudide-Token`,
@@ -96,6 +108,14 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
96
108
  <arg_value>v</arg_value></tool_call>` (a string kept as written, other
97
109
  types read as JSON), and sometimes a whole call into a native call's
98
110
  name; both are read as the call.
111
+ - A block is read as models get it wrong too: every quote escaped
112
+ (`{\"name\":…}`), quotes or backslashes in a string left unescaped, an
113
+ opener with the name as an attribute (`<tool_call name="bash">` with
114
+ `<parameter name="k">v</parameter>`), or JSON that is whole when the
115
+ stream ends without `</tool_call>`. A block that still can't be read,
116
+ or is cut short, is never sent on as the answer: the reply ends with an
117
+ error (a 502 without a stream), so the gateway can try again
118
+ (plugins#38). A `<tool_call>` in backticks or a code fence is text.
99
119
  - Calls from either source become OpenAI `tool_calls`. A name matching a
100
120
  requested tool but for case or punctuation (`Read`) goes on as the
101
121
  request's (`read`); any other goes on as written, so the agent can say
package/index.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // The account's fetch also sends Trae CN's own pages on api.trae.cn
10
10
  // (/trae/api/…) as the account, for magpie's daily check-in (每日签到:
11
11
  // /trae/api/v2/ug/checkin_credits/status, then /claim).
12
- import { generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
12
+ import { createHash, generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
13
13
  import { createServer, STATUS_CODES } from "node:http"
14
14
 
15
15
  const SITES = {
@@ -767,14 +767,72 @@ const nativeTools = (tools) =>
767
767
  return { type: "function", function: { name: f.name, description: f.description ?? "", parameters: typeof f.parameters === "string" ? f.parameters : JSON.stringify(f.parameters ?? {}) } }
768
768
  })
769
769
 
770
+ // SESSION carries the conversation magpie (or OpenCode) names a request by,
771
+ // from the chat.headers hook to the loader's fetch. It goes no further.
772
+ const SESSION = "x-magpie-trae-session"
773
+
774
+ // SALT is what the id is derived with, so it says nothing of the words it was
775
+ // made from. New in each process, as Trae's own session ids are one session's
776
+ // own within a run.
777
+ const SALT = randomBytes(16).toString("hex")
778
+
779
+ // conversationID is the conversation a chat belongs to, as the UUID the
780
+ // request's session_id wants: the session magpie or OpenCode names (its
781
+ // chat.headers hook), else the chat's first user message, which every later
782
+ // turn of the conversation repeats. "" when neither names one — a body that
783
+ // isn't a chat, or one with no user message in it — and the caller then makes
784
+ // a fresh id per request, as before: a made-up conversation of the request's
785
+ // own is the very splitting this is here to end.
786
+ //
787
+ // who is the account the request is made as (its uid, else its name), and it
788
+ // is part of what the id is made of, so one conversation answered by two
789
+ // accounts (magpie failing over, or rotating, inside it) is two conversations
790
+ // as Trae is concerned — an id a fresh random one never linked, and one that
791
+ // would let Trae read one conversation as another's. The cost is that such a
792
+ // conversation's two halves are counted apart again, which is what it did
793
+ // before this named one at all.
794
+ function conversationID(chat, session, who = "") {
795
+ const account = String(who ?? "").trim()
796
+ if (session) return uuidOf("magpie-trae-session|" + account + "|" + session)
797
+ const first = (Array.isArray(chat?.messages) ? chat.messages : []).find((m) => m?.role === "user")
798
+ const text = partText(first?.content ?? "")
799
+ return text.trim() ? uuidOf("magpie-trae-first|" + account + "|" + text) : ""
800
+ }
801
+
802
+ // uuidOf is a version 4 UUID made of a hash's first 16 bytes: the shape
803
+ // randomUUID() returns, since that is what session_id carried before.
804
+ function uuidOf(s) {
805
+ const b = Buffer.from(createHash("sha256").update(SALT + "|" + s).digest().subarray(0, 16))
806
+ b[6] = (b[6] & 0x0f) | 0x40
807
+ b[8] = (b[8] & 0x3f) | 0x80
808
+ const h = b.toString("hex")
809
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
810
+ }
811
+
812
+ // sessionOf takes the session a chat.headers hook named off the headers, with
813
+ // the header: it goes no further than here. Its length is capped as magpie's
814
+ // own session ids are, the id being derived from it anyway.
815
+ function sessionOf(headers) {
816
+ const s = String(headers.get(SESSION) ?? "").trim()
817
+ headers.delete(SESSION)
818
+ return s.slice(0, 128)
819
+ }
820
+
770
821
  // chatBody is the request for llm_utils_chat; modelName is the model the
771
822
  // function's list names for the config (its __dev one), when it named one,
772
823
  // and most the max_tokens it gives that model (0: none given), which a
773
824
  // request asking more is held to. A Max model (max: {base, window}) asks
774
825
  // the config base by its __max model, max_tokens always set, and gives
775
826
  // the prompt the room the window has beside it, as the IDE's Max mode does.
776
- function chatBody(req, fn, modelName, most = 0, max = null) {
777
- const session = randomUUID()
827
+ //
828
+ // session is the conversation's id, made by conversationID, so every request
829
+ // of one conversation carries one: Trae counts a conversation by the session
830
+ // the requests name, and a request naming a new session each time is counted
831
+ // as a conversation of its own — one user send that takes several steps (a
832
+ // tool result sent back, a retry, another function answering) shows up in the
833
+ // usage detail as several, each under no title at all. request_id stays a
834
+ // fresh id per request, being that request's own.
835
+ function chatBody(req, fn, modelName, most = 0, max = null, session = "") {
778
836
  const config = max?.base ?? req.model
779
837
  const body = {
780
838
  messages: traeMessages(req),
@@ -783,8 +841,8 @@ function chatBody(req, fn, modelName, most = 0, max = null) {
783
841
  model: config,
784
842
  ...(modelName ? { model_name: modelName } : {}),
785
843
  stream: true, // Trae answers in SSE either way
786
- request_id: session,
787
- session_id: session,
844
+ request_id: randomUUID(),
845
+ session_id: session || randomUUID(),
788
846
  }
789
847
  const asked = req.max_completion_tokens ?? req.max_tokens
790
848
  if (Number.isFinite(asked) && asked > 0) body.max_tokens = most > 0 ? Math.min(Math.floor(asked), most) : Math.floor(asked)
@@ -889,6 +947,8 @@ class TextTools {
889
947
  this.buf = ""
890
948
  this.after = false
891
949
  this.tools = tools
950
+ this.sent = "" // the text given out so far, to tell a block in code from a call
951
+ this.bad = new Set() // where the waiting block's JSON was tried and not read, not tried again
892
952
  }
893
953
  push(s, end = false) {
894
954
  this.buf += s
@@ -907,7 +967,8 @@ class TextTools {
907
967
  }
908
968
  this.after = false
909
969
  }
910
- const i = this.buf.indexOf(OPEN)
970
+ const om = this.buf.match(OPEN_AT)
971
+ const i = om ? om.index : -1
911
972
  const d = this.buf.search(DSML_AT)
912
973
  const r = this.buf.search(REASON_AT)
913
974
  if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
@@ -965,53 +1026,271 @@ class TextTools {
965
1026
  this.after = true
966
1027
  continue
967
1028
  }
968
- const o = objectEnd(this.buf, i + OPEN.length)
969
- const inner = this.buf.slice(i + OPEN.length).trimStart()
970
- if (inner.startsWith("<") || (end && !inner)) {
1029
+ const head = this.buf.slice(0, i)
1030
+ const body = this.buf.slice(i + om[0].length)
1031
+ if (quoted(this.sent + text + head)) {
1032
+ // a <tool_call> in backticks or a code fence is prose about one
1033
+ text += head + om[0]
1034
+ this.buf = body
1035
+ continue
1036
+ }
1037
+ const named = om[1]?.match(/\bname\s*=\s*"([^"]+)"/)?.[1]
1038
+ const inner = body.trimStart()
1039
+ if (!named && (inner.startsWith("<") || (end && !inner))) {
971
1040
  // no JSON in it: DeepSeek's own invoke, or only the closes of one
972
1041
  // (magpie#823 again); the opener is dropped and what follows read
973
1042
  // as it is
974
- text += this.buf.slice(0, i)
1043
+ text += head
975
1044
  this.buf = inner
976
1045
  this.after = true
977
1046
  continue
978
1047
  }
979
- const v = o > 0 ? looseJSON(this.buf.slice(i + OPEN.length, o)) : null
980
- if (v?.name) {
981
- text += this.buf.slice(0, i)
982
- calls.push({ name: String(v.name), arguments: callArgs(v) })
983
- this.buf = this.buf.slice(o)
984
- this.after = true
1048
+ const b = this.block(body, named, end)
1049
+ if (b.wait) break
1050
+ this.bad.clear()
1051
+ if (b.text) {
1052
+ // not a call: the opener is text, and what follows read on
1053
+ text += head + om[0]
1054
+ this.buf = body
985
1055
  continue
986
1056
  }
987
- let j = this.buf.indexOf(CLOSE, i + OPEN.length)
988
- if (j < 0) {
989
- // GLM's call, cut short at the end, is still the call
990
- if (!end || !glmCall(this.buf.slice(i + OPEN.length), this.tools)) break
991
- j = this.buf.length
1057
+ text += head
1058
+ if (b.error) {
1059
+ // a call that can't be read is not the answer (plugins#38): the
1060
+ // turn ends as a failure, so the gateway can try it again
1061
+ this.buf = ""
1062
+ this.sent += text
1063
+ return { text, calls, reasoning, error: b.error }
992
1064
  }
993
- text += this.buf.slice(0, i)
994
- const raw = this.buf.slice(i + OPEN.length, j).trim()
995
- this.buf = this.buf.slice(j + CLOSE.length)
996
- const w = looseJSON(raw) ?? glmCall(raw, this.tools) ?? {}
997
- if (w.name) {
998
- calls.push({ name: String(w.name), arguments: callArgs(w) })
999
- this.after = true
1000
- } else text += OPEN + raw + CLOSE
1065
+ calls.push(b.call)
1066
+ this.buf = b.rest
1067
+ this.after = true
1001
1068
  }
1002
1069
  if (end) {
1003
1070
  text += this.buf
1004
1071
  this.buf = ""
1005
1072
  } else {
1006
1073
  // keep back an opened block, or what may be the start of one
1007
- const at = [this.buf.indexOf(OPEN), this.buf.search(DSML_AT), this.buf.search(REASON_AT)].filter((x) => x >= 0)
1074
+ 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)
1008
1075
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
1009
1076
  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 }
1010
1077
  text += this.buf.slice(0, this.buf.length - keep)
1011
1078
  this.buf = this.buf.slice(this.buf.length - keep)
1012
1079
  }
1080
+ this.sent += text
1013
1081
  return { text, calls, reasoning }
1014
1082
  }
1083
+
1084
+ // block reads what follows a <tool_call> opener: {call, rest} for a
1085
+ // call, {wait} while it may still become one, {text} when it is no call
1086
+ // at all, {error} for one that can't be read: cut short, or not JSON
1087
+ // even as models get it wrong (plugins#38). named is the name an opener
1088
+ // gave as <tool_call name="…">.
1089
+ block(body, named, end) {
1090
+ if (!end && !body.trim()) return { wait: true }
1091
+ const close = body.indexOf(CLOSE)
1092
+ if (named) {
1093
+ if (close < 0 && !end) return { wait: true }
1094
+ const region = close >= 0 ? body.slice(0, close) : body
1095
+ const rest = close >= 0 ? body.slice(close + CLOSE.length) : ""
1096
+ if (region.trimStart().startsWith("{")) {
1097
+ const v = repairedJSON(region.trim())
1098
+ if (!v) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1099
+ return { call: v.name ? { name: String(v.name), arguments: callArgs(v) } : { name: named, arguments: JSON.stringify(v) }, rest }
1100
+ }
1101
+ if (region.includes("<arg_key>")) {
1102
+ const w = glmCall(named + region, this.tools)
1103
+ return w ? { call: { name: w.name, arguments: callArgs(w) }, rest } : { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1104
+ }
1105
+ const p = paramArgs(region, named, this.tools)
1106
+ // cut short at the end: whole only when each parameter was closed
1107
+ if (close < 0 && !p.closed) return { error: CUT_SHORT }
1108
+ return { call: { name: named, arguments: JSON.stringify(p.args) }, rest }
1109
+ }
1110
+ if (body.trimStart().startsWith("{")) {
1111
+ const ph = body.match(PARAM_HEAD)
1112
+ if (ph) {
1113
+ // DeepSeek's half-and-half: the name as JSON, then its parameters
1114
+ // (magpie#917)
1115
+ if (close < 0 && !end) return { wait: true }
1116
+ const region = body.slice(ph[0].length, close >= 0 ? close : body.length)
1117
+ const p = paramArgs(region, ph[1], this.tools)
1118
+ 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) : "" }
1120
+ }
1121
+ const o = objectEnd(body, 0)
1122
+ if (o > 0 && !this.bad.has(":" + o)) {
1123
+ const v = callJSON(body.slice(0, o))
1124
+ // a name alone may have its parameters still to come
1125
+ if (v && !end && Object.keys(v).length === 1 && "<parameter".startsWith(body.slice(o).replace(/^\s*>?\s*/, ""))) return { wait: true }
1126
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(o) }
1127
+ this.bad.add(":" + o)
1128
+ }
1129
+ // not whole, or not JSON as it stands: read up to a close (a file's
1130
+ // content may hold one), or at the end all of it
1131
+ for (let j = close; j >= 0; j = body.indexOf(CLOSE, j + 1)) {
1132
+ if (this.bad.has("<" + j)) continue
1133
+ 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) }
1135
+ this.bad.add("<" + j)
1136
+ }
1137
+ if (!end) return { wait: true }
1138
+ const v = callJSON(body)
1139
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: "" }
1140
+ return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1141
+ }
1142
+ // GLM's own (a name, then <arg_key>/<arg_value> pairs), or text
1143
+ if (!/^\s*[\w.:-]{1,128}\s*(?:<|$)/.test(body)) return { text: true }
1144
+ if (close < 0 && !end) return { wait: true }
1145
+ const region = close >= 0 ? body.slice(0, close) : body
1146
+ const w = glmCall(region, this.tools)
1147
+ // 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) : "" }
1149
+ if (/^\s*[\w.:-]{1,128}\s*<arg_key>/.test(body)) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1150
+ return { text: true }
1151
+ }
1152
+ }
1153
+
1154
+ const CUT_SHORT = "the model's tool call, written as text, was cut short; it is not sent on as the answer"
1155
+ const UNREADABLE = "the model's tool call, written as text, can't be read; it is not sent on as the answer"
1156
+
1157
+ // 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})?$/
1161
+ // DeepSeek's half-and-half call: {"name":"x"} (or its brace or > left
1162
+ // out), then <parameter name="k">v</parameter> or Qwen's <parameter=k>
1163
+ const PARAM_HEAD = /^\s*\{\s*"name"\s*:\s*"([^"]+)"\s*\}?\s*>?\s*(?=<parameter\b)/
1164
+ const PARAM_OPEN = /<parameter(?:\s+name\s*=\s*"([^"]+)"|=([^\s>]+))\s*>/g
1165
+
1166
+ // quoted says text ends inside a code fence or inline code: what comes
1167
+ // next is shown, not done
1168
+ function quoted(text) {
1169
+ if ((text.match(/```/g)?.length ?? 0) % 2) return true
1170
+ const line = text.slice(text.lastIndexOf("\n") + 1).replace(/```/g, "")
1171
+ return (line.match(/`/g)?.length ?? 0) % 2 === 1
1172
+ }
1173
+
1174
+ // paramArgs reads <parameter name="k">v</parameter> (or <parameter=k>)
1175
+ // arguments; a value runs to its close, the next parameter or the end. A
1176
+ // value is its text where the tool's schema says string (or it isn't
1177
+ // JSON), else its JSON. closed says each one had its close.
1178
+ function paramArgs(s, name, tools = []) {
1179
+ const props = (tools.find((t) => (t.function ?? t)?.name === name)?.function ?? {}).parameters?.properties ?? {}
1180
+ const opens = [...s.matchAll(PARAM_OPEN)]
1181
+ const args = {}
1182
+ let closed = opens.length > 0
1183
+ opens.forEach((m, k) => {
1184
+ const from = m.index + m[0].length
1185
+ const to = k + 1 < opens.length ? opens[k + 1].index : s.length
1186
+ let v = s.slice(from, to)
1187
+ const c = v.indexOf("</parameter>")
1188
+ if (c >= 0) v = v.slice(0, c)
1189
+ else closed = false
1190
+ v = v.replace(/^\r?\n|\r?\n$/g, "")
1191
+ const key = m[1] ?? m[2]
1192
+ if (props[key]?.type === "string") {
1193
+ args[key] = v
1194
+ return
1195
+ }
1196
+ try {
1197
+ args[key] = JSON.parse(v.trim())
1198
+ } catch {
1199
+ args[key] = v
1200
+ }
1201
+ })
1202
+ return { args, closed }
1203
+ }
1204
+
1205
+ // callJSON is a block's call ({name, …}) as a model may get its JSON
1206
+ // wrong, null when it isn't one even so
1207
+ function callJSON(s) {
1208
+ const v = repairedJSON(s)
1209
+ return v && typeof v.name === "string" && v.name ? v : null
1210
+ }
1211
+
1212
+ // repairedJSON reads a JSON object as looseJSON does, and else as models
1213
+ // get it wrong (plugins#38): every quote escaped ({\"name\":…}), a
1214
+ // backslash that escapes nothing JSON knows (\_ \| C:\Users in a file's
1215
+ // content), or quotes inside a string left unescaped ("官方" in a write's
1216
+ // content)
1217
+ function repairedJSON(s) {
1218
+ const v = looseJSON(s)
1219
+ if (v) return v
1220
+ const t = escapeRaw(s)
1221
+ const tries = []
1222
+ if (/^\s*\{\s*\\"/.test(s)) {
1223
+ try {
1224
+ const u = JSON.parse('"' + s.trim().replace(/[\n\r\t]/g, (c) => (c === "\n" ? "\\n" : c === "\r" ? "\\r" : "\\t")) + '"')
1225
+ tries.push(u, escapeRaw(u))
1226
+ } catch {}
1227
+ tries.push(s.replace(/\\"/g, '"'))
1228
+ }
1229
+ tries.push(fixEscapes(t), quoteFix(fixEscapes(t)))
1230
+ for (const x of tries) {
1231
+ try {
1232
+ const v = JSON.parse(x)
1233
+ if (v && typeof v === "object" && !Array.isArray(v)) return v
1234
+ } catch {}
1235
+ }
1236
+ return null
1237
+ }
1238
+
1239
+ // fixEscapes makes a backslash that escapes nothing JSON knows a backslash
1240
+ // of its own (\' is the quote it meant)
1241
+ function fixEscapes(s) {
1242
+ return s.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])|\\'|\\/g, (m, ok) => (ok ? m : m === "\\'" ? "'" : "\\\\"))
1243
+ }
1244
+
1245
+ // quoteFix escapes the quotes inside strings a model left unescaped: a
1246
+ // quote ends a key only before its colon, and a value only before what
1247
+ // may follow the last of one (closing braces or brackets, then the end or
1248
+ // a comma and the next key or value)
1249
+ function quoteFix(s) {
1250
+ let out = ""
1251
+ const stack = []
1252
+ let key = false
1253
+ for (let i = 0; i < s.length; ) {
1254
+ const ch = s[i]
1255
+ if (ch !== '"') {
1256
+ if (ch === "{") {
1257
+ stack.push("{")
1258
+ key = true
1259
+ } else if (ch === "[") stack.push("[")
1260
+ else if (ch === "}" || ch === "]") stack.pop()
1261
+ else if (ch === ",") key = stack.at(-1) === "{"
1262
+ else if (ch === ":") key = false
1263
+ out += ch
1264
+ i++
1265
+ continue
1266
+ }
1267
+ const isKey = key && stack.at(-1) === "{"
1268
+ const inArray = stack.at(-1) === "["
1269
+ out += ch
1270
+ i++
1271
+ for (; i < s.length; i++) {
1272
+ const c = s[i]
1273
+ if (c === "\\") {
1274
+ out += c + (s[i + 1] ?? "")
1275
+ i++
1276
+ continue
1277
+ }
1278
+ if (c !== '"') {
1279
+ out += c
1280
+ continue
1281
+ }
1282
+ const after = s.slice(i + 1)
1283
+ const ends = isKey ? /^\s*:/.test(after) : (inArray ? /^\s*(?:[}\]]\s*)*(?:$|,\s*["{[\d\-tfn])/ : /^\s*(?:[}\]]\s*)*(?:$|,\s*")/).test(after)
1284
+ if (ends) {
1285
+ out += c
1286
+ i++
1287
+ break
1288
+ }
1289
+ out += '\\"'
1290
+ }
1291
+ key = false
1292
+ }
1293
+ return out
1015
1294
  }
1016
1295
 
1017
1296
  // GLM's call template: the tool's name, then each argument as
@@ -1328,6 +1607,10 @@ async function* parts(events, tools = []) {
1328
1607
  if (t) yield { reasoning: t }
1329
1608
  if (r.text) yield { text: r.text }
1330
1609
  for (const c of r.calls) yield call({ ...c, id: callId() })
1610
+ if (r.error) {
1611
+ yield { error: r.error, code: null }
1612
+ return
1613
+ }
1331
1614
  }
1332
1615
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) nc.push(tc)
1333
1616
  const u = tokensOf(d.usage)
@@ -1339,6 +1622,10 @@ async function* parts(events, tools = []) {
1339
1622
  if (t) yield { reasoning: t }
1340
1623
  if (r.text) yield { text: r.text }
1341
1624
  for (const c of r.calls) yield call({ ...c, id: callId() })
1625
+ if (r.error) {
1626
+ yield { error: r.error, code: null }
1627
+ return
1628
+ }
1342
1629
  for (const c of nc.take(tools)) yield call(c)
1343
1630
  if (finish) yield { finish }
1344
1631
  }
@@ -1849,6 +2136,14 @@ const makePlugin = (site) => async ({ client }) => {
1849
2136
  models: { ...site.models, ...(was.models ?? {}) },
1850
2137
  }
1851
2138
  },
2139
+ // the conversation's id, which the requests below carry as their
2140
+ // session_id: magpie's (or OpenCode's) session for this request, when it
2141
+ // names one
2142
+ "chat.headers": async (input, output) => {
2143
+ const id = input?.model?.providerID ?? input?.provider?.info?.id
2144
+ if (id !== site.id || !input?.sessionID) return
2145
+ output.headers[SESSION] = String(input.sessionID).slice(0, 128)
2146
+ },
1852
2147
  provider: {
1853
2148
  id: site.id,
1854
2149
  // the account's own list, when Trae answers; else the list above
@@ -1927,11 +2222,16 @@ const makePlugin = (site) => async ({ client }) => {
1927
2222
  if (outer.aborted) ac.abort()
1928
2223
  else outer.addEventListener("abort", () => ac.abort(), { once: true })
1929
2224
  }
2225
+ // the conversation this request belongs to, from the chat.headers
2226
+ // hook (magpie's session) or the chat's own first user message, as
2227
+ // the account answering it, so a failover inside one conversation
2228
+ // is not one Trae reads as another's
2229
+ const session = conversationID(req, sessionOf(new Headers(init.headers ?? r0?.headers ?? {})), who)
1930
2230
  for (const fn of [...new Set(fns)]) {
1931
2231
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
1932
2232
  method: "POST",
1933
2233
  headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
1934
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max)),
2234
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session)),
1935
2235
  signal: ac.signal,
1936
2236
  })
1937
2237
  if (!res.ok) {
@@ -1982,4 +2282,4 @@ const makePlugin = (site) => async ({ client }) => {
1982
2282
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
1983
2283
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
1984
2284
 
1985
- export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES }
2285
+ export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, 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.4",
3
+ "version": "0.2.6",
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",