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

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 +21 -1
  2. package/index.mjs +106 -16
  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`,
@@ -126,7 +138,15 @@ a bigger `context_window_tokens.max` is listed again as its Max,
126
138
  can name the `__max` model, a SOLO list the windows). It asks the
127
139
  `__max` model through the function that names it, with `max_tokens` set
128
140
  and `user_message_context.model_info.prompt_max_tokens` the window less
129
- it, as Max mode does.
141
+ it, as Max mode does. The lists needn't agree on a model's *name*: chat_v3
142
+ calls deepseek-v4.1-flash "DeepSeek-V4-Flash 正式版", as it once called
143
+ DeepSeek-V4-Flash-Official, where the SOLO lists call it
144
+ "DeepSeek-V4.1-Flash" (yetone/magpie#681). Trae's usage page books a
145
+ request under the name of the list it was served through, so the plugin
146
+ takes the name most of a model's lists give — the one Trae's own client is
147
+ served under — and asks that model, and its Max, through a list carrying
148
+ it. Trae renamed its official entries on 2026-10-09, leaving the name its
149
+ lists mostly agree on the only thing that tells a misnamed entry apart.
130
150
 
131
151
  Reasoning: the models reason, but Trae's request takes no effort or
132
152
  thinking level, so the plugin lists no variants and a level an agent
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)
@@ -1984,24 +2042,40 @@ const makePlugin = (site) => async ({ client }) => {
1984
2042
  // deepseek-v4.1-flash "DeepSeek-V4-Flash 正式版", as it does
1985
2043
  // DeepSeek-V4-Flash-Official, where SOLO's lists call it
1986
2044
  // DeepSeek-V4.1-Flash (yetone/magpie#681) — Trae's usage page then books
1987
- // it under that name too. Such an entry is misnamed: its name is another
1988
- // model's in the same list, and other lists name it otherwise.
2045
+ // a request under the name of the list it was served through. Such an
2046
+ // entry is misnamed: its name is another model's in the same list, and
2047
+ // other lists name it otherwise.
1989
2048
  const nameOf = (m) => String(m.display_config?.display_name || m.display_name || m.display_model_name || m.config_name)
1990
- const names = new Map() // id → the names its lists give it
2049
+ const names = new Map() // id → the names its lists give it, and how many give each
1991
2050
  for (const fn of site.functions) {
1992
2051
  for (const m of lists[fn] ?? []) {
1993
2052
  if (!chatModel(m, site)) continue
1994
- const id = String(m.config_name)
1995
- names.set(id, (names.get(id) ?? new Set()).add(nameOf(m)))
2053
+ const id = String(m.config_name), name = nameOf(m)
2054
+ const by = names.get(id) ?? new Map()
2055
+ by.set(name, (by.get(name) ?? 0) + 1)
2056
+ names.set(id, by)
1996
2057
  }
1997
2058
  }
2059
+ // the name most of a model's lists give it: the one Trae's own client is
2060
+ // served under, and so the one its usage page books it as. Trae renames
2061
+ // entries when it likes, and its 2026-10-09 rename of
2062
+ // DeepSeek-V4-Flash-Official left chat_v3 the only list still calling
2063
+ // deepseek-v4.1-flash by another's name — which neither signal above
2064
+ // catches. A tie leaves the name to its entry.
2065
+ const agreed = new Map() // id → that name, "" when the lists tie
2066
+ for (const [id, by] of names) {
2067
+ const top = Math.max(...by.values())
2068
+ const most = [...by].filter(([, n]) => n === top)
2069
+ agreed.set(id, most.length === 1 ? most[0][0] : "")
2070
+ }
1998
2071
  const misnamed = (fn, m) => {
1999
2072
  const id = String(m.config_name), name = nameOf(m)
2000
2073
  return names.get(id).size > 1 && (lists[fn] ?? []).some((o) => chatModel(o, site) && String(o.config_name) !== id && nameOf(o) === name)
2001
2074
  }
2002
2075
  // a function's entry is worth more when it names a __dev model, then
2003
- // when it isn't misnamed; the first of the best is taken
2004
- const worth = (fn, m) => (devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1)
2076
+ // when it isn't misnamed, then when it carries the name its lists agree
2077
+ // on; the first of the best is taken
2078
+ const worth = (fn, m) => (devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1) + (nameOf(m) === agreed.get(String(m.config_name)) ? 1 : 0)
2005
2079
  const out = new Map()
2006
2080
  for (const fn of site.functions) {
2007
2081
  for (const m of lists[fn] ?? []) {
@@ -2017,8 +2091,11 @@ const makePlugin = (site) => async ({ client }) => {
2017
2091
  if (dev) modelNames.set(id, { fn, name: dev.model_name, most: Number(dev.max_tokens) || 0 })
2018
2092
  else modelNames.delete(id)
2019
2093
  if (out.has(id + MAX)) continue
2020
- // its Max is asked through the function whose entry names the __max model
2021
- const entries = [{ m, fn }, ...site.functions.flatMap((f) => (lists[f] ?? []).filter((o) => o !== m && chatModel(o, site) && String(o.config_name) === id).map((o) => ({ m: o, fn: f })))]
2094
+ // its Max is asked through the function whose entry names the __max
2095
+ // model, the entries carrying the agreed name first: Trae books the
2096
+ // Max under that name too
2097
+ const plain = (e) => (nameOf(e.m) === agreed.get(id) ? 0 : 1)
2098
+ const entries = [{ m, fn }, ...site.functions.flatMap((f) => (lists[f] ?? []).filter((o) => o !== m && chatModel(o, site) && String(o.config_name) === id).map((o) => ({ m: o, fn: f })))].sort((a, b) => plain(a) - plain(b))
2022
2099
  const max = maxModel(...entries.map((e) => e.m))
2023
2100
  maxes.set(id, max)
2024
2101
  if (max) {
@@ -2078,6 +2155,14 @@ const makePlugin = (site) => async ({ client }) => {
2078
2155
  models: { ...site.models, ...(was.models ?? {}) },
2079
2156
  }
2080
2157
  },
2158
+ // the conversation's id, which the requests below carry as their
2159
+ // session_id: magpie's (or OpenCode's) session for this request, when it
2160
+ // names one
2161
+ "chat.headers": async (input, output) => {
2162
+ const id = input?.model?.providerID ?? input?.provider?.info?.id
2163
+ if (id !== site.id || !input?.sessionID) return
2164
+ output.headers[SESSION] = String(input.sessionID).slice(0, 128)
2165
+ },
2081
2166
  provider: {
2082
2167
  id: site.id,
2083
2168
  // the account's own list, when Trae answers; else the list above
@@ -2156,11 +2241,16 @@ const makePlugin = (site) => async ({ client }) => {
2156
2241
  if (outer.aborted) ac.abort()
2157
2242
  else outer.addEventListener("abort", () => ac.abort(), { once: true })
2158
2243
  }
2244
+ // the conversation this request belongs to, from the chat.headers
2245
+ // hook (magpie's session) or the chat's own first user message, as
2246
+ // the account answering it, so a failover inside one conversation
2247
+ // is not one Trae reads as another's
2248
+ const session = conversationID(req, sessionOf(new Headers(init.headers ?? r0?.headers ?? {})), who)
2159
2249
  for (const fn of [...new Set(fns)]) {
2160
2250
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
2161
2251
  method: "POST",
2162
2252
  headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
2163
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max)),
2253
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session)),
2164
2254
  signal: ac.signal,
2165
2255
  })
2166
2256
  if (!res.ok) {
@@ -2211,4 +2301,4 @@ const makePlugin = (site) => async ({ client }) => {
2211
2301
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2212
2302
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2213
2303
 
2214
- 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 }
2304
+ 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.5",
3
+ "version": "0.2.7",
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",