@magpie-community/opencode-trae-auth 0.0.0-stage → 0.1.1
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.
- package/README.md +98 -2
- package/index.mjs +966 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @magpie-community/opencode-trae-auth
|
|
2
2
|
|
|
3
|
-
|
|
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 the one whose model list has the model (`chat_v3`, the
|
|
36
|
+
classic IDE's, first; else `solo_work_lite`, SOLO's). If Trae answers
|
|
37
|
+
4001, 4023 or 1005, the plugin tries the other once and remembers which
|
|
38
|
+
one worked.
|
|
39
|
+
- **Headers:** the IDE's: `Authorization: Cloud-IDE-JWT …`,
|
|
40
|
+
`X-Cloudide-Token`, `x-app-id`, `x-ide-version`, the device headers and
|
|
41
|
+
`x-uid`.
|
|
42
|
+
- **Answer:** Trae always answers in its own SSE events:
|
|
43
|
+
- `output` gives `response`/`content` and `reasoning_content`/`reasoning`.
|
|
44
|
+
- `token_usage`, `done` and `error` mark usage, the end and failures.
|
|
45
|
+
- Queue events are ignored.
|
|
46
|
+
|
|
47
|
+
The plugin turns these into an OpenAI stream or a single body.
|
|
48
|
+
- **Tools:**
|
|
49
|
+
- Trae's chat has no turn for a tool call or its result, so the tools are
|
|
50
|
+
named twice:
|
|
51
|
+
- natively in `tools`, with `parameters` as a JSON string;
|
|
52
|
+
- in a system prompt that asks for each call as a
|
|
53
|
+
`<tool_call>{"name", "arguments"}</tool_call>` block.
|
|
54
|
+
- Calls from either source become OpenAI `tool_calls`.
|
|
55
|
+
- Earlier calls and their results go back in as text.
|
|
56
|
+
- Images aren't sent.
|
|
57
|
+
|
|
58
|
+
The model list is the account's own, from `/api/ide/v1/get_detail_param`,
|
|
59
|
+
asked for both functions and put together: SOLO lists models `chat_v3`
|
|
60
|
+
doesn't (DeepSeek-V4.1-Flash). When neither can be read, the plugin uses the models Trae CN's `chat_v3` is
|
|
61
|
+
known to serve: GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus, DeepSeek V4 Pro
|
|
62
|
+
and DeepSeek V4 Flash.
|
|
63
|
+
|
|
64
|
+
## Renewal
|
|
65
|
+
|
|
66
|
+
The JWT is renewed with the refresh token at
|
|
67
|
+
`api.trae.cn/cloudide/api/v3/trae/oauth/ExchangeToken`. Trae issues a new
|
|
68
|
+
refresh token each time and spends the old one, so only one renewal runs at
|
|
69
|
+
a time.
|
|
70
|
+
|
|
71
|
+
magpie renews the JWT ten minutes before it ends (`refreshLead`) through
|
|
72
|
+
`auth.refresh`. Each request also checks it, two minutes before the end, for
|
|
73
|
+
OpenCode.
|
|
74
|
+
|
|
75
|
+
The account is marked for a new sign-in in these cases:
|
|
76
|
+
|
|
77
|
+
- the refresh token is turned away;
|
|
78
|
+
- a request is answered 401, or with code 1001.
|
|
79
|
+
|
|
80
|
+
Trae signs other clients out of an account when its token is renewed. The
|
|
81
|
+
IDE may therefore ask you to sign in again after magpie renews.
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
| Trae's answer | What the agent gets |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| 401 / code 1001 | 401, and the account is marked for a new sign-in |
|
|
88
|
+
| code 4008 / 1005 (quota, plan) | 429 |
|
|
89
|
+
| Anything else | Trae's message and code, as a 502 or Trae's own status |
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
magpie's usage card shows the account's credits:
|
|
94
|
+
|
|
95
|
+
- **Source:** `api.trae.cn/trae/api/v2/pay/ide_user_ent_usage`.
|
|
96
|
+
- **What it adds up:** each entitlement pack's `credits_limit` (-1 means
|
|
97
|
+
unlimited) and what the pack has used.
|
|
98
|
+
- **What it shows:** credits left as the balance, and the share used as a
|
|
99
|
+
window.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
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) => ({ "<": "<", ">": ">", "&": "&" })[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
|
+
// the first chat function whose model list has each model
|
|
731
|
+
const listedBy = new Map()
|
|
732
|
+
|
|
733
|
+
const save = async (auth) => {
|
|
734
|
+
try {
|
|
735
|
+
await client?.auth?.set?.({ path: { id: ID }, body: auth })
|
|
736
|
+
} catch {}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const renew = (a, keep) => {
|
|
740
|
+
const key = a.refresh
|
|
741
|
+
let r = renewing.get(key)
|
|
742
|
+
if (!r) {
|
|
743
|
+
r = (async () => {
|
|
744
|
+
const x = await exchange(a.refresh, a.clientId)
|
|
745
|
+
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 }
|
|
746
|
+
renewed.set(a.uid || a.name, { was: a.refresh, got })
|
|
747
|
+
if (keep) await save(toAuth(got))
|
|
748
|
+
return got
|
|
749
|
+
})().finally(() => renewing.delete(key))
|
|
750
|
+
renewing.set(key, r)
|
|
751
|
+
}
|
|
752
|
+
return r
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// fresh is the account with a token that has a while to go, renewed
|
|
756
|
+
// when it hasn't
|
|
757
|
+
const fresh = async (getAuth) => {
|
|
758
|
+
const a = fromAuth(await getAuth())
|
|
759
|
+
if (!a) throw new Expired("Trae CN: not signed in")
|
|
760
|
+
const last = renewed.get(a.uid || a.name)
|
|
761
|
+
if (last && last.was === a.refresh) return { ...last.got, renewed: false }
|
|
762
|
+
if (!a.expires || a.expires - Date.now() > EARLY_MS || !a.refresh) return a
|
|
763
|
+
try {
|
|
764
|
+
return await renew(a, true)
|
|
765
|
+
} catch (e) {
|
|
766
|
+
// a hiccup: the token in hand may still do
|
|
767
|
+
if (!(e instanceof Expired) && a.expires > Date.now()) return a
|
|
768
|
+
throw e
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// refresh is magpie's auth.refresh: the token renewed LEAD_MS before its
|
|
773
|
+
// end, as the fields that changed
|
|
774
|
+
const refresh = async (auth) => {
|
|
775
|
+
const a = fromAuth(auth)
|
|
776
|
+
if (!a || !a.refresh) return undefined
|
|
777
|
+
const last = renewed.get(a.uid || a.name)
|
|
778
|
+
if (last && last.was === a.refresh) return { access: last.got.token, refresh: last.got.refresh, expires: last.got.expires }
|
|
779
|
+
const s = await renew(a, false)
|
|
780
|
+
return { access: s.token, refresh: s.refresh, expires: s.expires }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// post asks one of Trae's JSON pages with the account
|
|
784
|
+
const post = async (a, url, body) => {
|
|
785
|
+
const res = await fetch(url, { method: "POST", headers: ideHeaders(a, { Accept: "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) })
|
|
786
|
+
const text = await res.text()
|
|
787
|
+
return { status: res.status, text, v: parseJSON(text) }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const usage = async (getAuth) => {
|
|
791
|
+
let a
|
|
792
|
+
try {
|
|
793
|
+
a = await fresh(getAuth)
|
|
794
|
+
} catch (e) {
|
|
795
|
+
return { error: e.message, signIn: e instanceof Expired ? "expired" : "kept" }
|
|
796
|
+
}
|
|
797
|
+
const signIn = a.renewed ? "renewed" : "kept"
|
|
798
|
+
let r
|
|
799
|
+
try {
|
|
800
|
+
r = await post(a, HOSTS.auth + "/trae/api/v2/pay/ide_user_ent_usage", { require_usage: true, req_source: 0 })
|
|
801
|
+
} catch (e) {
|
|
802
|
+
return { error: "Trae CN credits: " + (e?.message ?? e), signIn }
|
|
803
|
+
}
|
|
804
|
+
const e = errorOf(r.v)
|
|
805
|
+
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" }
|
|
806
|
+
if (r.status !== 200 || (e.code && String(e.code) !== "0")) return { error: `Trae CN credits: ${e.message || statusLine(r.status)}`, signIn, user: a.name }
|
|
807
|
+
const c = credits(r.v)
|
|
808
|
+
const out = { plan: r.v.is_credits_billing ? "Credits" : "Free", user: a.name || a.uid, signIn }
|
|
809
|
+
if (c.unlimited) out.balance = "unlimited"
|
|
810
|
+
else if (c.limit > 0) {
|
|
811
|
+
out.balance = `${round(Math.max(0, c.limit - c.used))} of ${round(c.limit)} credits left`
|
|
812
|
+
const w = { name: "Credits", used: Math.max(0, Math.min(100, (c.used / c.limit) * 100)) }
|
|
813
|
+
if (c.until) w.resetsAt = new Date(c.until).toISOString()
|
|
814
|
+
out.windows = [w]
|
|
815
|
+
}
|
|
816
|
+
return out
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// listOf is one function's model list, as the IDE asks for it
|
|
820
|
+
const listOf = async (a, fn) => {
|
|
821
|
+
const r = await post(a, apiOf(a) + "/api/ide/v1/get_detail_param", {
|
|
822
|
+
function: fn, config_names: null, need_prompt: false, current_config_info: null, poly_prompt: true, mode_type: null, agent_type: null,
|
|
823
|
+
})
|
|
824
|
+
if (r.status === 401 || lapsedCode(errorOf(r.v).code)) throw new Expired("Trae CN's sign-in has expired; sign in again")
|
|
825
|
+
if (r.status !== 200) throw new Error(`Trae CN models: ${statusLine(r.status)}`)
|
|
826
|
+
const list = r.v.config_info_list ?? r.v.data?.config_info_list ?? []
|
|
827
|
+
return list.filter((m) => m?.config_name)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// liveModels is the account's model list: every chat function's, since
|
|
831
|
+
// SOLO lists models the classic IDE's chat_v3 doesn't (DeepSeek-V4.1-Flash
|
|
832
|
+
// came to SOLO first, yetone/magpie#681); a model listed by one function
|
|
833
|
+
// only is asked through that one. One function's list failing leaves the
|
|
834
|
+
// others'; all failing is the error.
|
|
835
|
+
const liveModels = async (a) => {
|
|
836
|
+
const got = await Promise.allSettled(FUNCTIONS.map((fn) => listOf(a, fn)))
|
|
837
|
+
const expired = got.find((g) => g.status === "rejected" && g.reason instanceof Expired)
|
|
838
|
+
if (expired) throw expired.reason
|
|
839
|
+
if (got.every((g) => g.status === "rejected")) throw got[0].reason
|
|
840
|
+
const out = new Map()
|
|
841
|
+
got.forEach((g, i) => {
|
|
842
|
+
if (g.status !== "fulfilled") return
|
|
843
|
+
for (const m of g.value) {
|
|
844
|
+
const id = String(m.config_name)
|
|
845
|
+
if (!out.has(id)) out.set(id, { m, fn: FUNCTIONS[i] })
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
for (const [id, { fn }] of out) listedBy.set(id, fn)
|
|
849
|
+
return [...out.values()].map((x) => x.m)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const modelOf = (provider, m) => {
|
|
853
|
+
const id = String(m.config_name)
|
|
854
|
+
const was = provider.models?.[id] ?? {}
|
|
855
|
+
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
|
|
856
|
+
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" } }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
config: async (config) => {
|
|
861
|
+
config.provider ??= {}
|
|
862
|
+
const was = config.provider[ID] ?? {}
|
|
863
|
+
config.provider[ID] = {
|
|
864
|
+
name: "Trae CN",
|
|
865
|
+
npm: "@ai-sdk/openai-compatible",
|
|
866
|
+
api: HOSTS.api + "/v1",
|
|
867
|
+
...was,
|
|
868
|
+
models: { ...MODELS, ...(was.models ?? {}) },
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
provider: {
|
|
872
|
+
id: ID,
|
|
873
|
+
// the account's own list, when Trae answers; else the list above
|
|
874
|
+
async models(provider, { auth }) {
|
|
875
|
+
if (auth?.type !== "oauth" || !auth.access) return provider.models
|
|
876
|
+
try {
|
|
877
|
+
const ms = await liveModels(await fresh(async () => auth))
|
|
878
|
+
if (!ms.length) return provider.models
|
|
879
|
+
return Object.fromEntries(ms.map((m) => [String(m.config_name), modelOf(provider, m)]))
|
|
880
|
+
} catch (e) {
|
|
881
|
+
if (e instanceof Expired) throw e
|
|
882
|
+
return provider.models
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
auth: {
|
|
887
|
+
provider: ID,
|
|
888
|
+
usage,
|
|
889
|
+
// magpie renews the token LEAD_MS before its end, once, before its
|
|
890
|
+
// requests, models and usage ask; the check before each request
|
|
891
|
+
// stays for OpenCode, which doesn't call this
|
|
892
|
+
refreshLead: LEAD_MS,
|
|
893
|
+
refresh,
|
|
894
|
+
loader: async (getAuth) => {
|
|
895
|
+
const a0 = fromAuth(await getAuth())
|
|
896
|
+
if (!a0) return {}
|
|
897
|
+
return {
|
|
898
|
+
baseURL: apiOf(a0) + "/v1",
|
|
899
|
+
apiKey: "trae", // the engine's placeholder; the request carries the JWT
|
|
900
|
+
async fetch(input, init = {}) {
|
|
901
|
+
const r0 = input instanceof Request ? input : null
|
|
902
|
+
let body = init.body ?? (r0 ? await r0.text() : undefined)
|
|
903
|
+
if (body instanceof ArrayBuffer) body = new TextDecoder().decode(body)
|
|
904
|
+
else if (ArrayBuffer.isView(body)) body = new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength))
|
|
905
|
+
const req = parseJSON(body)
|
|
906
|
+
if (!Array.isArray(req.messages)) return errorResponse(400, "Trae CN: only chat completions are served")
|
|
907
|
+
let a
|
|
908
|
+
try {
|
|
909
|
+
a = await fresh(getAuth)
|
|
910
|
+
} catch (e) {
|
|
911
|
+
if (e instanceof Expired) return errorResponse(401, `${a0.name || a0.uid}: ${e.message}`, "expired")
|
|
912
|
+
throw e
|
|
913
|
+
}
|
|
914
|
+
const signIn = a.renewed ? "renewed" : "kept"
|
|
915
|
+
const who = a.uid || a.name
|
|
916
|
+
// the function that lists the model first, then the one that
|
|
917
|
+
// served this account last, then the rest
|
|
918
|
+
const fns = [...new Set([listedBy.get(String(req.model)), fnOf.get(who), ...FUNCTIONS].filter(Boolean))]
|
|
919
|
+
let last
|
|
920
|
+
for (const fn of fns) {
|
|
921
|
+
const res = await fetch(apiOf(a) + "/api/agent/v3/llm_utils_chat", {
|
|
922
|
+
method: "POST",
|
|
923
|
+
headers: ideHeaders(a, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
|
|
924
|
+
body: JSON.stringify(chatBody(req, fn)),
|
|
925
|
+
signal: init.signal ?? r0?.signal,
|
|
926
|
+
})
|
|
927
|
+
if (!res.ok) {
|
|
928
|
+
const text = await res.text()
|
|
929
|
+
const e = errorOf(text)
|
|
930
|
+
last = failure(res.status, e.code, e.message || text.trim().slice(0, 300))
|
|
931
|
+
if (res.status === 400 && wrongFunction(e.code)) continue
|
|
932
|
+
break
|
|
933
|
+
}
|
|
934
|
+
const ct = res.headers.get("content-type") ?? ""
|
|
935
|
+
if (!ct.includes("event-stream") && ct.includes("json")) {
|
|
936
|
+
// an answer that isn't a stream is an error in a 200
|
|
937
|
+
const e = errorOf(await res.text())
|
|
938
|
+
last = failure(502, e.code, e.message)
|
|
939
|
+
if (wrongFunction(e.code)) continue
|
|
940
|
+
break
|
|
941
|
+
}
|
|
942
|
+
const it = parts(sse(res.body))[Symbol.asyncIterator]()
|
|
943
|
+
const { held, done } = await first(it)
|
|
944
|
+
const err = held.find((p) => p.error)
|
|
945
|
+
if (err) {
|
|
946
|
+
last = failure(502, err.code, err.error)
|
|
947
|
+
if (wrongFunction(err.code)) continue
|
|
948
|
+
break
|
|
949
|
+
}
|
|
950
|
+
fnOf.set(who, fn)
|
|
951
|
+
const out = await openai(req, done ? held : chain(held, it))
|
|
952
|
+
out.headers.set("X-Magpie-Sign-In", signIn)
|
|
953
|
+
return out
|
|
954
|
+
}
|
|
955
|
+
if (!last.headers.has("X-Magpie-Sign-In")) last.headers.set("X-Magpie-Sign-In", signIn)
|
|
956
|
+
return last
|
|
957
|
+
},
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
methods: [{ type: "oauth", label: "Trae CN account (browser)", authorize: browserSignIn }],
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// for tests
|
|
966
|
+
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|