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

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 +9 -1
  2. package/index.mjs +70 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,7 +151,15 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
151
151
  request's (`read`); any other goes on as written, so the agent can say
152
152
  the tool doesn't exist.
153
153
  - Earlier calls and their results go back in as text.
154
- - Images aren't sent.
154
+ - Images: Trae's lists mark each model `multimodal` true or false (the
155
+ Doubao-Seed models, Kimi K3 and K2.6, Qwen 3.7 Plus and MiniMax M3 take
156
+ images; GLM-5 and DeepSeek V4 don't). A model marked true is listed
157
+ as one that takes images, and an image sent to it goes in the
158
+ message's content as an OpenAI `image_url` part beside the text, as
159
+ TRAE's own agent sends one (plugins#161). A model marked false gets
160
+ "[an image, which this model can't see]" in its place. A model whose
161
+ list hasn't been read is sent its images, unless the fallback list
162
+ says it takes none.
155
163
 
156
164
  The model list is the account's own, from
157
165
  `/api/ide/v1/batch_get_detail_param`, every function's list in one ask, as
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
  },
@@ -915,15 +919,36 @@ const partText = (content) => {
915
919
  .join("\n")
916
920
  }
917
921
 
918
- // traeMessages is the conversation as Trae's chat takes it
919
- function traeMessages(req) {
922
+ // imageOf is an image part of a message as Trae's chat takes one: OpenAI's
923
+ // image_url part, {url} in it, which is what TRAE's ai-agent sends
924
+ // (LLMRawMultiContent's image_url, LLMRawChatImageURL) and what the
925
+ // open-source relays pass llm_utils_chat unchanged. null for a part that
926
+ // isn't an image, or one with no url.
927
+ function imageOf(p) {
928
+ if (p?.type !== "image_url" && p?.type !== "input_image") return null
929
+ const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url ?? p.url
930
+ return typeof url === "string" && url ? { type: "image_url", image_url: { url } } : null
931
+ }
932
+
933
+ // partsOf is a message's text and, for a model that sees, its images apart
934
+ // from it; a model that doesn't is told in the text that one was there
935
+ const partsOf = (content, sees) => {
936
+ if (!sees || !Array.isArray(content)) return { text: partText(content), images: [] }
937
+ const images = content.map(imageOf).filter(Boolean)
938
+ return { text: partText(content.filter((p) => !imageOf(p))), images }
939
+ }
940
+
941
+ // traeMessages is the conversation as Trae's chat takes it. sees is whether
942
+ // the model takes images: Trae's list says so of each model (multimodal),
943
+ // and a model it says doesn't is sent the placeholder, not the image.
944
+ function traeMessages(req, sees = false) {
920
945
  const tools = Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none" ? req.tools : []
921
946
  const names = new Map()
922
947
  const out = []
923
948
  if (tools.length) out.push({ role: "system", text: toolPrompt(tools, req.tool_choice, req.parallel_tool_calls) })
924
949
  for (const m of req.messages ?? []) {
925
950
  const role = m.role === "developer" ? "system" : m.role
926
- let text = partText(m.content)
951
+ let { text, images } = partsOf(m.content, sees)
927
952
  if (role === "assistant") {
928
953
  const calls = (m.tool_calls ?? []).map((c) => {
929
954
  names.set(c.id, c.function?.name)
@@ -936,20 +961,24 @@ function traeMessages(req) {
936
961
  text = [text, ...calls].filter(Boolean).join("\n")
937
962
  out.push({ role: "assistant", text })
938
963
  } 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}` })
964
+ 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
965
  } else {
941
- out.push({ role: role === "system" ? "system" : "user", text })
966
+ out.push({ role: role === "system" ? "system" : "user", text, images })
942
967
  }
943
968
  }
944
- // Trae takes no empty message, nor two of one role in a row
969
+ // Trae takes no empty message, nor two of one role in a row; a message of
970
+ // an image alone is not empty
945
971
  const merged = []
946
972
  for (const m of out) {
947
- if (!m.text) continue
973
+ const images = m.images ?? []
974
+ if (!m.text && !images.length) continue
948
975
  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 })
976
+ if (last && last.role === m.role && m.role !== "system") {
977
+ last.text = [last.text, m.text].filter(Boolean).join("\n\n")
978
+ last.images = [...last.images, ...images]
979
+ } else merged.push({ role: m.role, text: m.text, images })
951
980
  }
952
- return merged.map((m) => ({ role: m.role, content: [{ type: "text", text: m.text }] }))
981
+ return merged.map((m) => ({ role: m.role, content: [...(m.text ? [{ type: "text", text: m.text }] : []), ...m.images] }))
953
982
  }
954
983
 
955
984
  // nativeTools are the tools as Trae names them: parameters a JSON string
@@ -1024,10 +1053,10 @@ function sessionOf(headers) {
1024
1053
  // tool result sent back, a retry, another function answering) shows up in the
1025
1054
  // usage detail as several, each under no title at all. request_id stays a
1026
1055
  // fresh id per request, being that request's own.
1027
- function chatBody(req, fn, modelName, most = 0, max = null, session = "") {
1056
+ function chatBody(req, fn, modelName, most = 0, max = null, session = "", sees = false) {
1028
1057
  const config = max?.base ?? req.model
1029
1058
  const body = {
1030
- messages: traeMessages(req),
1059
+ messages: traeMessages(req, sees),
1031
1060
  function: fn,
1032
1061
  config_name: config,
1033
1062
  model: config,
@@ -2270,6 +2299,12 @@ function chatModel(m, site) {
2270
2299
  return true
2271
2300
  }
2272
2301
 
2302
+ // seesOf is whether a list's entry says its model takes images: Trae's
2303
+ // lists mark each one multimodal true or false (Doubao-Seed, Kimi K3,
2304
+ // Qwen 3.7 Plus take them; GLM-5 and DeepSeek V4 don't). null when the
2305
+ // entry doesn't say.
2306
+ const seesOf = (m) => (typeof m?.multimodal === "boolean" ? m.multimodal : typeof m?.display_config?.multimodal === "boolean" ? m.display_config.multimodal : null)
2307
+
2273
2308
  // devModel is the model an entry serves requests with: its __dev one
2274
2309
  const devModel = (m) => (Array.isArray(m?.model_detail_list) ? m.model_detail_list : []).find((d) => /__dev$/.test(String(d?.model_name ?? "")))
2275
2310
 
@@ -2352,6 +2387,8 @@ const makePlugin = (site) => async ({ client }) => {
2352
2387
  const modelNames = new Map()
2353
2388
  // the Max each model the list last read has (null: none)
2354
2389
  const maxes = new Map()
2390
+ // whether each model the list last read takes images (its multimodal)
2391
+ const sight = new Map()
2355
2392
 
2356
2393
  const save = async (auth) => {
2357
2394
  try {
@@ -2595,6 +2632,9 @@ const makePlugin = (site) => async ({ client }) => {
2595
2632
  }
2596
2633
  for (const [id, { m, fn }] of out) {
2597
2634
  listedBy.set(id, fn)
2635
+ const sees = seesOf(m)
2636
+ if (sees === null) sight.delete(id)
2637
+ else sight.set(id, sees)
2598
2638
  const dev = devModel(m)
2599
2639
  if (dev) modelNames.set(id, { fn, name: dev.model_name, most: Number(dev.max_tokens) || 0 })
2600
2640
  else modelNames.delete(id)
@@ -2633,16 +2673,24 @@ const makePlugin = (site) => async ({ client }) => {
2633
2673
  const out = Number(devModel(m)?.max_tokens) || was.limit?.output || 0
2634
2674
  const name = m.display_config?.display_name || m.display_name || m.display_model_name || was.name || id
2635
2675
  const input = (k) => (MODEL.modalities?.input ?? []).includes(k)
2676
+ // the list's multimodal says whether the model takes images, over the
2677
+ // fallback list's word and MODEL's
2678
+ const sees = seesOf(m)
2679
+ const inputs = was.capabilities?.input ?? { text: true, image: input("image"), audio: input("audio"), video: input("video"), pdf: input("pdf") }
2680
+ const sight = sees === null ? {} : {
2681
+ modalities: { input: [...new Set([...((was.modalities ?? MODEL.modalities).input ?? ["text"]).filter((k) => k !== "image"), ...(sees ? ["image"] : [])])], output: (was.modalities ?? MODEL.modalities).output ?? ["text"] },
2682
+ attachment: sees,
2683
+ }
2636
2684
  return {
2637
- ...MODEL, ...was, id, providerID: site.id, name: String(name), limit: { context: ctx, output: out },
2685
+ ...MODEL, ...was, ...sight, id, providerID: site.id, name: String(name), limit: { context: ctx, output: out },
2638
2686
  api: was.api ?? { id, url: site.hosts.api, npm: "@ai-sdk/openai-compatible" },
2639
2687
  capabilities: {
2640
2688
  ...(was.capabilities ?? {}),
2641
2689
  reasoning: was.capabilities?.reasoning ?? MODEL.reasoning,
2642
2690
  temperature: was.capabilities?.temperature ?? MODEL.temperature,
2643
- attachment: was.capabilities?.attachment ?? MODEL.attachment,
2691
+ attachment: sees ?? was.capabilities?.attachment ?? MODEL.attachment,
2644
2692
  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") },
2693
+ input: sees === null ? inputs : { ...inputs, image: sees },
2646
2694
  output: was.capabilities?.output ?? { text: true, image: false, audio: false, video: false, pdf: false },
2647
2695
  },
2648
2696
  // Trae's request takes no effort or thinking level, so a model has
@@ -2739,6 +2787,10 @@ const makePlugin = (site) => async ({ client }) => {
2739
2787
  if (named) fns.unshift(named.fn)
2740
2788
  }
2741
2789
  const max = named?.max ?? null
2790
+ // a model the list hasn't said of is sent its images: the agent
2791
+ // sends them only to a model it was told takes them
2792
+ const base = max?.base ?? String(req.model)
2793
+ const sees = sight.get(base) ?? (site.models[base]?.modalities?.input?.includes("image") ?? true)
2742
2794
  // the answer's own signal: the caller's abort (a cancelled
2743
2795
  // request) and the stream's cancel both stop Trae's fetch, so
2744
2796
  // no read is left hanging on a connection the agent walked
@@ -2758,7 +2810,7 @@ const makePlugin = (site) => async ({ client }) => {
2758
2810
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
2759
2811
  method: "POST",
2760
2812
  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)),
2813
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees)),
2762
2814
  signal: ac.signal,
2763
2815
  })
2764
2816
  if (!res.ok) {
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.15",
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",