@magpie-community/opencode-trae-auth 0.2.19 → 0.2.21

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 +55 -14
  2. package/index.mjs +227 -22
  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
@@ -202,7 +203,10 @@ The model list is the account's own, from
202
203
  TRAE SOLO CN asks it (`/api/ide/v1/get_detail_param`, one function at a
203
204
  time, when the batch gives none). The lists are put together: SOLO and the
204
205
  TRAE agent list models `chat_v3` doesn't (deepseek-v4.1-flash is the TRAE
205
- 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
206
210
  `chat_completion`: summary, fast_apply…), configs switched off and the
207
211
  custom-model slots. Context is the list's `context_window_tokens.dev`,
208
212
  output the `__dev` model's `max_tokens`. A model with a `__max` model and
@@ -222,14 +226,48 @@ served under — and asks that model, and its Max, through a list carrying
222
226
  it. Trae renamed its official entries on 2026-10-09, leaving the name its
223
227
  lists mostly agree on the only thing that tells a misnamed entry apart.
224
228
 
225
- Reasoning: the models reason, but Trae's request takes no effort or
226
- thinking level, so the plugin lists no variants and a level an agent
227
- picks does nothing. Every model it lists says so in its `capabilities`
228
- (`reasoning: true`, with `toolcall`, `temperature`, `attachment`, the
229
- input and output modalities and an empty `variants`), the fallback
230
- list's own capabilities kept where it has an entry: magpie reads
231
- `capabilities.reasoning` alone for a model a plugin lists, and a model
232
- 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).
233
271
 
234
272
  When no
235
273
  list can be read, the plugin uses the realm's own fallback list. Trae CN's
@@ -369,9 +407,12 @@ answered it 9074 even with the client's headers.
369
407
  in today, and that holds for the day. No device id is ever changed or
370
408
  rotated to get round a 9095 or a 9074, and no account is given another
371
409
  account's device or its own client's device unless the client is signed
372
- in as that account. The credit a claim granted is its `credits` plus
373
- its `extra_credits`, either alone; the status answer's is the fallback
374
- when the claim says none.
410
+ in as that account. The credit a claim granted is its `credits`, or
411
+ its `extra_credits` when it names only that; Trae CN's claim usually
412
+ names neither (`{"code":0,"message":"success"}`), and then the status
413
+ answer's `credits` is the credit. The two are never added: the status
414
+ carries `extra_credits` beside `credits` (100 and 100 on a real
415
+ account) while the day gives 100.
375
416
 
