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

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 +8 -0
  2. package/index.mjs +254 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -96,6 +96,14 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
96
96
  <arg_value>v</arg_value></tool_call>` (a string kept as written, other
97
97
  types read as JSON), and sometimes a whole call into a native call's
98
98
  name; both are read as the call.
99
+ - A block is read as models get it wrong too: every quote escaped
100
+ (`{\"name\":…}`), quotes or backslashes in a string left unescaped, an
101
+ opener with the name as an attribute (`<tool_call name="bash">` with
102
+ `<parameter name="k">v</parameter>`), or JSON that is whole when the
103
+ stream ends without `</tool_call>`. A block that still can't be read,
104
+ or is cut short, is never sent on as the answer: the reply ends with an
105
+ error (a 502 without a stream), so the gateway can try again
106
+ (plugins#38). A `<tool_call>` in backticks or a code fence is text.
99
107
  - Calls from either source become OpenAI `tool_calls`. A name matching a
100
108
  requested tool but for case or punctuation (`Read`) goes on as the
101
109
  request's (`read`); any other goes on as written, so the agent can say
package/index.mjs CHANGED
@@ -889,6 +889,8 @@ class TextTools {
889
889
  this.buf = ""
890
890
  this.after = false
891
891
  this.tools = tools
892
+ this.sent = "" // the text given out so far, to tell a block in code from a call
893
+ this.bad = new Set() // where the waiting block's JSON was tried and not read, not tried again
892
894
  }
893
895
  push(s, end = false) {
894
896
  this.buf += s
@@ -907,7 +909,8 @@ class TextTools {
907
909
  }
908
910
  this.after = false
909
911
  }
910
- const i = this.buf.indexOf(OPEN)
912
+ const om = this.buf.match(OPEN_AT)
913
+ const i = om ? om.index : -1
911
914
  const d = this.buf.search(DSML_AT)
912
915
  const r = this.buf.search(REASON_AT)
913
916
  if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
@@ -965,53 +968,271 @@ class TextTools {
965
968
  this.after = true
966
969
  continue
967
970
  }
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)) {
971
+ const head = this.buf.slice(0, i)
972
+ const body = this.buf.slice(i + om[0].length)
973
+ if (quoted(this.sent + text + head)) {
974
+ // a <tool_call> in backticks or a code fence is prose about one
975
+ text += head + om[0]
976
+ this.buf = body
977
+ continue
978
+ }
979
+ const named = om[1]?.match(/\bname\s*=\s*"([^"]+)"/)?.[1]
980
+ const inner = body.trimStart()
981
+ if (!named && (inner.startsWith("<") || (end && !inner))) {
971
982
  // no JSON in it: DeepSeek's own invoke, or only the closes of one
972
983
  // (magpie#823 again); the opener is dropped and what follows read
973
984
  // as it is
974
- text += this.buf.slice(0, i)
985
+ text += head
975
986
  this.buf = inner
976
987
  this.after = true
977
988
  continue
978
989
  }
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
990
+ const b = this.block(body, named, end)
991
+ if (b.wait) break
992
+ this.bad.clear()
993
+ if (b.text) {
994
+ // not a call: the opener is text, and what follows read on
995
+ text += head + om[0]
996
+ this.buf = body
985
997
  continue
986
998
  }
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
999
+ text += head
1000
+ if (b.error) {
1001
+ // a call that can't be read is not the answer (plugins#38): the
1002
+ // turn ends as a failure, so the gateway can try it again
1003
+ this.buf = ""
1004
+ this.sent += text
1005
+ return { text, calls, reasoning, error: b.error }
992
1006
  }
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
1007
+ calls.push(b.call)
1008
+ this.buf = b.rest
1009
+ this.after = true
1001
1010
  }
1002
1011
  if (end) {
1003
1012
  text += this.buf
1004
1013
  this.buf = ""
1005
1014
  } else {
1006
1015
  // 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)
1016
+ 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
1017
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
1009
1018
  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
1019
  text += this.buf.slice(0, this.buf.length - keep)
1011
1020
  this.buf = this.buf.slice(this.buf.length - keep)
1012
1021
  }
1022
+ this.sent += text
1013
1023
  return { text, calls, reasoning }
