@magpie-community/opencode-trae-auth 0.2.14 → 0.2.16

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 +25 -6
  2. package/index.mjs +101 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,9 +42,11 @@ An account of one realm does not sign in to the other: trae.cn's accounts are
42
42
  not trae.ai's.
43
43
 
44
44
  At sign-in the plugin creates the account's device and names it to the
45
- authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
45
+ authorization page: `device_id` and `machine_id` (32 hex). Every
46
46
  request then sends that same device. The relays saw requests dropped when the
47
- device was new each time.
47
+ device was new each time. A Trae CN `device_id` is 16 digits since 0.2.16, the
48
+ shape of the ids Trae's device service gives its clients (15 or 16 digits);
49
+ Trae Global's stays 19.
48
50
 
49
51
  ### Enterprise (SaaS) accounts
50
52
 
@@ -151,7 +153,15 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
151
153
  request's (`read`); any other goes on as written, so the agent can say
152
154
  the tool doesn't exist.
153
155
  - Earlier calls and their results go back in as text.
154
- - Images aren't sent.
156
+ - Images: Trae's lists mark each model `multimodal` true or false (the
157
+ Doubao-Seed models, Kimi K3 and K2.6, Qwen 3.7 Plus and MiniMax M3 take
158
+ images; GLM-5 and DeepSeek V4 don't). A model marked true is listed
159
+ as one that takes images, and an image sent to it goes in the
160
+ message's content as an OpenAI `image_url` part beside the text, as
161
+ TRAE's own agent sends one (plugins#161). A model marked false gets
162
+ "[an image, which this model can't see]" in its place. A model whose
163
+ list hasn't been read is sent its images, unless the fallback list
164
+ says it takes none.
155
165
 
156
166
  The model list is the account's own, from
157
167
  `/api/ide/v1/batch_get_detail_param`, every function's list in one ask, as
@@ -306,11 +316,20 @@ answered it 9074 even with the client's headers.
306
316
  macOS `~/Library/Application Support/Trae CN/…`, Windows
307
317
  `%APPDATA%\Trae CN\…` (or `trae-cn`), Linux `~/.config/trae-cn/…`
308
318
  (or `Trae CN`; not seen on a real Linux install).
