@magpie-community/opencode-trae-auth 0.2.17 → 0.2.18

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 +40 -10
  2. package/index.mjs +144 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -122,11 +122,28 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
122
122
  that fails (an `error` event, or Trae's stream breaking off) ends with the
123
123
  error and `[DONE]`, with no finish.
124
124
  - **Tools:**
125
- - Trae's chat has no turn for a tool call or its result, so the tools are
126
- named twice:
127
- - natively in `tools`, with `parameters` as a JSON string;
128
- - in a system prompt that asks for each call as a
129
- `<tool_call>{"name", "arguments"}</tool_call>` block.
125
+ - The tools go natively in `tools`, with `parameters` as a JSON string.
126
+ A past call goes back as the turn Trae made it: an assistant
127
+ `tool_calls` message carrying it (each call
128
+ `{id, type: "function", function_call: {name, arguments}}`) and a
129
+ `tool` message after it answering it. Trae answers such a history with
130
+ native calls; asked for its calls in text instead — a system prompt
131
+ describing a `<tool_call>` block, earlier calls written out as one —
132
+ models write them as blocks, and a block the stream cuts short is an
133
+ error (plugins#38). Trae takes no two messages of one role in a row,
134
+ so an assistant turn's text is folded into the calls turn after it
135
+ and consecutive calls turns join one `tool_calls` list.
136
+ - A call with no name is dropped, and the `tool` result answering it
137
+ with it: Trae requires the name, and an empty `tool_calls` list with a
138
+ result naming no call is not a turn it takes.
139
+ - A result with no text goes as `(no output)` (`(the result's image
140
+ follows)` when it had only an image): Trae refuses the whole request
141
+ with 4027 when a result's text is empty, reading the calls turn as
142
+ unanswered (measured on Trae CN in #251). A result answering no call
143
+ the history sends goes back as text in a `user` turn.
144
+ - A request that names no tools to Trae (none sent, or `tool_choice:
145
+ "none"`, such as a summary) sends its past calls and results as text,
146
+ as before: every live run of the native turns carried the tools.
130
147
  - GLM also writes its own template, `<tool_call>name<arg_key>k</arg_key>
131
148
  <arg_value>v</arg_value></tool_call>` (a string kept as written, other
132
149
  types read as JSON), and sometimes a whole call into a native call's
@@ -152,7 +169,9 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
152
169
  requested tool but for case or punctuation (`Read`) goes on as the
153
170
  request's (`read`); any other goes on as written, so the agent can say
154
171
  the tool doesn't exist.
155
- - Earlier calls and their results go back in as text.
172
+ - Trae requires the message answering a call to follow the call: the
173
+ results of parallel calls go out as consecutive `tool` messages, with
174
+ nothing between them, or the later ones lose their answer (11148).
156
175
  - Images: Trae's lists mark each model `multimodal` true or false (the
157
176
  Doubao-Seed models, Kimi K3 and K2.6, Qwen 3.7 Plus and MiniMax M3 take
158
177
  images; GLM-5 and DeepSeek V4 don't). A model marked true is listed
@@ -162,6 +181,13 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
162
181
  "[an image, which this model can't see]" in its place. A model whose
163
182
  list hasn't been read is sent its images, unless the fallback list
164
183
  says it takes none.
184
+ A tool result's images go in a `user` message after the whole run of
185
+ results, not in the `tool` turn: measured on the live endpoint, an
186
+ `image_url` part in a `tool` turn reaches Kimi K2.6 but not
187
+ Doubao-Seed (it answered "Blue"/"Black" to a red image), while the
188
+ user-turn placement is read by both. The results of one turn stay
189
+ consecutive — nothing may go between them (11148) — so the images
190
+ wait for the last of them.
165
191
 
166
192
  The model list is the account's own, from
167
193
  `/api/ide/v1/batch_get_detail_param`, every function's list in one ask, as
@@ -283,9 +309,11 @@ Trae CN gives credits for a daily check-in (每日签到); Trae Global has no
283
309
  such page, so this is CN only. magpie can press it
284
310
  once a day for each account (Settings, or the switch on the usage card),
285
311
  through `auth.checkin`: `claimed` when Trae grants the credits, `done`
286
- when the status or Trae's 9095 says this device has already claimed,
287
- `inactive` when the page is disabled, and `failed` for a turned-away
288
- sign-in or Trae's 9074 busy answer.
312
+ when the status says today's is in already, `inactive` when the page is
313
+ disabled, `ineligible` when the claim answers 9095 (this device has
314
+ checked in today) or 9074 (to be done in Trae CN's own app; not asked
315
+ again that day), and `failed` for a turned-away sign-in or any other
316
+ answer, which is tried again later that day.
289
317
  It asks Trae CN's own pages through
290
318
  this plugin's fetch, which sends them as the account, as Trae CN's IDE
291
319
  (3.3.104) sends them: its Cloud-IDE-JWT (renewed first when near its
@@ -333,7 +361,9 @@ answered it 9074 even with the client's headers.
333
361
  in today, and that holds for the day. No device id is ever changed or
334
362
  rotated to get round a 9095 or a 9074, and no account is given another
335
363
  account's device or its own client's device unless the client is signed
336
- in as that account.
364
+ in as that account. The credit a claim granted is its `credits` plus
365
+ its `extra_credits`, either alone; the status answer's is the fallback
366
+ when the claim says none.
337
367
 
338
368
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
339
369
  that isn't a chat request is still refused (400). Needs 0.1.4 or later.
package/index.mjs CHANGED
@@ -821,7 +821,16 @@ function clientStoragePaths(platform, home, env) {
821
821
  const file = (root, name) => join(root, name, "User", "globalStorage", "storage.json")
822
822
  if (platform === "darwin") return [file(join(home, "Library", "Application Support"), "Trae CN")]
823
823
  if (platform === "win32") {
824
- const roots = [env.APPDATA, join(home, "AppData", "Roaming")].filter((r, i, all) => r && all.indexOf(r) === i)
824
+ // the two roots are often the same folder: APPDATA and the home's own
825
+ // AppData\Roaming. They are compared as one system spells them, so a
826
+ // difference of separator or case alone doesn't read the same file twice
827
+ const spell = (r) => String(r ?? "").replace(/[\\/]+/g, "\\").replace(/\\+$/, "").toLowerCase()
828
+ const roots = []
829
+ for (const r of [env.APPDATA, join(home, "AppData", "Roaming")]) {
830
+ if (!r) continue
831
+ if (roots.some((was) => spell(was) === spell(r))) continue
832
+ roots.push(r)
833
+ }
825
834
  return roots.flatMap((r) => ["Trae CN", "trae-cn"].map((n) => file(r, n)))
826
835
  }
827
836
  if (platform === "linux") return ["trae-cn", "Trae CN"].map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
@@ -914,29 +923,13 @@ const ugPage = (url) => {
914
923
  }
915
924
  }
916
925
 
917
- // Trae's chat takes text only and has no turn for a tool call or its
918
- // result. The agent's tools are named to it natively and in a system
919
- // prompt that asks for each call as a tagged block; earlier calls and
920
- // their results go in as text. A native call in its answer is taken too.
926
+ // A model that writes a tool call as text still writes it, mostly GLM and
927
+ // DeepSeek: the blocks are read as the calls they mean (the tools also go
928
+ // natively in the body, and a past call is a native turn, so an answer is
929
+ // usually native; the text form is read for when it is not).
921
930
  const OPEN = "<tool_call>"
922
931
  const CLOSE = "</tool_call>"
923
932
 
924
- function toolPrompt(tools, choice, parallel) {
925
- const defs = tools.map((t) => {
926
- const f = t.function ?? t
927
- return { name: f.name, description: f.description ?? "", parameters: f.parameters ?? {} }
928
- })
929
- const lines = [
930
- "You can call the client's tools, listed below as JSON. They run on the user's machine, not on yours: use no built-in or server tool to reach the user's files.",
931
- `To call a tool, write one block per call and nothing after it: ${OPEN}{"name":"tool_name","arguments":{...}}${CLOSE}. Use an exact tool name and fill arguments from its schema. Then stop and wait: the result comes back in the next message.`,
932
- "A call is not done until its result comes back; don't say it is, and don't repeat a call whose result you have.",
933
- ]
934
- if (parallel === false) lines.push("Call at most one tool at a time.")
935
- if (choice === "required") lines.push("Call a tool before answering.")
936
- else if (choice && typeof choice === "object" && (choice.function?.name || choice.name)) lines.push(`Call the tool ${choice.function?.name || choice.name} before answering.`)
937
- lines.push("Tools:", JSON.stringify(defs))
938
- return lines.join("\n")
939
- }
940
933
 
941
934
  const partText = (content) => {
942
935
  if (typeof content === "string") return content
@@ -970,43 +963,137 @@ const partsOf = (content, sees) => {
970
963
  // the model takes images: Trae's list says so of each model (multimodal),
971
964
  // and a model it says doesn't is sent the placeholder, not the image.
972
965
  function traeMessages(req, sees = false) {
973
- const tools = Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none" ? req.tools : []
966
+ // native is whether the body names the tools to Trae (chatBody's own
967
+ // test). Past calls go back as Trae's own turns only then: every live
968
+ // run of that shape carried the tools, and a request without them (a
969
+ // summary, tool_choice "none") keeps the text form it always had
970
+ const native = Array.isArray(req.tools) && req.tools.length > 0 && req.tool_choice !== "none"
974
971
  const names = new Map()
975
972
  const out = []
976
- if (tools.length) out.push({ role: "system", text: toolPrompt(tools, req.tool_choice, req.parallel_tool_calls) })
973
+ // dropped is every call id a turn carried but Trae cannot be sent: an
974
+ // assistant turn whose calls all lost their name takes its results with it
975
+ const dropped = new Set()
976
+ // images of tool results, held until the run of results ends: Trae
977
+ // takes no message between a call's results, so they cannot go after
978
+ // each one — one user message once they are all in
979
+ const pending = []
980
+ const flushImages = () => {
981
+ if (pending.length) out.push({ role: "user", images: pending.splice(0) })
982
+ }
977
983
  for (const m of req.messages ?? []) {
978
984
  const role = m.role === "developer" ? "system" : m.role
979
985
  let { text, images } = partsOf(m.content, sees)
980
- if (role === "assistant") {
981
- const calls = (m.tool_calls ?? []).map((c) => {
982
- names.set(c.id, c.function?.name)
986
+ if (!native && role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
987
+ const calls = m.tool_calls.map((c) => {
988
+ names.set(String(c.id ?? ""), c.function?.name)
983
989
  let args = c.function?.arguments ?? "{}"
984
990
  try {
985
991
  args = JSON.parse(args)
986
992
  } catch {}
987
993
  return OPEN + JSON.stringify({ name: c.function?.name, arguments: args }) + CLOSE
988
994
  })
989
- text = [text, ...calls].filter(Boolean).join("\n")
990
- out.push({ role: "assistant", text })
995
+ out.push({ role: "assistant", text: [text, ...calls].filter(Boolean).join("\n") })
996
+ continue
997
+ }
998
+ if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
999
+ // a past call goes back as the turn Trae made it: an assistant
1000
+ // tool_calls message, its result in the tool message after it. Trae
1001
+ // answers such a history with native calls; written out as a
1002
+ // <tool_call> block in the text it answers in blocks too, and a block
1003
+ // the stream cuts short ends the turn as an error (plugins#38).
1004
+ const calls = []
1005
+ for (const c of m.tool_calls) {
1006
+ const name = String(c.function?.name ?? c.name ?? "").trim()
1007
+ // a call Trae cannot be sent is dropped here; its result goes too
1008
+ // (an empty tool_calls list and a result naming no call are not a
1009
+ // turn Trae takes)
1010
+ if (!name) {
1011
+ if (c.id != null) dropped.add(String(c.id))
1012
+ continue
1013
+ }
1014
+ names.set(String(c.id ?? ""), name)
1015
+ let args = c.function?.arguments ?? c.arguments ?? "{}"
1016
+ if (typeof args !== "string") {
1017
+ try {
1018
+ args = JSON.stringify(args)
1019
+ } catch {
1020
+ args = "{}"
1021
+ }
1022
+ }
1023
+ calls.push({ id: String(c.id ?? "call_" + (names.size + 1)), type: "function", function_call: { name, arguments: args } })
1024
+ }
1025
+ if (calls.length) out.push({ role: "assistant", text, images, calls })
1026
+ else if (text || images.length) out.push({ role: "assistant", text, images })
1027
+ continue
1028
+ }
1029
+ if (role === "assistant") {
1030
+ flushImages()
1031
+ out.push({ role: "assistant", text, images })
991
1032
  } else if (role === "tool") {
992
- out.push({ role: "user", text: `Result of ${names.get(m.tool_call_id) || m.name || "the tool"} (call ${m.tool_call_id ?? "?"}):\n${text}`, images })
1033
+ const id = String(m.tool_call_id ?? "")
1034
+ if (dropped.has(id)) continue
1035
+ // a result answering no call this history sends (the text form, or
1036
+ // a call cut away before it) is no turn Trae takes: it goes as text
1037
+ if (!native || !names.has(id)) {
1038
+ flushImages()
1039
+ out.push({ role: "user", text: `Result of ${names.get(id) || m.name || "the tool"} (call ${m.tool_call_id ?? "?"}):\n${text}`, images })
1040
+ continue
1041
+ }
1042
+ // Trae's own result turn: the call it answers, its text as parts
1043
+ // (an empty one for a result with no text). Its images wait: a
1044
+ // tool turn's image_url reaches some models (Kimi) and not others
1045
+ // (Doubao-Seed), so they go in a user message after the whole run
1046
+ // of results, which every multimodal model reads
1047
+ if (images.length) pending.push(...images)
1048
+ // Trae refuses a result with no text (4027: the calls turn reads as
1049
+ // unanswered), so one that printed nothing says so (plugins#251)
1050
+ out.push({ role: "tool", tool_call_id: id, text: text || (images.length ? "(the result's image follows)" : "(no output)") })
993
1051
  } else {
1052
+ flushImages()
994
1053
  out.push({ role: role === "system" ? "system" : "user", text, images })
995
1054
  }
996
1055
  }
997
- // Trae takes no empty message, nor two of one role in a row; a message of
998
- // an image alone is not empty
1056
+ flushImages()
1057
+ // Trae takes no two messages of one role in a row, so an assistant turn is
1058
+ // folded into the turn after it: text into the calls turn it introduces
1059
+ // (agents send "let me read" and then the call, and parallel calls split
1060
+ // over two assistant turns), calls joined into one list. A message of an
1061
+ // image alone is not empty; tool results are turns of their own, each
1062
+ // answering its own call.
999
1063
  const merged = []
1000
1064
  for (const m of out) {
1001
1065
  const images = m.images ?? []
1066
+ if (m.role === "tool") {
1067
+ merged.push(m)
1068
+ continue
1069
+ }
1070
+ if (m.role === "assistant") {
1071
+ const last = merged.at(-1)
1072
+ if (last && last.role === "assistant") {
1073
+ // fold: text and images join, calls append to the same list
1074
+ last.text = [last.text, m.text].filter(Boolean).join("\n\n")
1075
+ last.images = [...(last.images ?? []), ...images]
1076
+ if (m.calls) last.calls = [...(last.calls ?? []), ...m.calls]
1077
+ if (!last.text && !last.images.length && !last.calls) merged.pop()
1078
+ continue
1079
+ }
1080
+ if (!m.text && !images.length && !m.calls) continue
1081
+ merged.push({ role: "assistant", text: m.text, images, ...(m.calls ? { calls: m.calls } : {}) })
1082
+ continue
1083
+ }
1002
1084
  if (!m.text && !images.length) continue
1003
1085
  const last = merged.at(-1)
1004
1086
  if (last && last.role === m.role && m.role !== "system") {
1005
1087
  last.text = [last.text, m.text].filter(Boolean).join("\n\n")
1006
- last.images = [...last.images, ...images]
1088
+ last.images = [...(last.images ?? []), ...images]
1007
1089
  } else merged.push({ role: m.role, text: m.text, images })
1008
1090
  }
1009
- return merged.map((m) => ({ role: m.role, content: [...(m.text ? [{ type: "text", text: m.text }] : []), ...m.images] }))
1091
+ return merged.map((m) => {
1092
+ const content = [...(m.text ? [{ type: "text", text: m.text }] : []), ...(m.images ?? [])]
1093
+ if (m.calls) return { role: "assistant", content: content.length ? content : null, tool_calls: m.calls }
1094
+ if (m.role === "tool") return { role: "tool", tool_call_id: m.tool_call_id, content } // results of one turn stay consecutive: nothing may go between them (11148)
1095
+ return { role: m.role, content }
1096
+ })
1010
1097
  }
1011
1098
 
1012
1099
  // nativeTools are the tools as Trae names them: parameters a JSON string
@@ -2008,7 +2095,13 @@ class NativeCalls {
2008
2095
  this.calls.push({ ...c })
2009
2096
  return
2010
2097
  }
2098
+ // a name comes as the arguments do: a snapshot that grows. A name that
2099
+ // starts with what was read is that snapshot further along, and takes
2100
+ // its place — a wrapper cut short at a tag, then the whole call again.
2101
+ // A name that doesn't start with it is a piece of the name's own, or
2102
+ // one no snapshot explains, and the first is kept as it was
2011
2103
  if (!o.name) o.name = c.name
2104
+ else if (c.name && c.name.startsWith(o.name) && c.name !== o.name) o.name = c.name
2012
2105
  if (c.index !== null && o.index === null) o.index = c.index
2013
2106
  const prev = o.arguments
2014
2107
  const next = c.arguments
@@ -2135,7 +2228,9 @@ async function* parts(events, tools = []) {
2135
2228
  if (data === "[DONE]") break
2136
2229
  if (name === "error" || (name !== "output" && d.code && d.message && !d.response && !d.content)) {
2137
2230
  const e = errorOf(d)
2138
- yield { error: e.message || "Trae CN returned an error", code: e.code }
2231
+ // the realm answering is named by the caller, which knows it; a bare
2232
+ // word here reads as neither realm's
2233
+ yield { error: e.message || "Trae returned an error", code: e.code }
2139
2234
  return
2140
2235
  }
2141
2236
  if (name === "token_usage") {
@@ -2151,9 +2246,12 @@ async function* parts(events, tools = []) {
2151
2246
  }
2152
2247
  if (!ANSWER.has(name)) continue
2153
2248
  const delta = d.delta && typeof d.delta === "object" ? d.delta : {}
2154
- const reasoning = d.reasoning_content ?? d.reasoning ?? delta.reasoning_content ?? ""
2155
- if (typeof reasoning === "string" && reasoning) {
2156
- const r = th.add(reasoning)
2249
+ // the reasoning's own fields, in the order Trae has put it under: an
2250
+ // empty one is no answer, so the next is asked (an event carrying both
2251
+ // reasoning_content: "" and reasoning: "…" says the thought in the second)
2252
+ const said = [d.reasoning_content, d.reasoning, delta.reasoning_content, delta.reasoning].find((v) => typeof v === "string" && v) ?? ""
2253
+ if (said) {
2254
+ const r = th.add(said)
2157
2255
  if (r) yield { reasoning: r }
2158
2256
  }
2159
2257
  let text = typeof d.response === "string" ? d.response : typeof d.content === "string" ? d.content : typeof delta.content === "string" ? delta.content : typeof d.delta === "string" ? d.delta : ""
@@ -2255,8 +2353,9 @@ async function openai(req, it, abort, site) {
2255
2353
  // a reply that failed (an error event, or Trae's stream breaking off)
2256
2354
  // ends with the error and [DONE], not a finish that says it stopped; the
2257
2355
  // stream is closed whatever happens, and an agent that goes away stops
2258
- // the reading of Trae's
2259
- const error = (message, code = null) => enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${message}`, type: "api_error", code } })}\n\n`)
2356
+ // the reading of Trae's. The realm answering names itself, so a Global
2357
+ // account's failure reads as Trae Global's
2358
+ const error = (message, code = null) => enc.encode(`data: ${JSON.stringify({ error: { message: `${site.realm}: ${message}`, type: "api_error", code } })}\n\n`)
2260
2359
  let gone = false
2261
2360
  const stream = new ReadableStream({
2262
2361
  async start(ctl) {
@@ -2562,8 +2661,12 @@ const makePlugin = (site) => async ({ client }) => {
2562
2661
  if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} — check in in the Trae CN app` }
2563
2662
  if (claim.status !== 200 || (ce.code && String(ce.code) !== "0")) return { outcome: "failed", message: ce.message || statusLine(claim.status) }
2564
2663
  if (!answer || typeof answer !== "object" || Array.isArray(answer)) return { outcome: "failed", message: `check-in claim: unreadable reply ${claimText.trim().slice(0, 120) || "(empty)"}` }
2565
- const credit = Number(answer.credits) + Number(answer.extra_credits || 0)
2566
- const fallback = Number(body.credits) + Number(body.extra_credits || 0)
2664
+ // a claim may answer extra_credits with no credits beside it, and either
2665
+ // may be absent: Number(undefined) is NaN, and NaN + n is NaN, which
2666
+ // reads as no credit at all
2667
+ const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
2668
+ const credit = n(answer.credits) + n(answer.extra_credits)
2669
+ const fallback = n(body.credits) + n(body.extra_credits)
2567
2670
  return { outcome: "claimed", ...(credit > 0 ? { credit } : fallback > 0 ? { credit: fallback } : {}) }
2568
2671
  }
2569
2672
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
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",