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

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 +57 -11
  2. package/index.mjs +317 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -86,8 +86,9 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
86
86
  - The function is the one whose model list has the model, the first that
87
87
  names a `__dev` model for it (in the order `chat_v3`, the classic IDE's;
88
88
  `solo_work_lite`, SOLO's Work mode; `solo_agent`, the TRAE agent's;
89
- `solo_agent_lite`). If Trae answers 4001, 4023 or 1005, the plugin tries
90
- the others once and remembers which one worked.
89
+ `solo_agent_lite`; `solo_coder`, SOLO's coder roster). If Trae answers
90
+ 4001, 4023 or 1005, the plugin tries the others once and remembers which
91
+ one worked.
91
92
  - `session_id` is the conversation's, as the IDE keeps one per conversation:
92
93
  the session magpie (or OpenCode) names the request by, else the chat's
93
94
  first user message, which every later turn repeats, together with the
@@ -165,6 +166,14 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
165
166
  or is cut short, is never sent on as the answer: the reply ends with an
166
167
  error (a 502 without a stream), so the gateway can try again
167
168
  (plugins#38). A `<tool_call>` in backticks or a code fence is text.
169
+ - Kimi and MiMo sometimes write a call in the tags they were trained on:
170
+ Qwen's `<function=name><parameter=k>v</parameter></function>` (bare or
171
+ inside `<tool_call>`), or Anthropic's `<function_calls>` around one or
172
+ more `<invoke name="…"><parameter name="k">v</parameter></invoke>`.
173
+ Each is read as a call, a parameter's value as JSON unless the tool's
174
+ schema says string, and one cut short is an error as above. These
175
+ tags are read only for a tool the request declared; for any other, or
176
+ in backticks or a code fence, they stay text.
168
177
  - Calls from either source become OpenAI `tool_calls`. A name matching a
169
178
  requested tool but for case or punctuation (`Read`) goes on as the
170
179
  request's (`read`); any other goes on as written, so the agent can say
@@ -194,7 +203,10 @@ The model list is the account's own, from
194
203
  TRAE SOLO CN asks it (`/api/ide/v1/get_detail_param`, one function at a
195
204
  time, when the batch gives none). The lists are put together: SOLO and the
196
205
  TRAE agent list models `chat_v3` doesn't (deepseek-v4.1-flash is the TRAE
197
- agent's, `solo_agent`). Left out are the IDE's helpers (`usage` other than
206
+ agent's, `solo_agent`), and SOLO's coder roster lists models no other chat
207
+ function does (`glm-5v-turbo`, `kimi-k2.5`, `minimax-m2.7`,
208
+ `qwen-3.6-plus`, each answering through `solo_coder`). Left out are the
209
+ IDE's helpers (`usage` other than
198
210
  `chat_completion`: summary, fast_apply…), configs switched off and the
199
211
  custom-model slots. Context is the list's `context_window_tokens.dev`,
200
212
  output the `__dev` model's `max_tokens`. A model with a `__max` model and
@@ -214,14 +226,48 @@ served under — and asks that model, and its Max, through a list carrying
214
226
  it. Trae renamed its official entries on 2026-10-09, leaving the name its
215
227
  lists mostly agree on the only thing that tells a misnamed entry apart.
216
228
 