319
+ - **Every other account's device (0.2.16):** its own device id, in the
320
+ client's shape. Trae CN answered a claim on the plugin's 19-digit id
321
+ 9074, while the same account and token on a 16-digit id were taken
322
+ (magpie-community/plugins#194). An account signed in since 0.2.16 has a
323
+ 16-digit id and checks in on it. One signed in before checks in on a
324
+ 16-digit id made from its own device id and uid: the same id every day,
325
+ never rotated, and no other account's. Its chat requests and renewals
326
+ keep the 19-digit device it signed in with.
309
327
  - **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
310
328
  the account for a new sign-in. Code 9095 means this device has checked
311
- in today. No device id is ever made up, changed or rotated to get round
312
- a 9095 or a 9074, and no account is given a device that isn't its
313
- own client's.
329
+ in today, and that holds for the day. No device id is ever changed or
330
+ rotated to get round a 9095 or a 9074, and no account is given another
331
+ account's device or its own client's device unless the client is signed
332
+ in as that account.
314
333
 
315
334
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
316
335
  that isn't a chat request is still refused (400). Needs 0.1.4 or later.
package/index.mjs CHANGED
@@ -15,6 +15,10 @@ import { createServer, STATUS_CODES } from "node:http"
15
15
  import { homedir, release, version as osVersion } from "node:os"
16
16
  import { join } from "node:path"
17
17
 
18
+ // SEES is a fallback model that takes images, as Trae's lists mark it
19
+ // (multimodal: true)
20
+ const SEES = { attachment: true, modalities: { input: ["text", "image"], output: ["text"] } }
21
+
18
22
  const SITES = {
19
23
  // Trae CN (trae.cn): the authorization page sends the browser back to a
20
24
  // callback on 127.0.0.1 with the account's Cloud-IDE-JWT and refresh
@@ -74,8 +78,8 @@ const SITES = {
74
78
  models: {
75
79
  "glm-5.2": { name: "GLM-5.2", limit: { context: 200_000, output: 32_000 } },
76
80
  "glm-5": { name: "GLM-5" },
77
- "kimi-k2.6": { name: "Kimi K2.6", limit: { context: 256_000, output: 0 } },
78
- "qwen-3.7-plus": { name: "Qwen 3.7 Plus" },
81
+ "kimi-k2.6": { name: "Kimi K2.6", limit: { context: 256_000, output: 0 }, ...SEES },
82
+ "qwen-3.7-plus": { name: "Qwen 3.7 Plus", ...SEES },
79
83
  "DeepSeek-V4-Pro": { name: "DeepSeek V4 Pro" },
80
84
  "DeepSeek-V4-Flash": { name: "DeepSeek V4 Flash" },
81
85
  },
@@ -292,8 +296,32 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
292
296
 
293
297
  // device is the machine an account signs in from, made once at sign-in and
294
298
  // named to trae.cn's authorization page, then sent with every request: a
295
- // fresh one each time is what the relays saw requests dropped for.
296
- const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex"), ...newDeviceKey() })
299
+ // fresh one each time is what the relays saw requests dropped for. Trae
300
+ // CN's id has the shape its clients' have, 16 digits: the device service
301
+ // gives the IDE a 15- or 16-digit one (iCubeAuthInfo://icube-dc:<id>, see
302
+ // clientDevice), and Trae CN answers a check-in claim on a 19-digit id
303
+ // 9074 where the same account and token on a 16-digit one are taken
304
+ // (magpie-community/plugins#194). Trae's global site keeps the 19 digits
305
+ // it was signed in with: nothing says its ids are shaped otherwise.
306
+ const newDevice = (site) => ({ deviceId: digits(site?.id === "trae-cn" ? 16 : 19), machineId: randomBytes(16).toString("hex"), ...newDeviceKey() })
307
+
308
+ // ugDevice is the device id a Trae CN account's check-in goes on when this
309
+ // machine's client isn't signed in as it: its own when that already has a
310
+ // client's shape (15 or 16 digits), else, for an account signed in before
311
+ // 0.2.16 with 19 digits, a 16-digit id made from its own device id and
312
+ // uid. That one is the same on every check-in of the account, never
313
+ // rotated and never another account's: it is the account's own device in
314
+ // the shape Trae CN takes (#194), not a new device to get past a refusal,
315
+ // and a 9095 (this device is in for today) on it stays the day's answer.
316
+ // The chat's pages and the renewal keep the device the account signed in
317
+ // with.
318
+ function ugDevice(a) {
319
+ const id = String(a.deviceId ?? "")
320
+ if (/^\d{15,16}$/.test(id)) return id
321
+ if (!id && !a.uid) return id
322
+ const n = BigInt("0x" + createHash("sha256").update(`trae-cn-device\0${id}\0${a.uid ?? ""}`).digest("hex").slice(0, 30))
323
+ return String(1_000_000_000_000_000n + (n % 9_000_000_000_000_000n))
324
+ }
297
325
 
298
326
  // newDeviceKey is the device's key pair, an ECDSA P-256 one as the clients
299
327
  // keep (iCubeAuthInfo://icube-dc:<device>), whose public half goes with a
@@ -623,7 +651,7 @@ async function signedIn(q, device, site) {
623
651
  // browserSignIn is the IDE's sign-in: trae.cn's authorization page, back
624
652
  // to a callback on 127.0.0.1 (the page takes no other kind)
625
653
  async function browserSignIn(site) {
626
- const device = newDevice()
654
+ const device = newDevice(site)
627
655
  const trace = randomUUID()
628
656
  let over = false
629
657
  let settle
@@ -915,15 +943,36 @@ const partText = (content) => {
915
943
  .join("\n")
916
944
  }
917
945
 
918
- // traeMessages is the conversation as Trae's chat takes it
919
- function traeMessages(req) {
946
+ // imageOf is an image part of a message as Trae's chat takes one: OpenAI's
947
+ // image_url part, {url} in it, which is what TRAE's ai-agent sends
948
+ // (LLMRawMultiContent's image_url, LLMRawChatImageURL) and what the
949
+ // open-source relays pass llm_utils_chat unchanged. null for a part that
950
+ // isn't an image, or one with no url.
951
+ function imageOf(p) {
952
+ if (p?.type !== "image_url" && p?.type !== "input_image") return null
953
+ const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url ?? p.url
954
+ return typeof url === "string" && url ? { type: "image_url", image_url: { url } } : null
955
+ }
956
+
957
+ // partsOf is a message's text and, for a model that sees, its images apart
958
+ // from it; a model that doesn't is told in the text that one was there
959
+ const partsOf = (content, sees) => {
960
+ if (!sees || !Array.isArray(content)) return { text: partText(content), images: [] }
961
+ const images = content.map(imageOf).filter(Boolean)
962
+ return { text: partText(content.filter((p) => !imageOf(p))), images }
963
+ }
964
+
965
+ // traeMessages is the conversation as Trae's chat takes it. sees is whether
966
+ // the model takes images: Trae's list says so of each model (multimodal),
967
+ // and a model it says doesn't is sent the placeholder, not the image.
968
+ function traeMessages(req, sees = false) {
920
969
  const tools = Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none" ? req.tools : []
921
970
  const names = new Map()
922
971
  const out = []
923
972
  if (tools.length) out.push({ role: "system", text: toolPrompt(tools, req.tool_choice, req.parallel_tool_calls) })
924
973
  for (const m of req.messages ?? []) {
925
974
  const role = m.role === "developer" ? "system" : m.role
926
- let text = partText(m.content)
975
+ let { text, images } = partsOf(m.content, sees)
927
976
  if (role === "assistant") {
928
977
  const calls = (m.tool_calls ?? []).map((c) => {
929
978
  names.set(c.id, c.function?.name)
@@ -936,20 +985,24 @@ function traeMessages(req) {
936
985
  text = [text, ...calls].filter(Boolean).join("\n")
937
986
  out.push({ role: "assistant", text })
938
987
  } else if (role === "tool") {
939
- out.push({ role: "user", text: `Result of ${names.get(m.tool_call_id) || m.name || "the tool"} (call ${m.tool_call_id ?? "?"}):\n${text}` })
988
+ 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 })
940
989
  } else {
941
- out.push({ role: role === "system" ? "system" : "user", text })
990
+ out.push({ role: role === "system" ? "system" : "user", text, images })
942
991
  }
943
992
  }
944
- // Trae takes no empty message, nor two of one role in a row
993
+ // Trae takes no empty message, nor two of one role in a row; a message of
994
+ // an image alone is not empty
945
995
  const merged = []
946
996
  for (const m of out) {
947
- if (!m.text) continue
997
+ const images = m.images ?? []
998
+ if (!m.text && !images.length) continue
948
999
  const last = merged.at(-1)
949
- if (last && last.role === m.role && m.role !== "system") last.text += "\n\n" + m.text
950
- else merged.push({ ...m })
1000
+ if (last && last.role === m.role && m.role !== "system") {
1001
+ last.text = [last.text, m.text].filter(Boolean).join("\n\n")
1002
+ last.images = [...last.images, ...images]
1003
+ } else merged.push({ role: m.role, text: m.text, images })
951
1004
  }
952
- return merged.map((m) => ({ role: m.role, content: [{ type: "text", text: m.text }] }))
1005
+ return merged.map((m) => ({ role: m.role, content: [...(m.text ? [{ type: "text", text: m.text }] : []), ...m.images] }))
953
1006
  }
954
1007
 
955
1008
  // nativeTools are the tools as Trae names them: parameters a JSON string
@@ -1024,10 +1077,10 @@ function sessionOf(headers) {
1024
1077
  // tool result sent back, a retry, another function answering) shows up in the
1025
1078
  // usage detail as several, each under no title at all. request_id stays a
1026
1079
  // fresh id per request, being that request's own.
1027
- function chatBody(req, fn, modelName, most = 0, max = null, session = "") {
1080
+ function chatBody(req, fn, modelName, most = 0, max = null, session = "", sees = false) {
1028
1081
  const config = max?.base ?? req.model
1029
1082
  const body = {
1030
- messages: traeMessages(req),
1083
+ messages: traeMessages(req, sees),
1031
1084
  function: fn,
1032
1085
  config_name: config,
1033
1086
  model: config,
@@ -2270,6 +2323,12 @@ function chatModel(m, site) {
2270
2323
  return true
2271
2324
  }
2272
2325
 
2326
+ // seesOf is whether a list's entry says its model takes images: Trae's
2327
+ // lists mark each one multimodal true or false (Doubao-Seed, Kimi K3,
2328
+ // Qwen 3.7 Plus take them; GLM-5 and DeepSeek V4 don't). null when the
2329
+ // entry doesn't say.
2330
+ const seesOf = (m) => (typeof m?.multimodal === "boolean" ? m.multimodal : typeof m?.display_config?.multimodal === "boolean" ? m.display_config.multimodal : null)
2331
+
2273
2332
  // devModel is the model an entry serves requests with: its __dev one
2274
2333
  const devModel = (m) => (Array.isArray(m?.model_detail_list) ? m.model_detail_list : []).find((d) => /__dev$/.test(String(d?.model_name ?? "")))
2275
2334
 
@@ -2352,6 +2411,8 @@ const makePlugin = (site) => async ({ client }) => {
2352
2411
  const modelNames = new Map()
2353
2412
  // the Max each model the list last read has (null: none)
2354
2413
  const maxes = new Map()
2414
+ // whether each model the list last read takes images (its multimodal)
2415
+ const sight = new Map()
2355
2416
 
2356
2417
  const save = async (auth) => {
2357
2418
  try {
@@ -2454,6 +2515,9 @@ const makePlugin = (site) => async ({ client }) => {
2454
2515
  // other account keeps its own device; none is given the client's.
2455
2516
  const deviceId = ug && site.id === "trae-cn" ? await clientDevice(a.uid) : undefined
2456
2517
  const own = deviceId ? { deviceId, ...thisMachine() } : undefined
2518
+ // every other Trae CN account checks in on its own device, in the
2519
+ // client's shape (ugDevice, #194)
2520
+ if (ug && site.id === "trae-cn" && !own) a = { ...a, deviceId: ugDevice(a) }
2457
2521
  const res = await fetch(url, {
2458
2522
  method,
2459
2523
  headers: ug ? ugHeaders(a, site, { Accept: "application/json" }, own) : ideHeaders(a, site, { Accept: "application/json" }),
@@ -2595,6 +2659,9 @@ const makePlugin = (site) => async ({ client }) => {
2595
2659
  }
2596
2660
  for (const [id, { m, fn }] of out) {
2597
2661
  listedBy.set(id, fn)
2662
+ const sees = seesOf(m)
2663
+ if (sees === null) sight.delete(id)
2664
+ else sight.set(id, sees)
2598
2665
  const dev = devModel(m)
2599
2666
  if (dev) modelNames.set(id, { fn, name: dev.model_name, most: Number(dev.max_tokens) || 0 })
2600
2667
  else modelNames.delete(id)
@@ -2633,16 +2700,24 @@ const makePlugin = (site) => async ({ client }) => {
2633
2700
  const out = Number(devModel(m)?.max_tokens) || was.limit?.output || 0
2634
2701
  const name = m.display_config?.display_name || m.display_name || m.display_model_name || was.name || id
2635
2702
  const input = (k) => (MODEL.modalities?.input ?? []).includes(k)
2703
+ // the list's multimodal says whether the model takes images, over the
2704
+ // fallback list's word and MODEL's
2705
+ const sees = seesOf(m)
2706
+ const inputs = was.capabilities?.input ?? { text: true, image: input("image"), audio: input("audio"), video: input("video"), pdf: input("pdf") }
2707
+ const sight = sees === null ? {} : {
2708
+ modalities: { input: [...new Set([...((was.modalities ?? MODEL.modalities).input ?? ["text"]).filter((k) => k !== "image"), ...(sees ? ["image"] : [])])], output: (was.modalities ?? MODEL.modalities).output ?? ["text"] },
2709
+ attachment: sees,
2710
+ }
2636
2711
  return {
2637
- ...MODEL, ...was, id, providerID: site.id, name: String(name), limit: { context: ctx, output: out },
2712
+ ...MODEL, ...was, ...sight, id, providerID: site.id, name: String(name), limit: { context: ctx, output: out },
2638
2713
  api: was.api ?? { id, url: site.hosts.api, npm: "@ai-sdk/openai-compatible" },
2639
2714
  capabilities: {
2640
2715
  ...(was.capabilities ?? {}),
2641
2716
  reasoning: was.capabilities?.reasoning ?? MODEL.reasoning,
2642
2717
  temperature: was.capabilities?.temperature ?? MODEL.temperature,
2643
- attachment: was.capabilities?.attachment ?? MODEL.attachment,
2718
+ attachment: sees ?? was.capabilities?.attachment ?? MODEL.attachment,
2644
2719
  toolcall: was.capabilities?.toolcall ?? MODEL.tool_call,
2645
- input: was.capabilities?.input ?? { text: true, image: input("image"), audio: input("audio"), video: input("video"), pdf: input("pdf") },
2720
+ input: sees === null ? inputs : { ...inputs, image: sees },
2646
2721
  output: was.capabilities?.output ?? { text: true, image: false, audio: false, video: false, pdf: false },
2647
2722
  },
2648
2723
  // Trae's request takes no effort or thinking level, so a model has
@@ -2739,6 +2814,10 @@ const makePlugin = (site) => async ({ client }) => {
2739
2814
  if (named) fns.unshift(named.fn)
2740
2815
  }
2741
2816
  const max = named?.max ?? null
2817
+ // a model the list hasn't said of is sent its images: the agent
2818
+ // sends them only to a model it was told takes them
2819
+ const base = max?.base ?? String(req.model)
2820
+ const sees = sight.get(base) ?? (site.models[base]?.modalities?.input?.includes("image") ?? true)
2742
2821
  // the answer's own signal: the caller's abort (a cancelled
2743
2822
  // request) and the stream's cancel both stop Trae's fetch, so
2744
2823
  // no read is left hanging on a connection the agent walked
@@ -2758,7 +2837,7 @@ const makePlugin = (site) => async ({ client }) => {
2758
2837
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
2759
2838
  method: "POST",
2760
2839
  headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
2761
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session)),
2840
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees)),
2762
2841
  signal: ac.signal,
2763
2842
  })
2764
2843
  if (!res.ok) {
@@ -2809,4 +2888,4 @@ const makePlugin = (site) => async ({ client }) => {
2809
2888
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2810
2889
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2811
2890
 
2812
- export const _internal = { ownClient, clientStoragePaths, clientDevice, clientDecode, thisMachine, traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, nameCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
2891
+ export const _internal = { ownClient, clientStoragePaths, clientDevice, clientDecode, thisMachine, traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, nameCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, ugDevice, 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.14",
3
+ "version": "0.2.16",
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",