@magpie-community/opencode-trae-auth 0.0.0-stage → 0.1.0

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 +96 -2
  2. package/index.mjs +940 -0
  3. package/package.json +10 -4
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # @magpie-community/opencode-trae-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Signs in to **Trae CN** (trae.cn, ByteDance's AI IDE) with your Trae CN
4
+ account, the way the IDE does, and makes its model requests. The free tier
5
+ works as well. Provider id: `trae-cn`.
6
+
7
+ > **Experimental.** The protocol was worked out from two open-source relays,
8
+ > [wangqi233/trae2api](https://github.com/wangqi233/trae2api) and
9
+ > [autumnsentiment/Trae2api-cn](https://github.com/autumnsentiment/Trae2api-cn).
10
+ > The plugin hasn't been run against a real Trae CN account yet. If something
11
+ > fails, open an issue and include the error magpie shows.
12
+
13
+ ## Sign-in
14
+
15
+ One way, **Trae CN account (browser)**. The plugin opens trae.cn's
16
+ authorization page (`www.trae.cn/authorization`, the IDE's client
17
+ `ono9krqynydwx5`). Sign in there and allow the sign-in. trae.cn then sends the
18
+ browser back to a callback on `http://127.0.0.1:<port>/authorize`; it accepts
19
+ no other kind of callback. The callback carries the account's Cloud-IDE-JWT,
20
+ refresh token and account. Nothing needs pasting back.
21
+
22
+ At sign-in the plugin creates the account's device and names it to the
23
+ authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
24
+ request then sends that same device. The relays saw requests dropped when the
25
+ device was new each time.
26
+
27
+ ## Requests
28
+
29
+ Chat completions (`@ai-sdk/openai-compatible`) are translated for the IDE
30
+ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
31
+
32
+ - **Request:**
33
+ - The messages go as `{role, content: [{type: "text", text}]}`.
34
+ - The model goes as `config_name` and `model`.
35
+ - The function is `chat_v3`, the classic IDE's. If Trae answers 4001, 4023
36
+ or 1005, the plugin tries `solo_work_lite` (SOLO's) once and remembers
37
+ which one worked.
38
+ - **Headers:** the IDE's: `Authorization: Cloud-IDE-JWT …`,
39
+ `X-Cloudide-Token`, `x-app-id`, `x-ide-version`, the device headers and
40
+ `x-uid`.
41
+ - **Answer:** Trae always answers in its own SSE events:
42
+ - `output` gives `response`/`content` and `reasoning_content`/`reasoning`.
43
+ - `token_usage`, `done` and `error` mark usage, the end and failures.
44
+ - Queue events are ignored.
45
+
46
+ The plugin turns these into an OpenAI stream or a single body.
47
+ - **Tools:**
48
+ - Trae's chat has no turn for a tool call or its result, so the tools are
49
+ named twice:
50
+ - natively in `tools`, with `parameters` as a JSON string;
51
+ - in a system prompt that asks for each call as a
52
+ `<tool_call>{"name", "arguments"}</tool_call>` block.
53
+ - Calls from either source become OpenAI `tool_calls`.
54
+ - Earlier calls and their results go back in as text.
55
+ - Images aren't sent.
56
+
57
+ The model list is the account's own, from `/api/ide/v1/get_detail_param`.
58
+ When that can't be read, the plugin uses the models Trae CN's `chat_v3` is
59
+ known to serve: GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus, DeepSeek V4 Pro
60
+ and DeepSeek V4 Flash.
61
+
62
+ ## Renewal
63
+
64
+ The JWT is renewed with the refresh token at
65
+ `api.trae.cn/cloudide/api/v3/trae/oauth/ExchangeToken`. Trae issues a new
66
+ refresh token each time and spends the old one, so only one renewal runs at
67
+ a time.
68
+
69
+ magpie renews the JWT ten minutes before it ends (`refreshLead`) through
70
+ `auth.refresh`. Each request also checks it, two minutes before the end, for
71
+ OpenCode.
72
+
73
+ The account is marked for a new sign-in in these cases:
74
+
75
+ - the refresh token is turned away;
76
+ - a request is answered 401, or with code 1001.
77
+
78
+ Trae signs other clients out of an account when its token is renewed. The
79
+ IDE may therefore ask you to sign in again after magpie renews.
80
+
81
+ ## Errors
82
+
83
+ | Trae's answer | What the agent gets |
84
+ | --- | --- |
85
+ | 401 / code 1001 | 401, and the account is marked for a new sign-in |
86
+ | code 4008 / 1005 (quota, plan) | 429 |
87
+ | Anything else | Trae's message and code, as a 502 or Trae's own status |
88
+
89
+ ## Usage
90
+
91
+ magpie's usage card shows the account's credits:
92
+
93
+ - **Source:** `api.trae.cn/trae/api/v2/pay/ide_user_ent_usage`.
94
+ - **What it adds up:** each entitlement pack's `credits_limit` (-1 means
95
+ unlimited) and what the pack has used.
96
+ - **What it shows:** credits left as the balance, and the share used as a
97
+ window.
package/index.mjs ADDED
@@ -0,0 +1,940 @@
1
+ // Trae CN (trae.cn), ByteDance's AI IDE, as the IDE signs in to it and
2
+ // asks its models: trae.cn's authorization page sends the browser back to
3
+ // a callback on 127.0.0.1 with the account's Cloud-IDE-JWT and refresh
4
+ // token; the refresh token is exchanged for a new JWT at
5
+ // /cloudide/api/v3/trae/oauth/ExchangeToken; model requests go to the IDE
6
+ // agent's /api/agent/v3/llm_utils_chat, which answers in its own SSE
7
+ // events. Worked out from two open-source relays (wangqi233/trae2api and
8
+ // autumnsentiment/Trae2api-cn); not run against a real account here.
9
+ import { randomBytes, randomUUID, randomInt } from "node:crypto"
10
+ import { createServer, STATUS_CODES } from "node:http"
11
+
12
+ const ID = "trae-cn"
13
+ // where each part of Trae CN is served; tests point them at a fake
14
+ const HOSTS = {
15
+ web: "https://www.trae.cn", // the authorization page
16
+ auth: "https://api.trae.cn", // tokens, the account, credits
17
+ api: "https://trae-api-cn.mchost.guru", // the models
18
+ }
19
+ const CLIENT_ID = "ono9krqynydwx5" // Trae CN's IDE
20
+ const APP_ID = "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8"
21
+ const IDE_VERSION = "3.3.65"
22
+ const IDE_VERSION_CODE = "20260401"
23
+ const PLUGIN_VERSION = "2.3.24254"
24
+ const BRAND = "ASUS TUF Gaming A15 FA507RM_FA507RM"
25
+ const SIGN_IN_TIMEOUT = 10 * 60 * 1000
26
+ const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
27
+ const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
28
+ const DAY = 24 * 3600 * 1000
29
+ // the IDE's chat functions: the classic IDE's agent, then SOLO's
30
+ const FUNCTIONS = ["chat_v3", "solo_work_lite"]
31
+
32
+ const MODEL = { attachment: false, tool_call: true, reasoning: true, temperature: true, limit: { context: 128_000, output: 32_000 }, modalities: { input: ["text"], output: ["text"] } }
33
+ // what Trae CN's chat_v3 is known to serve; the live list replaces it
34
+ const MODELS = {
35
+ "glm-5.2": { name: "GLM-5.2", ...MODEL, limit: { context: 200_000, output: 32_000 } },
36
+ "glm-5": { name: "GLM-5", ...MODEL },
37
+ "kimi-k2.6": { name: "Kimi K2.6", ...MODEL, limit: { context: 256_000, output: 32_000 } },
38
+ "qwen-3.7-plus": { name: "Qwen 3.7 Plus", ...MODEL },
39
+ "DeepSeek-V4-Pro": { name: "DeepSeek V4 Pro", ...MODEL },
40
+ "DeepSeek-V4-Flash": { name: "DeepSeek V4 Flash", ...MODEL },
41
+ }
42
+
43
+ const digits = (n) => Array.from({ length: n }, (_, i) => (i === 0 ? randomInt(1, 10) : randomInt(0, 10))).join("")
44
+ const statusLine = (status) => `${status} ${STATUS_CODES[status] ?? ""}`.trim()
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
46
+
47
+ // device is the machine an account signs in from, made once at sign-in and
48
+ // named to trae.cn's authorization page, then sent with every request: a
49
+ // fresh one each time is what the relays saw requests dropped for.
50
+ const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex") })
51
+
52
+ // whenOf reads one of Trae's expiry times: seconds, milliseconds or ISO
53
+ function whenOf(v) {
54
+ if (v === undefined || v === null || v === "") return 0
55
+ if (typeof v === "string" && !/^\d+$/.test(v.trim())) {
56
+ const t = Date.parse(v)
57
+ return isNaN(t) ? 0 : t
58
+ }
59
+ const n = Number(v)
60
+ if (!Number.isFinite(n) || n <= 0) return 0
61
+ return n < 1e11 ? n * 1000 : n
62
+ }
63
+
64
+ // jwtExp is a JWT's exp, in ms, 0 when it has none
65
+ function jwtExp(token) {
66
+ try {
67
+ const p = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString())
68
+ return whenOf(p?.exp)
69
+ } catch {
70
+ return 0
71
+ }
72
+ }
73
+
74
+ const parseJSON = (s) => {
75
+ if (s && typeof s === "object") return s
76
+ try {
77
+ const v = JSON.parse(String(s ?? ""))
78
+ return v && typeof v === "object" ? v : {}
79
+ } catch {
80
+ return {}
81
+ }
82
+ }
83
+
84
+ // ---- the sign-in as kept ------------------------------------------------------
85
+
86
+ // The sign-in is kept as OpenCode keeps an OAuth one: access the
87
+ // Cloud-IDE-JWT, refresh the refresh token, expires the JWT's end; beside
88
+ // them the account, the client it was issued to and its device.
89
+ function toAuth(s) {
90
+ return {
91
+ type: "oauth",
92
+ access: s.token,
93
+ refresh: s.refresh,
94
+ expires: s.expires || jwtExp(s.token) || Date.now() + DAY,
95
+ accountId: s.name || s.uid,
96
+ uid: s.uid,
97
+ clientId: s.clientId || CLIENT_ID,
98
+ deviceId: s.deviceId,
99
+ machineId: s.machineId,
100
+ // the model host the sign-in named, if it named one
101
+ ...(/mchost\.guru|trae-api-/i.test(s.api ?? "") ? { api: s.api } : {}),
102
+ }
103
+ }
104
+
105
+ function fromAuth(auth) {
106
+ if (auth?.type !== "oauth" || !auth.access) return null
107
+ return {
108
+ token: auth.access,
109
+ refresh: auth.refresh ?? "",
110
+ expires: Number(auth.expires) || 0,
111
+ uid: String(auth.uid ?? ""),
112
+ name: String(auth.accountId ?? ""),
113
+ clientId: auth.clientId || CLIENT_ID,
114
+ deviceId: auth.deviceId || "",
115
+ machineId: auth.machineId || "",
116
+ api: auth.api || "",
117
+ }
118
+ }
119
+
120
+ // apiOf is the model host for an account: the one its sign-in named when
121
+ // that is a model host (trae.cn's own hosts serve no models), else Trae
122
+ // CN's
123
+ function apiOf(a) {
124
+ const h = String(a?.api ?? "").replace(/\/+$/, "")
125
+ return /mchost\.guru|trae-api-/i.test(h) ? h : HOSTS.api
126
+ }
127
+
128
+ // ---- errors --------------------------------------------------------------------
129
+
130
+ class Expired extends Error {
131
+ constructor(msg) {
132
+ super(msg)
133
+ this.signIn = "expired"
134
+ }
135
+ }
136
+
137
+ // errorOf is what Trae said went wrong, from its several shapes: a business
138
+ // {code, message}, {error: {code, message}}, or ByteDance's
139
+ // {ResponseMetadata: {Error: {Code, Message}}}
140
+ function errorOf(v) {
141
+ v = parseJSON(v)
142
+ const meta = v?.ResponseMetadata?.Error
143
+ if (meta && (meta.Code || meta.Message)) return { code: meta.Code ?? "", message: String(meta.Message ?? meta.Code ?? "") }
144
+ const e = v?.error && typeof v.error === "object" ? v.error : v
145
+ const code = e?.code ?? e?.Code ?? v?.code ?? ""
146
+ const message = e?.message ?? e?.msg ?? e?.Message ?? (typeof v?.error === "string" ? v.error : "") ?? ""
147
+ return { code, message: String(message || "") }
148
+ }
149
+
150
+ // lapsed: Trae turned the token away. 1001 is its "not signed in".
151
+ const lapsedCode = (code) => [1001, "1001", 401, "401"].includes(code)
152
+ const lapsedWords = /not ?log(ged)? ?in|unauthori[sz]ed|token (is )?(expired|invalid)|jwt|未登录|登录(已)?(过期|失效)/i
153
+ const quotaCode = (code) => [4008, "4008", 1005, "1005"].includes(code)
154
+ const quotaWords = /quota|credit|insufficient|exceed|limit|额度|积分|次数|上限|用完/i
155
+
156
+ // errorResponse is an OpenAI-style error, as the agent reads one
157
+ const errorResponse = (status, message, signIn) => {
158
+ const headers = { "content-type": "application/json" }
159
+ if (signIn) headers["X-Magpie-Sign-In"] = signIn
160
+ return new Response(JSON.stringify({ error: { message, type: status === 429 ? "rate_limit_error" : "api_error", code: null } }), { status, headers })
161
+ }
162
+
163
+ // ---- tokens -----------------------------------------------------------------------
164
+
165
+ // exchange trades a refresh token for a new Cloud-IDE-JWT, as the IDE
166
+ // does: the answer's Result holds the new token pair and the account.
167
+ // Trae issues a new refresh token each time and spends the old one.
168
+ async function exchange(refresh, clientId, host = HOSTS.auth) {
169
+ let res
170
+ try {
171
+ res = await fetch(host.replace(/\/+$/, "") + "/cloudide/api/v3/trae/oauth/ExchangeToken", {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({ ClientID: clientId || CLIENT_ID, RefreshToken: refresh, ClientSecret: "-", UserID: "" }),
175
+ signal: AbortSignal.timeout(20_000),
176
+ })
177
+ } catch (e) {
178
+ throw new Error("Trae CN: renewing the sign-in: " + (e?.message ?? e))
179
+ }
180
+ const text = await res.text()
181
+ const v = parseJSON(text)
182
+ const r = v.Result ?? v.result ?? v
183
+ const token = r?.Token ?? r?.token ?? ""
184
+ if (res.ok && token) {
185
+ const info = parseJSON(r.UserInfo ?? r.userInfo)
186
+ return {
187
+ token,
188
+ refresh: r.RefreshToken ?? r.refreshToken ?? refresh,
189
+ expires: whenOf(r.TokenExpireAt ?? r.tokenExpireAt) || jwtExp(token),
190
+ clientId: r.ClientID ?? r.clientId ?? clientId,
191
+ uid: String(info.UserID ?? info.userId ?? r.UserID ?? ""),
192
+ name: String(info.ScreenName ?? info.screenName ?? ""),
193
+ api: info.Host ?? "",
194
+ }
195
+ }
196
+ const e = errorOf(text)
197
+ const msg = e.message || (text.trim() ? text.trim().slice(0, 200) : statusLine(res.status))
198
+ // a refresh token Trae no longer takes (spent, revoked, past its end, or
199
+ // of another client) is a sign-in to make again
200
+ if ([400, 401, 403].includes(res.status) || /refresh|expired|invalid|not matched|revoked|unauthori/i.test(msg) || String(e.code) === "10101") {
201
+ throw new Expired(`Trae CN's sign-in has expired (${msg}); sign in again`)
202
+ }
203
+ throw new Error(`Trae CN: renewing the sign-in: ${statusLine(res.status)} ${msg}`.trim())
204
+ }
205
+
206
+ // ---- the browser sign-in --------------------------------------------------------
207
+
208
+ const page = (ok, title, text) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title>
209
+ <style>body{font:15px/1.5 -apple-system,system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#f6f6f4;color:#222}
210
+ @media(prefers-color-scheme:dark){body{background:#1c1c1c;color:#eee}}main{max-width:420px;padding:24px}h1{font-size:20px;margin:0 0 8px}p{margin:0;opacity:.75}
211
+ .dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;background:${ok ? "#2a9d58" : "#d1453b"}}</style>
212
+ <main><h1><span class="dot"></span>${title}</h1><p>${String(text).replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c])}</p></main>`
213
+
214
+ // signedIn reads trae.cn's callback: userJwt (the token pair) and userInfo
215
+ // (the account), or, from its older page, a refresh token alone, which is
216
+ // exchanged for the pair
217
+ async function signedIn(q, device) {
218
+ const jwt = parseJSON(q.get("userJwt"))
219
+ const info = parseJSON(q.get("userInfo"))
220
+ const host = q.get("host") || info.Host || ""
221
+ let s = {
222
+ token: jwt.Token ?? jwt.token ?? "",
223
+ refresh: jwt.RefreshToken ?? jwt.refreshToken ?? q.get("refreshToken") ?? "",
224
+ expires: whenOf(jwt.TokenExpireAt ?? jwt.tokenExpireAt),
225
+ clientId: jwt.ClientID ?? jwt.clientId ?? q.get("clientId") ?? CLIENT_ID,
226
+ uid: String(info.UserID ?? info.userId ?? q.get("userId") ?? ""),
227
+ name: String(info.ScreenName ?? info.screenName ?? ""),
228
+ api: host,
229
+ }
230
+ if (!s.token && s.refresh) {
231
+ const x = await exchange(s.refresh, s.clientId)
232
+ s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api }
233
+ }
234
+ if (!s.token) throw new Error("trae.cn sent back no token")
235
+ if (!s.refresh) throw new Error("trae.cn sent back no refresh token")
236
+ return { ...s, ...device }
237
+ }
238
+
239
+ // browserSignIn is the IDE's sign-in: trae.cn's authorization page, back
240
+ // to a callback on 127.0.0.1 (the page takes no other kind)
241
+ async function browserSignIn() {
242
+ const device = newDevice()
243
+ const trace = randomUUID()
244
+ let over = false
245
+ let settle
246
+ const done = new Promise((r) => (settle = r))
247
+ const finish = (result) => {
248
+ if (over) return
249
+ over = true
250
+ settle(result)
251
+ }
252
+ const server = createServer(async (req, res) => {
253
+ // trae.cn's page may call the callback from script as well as open it
254
+ const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }
255
+ if (req.method === "OPTIONS") return res.writeHead(204, cors).end()
256
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
257
+ const html = (body) => {
258
+ res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" })
259
+ res.end(body)
260
+ }
261
+ if (url.pathname !== "/authorize") return res.writeHead(404, cors).end()
262
+ const q = url.searchParams
263
+ if (over) return html(page(false, "This sign-in is over", "Start it again in magpie."))
264
+ if (q.get("error")) {
265
+ const msg = q.get("error_description") || q.get("error")
266
+ finish({ type: "failed", error: msg })
267
+ return html(page(false, "Sign-in didn't finish", msg))
268
+ }
269
+ if (!q.get("userJwt") && !q.get("refreshToken")) return html(page(false, "Nothing to sign in with", "trae.cn sent no token; start the sign-in again."))
270
+ try {
271
+ const s = await signedIn(q, device)
272
+ finish({ ...toAuth(s), type: "success" })
273
+ html(page(true, "You're signed in", `${s.name || s.uid || "Your Trae CN account"} is signed in. You can close this tab.`))
274
+ } catch (e) {
275
+ finish({ type: "failed", error: e.message })
276
+ html(page(false, "Sign-in didn't finish", e.message))
277
+ }
278
+ })
279
+ await new Promise((resolve, reject) => {
280
+ server.once("error", reject)
281
+ server.listen(0, "127.0.0.1", resolve)
282
+ })
283
+ const callback = `http://127.0.0.1:${server.address().port}/authorize`
284
+ const timer = setTimeout(() => finish({ type: "failed", error: "the sign-in timed out" }), SIGN_IN_TIMEOUT)
285
+ done.then(() => {
286
+ clearTimeout(timer)
287
+ setTimeout(() => server.close(), 5_000).unref?.()
288
+ })
289
+ const q = new URLSearchParams({
290
+ login_version: "1",
291
+ auth_from: "solo",
292
+ login_channel: "native_ide",
293
+ plugin_version: PLUGIN_VERSION,
294
+ auth_type: "local",
295
+ client_id: CLIENT_ID,
296
+ redirect: "0",
297
+ login_trace_id: trace,
298
+ auth_callback_url: callback,
299
+ machine_id: device.machineId,
300
+ device_id: device.deviceId,
301
+ x_device_id: device.deviceId,
302
+ x_machine_id: device.machineId,
303
+ x_device_brand: BRAND,
304
+ x_device_type: "windows",
305
+ x_os_version: "Windows 10 Pro",
306
+ x_env: "",
307
+ x_app_version: IDE_VERSION,
308
+ x_app_type: "stable",
309
+ hide_saas_login: "true",
310
+ })
311
+ return {
312
+ url: `${HOSTS.web}/authorization?${q}`,
313
+ instructions: "Sign in to Trae CN (trae.cn) in the browser and allow the sign-in. It finishes here by itself.",
314
+ method: "auto",
315
+ callback: () => done,
316
+ }
317
+ }
318
+
319
+ // ---- requests ---------------------------------------------------------------------
320
+
321
+ // headers are the IDE's, for the account and its device
322
+ function ideHeaders(a, extra = {}) {
323
+ return {
324
+ "Content-Type": "application/json",
325
+ Authorization: `Cloud-IDE-JWT ${a.token}`,
326
+ "X-Cloudide-Token": a.token,
327
+ "x-ide-token": a.token,
328
+ "x-uid": a.uid,
329
+ "x-app-id": APP_ID,
330
+ "x-device-id": a.deviceId,
331
+ "x-machine-id": a.machineId,
332
+ "x-request-id": randomUUID(),
333
+ "x-ide-version": IDE_VERSION,
334
+ "x-ide-version-code": IDE_VERSION_CODE,
335
+ "x-ide-version-type": "stable",
336
+ "x-device-cpu": "AMD",
337
+ "x-device-brand": BRAND,
338
+ "x-device-type": "windows",
339
+ "x-os-version": "Windows 10",
340
+ "x-system-type": "Windows",
341
+ ...extra,
342
+ }
343
+ }
344
+
345
+ // Trae's chat takes text only and has no turn for a tool call or its
346
+ // result. The agent's tools are named to it natively and in a system
347
+ // prompt that asks for each call as a tagged block; earlier calls and
348
+ // their results go in as text. A native call in its answer is taken too.
349
+ const OPEN = "<tool_call>"
350
+ const CLOSE = "</tool_call>"
351
+
352
+ function toolPrompt(tools, choice, parallel) {
353
+ const defs = tools.map((t) => {
354
+ const f = t.function ?? t
355
+ return { name: f.name, description: f.description ?? "", parameters: f.parameters ?? {} }
356
+ })
357
+ const lines = [
358
+ "You can call the client's tools, listed below as JSON. They run on the user's machine, not on yours: use no built-in or server tool to reach the user's files.",
359
+ `To call a tool, write one block per call and nothing after it: ${OPEN}{"name":"tool_name","arguments":{...}}${CLOSE}. Use an exact tool name and fill arguments from its schema. Then stop and wait: the result comes back in the next message.`,
360
+ "A call is not done until its result comes back; don't say it is, and don't repeat a call whose result you have.",
361
+ ]
362
+ if (parallel === false) lines.push("Call at most one tool at a time.")
363
+ if (choice === "required") lines.push("Call a tool before answering.")
364
+ else if (choice && typeof choice === "object" && (choice.function?.name || choice.name)) lines.push(`Call the tool ${choice.function?.name || choice.name} before answering.`)
365
+ lines.push("Tools:", JSON.stringify(defs))
366
+ return lines.join("\n")
367
+ }
368
+
369
+ const partText = (content) => {
370
+ if (typeof content === "string") return content
371
+ if (!Array.isArray(content)) return content == null ? "" : String(content)
372
+ return content
373
+ .map((p) => (typeof p === "string" ? p : p?.type === "text" || p?.type === "input_text" ? p.text ?? "" : p?.type === "image_url" ? "[an image, which this model can't see]" : ""))
374
+ .filter(Boolean)
375
+ .join("\n")
376
+ }
377
+
378
+ // traeMessages is the conversation as Trae's chat takes it
379
+ function traeMessages(req) {
380
+ const tools = Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none" ? req.tools : []
381
+ const names = new Map()
382
+ const out = []
383
+ if (tools.length) out.push({ role: "system", text: toolPrompt(tools, req.tool_choice, req.parallel_tool_calls) })
384
+ for (const m of req.messages ?? []) {
385
+ const role = m.role === "developer" ? "system" : m.role
386
+ let text = partText(m.content)
387
+ if (role === "assistant") {
388
+ const calls = (m.tool_calls ?? []).map((c) => {
389
+ names.set(c.id, c.function?.name)
390
+ let args = c.function?.arguments ?? "{}"
391
+ try {
392
+ args = JSON.parse(args)
393
+ } catch {}
394
+ return OPEN + JSON.stringify({ name: c.function?.name, arguments: args }) + CLOSE
395
+ })
396
+ text = [text, ...calls].filter(Boolean).join("\n")
397
+ out.push({ role: "assistant", text })
398
+ } else if (role === "tool") {
399
+ out.push({ role: "user", text: `Result of ${names.get(m.tool_call_id) || m.name || "the tool"} (call ${m.tool_call_id ?? "?"}):\n${text}` })
400
+ } else {
401
+ out.push({ role: role === "system" ? "system" : "user", text })
402
+ }
403
+ }
404
+ // Trae takes no empty message, nor two of one role in a row
405
+ const merged = []
406
+ for (const m of out) {
407
+ if (!m.text) continue
408
+ const last = merged.at(-1)
409
+ if (last && last.role === m.role && m.role !== "system") last.text += "\n\n" + m.text
410
+ else merged.push({ ...m })
411
+ }
412
+ return merged.map((m) => ({ role: m.role, content: [{ type: "text", text: m.text }] }))
413
+ }
414
+
415
+ // nativeTools are the tools as Trae names them: parameters a JSON string
416
+ const nativeTools = (tools) =>
417
+ tools.map((t) => {
418
+ const f = t.function ?? t
419
+ return { type: "function", function: { name: f.name, description: f.description ?? "", parameters: typeof f.parameters === "string" ? f.parameters : JSON.stringify(f.parameters ?? {}) } }
420
+ })
421
+
422
+ function chatBody(req, fn) {
423
+ const session = randomUUID()
424
+ const body = {
425
+ messages: traeMessages(req),
426
+ function: fn,
427
+ config_name: req.model,
428
+ model: req.model,
429
+ stream: true, // Trae answers in SSE either way
430
+ request_id: session,
431
+ session_id: session,
432
+ }
433
+ const max = req.max_completion_tokens ?? req.max_tokens
434
+ if (Number.isFinite(max) && max > 0) body.max_tokens = Math.floor(max)
435
+ if (typeof req.temperature === "number") body.temperature = req.temperature
436
+ if (Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none") {
437
+ body.tools = nativeTools(req.tools)
438
+ const c = req.tool_choice
439
+ if (c && typeof c === "object") body.tool_choice = c.function?.name || c.name || "auto"
440
+ else if (typeof c === "string") body.tool_choice = c
441
+ if (typeof req.parallel_tool_calls === "boolean") body.parallel_tool_calls = req.parallel_tool_calls
442
+ }
443
+ return body
444
+ }
445
+
446
+ // sse reads Trae's events: {event, data} for each, data parsed when JSON
447
+ async function* sse(stream) {
448
+ const reader = stream.getReader()
449
+ const dec = new TextDecoder()
450
+ let buf = ""
451
+ let event = ""
452
+ let data = []
453
+ const flush = function* () {
454
+ if (!data.length) return
455
+ const raw = data.join("\n")
456
+ data = []
457
+ let v = raw
458
+ try {
459
+ v = JSON.parse(raw)
460
+ } catch {}
461
+ yield { event: event || (v && typeof v === "object" ? String(v.event ?? v.type ?? "") : ""), data: v }
462
+ event = ""
463
+ }
464
+ for (;;) {
465
+ const { value, done } = await reader.read()
466
+ if (done) break
467
+ buf += dec.decode(value, { stream: true })
468
+ let i
469
+ while ((i = buf.indexOf("\n")) >= 0) {
470
+ const line = buf.slice(0, i).replace(/\r$/, "")
471
+ buf = buf.slice(i + 1)
472
+ if (!line) {
473
+ yield* flush()
474
+ continue
475
+ }
476
+ if (line.startsWith(":")) continue
477
+ if (line.startsWith("event:")) {
478
+ yield* flush()
479
+ event = line.slice(6).trim()
480
+ } else if (line.startsWith("data:")) data.push(line.slice(5).trimStart())
481
+ }
482
+ }
483
+ buf += dec.decode()
484
+ if (buf.startsWith("data:")) data.push(buf.slice(5).trimStart())
485
+ yield* flush()
486
+ }
487
+
488
+ // normalised event names: "TokenUsage", "token-usage" → token_usage
489
+ const eventName = (s) =>
490
+ String(s ?? "")
491
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
492
+ .replace(/[^A-Za-z0-9]+/g, "_")
493
+ .toLowerCase()
494
+ .replace(/^_|_$/g, "")
495
+
496
+ // usageOf maps Trae's token counts to OpenAI's
497
+ function tokensOf(u) {
498
+ if (!u || typeof u !== "object") return null
499
+ const p = Number(u.prompt_tokens ?? u.input_tokens ?? u.promptTokens ?? u.inputTokens ?? 0) || 0
500
+ const c = Number(u.completion_tokens ?? u.output_tokens ?? u.completionTokens ?? u.outputTokens ?? 0) || 0
501
+ if (!p && !c) return null
502
+ return { prompt_tokens: p, completion_tokens: c, total_tokens: Number(u.total_tokens ?? u.totalTokens) || p + c }
503
+ }
504
+
505
+ let callSeq = 0
506
+ const callId = () => "call_" + randomBytes(12).toString("hex") + (callSeq++).toString(36)
507
+
508
+ // TextTools splits the model's text into what the agent sees and the tool
509
+ // calls it wrote as blocks, holding back the start of a block until it
510
+ // is whole
511
+ class TextTools {
512
+ constructor() {
513
+ this.buf = ""
514
+ }
515
+ push(s, end = false) {
516
+ this.buf += s
517
+ let text = ""
518
+ const calls = []
519
+ for (;;) {
520
+ const i = this.buf.indexOf(OPEN)
521
+ if (i < 0) break
522
+ const j = this.buf.indexOf(CLOSE, i + OPEN.length)
523
+ if (j < 0) break
524
+ text += this.buf.slice(0, i)
525
+ const raw = this.buf.slice(i + OPEN.length, j).trim()
526
+ this.buf = this.buf.slice(j + CLOSE.length)
527
+ const v = parseJSON(raw)
528
+ if (v.name) calls.push({ name: String(v.name), arguments: typeof v.arguments === "string" ? v.arguments : JSON.stringify(v.arguments ?? v.input ?? v.parameters ?? {}) })
529
+ else text += OPEN + raw + CLOSE
530
+ }
531
+ if (end) {
532
+ text += this.buf
533
+ this.buf = ""
534
+ } else {
535
+ // keep back an opened block, or what may be the start of one
536
+ const i = this.buf.indexOf(OPEN)
537
+ let keep = i >= 0 ? this.buf.length - i : 0
538
+ if (!keep) for (let k = Math.min(OPEN.length - 1, this.buf.length); k > 0; k--) if (OPEN.startsWith(this.buf.slice(-k))) { keep = k; break }
539
+ text += this.buf.slice(0, this.buf.length - keep)
540
+ this.buf = this.buf.slice(this.buf.length - keep)
541
+ }
542
+ return { text, calls }
543
+ }
544
+ }
545
+
546
+ // nativeCall is a tool call as Trae's events carry one
547
+ function nativeCall(tc) {
548
+ const f = tc?.function ?? tc?.function_call ?? tc
549
+ const name = f?.name ?? tc?.tool_name ?? ""
550
+ if (!name) return null
551
+ const a = f?.arguments ?? tc?.params ?? tc?.input ?? tc?.parameters ?? {}
552
+ return { id: tc?.id || tc?.tool_call_id || "", name: String(name), arguments: typeof a === "string" ? a : JSON.stringify(a) }
553
+ }
554
+
555
+ // parts turns Trae's events into what the answer is made of: text,
556
+ // reasoning, tool calls, the token counts, the finish, or an error.
557
+ async function* parts(events) {
558
+ const tt = new TextTools()
559
+ const seen = new Set()
560
+ for await (const { event, data } of events) {
561
+ const name = eventName(event)
562
+ const d = data && typeof data === "object" ? data : {}
563
+ if (data === "[DONE]") break
564
+ if (name === "error" || (name !== "output" && d.code && d.message && !d.response && !d.content)) {
565
+ const e = errorOf(d)
566
+ yield { error: e.message || "Trae CN returned an error", code: e.code }
567
+ return
568
+ }
569
+ if (name === "token_usage") {
570
+ const u = tokensOf(d.usage ?? d)
571
+ if (u) yield { usage: u }
572
+ continue
573
+ }
574
+ if (name === "done" || name === "response_done" || name === "stream_done") {
575
+ const u = tokensOf(d.usage)
576
+ if (u) yield { usage: u }
577
+ break
578
+ }
579
+ if (name && name !== "output" && name !== "message") continue // queueing, metadata, timing
580
+ const reasoning = d.reasoning_content ?? d.reasoning ?? ""
581
+ if (typeof reasoning === "string" && reasoning) yield { reasoning }
582
+ let text = typeof d.response === "string" ? d.response : typeof d.content === "string" ? d.content : ""
583
+ // the IDE's own progress notes, not the model's
584
+ if (/^(Building prompt:|Completed building prompt)/.test(text)) text = ""
585
+ if (text) {
586
+ const r = tt.push(text)
587
+ if (r.text) yield { text: r.text }
588
+ for (const c of r.calls) yield { call: { ...c, id: callId() } }
589
+ }
590
+ for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
591
+ const c = nativeCall(tc)
592
+ if (!c) continue
593
+ const key = c.id || c.name + c.arguments
594
+ if (seen.has(key)) continue
595
+ seen.add(key)
596
+ yield { call: { ...c, id: c.id || callId() } }
597
+ }
598
+ const u = tokensOf(d.usage)
599
+ if (u) yield { usage: u }
600
+ }
601
+ const r = tt.push("", true)
602
+ if (r.text) yield { text: r.text }
603
+ for (const c of r.calls) yield { call: { ...c, id: callId() } }
604
+ }
605
+
606
+ // first reads events up to the first that is the answer's, so an answer
607
+ // that is only an error (signed out, out of credit, a model this account
608
+ // lacks) is known before anything is sent on
609
+ async function first(it) {
610
+ const held = []
611
+ for (;;) {
612
+ const n = await it.next()
613
+ if (n.done) return { held, done: true }
614
+ held.push(n.value)
615
+ if (!n.value.usage) return { held, done: false }
616
+ }
617
+ }
618
+
619
+ async function* chain(held, it) {
620
+ yield* held
621
+ for (;;) {
622
+ const n = await it.next()
623
+ if (n.done) return
624
+ yield n.value
625
+ }
626
+ }
627
+
628
+ // failure is the response for an error Trae answered with
629
+ function failure(status, code, message) {
630
+ message = message || statusLine(status)
631
+ if (status === 401 || lapsedCode(code) || (status === 403 && lapsedWords.test(message))) {
632
+ return errorResponse(401, `Trae CN's sign-in has expired (${message}); sign in again`, "expired")
633
+ }
634
+ if (status === 429 || quotaCode(code) || (status === 403 && quotaWords.test(message))) return errorResponse(429, `Trae CN: ${message}`)
635
+ return errorResponse(status >= 400 ? status : 502, `Trae CN: ${message}${code ? ` (code ${code})` : ""}`)
636
+ }
637
+
638
+ // openai is the answer as an OpenAI chat completion: a stream, or one body
639
+ async function openai(req, it) {
640
+ const id = "chatcmpl-" + randomBytes(12).toString("hex")
641
+ const created = Math.floor(Date.now() / 1000)
642
+ const model = req.model
643
+ if (!req.stream) {
644
+ let text = ""
645
+ let reasoning = ""
646
+ const calls = []
647
+ let usage = null
648
+ for await (const p of it) {
649
+ if (p.error) return failure(502, p.code, p.error)
650
+ if (p.text) text += p.text
651
+ if (p.reasoning) reasoning += p.reasoning
652
+ if (p.call) calls.push(p.call)
653
+ if (p.usage) usage = p.usage
654
+ }
655
+ const message = { role: "assistant", content: text || (calls.length ? null : "") }
656
+ if (reasoning) message.reasoning_content = reasoning
657
+ if (calls.length) message.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } }))
658
+ const body = { id, object: "chat.completion", created, model, choices: [{ index: 0, message, finish_reason: calls.length ? "tool_calls" : "stop" }] }
659
+ if (usage) body.usage = usage
660
+ return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })
661
+ }
662
+ const enc = new TextEncoder()
663
+ const chunk = (delta, finish = null, extra = {}) => enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finish }], ...extra })}\n\n`)
664
+ const stream = new ReadableStream({
665
+ async start(ctl) {
666
+ let calls = 0
667
+ let usage = null
668
+ ctl.enqueue(chunk({ role: "assistant", content: "" }))
669
+ try {
670
+ for await (const p of it) {
671
+ if (p.error) {
672
+ ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${p.error}`, type: "api_error", code: p.code ?? null } })}\n\n`))
673
+ break
674
+ }
675
+ if (p.text) ctl.enqueue(chunk({ content: p.text }))
676
+ if (p.reasoning) ctl.enqueue(chunk({ reasoning_content: p.reasoning }))
677
+ if (p.call) ctl.enqueue(chunk({ tool_calls: [{ index: calls++, id: p.call.id, type: "function", function: { name: p.call.name, arguments: p.call.arguments } }] }))
678
+ if (p.usage) usage = p.usage
679
+ }
680
+ ctl.enqueue(chunk({}, calls ? "tool_calls" : "stop"))
681
+ if (usage) ctl.enqueue(enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage })}\n\n`))
682
+ ctl.enqueue(enc.encode("data: [DONE]\n\n"))
683
+ } catch (e) {
684
+ ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${e?.message ?? e}`, type: "api_error", code: null } })}\n\n`))
685
+ }
686
+ ctl.close()
687
+ },
688
+ })
689
+ return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
690
+ }
691
+
692
+ // a model or function this account's chat_v3 doesn't take: SOLO's may
693
+ const wrongFunction = (code) => ["4001", "4023", "1005"].includes(String(code))
694
+
695
+ // ---- usage ------------------------------------------------------------------------
696
+
697
+ // credits adds up the account's entitlement packs as the IDE shows them:
698
+ // each pack with a credit limit (-1 unlimited) and what it used
699
+ function credits(v) {
700
+ let limit = 0
701
+ let used = 0
702
+ let unlimited = false
703
+ let soonest = 0
704
+ for (const p of v?.user_entitlement_pack_list ?? []) {
705
+ const bi = p?.entitlement_base_info ?? {}
706
+ const l = Number(bi?.quota?.credits_limit)
707
+ if (!Number.isFinite(l)) continue
708
+ const u = Number(p?.usage?.credits_amount) || 0
709
+ if (l === -1) unlimited = true
710
+ else if (l >= 0) limit += l
711
+ if (u > 0) used += u
712
+ const end = whenOf(bi.end_time ?? bi.expire_time ?? p.end_time)
713
+ if (end && (!soonest || end < soonest)) soonest = end
714
+ }
715
+ return { limit, used, unlimited, until: soonest }
716
+ }
717
+
718
+ const round = (n) => Math.round(n * 100) / 100
719
+
720
+ // ---- the plugin ---------------------------------------------------------------------
721
+
722
+ export const TraeCNAuthPlugin = async ({ client }) => {
723
+ // renewals under way, one to a refresh token (Trae spends it once), and
724
+ // the last each account got here, so a sign-in from before it is given
725
+ // that one rather than spending the token again
726
+ const renewing = new Map()
727
+ const renewed = new Map()
728
+ // the chat function that served each account last
729
+ const fnOf = new Map()
730
+
731
+ const save = async (auth) => {
732
+ try {
733
+ await client?.auth?.set?.({ path: { id: ID }, body: auth })
734
+ } catch {}
735
+ }
736
+
737
+ const renew = (a, keep) => {
738
+ const key = a.refresh
739
+ let r = renewing.get(key)
740
+ if (!r) {
741
+ r = (async () => {
742
+ const x = await exchange(a.refresh, a.clientId)
743
+ const got = { ...a, token: x.token, refresh: x.refresh, expires: x.expires || jwtExp(x.token) || Date.now() + DAY, clientId: x.clientId || a.clientId, renewed: true }
744
+ renewed.set(a.uid || a.name, { was: a.refresh, got })
745
+ if (keep) await save(toAuth(got))
746
+ return got
747
+ })().finally(() => renewing.delete(key))
748
+ renewing.set(key, r)
749
+ }
750
+ return r
751
+ }
752
+
753
+ // fresh is the account with a token that has a while to go, renewed
754
+ // when it hasn't
755
+ const fresh = async (getAuth) => {
756
+ const a = fromAuth(await getAuth())
757
+ if (!a) throw new Expired("Trae CN: not signed in")
758
+ const last = renewed.get(a.uid || a.name)
759
+ if (last && last.was === a.refresh) return { ...last.got, renewed: false }
760
+ if (!a.expires || a.expires - Date.now() > EARLY_MS || !a.refresh) return a
761
+ try {
762
+ return await renew(a, true)
763
+ } catch (e) {
764
+ // a hiccup: the token in hand may still do
765
+ if (!(e instanceof Expired) && a.expires > Date.now()) return a
766
+ throw e
767
+ }
768
+ }
769
+
770
+ // refresh is magpie's auth.refresh: the token renewed LEAD_MS before its
771
+ // end, as the fields that changed
772
+ const refresh = async (auth) => {
773
+ const a = fromAuth(auth)
774
+ if (!a || !a.refresh) return undefined
775
+ const last = renewed.get(a.uid || a.name)
776
+ if (last && last.was === a.refresh) return { access: last.got.token, refresh: last.got.refresh, expires: last.got.expires }
777
+ const s = await renew(a, false)
778
+ return { access: s.token, refresh: s.refresh, expires: s.expires }
779
+ }
780
+
781
+ // post asks one of Trae's JSON pages with the account
782
+ const post = async (a, url, body) => {
783
+ const res = await fetch(url, { method: "POST", headers: ideHeaders(a, { Accept: "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) })
784
+ const text = await res.text()
785
+ return { status: res.status, text, v: parseJSON(text) }
786
+ }
787
+
788
+ const usage = async (getAuth) => {
789
+ let a
790
+ try {
791
+ a = await fresh(getAuth)
792
+ } catch (e) {
793
+ return { error: e.message, signIn: e instanceof Expired ? "expired" : "kept" }
794
+ }
795
+ const signIn = a.renewed ? "renewed" : "kept"
796
+ let r
797
+ try {
798
+ r = await post(a, HOSTS.auth + "/trae/api/v2/pay/ide_user_ent_usage", { require_usage: true, req_source: 0 })
799
+ } catch (e) {
800
+ return { error: "Trae CN credits: " + (e?.message ?? e), signIn }
801
+ }
802
+ const e = errorOf(r.v)
803
+ if (r.status === 401 || lapsedCode(e.code)) return { error: `${a.name || a.uid}: Trae CN's sign-in has expired — sign in again`, signIn: "expired" }
804
+ if (r.status !== 200 || (e.code && String(e.code) !== "0")) return { error: `Trae CN credits: ${e.message || statusLine(r.status)}`, signIn, user: a.name }
805
+ const c = credits(r.v)
806
+ const out = { plan: r.v.is_credits_billing ? "Credits" : "Free", user: a.name || a.uid, signIn }
807
+ if (c.unlimited) out.balance = "unlimited"
808
+ else if (c.limit > 0) {
809
+ out.balance = `${round(Math.max(0, c.limit - c.used))} of ${round(c.limit)} credits left`
810
+ const w = { name: "Credits", used: Math.max(0, Math.min(100, (c.used / c.limit) * 100)) }
811
+ if (c.until) w.resetsAt = new Date(c.until).toISOString()
812
+ out.windows = [w]
813
+ }
814
+ return out
815
+ }
816
+
817
+ // liveModels is the account's model list, as the IDE asks for it
818
+ const liveModels = async (a) => {
819
+ const r = await post(a, apiOf(a) + "/api/ide/v1/get_detail_param", {
820
+ function: FUNCTIONS[0], config_names: null, need_prompt: false, current_config_info: null, poly_prompt: true, mode_type: null, agent_type: null,
821
+ })
822
+ if (r.status === 401 || lapsedCode(errorOf(r.v).code)) throw new Expired("Trae CN's sign-in has expired; sign in again")
823
+ if (r.status !== 200) throw new Error(`Trae CN models: ${statusLine(r.status)}`)
824
+ const list = r.v.config_info_list ?? r.v.data?.config_info_list ?? []
825
+ return list.filter((m) => m?.config_name)
826
+ }
827
+
828
+ const modelOf = (provider, m) => {
829
+ const id = String(m.config_name)
830
+ const was = provider.models?.[id] ?? {}
831
+ const ctx = Number(m.context_window_size?.max?.[0] ?? m.context_window_size?.max ?? m.context_window_tokens?.max ?? m.prompt_max_tokens) || was.limit?.context || MODEL.limit.context
832
+ return { ...MODEL, ...was, id, providerID: ID, name: String(m.display_name || m.display_model_name || was.name || id), limit: { context: ctx, output: was.limit?.output ?? MODEL.limit.output }, api: was.api ?? { id, url: HOSTS.api, npm: "@ai-sdk/openai-compatible" } }
833
+ }
834
+
835
+ return {
836
+ config: async (config) => {
837
+ config.provider ??= {}
838
+ const was = config.provider[ID] ?? {}
839
+ config.provider[ID] = {
840
+ name: "Trae CN",
841
+ npm: "@ai-sdk/openai-compatible",
842
+ api: HOSTS.api + "/v1",
843
+ ...was,
844
+ models: { ...MODELS, ...(was.models ?? {}) },
845
+ }
846
+ },
847
+ provider: {
848
+ id: ID,
849
+ // the account's own list, when Trae answers; else the list above
850
+ async models(provider, { auth }) {
851
+ if (auth?.type !== "oauth" || !auth.access) return provider.models
852
+ try {
853
+ const ms = await liveModels(await fresh(async () => auth))
854
+ if (!ms.length) return provider.models
855
+ return Object.fromEntries(ms.map((m) => [String(m.config_name), modelOf(provider, m)]))
856
+ } catch (e) {
857
+ if (e instanceof Expired) throw e
858
+ return provider.models
859
+ }
860
+ },
861
+ },
862
+ auth: {
863
+ provider: ID,
864
+ usage,
865
+ // magpie renews the token LEAD_MS before its end, once, before its
866
+ // requests, models and usage ask; the check before each request
867
+ // stays for OpenCode, which doesn't call this
868
+ refreshLead: LEAD_MS,
869
+ refresh,
870
+ loader: async (getAuth) => {
871
+ const a0 = fromAuth(await getAuth())
872
+ if (!a0) return {}
873
+ return {
874
+ baseURL: apiOf(a0) + "/v1",
875
+ apiKey: "trae", // the engine's placeholder; the request carries the JWT
876
+ async fetch(input, init = {}) {
877
+ const r0 = input instanceof Request ? input : null
878
+ let body = init.body ?? (r0 ? await r0.text() : undefined)
879
+ if (body instanceof ArrayBuffer) body = new TextDecoder().decode(body)
880
+ else if (ArrayBuffer.isView(body)) body = new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength))
881
+ const req = parseJSON(body)
882
+ if (!Array.isArray(req.messages)) return errorResponse(400, "Trae CN: only chat completions are served")
883
+ let a
884
+ try {
885
+ a = await fresh(getAuth)
886
+ } catch (e) {
887
+ if (e instanceof Expired) return errorResponse(401, `${a0.name || a0.uid}: ${e.message}`, "expired")
888
+ throw e
889
+ }
890
+ const signIn = a.renewed ? "renewed" : "kept"
891
+ const who = a.uid || a.name
892
+ const fns = fnOf.has(who) ? [fnOf.get(who), ...FUNCTIONS.filter((f) => f !== fnOf.get(who))] : FUNCTIONS
893
+ let last
894
+ for (const fn of fns) {
895
+ const res = await fetch(apiOf(a) + "/api/agent/v3/llm_utils_chat", {
896
+ method: "POST",
897
+ headers: ideHeaders(a, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
898
+ body: JSON.stringify(chatBody(req, fn)),
899
+ signal: init.signal ?? r0?.signal,
900
+ })
901
+ if (!res.ok) {
902
+ const text = await res.text()
903
+ const e = errorOf(text)
904
+ last = failure(res.status, e.code, e.message || text.trim().slice(0, 300))
905
+ if (res.status === 400 && wrongFunction(e.code)) continue
906
+ break
907
+ }
908
+ const ct = res.headers.get("content-type") ?? ""
909
+ if (!ct.includes("event-stream") && ct.includes("json")) {
910
+ // an answer that isn't a stream is an error in a 200
911
+ const e = errorOf(await res.text())
912
+ last = failure(502, e.code, e.message)
913
+ if (wrongFunction(e.code)) continue
914
+ break
915
+ }
916
+ const it = parts(sse(res.body))[Symbol.asyncIterator]()
917
+ const { held, done } = await first(it)
918
+ const err = held.find((p) => p.error)
919
+ if (err) {
920
+ last = failure(502, err.code, err.error)
921
+ if (wrongFunction(err.code)) continue
922
+ break
923
+ }
924
+ fnOf.set(who, fn)
925
+ const out = await openai(req, done ? held : chain(held, it))
926
+ out.headers.set("X-Magpie-Sign-In", signIn)
927
+ return out
928
+ }
929
+ if (!last.headers.has("X-Magpie-Sign-In")) last.headers.set("X-Magpie-Sign-In", signIn)
930
+ return last
931
+ },
932
+ }
933
+ },
934
+ methods: [{ type: "oauth", label: "Trae CN account (browser)", authorize: browserSignIn }],
935
+ },
936
+ }
937
+ }
938
+
939
+ // for tests
940
+ export const _internal = { HOSTS, MODELS, TextTools, traeMessages, chatBody, credits, whenOf, newDevice }
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenCode provider plugin for Trae CN (trae.cn): your Trae CN account's models, free tier included, with Trae's own browser sign-in",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md"],
9
+ "keywords": ["opencode", "opencode-plugin", "trae", "trae-cn", "bytedance"],
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/trae" }
12
+ }