217
- Reasoning: the models reason, but Trae's request takes no effort or
218
- thinking level, so the plugin lists no variants and a level an agent
219
- picks does nothing. Every model it lists says so in its `capabilities`
220
- (`reasoning: true`, with `toolcall`, `temperature`, `attachment`, the
221
- input and output modalities and an empty `variants`), the fallback
222
- list's own capabilities kept where it has an entry: magpie reads
223
- `capabilities.reasoning` alone for a model a plugin lists, and a model
224
- without it is shown as one that doesn't reason (0.2.4).
229
+ Reasoning: the models reason, and each entry of the account's list says
230
+ which levels it takes — its `reasoning_effort_config`, with the options
231
+ (`light`, `high`, `extra_high`) and the default. The request takes the same
232
+ names as `reasoning_effort`, so a level an agent picks is sent as Trae
233
+ spells it: the plugin offers each model's own levels as `variants`
234
+ (`low`/`high`/`xhigh` for `light`/`high`/`extra_high`), and sends nothing
235
+ for a level the model doesn't name, or for a model whose entry names none —
236
+ Trae then serves it at its own default. An entry naming levels this map
237
+ can't spell offers none, rather than being sent one Trae would reject. A
238
+ request that wants levels for a model the list hasn't named reads the list,
239
+ and that read is remembered for five minutes whether it produced an entry or
240
+ not: a list that fails, or one that doesn't name the model, costs one read
241
+ rather than one per request. (A read costs up to two 20s timeouts when Trae's
242
+ list endpoint is slow, while the chat itself needs no list to answer.) A read
243
+ in flight is shared, not skipped: requests that arrive together — a title
244
+ request beside a session's first turn, subagents — ride the one read and all
245
+ see what it named, so a Max asked that way is still sent under its base
246
+ model's name rather than the agent's own `-max` id. The host's own catalog
247
+ refresh rides the same read, so a refresh and a request never ask twice.
248
+ A request's read swallows an expiry (the chat can still answer); the
249
+ catalog's refresh raises it, so a lapsed account is still marked — including
250
+ when the refresh merely rides a read a request started, which is why the lapse
251
+ travels on the read's record instead of being thrown from it. The host's
252
+ refresh is strict, so it reads again rather than serving a cached lapse: a
253
+ re-sign-in is asked afresh instead of being told again that the account lapsed.
254
+
255
+ The entry kept for a model is the one the request is asked through, and so
256
+ the one whose levels the request is held to, so the lists' disagreement
257
+ about a model's levels matters: `computer_use_subagent`, for one, is named
258
+ with `light`/`high` by `solo_agent` and `solo_agent_lite` and with none by
259
+ `solo_work_lite`. The three signals that pick the entry — its `__dev`
260
+ model, its not carrying another model's name, its carrying the name the
261
+ lists agree on — decide first; of entries that tie on all three, one that
262
+ names levels is kept, so a model Trae serves with levels is not left
263
+ without them by a sibling entry that names none.
264
+
265
+ Every model it lists also says it reasons in its `capabilities`
266
+ (`reasoning: true`, with `toolcall`, `temperature`, `attachment` and the
267
+ input and output modalities), the fallback list's own capabilities kept
268
+ where it has an entry: magpie reads `capabilities.reasoning` alone for a
269
+ model a plugin lists, and a model without it is shown as one that doesn't
270
+ reason (0.2.4).
225
271
 
226
272
  When no
227
273
  list can be read, the plugin uses the realm's own fallback list. Trae CN's