376
417
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
377
418
  that isn't a chat request is still refused (400). Needs 0.1.4 or later.
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) {
@@ -2550,6 +2566,55 @@ const maxModel = (...ms) => {
2550
2566
  // deepseek-v4.1-flash in Max mode
2551
2567
  const MAX = "-max"
2552
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
+
2553
2618
  // ---- usage ------------------------------------------------------------------------
2554
2619
 
2555
2620
  // credits adds up the account's entitlement packs as the IDE shows them:
@@ -2610,6 +2675,88 @@ const makePlugin = (site) => async ({ client }) => {
2610
2675
  const maxes = new Map()
2611
2676
  // whether each model the list last read takes images (its multimodal)
2612
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
+ }
2613
2760
 
2614
2761
  const save = async (auth) => {
2615
2762
  try {
@@ -2755,13 +2902,18 @@ const makePlugin = (site) => async ({ client }) => {
2755
2902
  if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} — check in in the Trae CN app` }
2756
2903
  if (claim.status !== 200 || (ce.code && String(ce.code) !== "0")) return { outcome: "failed", message: ce.message || statusLine(claim.status) }
2757
2904
  if (!answer || typeof answer !== "object" || Array.isArray(answer)) return { outcome: "failed", message: `check-in claim: unreadable reply ${claimText.trim().slice(0, 120) || "(empty)"}` }
2758
- // a claim may answer extra_credits with no credits beside it, and either
2759
- // may be absent: Number(undefined) is NaN, and NaN + n is NaN, which
2760
- // reads as no credit at all
2905
+ // The credit is the day's credits, never credits plus extra_credits.
2906
+ // Trae CN's real claim answers {"code":0,"message":"success"}, with no
2907
+ // credit at all (plugins#194), so the status's credits stand in. Its
2908
+ // status carries extra_credits beside credits, at the same 100 on a
2909
+ // real account (yetone/magpie#808: credits 100, extra_credits 100),
2910
+ // while the account gets 100: adding the two showed 200 (zhujiu on
2911
+ // magpie's Discord, 10-25). A claim that names its own credit is
2912
+ // taken first; one naming extra_credits alone is that (#260). Either
2913
+ // may be absent, and Number(undefined) is NaN, which reads as none.
2761
2914
  const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
2762
- const credit = n(answer.credits) + n(answer.extra_credits)
2763
- const fallback = n(body.credits) + n(body.extra_credits)
2764
- return { outcome: "claimed", ...(credit > 0 ? { credit } : fallback > 0 ? { credit: fallback } : {}) }
2915
+ const credit = n(answer.credits) || n(answer.extra_credits) || n(body.credits)
2916
+ return { outcome: "claimed", ...(credit > 0 ? { credit } : {}) }
2765
2917
  }
2766
2918
 
2767
2919
  const usage = async (getAuth) => {
@@ -2880,8 +3032,16 @@ const makePlugin = (site) => async ({ client }) => {
2880
3032
  }
2881
3033
  // a function's entry is worth more when it names a __dev model, then
2882
3034
  // when it isn't misnamed, then when it carries the name its lists agree
2883
- // on; the first of the best is taken
2884
- const worth = (fn, m) => (devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1) + (nameOf(m) === agreed.get(String(m.config_name)) ? 1 : 0)
3035
+ // on; the first of the best is taken. Those three decide first, so the
3036
+ // entry kept is the one Trae's own client asks through.
3037
+ //
3038
+ // Last among equals comes whether the entry names reasoning levels: the
3039
+ // entry kept is the one the request is asked through, and so the one
3040
+ // whose reasoning_effort_config the request is held to (liveEntryOf). An
3041
+ // entry that names none while a sibling names some would leave the model
3042
+ // with no level to pick although Trae serves it with levels — doubling
3043
+ // the three keeps them decisive and lets this only break a tie.
3044
+ 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)
2885
3045
  const out = new Map()
2886
3046
  for (const fn of site.functions) {
2887
3047
  for (const m of lists[fn] ?? []) {
@@ -2893,6 +3053,7 @@ const makePlugin = (site) => async ({ client }) => {
2893
3053
  }
2894
3054
  for (const [id, { m, fn }] of out) {
2895
3055
  listedBy.set(id, fn)
3056
+ liveEntry.set(id, m)
2896
3057
  const sees = seesOf(m)
2897
3058
  if (sees === null) sight.delete(id)
2898
3059
  else sight.set(id, sees)
@@ -2916,7 +3077,17 @@ const makePlugin = (site) => async ({ client }) => {
2916
3077
  modelNames.delete(id + MAX)
2917
3078
  }
2918
3079
  }
2919
- return [...out.values()].map((x) => x.m)
3080
+ // this account's list has just answered: recorded, so a request that wants
3081
+ // levels for a model the list doesn't name waits REREAD_MS before reading
3082
+ // again, and a request arriving meanwhile joins this read instead of
3083
+ // asking — the catalog read a host makes counts as that read too
3084
+ const models = [...out.values()].map((x) => x.m)
3085
+ const who = a.uid || a.name || String(site.id)
3086
+ const at = readAt.get(who)
3087
+ // a readList run records itself; a read nobody recorded (a caller reaching
3088
+ // liveModels directly) is recorded here, unless a read is in flight
3089
+ if (at === undefined || !at.p) readAt.set(who, { at: Date.now(), p: null, done: models, expired: null })
3090
+ return models
2920
3091
  }
2921
3092
 
2922
3093
  // modelOf is a model of the account's list as OpenCode's provider
@@ -2954,9 +3125,13 @@ const makePlugin = (site) => async ({ client }) => {
2954
3125
  input: sees === null ? inputs : { ...inputs, image: sees },
2955
3126
  output: was.capabilities?.output ?? { text: true, image: false, audio: false, video: false, pdf: false },
2956
3127
  },
2957
- // Trae's request takes no effort or thinking level, so a model has
2958
- // none to pick from (see the README's Reasoning)
2959
- variants: was.variants ?? {},
3128
+ // the levels this entry takes, as OpenCode's variants: Trae's own
3129
+ // reasoning_effort_config, which a model without one doesn't carry (it
3130
+ // is then served at Trae's default, with no level to pick). A config
3131
+ // model with an empty variants says nothing — magpie's host fills one in
3132
+ // for every model it hands over — so only a non-empty one is kept, or a
3133
+ // model in the fallback table would lose the levels its entry names.
3134
+ variants: Object.keys(was.variants ?? {}).length ? was.variants : variantsOf(levelsOf(m)),
2960
3135
  }
2961
3136
  }
2962
3137
 
@@ -2982,11 +3157,19 @@ const makePlugin = (site) => async ({ client }) => {
2982
3157
  },
2983
3158
  provider: {
2984
3159
  id: site.id,
2985
- // the account's own list, when Trae answers; else the list above
3160
+ // the account's own list, when Trae answers; else the list above.
3161
+ // A refresh rides readList's one read per REREAD_MS, so a request that
3162
+ // wants levels while the host is refreshing joins that read instead of
3163
+ // asking for the list a second time (and vice versa).
2986
3164
  async models(provider, { auth }) {
2987
3165
  if (auth?.type !== "oauth" || !auth.access) return provider.models
2988
3166
  try {
2989
- const ms = await liveModels(await fresh(async () => auth))
3167
+ const { models: ms, expired } = await readList(await fresh(async () => auth), { strict: true })
3168
+ // a lapse must reach the host here, so the account is marked: a
3169
+ // request path deliberately lets it pass instead. A fresh instance:
3170
+ // the recorded one already served as a rejection inside the read, and
3171
+ // re-throwing that same object carries its unhandled state with it
3172
+ if (expired) throw new Expired(expired.message)
2990
3173
  if (!ms.length) return provider.models
2991
3174
  const ids = new Set(ms.map((m) => String(m.config_name)))
2992
3175
  return Object.fromEntries(ms.flatMap((m) => {
@@ -3043,8 +3226,12 @@ const makePlugin = (site) => async ({ client }) => {
3043
3226
  let last
3044
3227
  let named = modelNames.get(String(req.model))
3045
3228
  if (!named && String(req.model).endsWith(MAX)) {
3046
- // a Max asked before the list was read here: read it
3047
- await liveModels(a).catch(() => {})
3229
+ // a Max asked before the list was read here: read it (or ride
3230
+ // the read already in flight), once per REREAD_MS — a list that
3231
+ // doesn't name this Max is not read for again on every request
3232
+ // (the chat can answer without it), and a Max the read does name
3233
+ // is asked through its base, not under this unlisted id
3234
+ await readList(a)
3048
3235
  named = modelNames.get(String(req.model))
3049
3236
  if (named) fns.unshift(named.fn)
3050
3237
  }
@@ -3053,6 +3240,24 @@ const makePlugin = (site) => async ({ client }) => {
3053
3240
  // sends them only to a model it was told takes them
3054
3241
  const base = max?.base ?? String(req.model)
3055
3242
  const sees = sight.get(base) ?? (site.models[base]?.modalities?.input?.includes("image") ?? true)
3243
+ // the levels the base model takes, from the entry its lists gave
3244
+ // it: what the request's effort is sent as, and left out when the
3245
+ // model has no entry for it. An entry naming levels this build
3246
+ // can't spell counts as no level here — levelsOf says null for it
3247
+ // so a caller can tell the two apart, and this one sends nothing
3248
+ // either way.
3249
+ //
3250
+ // The list is read once per REREAD_MS for a model it hasn't named,
3251
+ // and a read already in flight is shared rather than skipped: a
3252
+ // parallel request that skipped it would drop its level for that
3253
+ // turn. An answer that leaves the model out — a list that failed,
3254
+ // or one that simply doesn't name it — is remembered too, instead
3255
+ // of being asked for again on every request that carries an effort.
3256
+ let levels = levelsOf(liveEntryOf(base)) ?? []
3257
+ if (!liveEntry.has(base) && (req.reasoning_effort ?? req.reasoningEffort)) {
3258
+ await readList(a)
3259
+ levels = levelsOf(liveEntryOf(base)) ?? []
3260
+ }
3056
3261
  // the answer's own signal: the caller's abort (a cancelled
3057
3262
  // request) and the stream's cancel both stop Trae's fetch, so
3058
3263
  // no read is left hanging on a connection the agent walked
@@ -3072,7 +3277,7 @@ const makePlugin = (site) => async ({ client }) => {
3072
3277
  const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
3073
3278
  method: "POST",
3074
3279
  headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
3075
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees)),
3280
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max, session, sees, levels)),
3076
3281
  signal: ac.signal,
3077
3282
  })
3078
3283
  if (!res.ok) {
@@ -3123,4 +3328,4 @@ const makePlugin = (site) => async ({ client }) => {
3123
3328
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
3124
3329
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
3125
3330
 
3126
- 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 }
3331
+ 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.19",
3
+ "version": "0.2.21",
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",