1014
1024
  }
1025
+
1026
+ // block reads what follows a <tool_call> opener: {call, rest} for a
1027
+ // call, {wait} while it may still become one, {text} when it is no call
1028
+ // at all, {error} for one that can't be read: cut short, or not JSON
1029
+ // even as models get it wrong (plugins#38). named is the name an opener
1030
+ // gave as <tool_call name="…">.
1031
+ block(body, named, end) {
1032
+ if (!end && !body.trim()) return { wait: true }
1033
+ const close = body.indexOf(CLOSE)
1034
+ if (named) {
1035
+ if (close < 0 && !end) return { wait: true }
1036
+ const region = close >= 0 ? body.slice(0, close) : body
1037
+ const rest = close >= 0 ? body.slice(close + CLOSE.length) : ""
1038
+ if (region.trimStart().startsWith("{")) {
1039
+ const v = repairedJSON(region.trim())
1040
+ if (!v) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1041
+ return { call: v.name ? { name: String(v.name), arguments: callArgs(v) } : { name: named, arguments: JSON.stringify(v) }, rest }
1042
+ }
1043
+ if (region.includes("<arg_key>")) {
1044
+ const w = glmCall(named + region, this.tools)
1045
+ return w ? { call: { name: w.name, arguments: callArgs(w) }, rest } : { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1046
+ }
1047
+ const p = paramArgs(region, named, this.tools)
1048
+ // cut short at the end: whole only when each parameter was closed
1049
+ if (close < 0 && !p.closed) return { error: CUT_SHORT }
1050
+ return { call: { name: named, arguments: JSON.stringify(p.args) }, rest }
1051
+ }
1052
+ if (body.trimStart().startsWith("{")) {
1053
+ const ph = body.match(PARAM_HEAD)
1054
+ if (ph) {
1055
+ // DeepSeek's half-and-half: the name as JSON, then its parameters
1056
+ // (magpie#917)
1057
+ if (close < 0 && !end) return { wait: true }
1058
+ const region = body.slice(ph[0].length, close >= 0 ? close : body.length)
1059
+ const p = paramArgs(region, ph[1], this.tools)
1060
+ if (close < 0 && !p.closed) return { error: CUT_SHORT }
1061
+ return { call: { name: ph[1], arguments: JSON.stringify(p.args) }, rest: close >= 0 ? body.slice(close + CLOSE.length) : "" }
1062
+ }
1063
+ const o = objectEnd(body, 0)
1064
+ if (o > 0 && !this.bad.has(":" + o)) {
1065
+ const v = callJSON(body.slice(0, o))
1066
+ // a name alone may have its parameters still to come
1067
+ if (v && !end && Object.keys(v).length === 1 && "<parameter".startsWith(body.slice(o).replace(/^\s*>?\s*/, ""))) return { wait: true }
1068
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(o) }
1069
+ this.bad.add(":" + o)
1070
+ }
1071
+ // not whole, or not JSON as it stands: read up to a close (a file's
1072
+ // content may hold one), or at the end all of it
1073
+ for (let j = close; j >= 0; j = body.indexOf(CLOSE, j + 1)) {
1074
+ if (this.bad.has("<" + j)) continue
1075
+ const v = callJSON(body.slice(0, j))
1076
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(j + CLOSE.length) }
1077
+ this.bad.add("<" + j)
1078
+ }
1079
+ if (!end) return { wait: true }
1080
+ const v = callJSON(body)
1081
+ if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: "" }
1082
+ return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1083
+ }
1084
+ // GLM's own (a name, then <arg_key>/<arg_value> pairs), or text
1085
+ if (!/^\s*[\w.:-]{1,128}\s*(?:<|$)/.test(body)) return { text: true }
1086
+ if (close < 0 && !end) return { wait: true }
1087
+ const region = close >= 0 ? body.slice(0, close) : body
1088
+ const w = glmCall(region, this.tools)
1089
+ // GLM's call, cut short at the end, is still the call
1090
+ if (w) return { call: { name: w.name, arguments: callArgs(w) }, rest: close >= 0 ? body.slice(close + CLOSE.length) : "" }
1091
+ if (/^\s*[\w.:-]{1,128}\s*<arg_key>/.test(body)) return { error: close >= 0 ? UNREADABLE : CUT_SHORT }
1092
+ return { text: true }
1093
+ }
1094
+ }
1095
+
1096
+ const CUT_SHORT = "the model's tool call, written as text, was cut short; it is not sent on as the answer"
1097
+ const UNREADABLE = "the model's tool call, written as text, can't be read; it is not sent on as the answer"
1098
+
1099
+ // an opener: <tool_call>, one with attributes (<tool_call name="bash">),
1100
+ // or <tool_call right before its JSON; and one still being written
1101
+ const OPEN_AT = /<tool_call(?:(\s[^<>{}]*)?>|(?=\s*\{))/
1102
+ const OPEN_HOLD = /<tool_call(?:\s[^<>{}\n]{0,200})?$/
1103
+ // DeepSeek's half-and-half call: {"name":"x"} (or its brace or > left
1104
+ // out), then <parameter name="k">v</parameter> or Qwen's <parameter=k>
1105
+ const PARAM_HEAD = /^\s*\{\s*"name"\s*:\s*"([^"]+)"\s*\}?\s*>?\s*(?=<parameter\b)/
1106
+ const PARAM_OPEN = /<parameter(?:\s+name\s*=\s*"([^"]+)"|=([^\s>]+))\s*>/g
1107
+
1108
+ // quoted says text ends inside a code fence or inline code: what comes
1109
+ // next is shown, not done
1110
+ function quoted(text) {
1111
+ if ((text.match(/```/g)?.length ?? 0) % 2) return true
1112
+ const line = text.slice(text.lastIndexOf("\n") + 1).replace(/```/g, "")
1113
+ return (line.match(/`/g)?.length ?? 0) % 2 === 1
1114
+ }
1115
+
1116
+ // paramArgs reads <parameter name="k">v</parameter> (or <parameter=k>)
1117
+ // arguments; a value runs to its close, the next parameter or the end. A
1118
+ // value is its text where the tool's schema says string (or it isn't
1119
+ // JSON), else its JSON. closed says each one had its close.
1120
+ function paramArgs(s, name, tools = []) {
1121
+ const props = (tools.find((t) => (t.function ?? t)?.name === name)?.function ?? {}).parameters?.properties ?? {}
1122
+ const opens = [...s.matchAll(PARAM_OPEN)]
1123
+ const args = {}
1124
+ let closed = opens.length > 0
1125
+ opens.forEach((m, k) => {
1126
+ const from = m.index + m[0].length
1127
+ const to = k + 1 < opens.length ? opens[k + 1].index : s.length
1128
+ let v = s.slice(from, to)
1129
+ const c = v.indexOf("</parameter>")
1130
+ if (c >= 0) v = v.slice(0, c)
1131
+ else closed = false
1132
+ v = v.replace(/^\r?\n|\r?\n$/g, "")
1133
+ const key = m[1] ?? m[2]
1134
+ if (props[key]?.type === "string") {
1135
+ args[key] = v
1136
+ return
1137
+ }
1138
+ try {
1139
+ args[key] = JSON.parse(v.trim())
1140
+ } catch {
1141
+ args[key] = v
1142
+ }
1143
+ })
1144
+ return { args, closed }
1145
+ }
1146
+
1147
+ // callJSON is a block's call ({name, …}) as a model may get its JSON
1148
+ // wrong, null when it isn't one even so
1149
+ function callJSON(s) {
1150
+ const v = repairedJSON(s)
1151
+ return v && typeof v.name === "string" && v.name ? v : null
1152
+ }
1153
+
1154
+ // repairedJSON reads a JSON object as looseJSON does, and else as models
1155
+ // get it wrong (plugins#38): every quote escaped ({\"name\":…}), a
1156
+ // backslash that escapes nothing JSON knows (\_ \| C:\Users in a file's
1157
+ // content), or quotes inside a string left unescaped ("官方" in a write's
1158
+ // content)
1159
+ function repairedJSON(s) {
1160
+ const v = looseJSON(s)
1161
+ if (v) return v
1162
+ const t = escapeRaw(s)
1163
+ const tries = []
1164
+ if (/^\s*\{\s*\\"/.test(s)) {
1165
+ try {
1166
+ const u = JSON.parse('"' + s.trim().replace(/[\n\r\t]/g, (c) => (c === "\n" ? "\\n" : c === "\r" ? "\\r" : "\\t")) + '"')
1167
+ tries.push(u, escapeRaw(u))
1168
+ } catch {}
1169
+ tries.push(s.replace(/\\"/g, '"'))
1170
+ }
1171
+ tries.push(fixEscapes(t), quoteFix(fixEscapes(t)))
1172
+ for (const x of tries) {
1173
+ try {
1174
+ const v = JSON.parse(x)
1175
+ if (v && typeof v === "object" && !Array.isArray(v)) return v
1176
+ } catch {}
1177
+ }
1178
+ return null
1179
+ }
1180
+
1181
+ // fixEscapes makes a backslash that escapes nothing JSON knows a backslash
1182
+ // of its own (\' is the quote it meant)
1183
+ function fixEscapes(s) {
1184
+ return s.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])|\\'|\\/g, (m, ok) => (ok ? m : m === "\\'" ? "'" : "\\\\"))
1185
+ }
1186
+
1187
+ // quoteFix escapes the quotes inside strings a model left unescaped: a
1188
+ // quote ends a key only before its colon, and a value only before what
1189
+ // may follow the last of one (closing braces or brackets, then the end or
1190
+ // a comma and the next key or value)
1191
+ function quoteFix(s) {
1192
+ let out = ""
1193
+ const stack = []
1194
+ let key = false
1195
+ for (let i = 0; i < s.length; ) {
1196
+ const ch = s[i]
1197
+ if (ch !== '"') {
1198
+ if (ch === "{") {
1199
+ stack.push("{")
1200
+ key = true
1201
+ } else if (ch === "[") stack.push("[")
1202
+ else if (ch === "}" || ch === "]") stack.pop()
1203
+ else if (ch === ",") key = stack.at(-1) === "{"
1204
+ else if (ch === ":") key = false
1205
+ out += ch
1206
+ i++
1207
+ continue
1208
+ }
1209
+ const isKey = key && stack.at(-1) === "{"
1210
+ const inArray = stack.at(-1) === "["
1211
+ out += ch
1212
+ i++
1213
+ for (; i < s.length; i++) {
1214
+ const c = s[i]
1215
+ if (c === "\\") {
1216
+ out += c + (s[i + 1] ?? "")
1217
+ i++
1218
+ continue
1219
+ }
1220
+ if (c !== '"') {
1221
+ out += c
1222
+ continue
1223
+ }
1224
+ const after = s.slice(i + 1)
1225
+ const ends = isKey ? /^\s*:/.test(after) : (inArray ? /^\s*(?:[}\]]\s*)*(?:$|,\s*["{[\d\-tfn])/ : /^\s*(?:[}\]]\s*)*(?:$|,\s*")/).test(after)
1226
+ if (ends) {
1227
+ out += c
1228
+ i++
1229
+ break
1230
+ }
1231
+ out += '\\"'
1232
+ }
1233
+ key = false
1234
+ }
1235
+ return out
1015
1236
  }
1016
1237
 
1017
1238
  // GLM's call template: the tool's name, then each argument as
@@ -1328,6 +1549,10 @@ async function* parts(events, tools = []) {
1328
1549
  if (t) yield { reasoning: t }
1329
1550
  if (r.text) yield { text: r.text }
1330
1551
  for (const c of r.calls) yield call({ ...c, id: callId() })
1552
+ if (r.error) {
1553
+ yield { error: r.error, code: null }
1554
+ return
1555
+ }
1331
1556
  }
1332
1557
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) nc.push(tc)
1333
1558
  const u = tokensOf(d.usage)
@@ -1339,6 +1564,10 @@ async function* parts(events, tools = []) {
1339
1564
  if (t) yield { reasoning: t }
1340
1565
  if (r.text) yield { text: r.text }
1341
1566
  for (const c of r.calls) yield call({ ...c, id: callId() })
1567
+ if (r.error) {
1568
+ yield { error: r.error, code: null }
1569
+ return
1570
+ }
1342
1571
  for (const c of nc.take(tools)) yield call(c)
1343
1572
  if (finish) yield { finish }
1344
1573
  }
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.5",
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",