package/index.mjs CHANGED
@@ -70,8 +70,10 @@ const SITES = {
70
70
  brand: "ASUS TUF Gaming A15 FA507RM_FA507RM",
71
71
  // the IDE's chat functions: the classic IDE's agent, then SOLO's Work
72
72
  // mode, then the TRAE agent (solo_agent, which has deepseek-v4.1-flash),
73
- // then SOLO Lite's agent
74
- functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite"],
73
+ // then SOLO Lite's agent, then SOLO's coder roster, which lists models no
74
+ // other chat function does (glm-5v-turbo, kimi-k2.5, minimax-m2.7,
75
+ // qwen-3.6-plus — each answers through it)
76
+ functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite", "solo_coder"],
75
77
  // what Trae CN's chat_v3 is known to serve; the live list replaces it
76
78
  // (the MODEL defaults are spread in at the end of the module, once MODEL
77
79
  // is defined, so a site's entry only names what differs)
@@ -172,7 +174,7 @@ const SITES = {
172
174
  clientVersionCode: "20260917",
173
175
  pluginVersion: "2.3.62834",
174
176
  brand: "ASUS TUF Gaming A15 FA507RM_FA507RM",
175
- functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite"],
177
+ functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite", "solo_coder"],
176
178
  // what Trae Global's chat is known to serve (its catalog barely overlaps
177
179
  // the CN one's); the live list replaces it
178
180
  models: {
@@ -283,6 +285,13 @@ const SIGN_IN_TIMEOUT = 10 * 60 * 1000
283
285
  const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
284
286
  const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
285
287
  const DAY = 24 * 3600 * 1000
288
+ // REREAD_MS is how long a list read keeps its answer before a request that
289
+ // wants levels may read the list again: it holds whether the read produced an
290
+ // entry for the model or not, so a list answering badly (or one that doesn't
291
+ // name the model) costs one read, not one per request. A request path that
292
+ // reads on every attempt would otherwise pay two 20s timeouts on each turn —
293
+ // the branch costs the chat itself nothing to answer from.
294
+ const REREAD_MS = 5 * 60 * 1000
286
295
 
287
296
  // a reply limit of 0 is one Trae doesn't say: magpie then takes models.dev's
288
297
  // for the model (magpie's catalog), where a made-up 32K told agents a far
@@ -1168,7 +1177,12 @@ function sessionOf(headers) {
1168
1177
  // tool result sent back, a retry, another function answering) shows up in the
1169
1178
  // usage detail as several, each under no title at all. request_id stays a
1170
1179
  // fresh id per request, being that request's own.
1171
- function chatBody(req, fn, modelName, most = 0, max = null, session = "", sees = false) {
1180
+ //
1181
+ // levels is the model's own levels (levelsOf of its entry): the effort the
1182
+ // request asks for is sent as the Trae name that model takes, and left out
1183
+ // when it takes none of that name — Trae rejects a level a model has no entry
1184
+ // for, and serves its default when none is asked.
1185
+ function chatBody(req, fn, modelName, most = 0, max = null, session = "", sees = false, levels = null) {
1172
1186
  const config = max?.base ?? req.model
1173
1187
  const body = {
1174
1188
  messages: traeMessages(req, sees),
@@ -1180,6 +1194,8 @@ function chatBody(req, fn, modelName, most = 0, max = null, session = "", sees =
1180
1194
  request_id: randomUUID(),
1181
1195
  session_id: session || randomUUID(),
1182
1196
  }
1197
+ const effort = effortOf(levels, req.reasoning_effort ?? req.reasoningEffort)
1198
+ if (effort) body.reasoning_effort = effort
1183
1199
  const asked = req.max_completion_tokens ?? req.max_tokens
1184
1200
  if (Number.isFinite(asked) && asked > 0) body.max_tokens = most > 0 ? Math.min(Math.floor(asked), most) : Math.floor(asked)
1185
1201
  if (max?.window) {
@@ -1332,7 +1348,8 @@ class TextTools {
1332
1348
  const r = this.buf.search(REASON_AT)
1333
1349
  const sm = this.buf.match(STRAY_AT)
1334
1350
  const s = sm ? sm.index : -1
1335
- if (s >= 0 && [i, d, r].every((x) => x < 0 || s < x)) {
1351
+ const x = this.buf.search(XML_AT)
1352
+ if (s >= 0 && [i, d, r, x].every((y) => y < 0 || s < y)) {
1336
1353
  // a close with no call open, with what GLM left before it
1337
1354
  // (}}</tool_call>, magpie#1945): not the answer, dropped; in code,
1338
1355
  // shown
@@ -1351,7 +1368,7 @@ class TextTools {
1351
1368
  this.after = true
1352
1369
  continue
1353
1370
  }
1354
- if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
1371
+ if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d) && (x < 0 || r < x)) {
1355
1372
  const o = objectEnd(this.buf, r)
1356
1373
  if (o === -1 && !end) {
1357
1374
  // not whole yet: held, the text before it sent
@@ -1371,7 +1388,39 @@ class TextTools {
1371
1388
  this.buf = this.buf.slice(r + 1)
1372
1389
  continue
1373
1390
  }
1374
- if (i < 0 && d < 0) break
1391
+ if (i < 0 && d < 0 && x < 0) break
1392
+ if (x >= 0 && (i < 0 || x < i) && (d < 0 || x < d)) {
1393
+ // Anthropic's <function_calls><invoke name="…"> or Qwen's
1394
+ // <function=…>, as Kimi and MiMo write a call (01huadalang on
1395
+ // magpie's Discord, dsh): a call only for a tool the request
1396
+ // declared, and never in a code fence or backticks
1397
+ const head = this.buf.slice(0, x)
1398
+ const at = this.buf.slice(x)
1399
+ const opener = at.match(/^<[^<>]*>?/)[0]
1400
+ if (quoted(this.sent + text + head)) {
1401
+ text += head + opener
1402
+ this.buf = at.slice(opener.length)
1403
+ continue
1404
+ }
1405
+ const b = this.xmlCall(at, end)
1406
+ if (b.wait) break
1407
+ if (b.text) {
1408
+ text += head + opener
1409
+ this.buf = at.slice(opener.length)
1410
+ continue
1411
+ }
1412
+ text += head
1413
+ if (b.error) {
1414
+ this.buf = ""
1415
+ this.sent += text
1416
+ return { text, calls, reasoning, error: b.error }
1417
+ }
1418
+ calls.push(b.call)
1419
+ this.mark = this.sent.length + text.length
1420
+ this.buf = b.rest
1421
+ this.after = true
1422
+ continue
1423
+ }
1375
1424
  if (d >= 0 && (i < 0 || d < i)) {
1376
1425
  text += this.buf.slice(0, d)
1377
1426
  this.buf = this.buf.slice(d)
@@ -1462,7 +1511,7 @@ class TextTools {
1462
1511
  this.buf = ""
1463
1512
  } else {
1464
1513
  // keep back an opened block, or what may be the start of one
1465
- 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)
1514
+ const at = [this.buf.search(OPEN_AT), this.buf.search(OPEN_HOLD), this.buf.search(DSML_AT), this.buf.search(REASON_AT), this.buf.search(XML_AT), this.buf.search(XML_HOLD)].filter((x) => x >= 0)
1466
1515
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
1467
1516
  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 }
1468
1517
  // and braces, brackets, quotes or a > a close may still come after
@@ -1475,6 +1524,60 @@ class TextTools {
1475
1524
  return { text, calls, reasoning }
1476
1525
  }
1477
1526
 
1527
+ // xmlCall reads a call written in Anthropic's tags (<function_calls>
1528
+ // around one or more <invoke name="…"><parameter name="k">v</parameter>
1529
+ // </invoke>) or Qwen's (<function=…><parameter=k>v</parameter>
1530
+ // </function>), s starting at its first tag: {call, rest}, {wait}
1531
+ // while it may still become one, {text} when it is no call (a tool the
1532
+ // request didn't declare, a tag named in prose), {error} for one cut
1533
+ // short. Each invoke is a call of its own; the tags between them are
1534
+ // dropped as the leftovers of one.
1535
+ xmlCall(s, end) {
1536
+ const w = s.match(/^<(?:antml:)?function_calls>/)
1537
+ if (w) {
1538
+ const rest = s.slice(w[0].length).replace(/^\s+/, "")
1539
+ if (!end && (!rest || XML_STARTS.some((k) => k.startsWith(rest)))) return { wait: true }
1540
+ return this.xmlInvoke(rest, end)
1541
+ }
1542
+ return this.xmlInvoke(s, end)
1543
+ }
1544
+
1545
+ xmlInvoke(s, end) {
1546
+ let m = s.match(/^<(?:antml:)?invoke\s+name\s*=\s*"([^"<>]+)"\s*>/)
1547
+ let closeRe = /<\/(?:antml:)?invoke\s*>/
1548
+ if (!m) {
1549
+ m = s.match(/^<function=([^\s<>]+?)\s*>/)
1550
+ closeRe = /<\/function\s*>/
1551
+ }
1552
+ if (!m) {
1553
+ // an opener still being written
1554
+ if (!end && (XML_STARTS.some((k) => k.startsWith(s)) || /^<(?:antml:)?(?:invoke\s|function=)[^<>]{0,300}$/.test(s))) return { wait: true }
1555
+ return { text: true }
1556
+ }
1557
+ const declared = this.tools.map((t) => (t.function ?? t)?.name).filter(Boolean)
1558
+ const name = toolNamed(m[1].trim(), this.tools)
1559
+ if (!declared.includes(name)) return { text: true }
1560
+ const body = s.slice(m[0].length)
1561
+ const c = body.match(closeRe)
1562
+ const n = body.search(XML_NEXT)
1563
+ let stop = c ? c.index : -1
1564
+ if (n >= 0 && (stop < 0 || n < stop)) stop = n
1565
+ if (stop < 0) {
1566
+ if (!end) return { wait: true }
1567
+ stop = body.length
1568
+ }
1569
+ const whole = !!c && c.index === stop
1570
+ const region = body.slice(0, stop).replace(/<(\/?)antml:parameter\b/g, "<$1parameter")
1571
+ const p = paramArgs(region, name, this.tools)
1572
+ if (!whole && !p.closed) {
1573
+ // no parameter and no close: a tag named in passing; a parameter
1574
+ // left open: the call cut short (plugins#38)
1575
+ if (!/<parameter\b/.test(region)) return { text: true }
1576
+ return { error: CUT_SHORT }
1577
+ }
1578
+ return { call: { name, arguments: JSON.stringify(p.args) }, rest: whole ? body.slice(stop + c[0].length) : body.slice(stop) }
1579
+ }
1580
+
1478
1581
  // block reads what follows a <tool_call> opener: {call, rest} for a
1479
1582
  // call, {wait} while it may still become one, {text} when it is no call
1480
1583
  // at all, {error} for one that can't be read: cut short, or not JSON
@@ -1960,11 +2063,18 @@ const DSML_INVOKE = new RegExp(String.raw`^<\s*[||][\s||]*DSML[\s||]*invok
1960
2063
  const DSML_INVOKE_AT = new RegExp(String.raw`<\s*[||][\s||]*DSML[\s||]*invoke\s+name\s*=`)
1961
2064
  const DSML_CLOSE = new RegExp(String.raw`<\s*\/\s*[||][\s||]*DSML[\s||]*invoke\s*>`)
1962
2065
  const DSML_PARAM = new RegExp(String.raw`<\s*[||][\s||]*DSML[\s||]*parameter\s+name\s*=\s*"([^"]+)"((?:\s+\w+\s*=\s*"[^"]*")*)\s*>([\s\S]*?)` + DSML + String.raw`parameter\s*>`, "g")
2066
+ // Anthropic's and Qwen's call tags (xmlCall): where one starts, an
2067
+ // opener still being written, the openers a wrapper is followed by, and
2068
+ // the next call's opener
2069
+ const XML_AT = /<(?:antml:)?(?:function_calls>|invoke\s+name\s*=|function=)/
2070
+ const XML_HOLD = /<(?:antml:)?(?:invoke\s[^<>]{0,300}|function=[^<>]{0,300})$/
2071
+ const XML_STARTS = ["<invoke ", "<antml:invoke ", "<function="]
2072
+ const XML_NEXT = /<(?:antml:)?invoke\s+name\s*=|<function=/
1963
2073
  // what is left after a call: blanks, our close, DeepSeek's tags but the
1964
- // next call's
1965
- const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_?calls?>|<tool_?calls>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
2074
+ // next call's, the closes of Anthropic's and Qwen's
2075
+ const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_?calls?>|<tool_?calls>|<\/(?:antml:)?(?:function_calls|invoke|function)>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
1966
2076
  // what text may be the start of, so held back
1967
- const MARKS = [OPEN, CLOSE, "<toolcall>", "</toolcall>", "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|", '{"reasoning_content"']
2077
+ const MARKS = [OPEN, CLOSE, "<toolcall>", "</toolcall>", "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|", '{"reasoning_content"', "<function_calls>", "</function_calls>", "<antml:function_calls>", "</antml:function_calls>", "<invoke ", "<antml:invoke ", "</invoke>", "</antml:invoke>", "<function=", "</function>"]
1968
2078
  // reasoning a model wrote into its text as JSON
1969
2079
  const REASON_AT = /\{\s*"reasoning_content"\s*:/
1970
2080
  const MARK_MAX = Math.max(...MARKS.map((m) => m.length))
@@ -2456,6 +2566,55 @@ const maxModel = (...ms) => {
2456
2566
  // deepseek-v4.1-flash in Max mode
2457
2567
  const MAX = "-max"
2458
2568
 
2569
+ // ---- reasoning levels --------------------------------------------------------------
2570
+ //
2571
+ // A list entry carries a reasoning_effort_config: the levels its model takes
2572
+ // (light, high, extra_high) and the one it defaults to. The request takes the
2573
+ // same names, as reasoning_effort, so a level an agent picks is sent as Trae
2574
+ // spells it. OpenCode knows the three as low/high/xhigh, so the map goes both
2575
+ // ways.
2576
+ //
2577
+ // An entry without the config takes no level: Trae serves it at its own
2578
+ // default and nothing is sent. The plugin used to send none for every model,
2579
+ // which read as "Trae takes no level" — it takes them, per model, and an
2580
+ // entry that names none is the exception rather than the rule.
2581
+ const LEVELS = { light: "low", high: "high", extra_high: "xhigh" }
2582
+
2583
+ // levelsOf is an entry's levels in OpenCode's names, in Trae's order: [] when
2584
+ // the entry names none, null when it names ones this map can't spell (so a
2585
+ // caller can tell "takes no level" from "takes a level we can't name").
2586
+ const levelsOf = (m) => {
2587
+ const cfg = m?.reasoning_effort_config
2588
+ if (!cfg || typeof cfg !== "object") return []
2589
+ const options = Array.isArray(cfg.options) ? cfg.options : []
2590
+ const known = options.filter((o) => typeof o === "string" && LEVELS[o])
2591
+ return known.length ? known.map((o) => LEVELS[o]) : options.length ? null : []
2592
+ }
2593
+
2594
+ // variantsOf is a model's levels as OpenCode's variants. The value is the
2595
+ // effort OpenCode puts in the request body, so it names the level the way this
2596
+ // plugin's levels do: an agent in OpenCode writes `reasoning_effort` straight
2597
+ // from it, and effortOf below turns that name into Trae's. Naming Trae's
2598
+ // spelling here would make the body say "light", which is not one of the
2599
+ // levels this plugin offers, and the field would then be dropped. {} when the
2600
+ // model takes none.
2601
+ const variantsOf = (levels) => Object.fromEntries((levels ?? []).map((level) => [level, { reasoningEffort: level }]))
2602
+
2603
+ // effortOf is the name to send Trae for an effort the request carries, or
2604
+ // undefined when the model doesn't take that one: a level Trae has no entry for
2605
+ // is rejected, so nothing is sent instead. The effort arrives in either
2606
+ // spelling — this plugin's own (low/high/xhigh) from its variants, or Trae's
2607
+ // (light/extra_high) from a client that read them off the list — and both name
2608
+ // the same level.
2609
+ const effortOf = (levels, effort) => {
2610
+ if (typeof effort !== "string" || !levels) return undefined
2611
+ const mine = Object.values(LEVELS).includes(effort) ? effort
2612
+ : Object.prototype.hasOwnProperty.call(LEVELS, effort) ? LEVELS[effort]
2613
+ : undefined
2614
+ if (mine === undefined || !levels.includes(mine)) return undefined
2615
+ return Object.keys(LEVELS).find((k) => LEVELS[k] === mine)
2616
+ }
2617
+
2459
2618
  // ---- usage ------------------------------------------------------------------------
2460
2619
 
2461
2620
  // credits adds up the account's entitlement packs as the IDE shows them:
@@ -2516,6 +2675,88 @@ const makePlugin = (site) => async ({ client }) => {
2516
2675
  const maxes = new Map()
2517
2676
  // whether each model the list last read takes images (its multimodal)
2518
2677
  const sight = new Map()
2678
+ // the entry the lists last gave each model, for what only the entry says:
2679
+ // the reasoning levels it takes (reasoning_effort_config)
2680
+ const liveEntry = new Map()
2681
+ const liveEntryOf = (id) => liveEntry.get(String(id)) ?? site.models?.[String(id)]?.entry ?? null
2682
+ // when each account's list was last read, and what that read returned:
2683
+ // {at, p, done, expired}. A read that answered, failed or hung all leave
2684
+ // their stamp, so a list that fails or leaves the model out costs one read
2685
+ // per REREAD_MS rather than one per request. The promise is kept beside the
2686
+ // stamp because a caller that arrives while a read is in flight must ride
2687
+ // THAT read, not start a second and not skip the answer it was about to
2688
+ // learn: agents send parallel requests at a session's start (a title request
2689
+ // beside the first turn, subagents), and a caller that skipped the entry a
2690
+ // read just produced would send a Max out under the agent's own id, which
2691
+ // Trae's list doesn't name.
2692
+ //
2693
+ // The record is the thing the promise resolves to, so a joiner sees the same
2694
+ // outcome the read settled on — including an expiry it hit — rather than the
2695
+ // bare `null` a failed read answers. One account per key; a read is that
2696
+ // account's.
2697
+ const readAt = new Map()
2698
+ // runRead starts the one read behind readList and answers its record. `p`
2699
+ // resolves to that record — not to the models — so a caller that joins a
2700
+ // read in flight sees the same outcome the read settled on, an expiry
2701
+ // included, rather than the bare null a failed read answers.
2702
+ const runRead = (a, who) => {
2703
+ const rec = { at: Date.now(), p: null, done: undefined, expired: null }
2704
+ // the record goes in BEFORE the read starts, so a caller arriving during it
2705
+ // finds `rec.p` and joins; `p` resolves to this same record
2706
+ readAt.set(who, rec)
2707
+ const body = (async () => {
2708
+ try {
2709
+ return await liveModels(a)
2710
+ } catch (e) {
2711
+ // a request's read swallows this (the chat can still answer); a caller
2712
+ // that must hear it reads `expired` off the record. Recorded on the
2713
+ // record, so a joiner hears it too.
2714
+ if (e instanceof Expired) rec.expired = e
2715
+ return null
2716
+ }
2717
+ })()
2718
+ // every rejection is claimed here, at creation: a read nobody awaits (the
2719
+ // host's refresh ending early, a caller that walked away) must not surface
2720
+ // as an unhandled rejection. `p` itself never rejects — the expiry rides on
2721
+ // the record instead.
2722
+ body.catch(() => {})
2723
+ rec.p = body.then((models) => {
2724
+ // only this read settles its own record: a later read may have replaced it
2725
+ if (readAt.get(who) === rec) {
2726
+ rec.at = Date.now()
2727
+ rec.done = models ?? []
2728
+ rec.p = null
2729
+ }
2730
+ return rec
2731
+ })
2732
+ rec.p.catch(() => {})
2733
+ return rec.p
2734
+ }
2735
+ // readList answers the models of the read this caller rode on (its own, or
2736
+ // the one in flight, whose record it waits for and then reads), and whether
2737
+ // that read found the account lapsed. The record is the account's, not the
2738
+ // token's: a re-sign-in is the same account's list, and the host's refresh is
2739
+ // strict, so it reads again rather than serving a cached lapse. A request
2740
+ // path only reaches here for a model it has no entry for, and that read is
2741
+ // the one that fills the entry in.
2742
+ //
2743
+ // The lapse is answered, not thrown: several callers can ride one read, and a
2744
+ // throw out of a shared promise surfaces once as an unhandled rejection for
2745
+ // the callers that did not expect it. Each caller decides — provider.models
2746
+ // raises it, a request path keeps going. `strict` only says whether a cached
2747
+ // lapse is worth reading again for (the host wants the truth; a request does
2748
+ // not pay a round trip to learn it).
2749
+ const readList = async (a, { strict = false } = {}) => {
2750
+ const who = a.uid || a.name || String(site.id)
2751
+ const was = readAt.get(who)
2752
+ let rec
2753
+ if (was === undefined) rec = await runRead(a, who)
2754
+ else if (was.p) rec = await was.p
2755
+ else if (was.done === undefined || Date.now() - was.at >= REREAD_MS) rec = await runRead(a, who)
2756
+ else if (strict && was.expired) rec = await runRead(a, who)
2757
+ else rec = was
2758
+ return { models: rec.done, expired: rec.expired }
2759
+ }
2519
2760
 
2520
2761
  const save = async (auth) => {
2521
2762
  try {
@@ -2786,8 +3027,16 @@ const makePlugin = (site) => async ({ client }) => {
2786
3027
  }
2787
3028
  // a function's entry is worth more when it names a __dev model, then
2788
3029
  // when it isn't misnamed, then when it carries the name its lists agree
2789
- // on; the first of the best is taken
2790
- const worth = (fn, m) => (devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1) + (nameOf(m) === agreed.get(String(m.config_name)) ? 1 : 0)
3030
+ // on; the first of the best is taken. Those three decide first, so the
3031
+ // entry kept is the one Trae's own client asks through.
3032
+ //
3033
+ // Last among equals comes whether the entry names reasoning levels: the
3034
+ // entry kept is the one the request is asked through, and so the one
3035
+ // whose reasoning_effort_config the request is held to (liveEntryOf). An
3036
+ // entry that names none while a sibling names some would leave the model
3037
+ // with no level to pick although Trae serves it with levels — doubling
3038
+ // the three keeps them decisive and lets this only break a tie.
3039
+ const worth = (fn, m) => ((devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1) + (nameOf(m) === agreed.get(String(m.config_name)) ? 1 : 0)) * 2 + (levelsOf(m)?.length ? 1 : 0)
2791
3040
  const out = new Map()
2792
3041
  for (const fn of site.functions) {
2793
3042
  for (const m of lists[fn] ?? []) {
@@ -2799,6 +3048,7 @@ const makePlugin = (site) => async ({ client }) => {
2799
3048
  }
2800
3049
  for (const [id, { m, fn }] of out) {
2801
3050
  listedBy.set(id, fn)
3051
+ liveEntry.set(id, m)
2802
3052
  const sees = seesOf(m)
2803
3053
  if (sees === null) sight.delete(id)
2804
3054
  else sight.set(id, sees)
@@ -2822,7 +3072,17 @@ const makePlugin = (site) => async ({ client }) => {
2822
3072
  modelNames.delete(id + MAX)
2823
3073
  }
2824
3074
  }
2825
- return [...out.values()].map((x) => x.m)
3075
+ // this account's list has just answered: recorded, so a request that wants
3076
+ // levels for a model the list doesn't name waits REREAD_MS before reading
3077
+ // again, and a request arriving meanwhile joins this read instead of
3078
+ // asking — the catalog read a host makes counts as that read too
3079
+ const models = [...out.values()].map((x) => x.m)
3080
+ const who = a.uid || a.name || String(site.id)
3081
+ const at = readAt.get(who)
3082
+ // a readList run records itself; a read nobody recorded (a caller reaching
3083
+ // liveModels directly) is recorded here, unless a read is in flight
3084
+ if (at === undefined || !at.p) readAt.set(who, { at: Date.now(), p: null, done: models, expired: null })
3085
+ return models
2826
3086
  }
2827
3087
 
2828
3088
  // modelOf is a model of the account's list as OpenCode's provider
@@ -2860,9 +3120,13 @@ const makePlugin = (site) => async ({ client }) => {
2860
3120
  input: sees === null ? inputs : { ...inputs, image: sees },
2861
3121
  output: was.capabilities?.output ?? { text: true, image: false, audio: false, video: false, pdf: false },
2862
3122
  },
2863
- // Trae's request takes no effort or thinking level, so a model has
2864
- // none to pick from (see the README's Reasoning)
2865
- variants: was.variants ?? {},
3123
+ // the levels this entry takes, as OpenCode's variants: Trae's own
3124
+ // reasoning_effort_config, which a model without one doesn't carry (it
3125
+ // is then served at Trae's default, with no level to pick). A config
3126
+ // model with an empty variants says nothing — magpie's host fills one in
3127
+ // for every model it hands over — so only a non-empty one is kept, or a
3128
+ // model in the fallback table would lose the levels its entry names.
3129
+ variants: Object.keys(was.variants ?? {}).length ? was.variants : variantsOf(levelsOf(m)),
2866
3130
  }
2867
3131
  }
2868
3132
 
@@ -2888,11 +3152,19 @@ const makePlugin = (site) => async ({ client }) => {
2888
3152
  },
2889
3153
  provider: {
2890
3154
  id: site.id,
2891
- // the account's own list, when Trae answers; else the list above
3155
+ // the account's own list, when Trae answers; else the list above.
3156
+ // A refresh rides readList's one read per REREAD_MS, so a request that
3157
+ // wants levels while the host is refreshing joins that read instead of
3158
+ // asking for the list a second time (and vice versa).
2892
3159
  async models(provider, { auth }) {
2893
3160
  if (auth?.type !== "oauth" || !auth.access) return provider.models
2894
3161
  try {
2895
- const ms = await liveModels(await fresh(async () => auth))
3162
+ const { models: ms, expired } = await readList(await fresh(async () => auth), { strict: true })
3163
+ // a lapse must reach the host here, so the account is marked: a
3164
+ // request path deliberately lets it pass instead. A fresh instance:
3165
+ // the recorded one already served as a rejection inside the read, and
3166
+ // re-throwing that same object carries its unhandled state with it
3167
+ if (expired) throw new Expired(expired.message)
2896
3168
  if (!ms.length) return provider.models
2897
3169
  const ids = new Set(ms.map((m) => String(m.config_name)))
2898
3170
  return Object.fromEntries(ms.flatMap((m) => {
@@ -2949,8 +3221,12 @@ const makePlugin = (site) => async ({ client }) => {
2949
3221
  let last
2950
3222
  let named = modelNames.get(String(req.model))
2951
3223
  if (!named && String(req.model).endsWith(MAX)) {
2952
- // a Max asked before the list was read here: read it
2953
- await liveModels(a).catch(() => {})
3224
+ // a Max asked before the list was read here: read it (or ride
3225
+ // the read already in flight), once per REREAD_MS — a list that
3226
+ // doesn't name this Max is not read for again on every request
3227
+ // (the chat can answer without it), and a Max the read does name
3228
+ // is asked through its base, not under this unlisted id
3229
+ await readList(a)
2954
3230
  named = modelNames.get(String(req.model))
2955
3231
  if (named) fns.unshift(named.fn)
2956
3232
  }
@@ -2959,6 +3235,24 @@ const makePlugin = (site) => async ({ client }) => {
2959
3235
  // sends them only to a model it was told takes them
2960
3236
  const base = max?.base ?? String(req.model)
2961
3237
  const sees = sight.get(base) ?? (site.models[base]?.modalities?.input?.includes("image") ?? true)
3238
+ // the levels the base model takes, from the entry its lists gave
3239
+ // it: what the request's effort is sent as, and left out when the
3240
+ // model has no entry for it. An entry naming levels this build
3241
+ // can't spell counts as no level here — levelsOf says null for it
3242
+ // so a caller can tell the two apart, and this one sends nothing
3243
+ // either way.
3244
+ //
3245
+ // The list is read once per REREAD_MS for a model it hasn't named,
3246
+ // and a read already in flight is shared rather than skipped: a
3247
+ // parallel request that skipped it would drop its level for that
3248
+ // turn. An answer that leaves the model out — a list that failed,
3249
+ // or one that simply doesn't name it — is remembered too, instead
3250
+ // of being asked for again on every request that carries an effort.
3251
+ let levels = levelsOf(liveEntryOf(base)) ?? []
3252
+ if (!liveEntry.has(base) && (req.reasoning_effort ?? req.reasoningEffort)) {
3253
+ await readList(a)
3254
+ levels = levelsOf(liveEntryOf(base)) ?? []
3255
+ }
2962
3256
  // the answer's own signal: the caller's abort (a cancelled
2963
3257
  // request) and the stream's cancel both stop Trae's fetch, so
2964
3258
  // no read is left hanging on a connection the agent walked
@@ -2978,7 +3272,7 @@ const makePlugin = (site) => async ({ client }) => {
2978
3272
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
2979
3273
  method: "POST",
2980
3274
  headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
2981
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees)),
3275
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees, levels)),
2982
3276
  signal: ac.signal,
2983
3277
  })
2984
3278
  if (!res.ok) {
@@ -3029,4 +3323,4 @@ const makePlugin = (site) => async ({ client }) => {
3029
3323
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
3030
3324
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
3031
3325
 
3032
- 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 }
3326
+ 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, LEVELS, levelsOf, variantsOf, effortOf }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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",