@magpie-community/opencode-trae-auth 0.1.15 → 0.2.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 +88 -30
- package/index.mjs +422 -181
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,29 +1,60 @@
|
|
|
1
1
|
# @magpie-community/opencode-trae-auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
Two providers, one package: **Trae CN** (trae.cn, ByteDance's AI IDE, provider
|
|
4
|
+
id `trae-cn`) and **Trae Global** (trae.ai, the international deployment,
|
|
5
|
+
provider id `trae-global`). Each signs in with its own Trae account, the way
|
|
6
|
+
the IDE does, and makes its model requests. The free tier works as well.
|
|
7
|
+
|
|
8
|
+
The two are separate services — different domains, a different client's
|
|
9
|
+
catalog, barely overlapping model lists — so they are two providers rather
|
|
10
|
+
than a setting. They share one implementation: the request, stream and
|
|
11
|
+
tool-call machinery is the same code, and each realm is a table (hosts,
|
|
12
|
+
client, versions, usage page, fallback models, error codes) in `index.mjs`.
|
|
13
|
+
A fix to that machinery reaches both.
|
|
14
|
+
|
|
15
|
+
| Provider id | Signs in to | Endpoints |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `trae-cn` | Trae CN (trae.cn) | `api.trae.cn`, `trae-api-cn.mchost.guru` |
|
|
18
|
+
| `trae-global` | Trae international (trae.ai) | `growsg-normal.trae.ai`, `coresg-normal.trae.ai` (SG) / `coreva-normal.trae.ai` (US) |
|
|
19
|
+
|
|
20
|
+
> **Experimental (both).** The CN protocol was worked out from two
|
|
21
|
+
> open-source relays, [wangqi233/trae2api](https://github.com/wangqi233/trae2api)
|
|
22
|
+
> and [autumnsentiment/Trae2api-cn](https://github.com/autumnsentiment/Trae2api-cn),
|
|
23
|
+
> and the international one from the same relays' realm table. Neither has
|
|
24
|
+
> been run against a real account here: Trae CN has not been tried at all, and
|
|
25
|
+
> Trae Global has been tried on one Free account in the SG region only — no
|
|
26
|
+
> Pro and no US account. Paid-account behaviour (which models a plan lists,
|
|
27
|
+
> what its allowance shows) is implemented from the interface's shape, not
|
|
28
|
+
> confirmed on a live one. If something fails, open an issue and include the
|
|
29
|
+
> error magpie shows.
|
|
12
30
|
|
|
13
31
|
## Sign-in
|
|
14
32
|
|
|
15
|
-
One way, **Trae CN account (browser)
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
33
|
+
One way per provider, **Trae CN account (browser)** or **Trae Global account
|
|
34
|
+
(browser)**. The plugin opens that realm's authorization page
|
|
35
|
+
(`www.trae.cn/authorization` or `www.trae.ai/authorization`, the IDE's client
|
|
36
|
+
`ono9krqynydwx5`). Sign in there and allow the sign-in; the page then sends the
|
|
37
|
+
browser back to a callback on `http://127.0.0.1:<port>/authorize` — it accepts
|
|
19
38
|
no other kind of callback. The callback carries the account's Cloud-IDE-JWT,
|
|
20
39
|
refresh token and account. Nothing needs pasting back.
|
|
21
40
|
|
|
41
|
+
An account of one realm does not sign in to the other: trae.cn's accounts are
|
|
42
|
+
not trae.ai's.
|
|
43
|
+
|
|
22
44
|
At sign-in the plugin creates the account's device and names it to the
|
|
23
45
|
authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
|
|
24
46
|
request then sends that same device. The relays saw requests dropped when the
|
|
25
47
|
device was new each time.
|
|
26
48
|
|
|
49
|
+
### The international realm's region
|
|
50
|
+
|
|
51
|
+
Trae Global has two deployments. The sign-in's `AIRegion` (or a US auth host
|
|
52
|
+
such as `api-us-east.trae.ai`) selects the US one; anything else uses the SG
|
|
53
|
+
one. The US account's model requests go to `coreva-normal.trae.ai` and its
|
|
54
|
+
usage page to `api-us-east.trae.ai`; the SG account uses
|
|
55
|
+
`coresg-normal.trae.ai` and `api-sg-central.trae.ai`. Nothing has to be
|
|
56
|
+
configured: the region rides in the saved sign-in.
|
|
57
|
+
|
|
27
58
|
## Requests
|
|
28
59
|
|
|
29
60
|
Chat completions (`@ai-sdk/openai-compatible`) are translated for the IDE
|
|
@@ -94,16 +125,18 @@ thinking level, so the plugin lists no variants and a level an agent
|
|
|
94
125
|
picks does nothing.
|
|
95
126
|
|
|
96
127
|
When no
|
|
97
|
-
list can be read, the plugin uses the
|
|
98
|
-
known to serve
|
|
99
|
-
and DeepSeek V4 Flash.
|
|
128
|
+
list can be read, the plugin uses the realm's own fallback list. Trae CN's
|
|
129
|
+
`chat_v3` is known to serve GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus,
|
|
130
|
+
DeepSeek V4 Pro and DeepSeek V4 Flash; Trae Global's serves GPT-5.4,
|
|
131
|
+
Gemini 3 Flash, Kimi K2.5, MiniMax M2 and DeepSeek V3.2. The live list
|
|
132
|
+
replaces either.
|
|
100
133
|
|
|
101
134
|
## Renewal
|
|
102
135
|
|
|
103
|
-
The JWT is renewed with the refresh token at
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
a time.
|
|
136
|
+
The JWT is renewed with the refresh token at the realm's auth host,
|
|
137
|
+
`/cloudide/api/v3/trae/oauth/ExchangeToken` (`api.trae.cn` for Trae CN,
|
|
138
|
+
`growsg-normal.trae.ai` for Trae Global). Trae issues a new refresh token
|
|
139
|
+
each time and spends the old one, so only one renewal runs at a time.
|
|
107
140
|
|
|
108
141
|
magpie renews the JWT ten minutes before it ends (`refreshLead`) through
|
|
109
142
|
`auth.refresh`. Each request also checks it, two minutes before the end, for
|
|
@@ -112,7 +145,8 @@ OpenCode.
|
|
|
112
145
|
The account is marked for a new sign-in in these cases:
|
|
113
146
|
|
|
114
147
|
- the refresh token is turned away;
|
|
115
|
-
- a request is answered 401, or with code
|
|
148
|
+
- a request is answered 401, or with a code that means the sign-in lapsed:
|
|
149
|
+
1001 for both realms, and 20101 for Trae Global.
|
|
116
150
|
|
|
117
151
|
Trae signs other clients out of an account when its token is renewed. The
|
|
118
152
|
IDE may therefore ask you to sign in again after magpie renews.
|
|
@@ -121,13 +155,16 @@ IDE may therefore ask you to sign in again after magpie renews.
|
|
|
121
155
|
|
|
122
156
|
| Trae's answer | What the agent gets |
|
|
123
157
|
| --- | --- |
|
|
124
|
-
| 401 / code 1001 | 401, and the account is marked for a new sign-in |
|
|
125
|
-
| code 4008 / 1005 (quota, plan) | 429 |
|
|
158
|
+
| 401 / code 1001 (both), 20101 (Global) | 401, and the account is marked for a new sign-in |
|
|
159
|
+
| code 4008 / 1005 (quota, plan; both), 4011 (Global) | 429 |
|
|
126
160
|
| Anything else | Trae's message and code, as a 502 or Trae's own status |
|
|
127
161
|
|
|
128
162
|
## Usage
|
|
129
163
|
|
|
130
|
-
magpie's usage card shows the account's
|
|
164
|
+
magpie's usage card shows the account's plan and allowance, from whichever
|
|
165
|
+
page the realm bills on:
|
|
166
|
+
|
|
167
|
+
**Trae CN** — the credits page:
|
|
131
168
|
|
|
132
169
|
- **Source:** `api.trae.cn/trae/api/v2/pay/ide_user_ent_usage`.
|
|
133
170
|
- **What it adds up:** each entitlement pack's `credits_limit` (-1 means
|
|
@@ -136,21 +173,42 @@ magpie's usage card shows the account's credits:
|
|
|
136
173
|
total, with the share, which magpie shows as used or left like
|
|
137
174
|
WorkBuddy's.
|
|
138
175
|
|
|
176
|
+
**Trae Global** — the dollar billing's entitlements:
|
|
177
|
+
|
|
178
|
+
- **Source:** `POST api-sg-central.trae.ai/trae/api/v1/pay/user_current_entitlement_list`
|
|
179
|
+
(a US account asks `api-us-east.trae.ai`).
|
|
180
|
+
- **What it adds up:** the pack's allowance in dollars
|
|
181
|
+
(`basic_usage_limit` plus `bonus_usage_limit`) and what was used of each
|
|
182
|
+
(`basic_usage_amount`, `bonus_usage_amount`).
|
|
183
|
+
- **What it shows:** one Dollar Usage window, the dollars used of the
|
|
184
|
+
allowance, resetting when the pack ends.
|
|
185
|
+
|
|
186
|
+
A 200 carrying an error code is shown as Trae's own message rather than as
|
|
187
|
+
an empty answer.
|
|
188
|
+
|
|
139
189
|
## Daily check-in
|
|
140
190
|
|
|
141
|
-
Trae CN gives credits for a daily check-in (每日签到)
|
|
191
|
+
Trae CN gives credits for a daily check-in (每日签到); Trae Global has no
|
|
192
|
+
such page, so this is CN only. magpie can press it
|
|
142
193
|
once a day for each account (Settings, or the switch on the usage card).
|
|
143
194
|
It asks Trae CN's own pages through
|
|
144
|
-
this plugin's fetch, which sends them as the account
|
|
145
|
-
(renewed first when near its
|
|
195
|
+
this plugin's fetch, which sends them as the account, as Trae CN's IDE
|
|
196
|
+
(3.3.104) sends them: its Cloud-IDE-JWT (renewed first when near its
|
|
197
|
+
end), its device (`x-device-id`, `x-device-brand`, `x-device-type`,
|
|
198
|
+
`x-os-version`, `x-app-version`) and a client's User-Agent (TRAE SOLO
|
|
199
|
+
CN's Electron shell). Since 0.2.1: Bun's own User-Agent and the chat's
|
|
200
|
+
headers got 9074 「当前参与用户太多,请稍后再试」 every time
|
|
201
|
+
(yetone/magpie#808).
|
|
146
202
|
|
|
147
203
|
- **Status:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/status`,
|
|
148
|
-
body `{}
|
|
204
|
+
body `{"req_source":1}` (magpie's `{}` is sent as that): `enable`,
|
|
205
|
+
`checked_in`, `credits`.
|
|
149
206
|
- **Claim:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/claim`,
|
|
150
|
-
|
|
207
|
+
the same, only while it is on and today's isn't in.
|
|
151
208
|
- **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
|
|
152
209
|
the account for a new sign-in. Code 9095 means this device has checked
|
|
153
|
-
in today; nothing sends another device id to get round it
|
|
210
|
+
in today; nothing sends another device id to get round it, nor to get
|
|
211
|
+
round a 9074.
|
|
154
212
|
|
|
155
213
|
Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
|
|
156
214
|
that isn't a chat request is still refused (400). Needs 0.1.4 or later.
|
package/index.mjs
CHANGED
|
@@ -12,45 +12,205 @@
|
|
|
12
12
|
import { randomBytes, randomUUID, randomInt } from "node:crypto"
|
|
13
13
|
import { createServer, STATUS_CODES } from "node:http"
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
const SITES = {
|
|
16
|
+
// Trae CN (trae.cn): the authorization page sends the browser back to a
|
|
17
|
+
// callback on 127.0.0.1 with the account's Cloud-IDE-JWT and refresh
|
|
18
|
+
// token; the refresh token is exchanged for a new JWT at
|
|
19
|
+
// /cloudide/api/v3/trae/oauth/ExchangeToken; model requests go to the IDE
|
|
20
|
+
// agent's /api/agent/v3/llm_utils_chat, which answers in its own SSE
|
|
21
|
+
// events. Worked out from two open-source relays (wangqi233/trae2api and
|
|
22
|
+
// autumnsentiment/Trae2api-cn); not run against a real account here.
|
|
23
|
+
// The account's fetch also sends Trae CN's own pages on api.trae.cn
|
|
24
|
+
// (/trae/api/…) as the account, for magpie's daily check-in (每日签到:
|
|
25
|
+
// /trae/api/v2/ug/checkin_credits/status, then /claim).
|
|
26
|
+
"trae-cn": {
|
|
27
|
+
id: "trae-cn",
|
|
28
|
+
name: "Trae CN",
|
|
29
|
+
realm: "Trae CN", // the name the errors and messages carry
|
|
30
|
+
webHost: "trae.cn", // the bare host the sign-in copy names
|
|
31
|
+
// where each part of Trae CN is served; tests point them at a fake
|
|
32
|
+
hosts: {
|
|
33
|
+
web: "https://www.trae.cn", // the authorization page
|
|
34
|
+
auth: "https://api.trae.cn", // tokens, the account, credits
|
|
35
|
+
api: "https://trae-api-cn.mchost.guru", // the models
|
|
36
|
+
},
|
|
37
|
+
clientId: "ono9krqynydwx5", // Trae CN's IDE
|
|
38
|
+
appId: "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
|
39
|
+
ideVersion: "3.3.65", // named to the authorization page
|
|
40
|
+
ideVersionCode: "20260401",
|
|
41
|
+
// the client the model host is told it serves: TRAE SOLO CN 0.1.69, as
|
|
42
|
+
// its ai-agent names itself (same app id). Trae offers a model only to
|
|
43
|
+
// clients new enough for it, and an April IDE was offered no
|
|
44
|
+
// deepseek-v4.1-flash.
|
|
45
|
+
clientVersion: "0.1.69",
|
|
46
|
+
clientVersionCode: "20260917",
|
|
47
|
+
pluginVersion: "2.3.24254",
|
|
48
|
+
brand: "ASUS TUF Gaming A15 FA507RM_FA507RM",
|
|
49
|
+
// the IDE's chat functions: the classic IDE's agent, then SOLO's Work
|
|
50
|
+
// mode, then the TRAE agent (solo_agent, which has deepseek-v4.1-flash),
|
|
51
|
+
// then SOLO Lite's agent
|
|
52
|
+
functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite"],
|
|
53
|
+
// what Trae CN's chat_v3 is known to serve; the live list replaces it
|
|
54
|
+
// (the MODEL defaults are spread in at the end of the module, once MODEL
|
|
55
|
+
// is defined, so a site's entry only names what differs)
|
|
56
|
+
models: {
|
|
57
|
+
"glm-5.2": { name: "GLM-5.2", limit: { context: 200_000, output: 32_000 } },
|
|
58
|
+
"glm-5": { name: "GLM-5" },
|
|
59
|
+
"kimi-k2.6": { name: "Kimi K2.6", limit: { context: 256_000, output: 0 } },
|
|
60
|
+
"qwen-3.7-plus": { name: "Qwen 3.7 Plus" },
|
|
61
|
+
"DeepSeek-V4-Pro": { name: "DeepSeek V4 Pro" },
|
|
62
|
+
"DeepSeek-V4-Flash": { name: "DeepSeek V4 Flash" },
|
|
63
|
+
},
|
|
64
|
+
// the model host for an account: Trae CN's own hosts serve no models,
|
|
65
|
+
// so only a host that is one is taken, else the one gateway
|
|
66
|
+
apiOf: (a) => {
|
|
67
|
+
const h = String(a?.api ?? "").replace(/\/+$/, "")
|
|
68
|
+
return /mchost\.guru|trae-api-/i.test(h) ? h : SITES["trae-cn"].hosts.api
|
|
69
|
+
},
|
|
70
|
+
// whether a host named by a sign-in is one of the model hosts
|
|
71
|
+
modelHost: (h) => /mchost\.guru|trae-api-/i.test(String(h ?? "")),
|
|
72
|
+
// ownPage: url is one of Trae CN's own JSON pages on api.trae.cn
|
|
73
|
+
// (/trae/api/…: the daily check-in, credits), which the account's fetch
|
|
74
|
+
// sends as the account rather than as a chat
|
|
75
|
+
ownPage: (url) => {
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(url)
|
|
78
|
+
return u.origin === new URL(SITES["trae-cn"].hosts.auth).origin && u.pathname.startsWith("/trae/api/")
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
// Trae CN's account info carries no region: its one deployment is the
|
|
84
|
+
// one it is asked through
|
|
85
|
+
regionOf: () => "",
|
|
86
|
+
// where usage is asked, and which page it is (CN: credits)
|
|
87
|
+
usage: {
|
|
88
|
+
kind: "credits",
|
|
89
|
+
path: "/trae/api/v2/pay/ide_user_ent_usage",
|
|
90
|
+
label: "credits",
|
|
91
|
+
host: (site) => site.hosts.auth,
|
|
92
|
+
body: { require_usage: true, req_source: 0 },
|
|
93
|
+
read: (v, a, signIn, { credits, round }) => {
|
|
94
|
+
const c = credits(v)
|
|
95
|
+
const out = { plan: v.is_credits_billing ? "Credits" : "Free", user: a.name || a.uid, signIn }
|
|
96
|
+
if (c.unlimited) out.balance = "unlimited"
|
|
97
|
+
else if (c.limit > 0) {
|
|
98
|
+
// the count rides on the window (amount of limit, in credits), as
|
|
99
|
+
// WorkBuddy's does, so magpie says it in the window's row, used or
|
|
100
|
+
// left, in its own words and number format, rather than as an
|
|
101
|
+
// English balance line beside it (yetone/magpie#694)
|
|
102
|
+
const w = { name: "Credits", used: Math.max(0, Math.min(100, (c.used / c.limit) * 100)), amount: round(c.used), limit: round(c.limit), unit: "credits" }
|
|
103
|
+
if (c.until) w.resetsAt = new Date(c.until).toISOString()
|
|
104
|
+
out.windows = [w]
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
// Trae international (trae.ai): the same IDE sign-in flow against the
|
|
111
|
+
// international deployment. Its own authorization page, its own auth and
|
|
112
|
+
// chat hosts, a second (US) deployment the account's region selects, and
|
|
113
|
+
// the dollar billing's entitlements rather than the CN credits page.
|
|
114
|
+
// Worked out from the realm table of wangqi233/trae2api (cn/sg dual-region)
|
|
115
|
+
// and the login URL of wefew/trae2api; run against one Free SG account.
|
|
116
|
+
"trae-global": {
|
|
117
|
+
id: "trae-global",
|
|
118
|
+
name: "Trae Global",
|
|
119
|
+
realm: "Trae Global",
|
|
120
|
+
// its lists mark Auto-mode channels and pay-as-you-go twins
|
|
121
|
+
// is_invisible_to_user, which its picker hides; Trae CN's lists are
|
|
122
|
+
// served whole, as they were before Global joined this package
|
|
123
|
+
hidesInvisible: true,
|
|
124
|
+
webHost: "trae.ai",
|
|
125
|
+
hosts: {
|
|
126
|
+
web: "https://www.trae.ai", // the authorization page
|
|
127
|
+
auth: "https://growsg-normal.trae.ai", // tokens, the account
|
|
128
|
+
api: "https://coresg-normal.trae.ai", // the models, SG
|
|
129
|
+
us: "https://coreva-normal.trae.ai", // the models, US
|
|
130
|
+
pay: "https://api-sg-central.trae.ai", // usage
|
|
131
|
+
usPay: "https://api-us-east.trae.ai", // usage, the US deployment
|
|
132
|
+
},
|
|
133
|
+
clientId: "ono9krqynydwx5",
|
|
134
|
+
appId: "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
|
135
|
+
ideVersion: "3.5.51", // named to the international IDE's current build
|
|
136
|
+
ideVersionCode: "20260401",
|
|
137
|
+
clientVersion: "0.1.69",
|
|
138
|
+
clientVersionCode: "20260917",
|
|
139
|
+
pluginVersion: "2.3.62834",
|
|
140
|
+
brand: "ASUS TUF Gaming A15 FA507RM_FA507RM",
|
|
141
|
+
functions: ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite"],
|
|
142
|
+
// what Trae Global's chat is known to serve (its catalog barely overlaps
|
|
143
|
+
// the CN one's); the live list replaces it
|
|
144
|
+
models: {
|
|
145
|
+
"gpt-5.4": { name: "GPT-5.4" },
|
|
146
|
+
"gemini-3-flash": { name: "Gemini 3 Flash", limit: { context: 1_000_000, output: 0 } },
|
|
147
|
+
"kimi-k2.5": { name: "Kimi K2.5", limit: { context: 256_000, output: 0 } },
|
|
148
|
+
"minimax-m2": { name: "MiniMax M2" },
|
|
149
|
+
"deepseek-v3.2": { name: "DeepSeek V3.2" },
|
|
150
|
+
},
|
|
151
|
+
// the model host: the account's region (or a US host the sign-in named)
|
|
152
|
+
// selects the US deployment, else the SG one
|
|
153
|
+
apiOf: (a) => {
|
|
154
|
+
const h = String(a?.api ?? "").replace(/\/+$/, "")
|
|
155
|
+
const region = String(a?.region ?? "").toUpperCase()
|
|
156
|
+
if (/coreva/i.test(h) || /api-us(?:[-.]|$)/i.test(h) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
|
|
157
|
+
if (/coresg/i.test(h)) return h
|
|
158
|
+
return SITES["trae-global"].hosts.api
|
|
159
|
+
},
|
|
160
|
+
// a sign-in names a host of Trae's, which can be an auth host
|
|
161
|
+
// (api-us-east), not the chat host
|
|
162
|
+
modelHost: (h) => /trae\.ai/i.test(String(h ?? "")),
|
|
163
|
+
// Trae Global serves no own JSON pages through the account's fetch
|
|
164
|
+
ownPage: () => false,
|
|
165
|
+
// the account's region, from the sign-in's user info
|
|
166
|
+
regionOf: (s) => String(s?.region ?? s?.aiRegion ?? s?.Region ?? s?.AIRegion ?? "").trim(),
|
|
167
|
+
// the international deployment also answers these
|
|
168
|
+
lapsed: [1001, "1001", 20101, "20101", 401, "401"],
|
|
169
|
+
quota: [4008, "4008", 1005, "1005", 4011, "4011"],
|
|
170
|
+
// where usage is asked, and which page it is (Global: the dollar
|
|
171
|
+
// billing's entitlements)
|
|
172
|
+
usage: {
|
|
173
|
+
kind: "dollar",
|
|
174
|
+
path: "/trae/api/v1/pay/user_current_entitlement_list",
|
|
175
|
+
label: "usage",
|
|
176
|
+
// the US account pays on its own host, the rest on the central one —
|
|
177
|
+
// the auth host's own /cloudide/api/v2/pay pages answer 403 Api Scope
|
|
178
|
+
// Forbidden here
|
|
179
|
+
host: (site, a) => (/^US(?:[-_ ]|$)/i.test(String(a?.region ?? "")) || /api-us/i.test(String(a?.api ?? "")) ? site.hosts.usPay : site.hosts.pay),
|
|
180
|
+
body: {},
|
|
181
|
+
read: (v, a, signIn, { round }) => {
|
|
182
|
+
const d = dollarUsageOf(v)
|
|
183
|
+
if (!d) return { error: "Trae Global usage: no entitlement pack in the answer", signIn, user: a.name }
|
|
184
|
+
const total = d.basic + d.bonus
|
|
185
|
+
const used = d.used + d.usedBonus
|
|
186
|
+
const out = { plan: d.plan || "Free", user: a.name || a.uid, signIn }
|
|
187
|
+
if (total > 0) {
|
|
188
|
+
// the allowance rides the window (amount of limit, in dollars), as
|
|
189
|
+
// WorkBuddy's does, so magpie says it in the window's row, used or
|
|
190
|
+
// left, in its own words and number format (yetone/magpie#694)
|
|
191
|
+
const w = { name: "Dollar Usage", used: Math.round(Math.max(0, Math.min(100, (used / total) * 100)) * 100) / 100, amount: round(used), limit: round(total), unit: "usd" }
|
|
192
|
+
if (d.end) w.resetsAt = new Date(d.end).toISOString()
|
|
193
|
+
out.windows = [w]
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
21
199
|
}
|
|
22
|
-
|
|
23
|
-
const APP_ID = "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8"
|
|
24
|
-
const IDE_VERSION = "3.3.65" // named to the authorization page
|
|
25
|
-
const IDE_VERSION_CODE = "20260401"
|
|
26
|
-
// the client the model host is told it serves: TRAE SOLO CN 0.1.69, as its
|
|
27
|
-
// ai-agent names itself (same app id). Trae offers a model only to clients
|
|
28
|
-
// new enough for it, and an April IDE was offered no deepseek-v4.1-flash.
|
|
29
|
-
const CLIENT_VERSION = "0.1.69"
|
|
30
|
-
const CLIENT_VERSION_CODE = "20260917"
|
|
31
|
-
const PLUGIN_VERSION = "2.3.24254"
|
|
32
|
-
const BRAND = "ASUS TUF Gaming A15 FA507RM_FA507RM"
|
|
200
|
+
|
|
33
201
|
const SIGN_IN_TIMEOUT = 10 * 60 * 1000
|
|
34
202
|
const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
|
|
35
203
|
const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
|
|
36
204
|
const DAY = 24 * 3600 * 1000
|
|
37
|
-
// the IDE's chat functions: the classic IDE's agent, then SOLO's Work
|
|
38
|
-
// mode, then the TRAE agent (solo_agent, which has deepseek-v4.1-flash),
|
|
39
|
-
// then SOLO Lite's agent
|
|
40
|
-
const FUNCTIONS = ["chat_v3", "solo_work_lite", "solo_agent", "solo_agent_lite"]
|
|
41
205
|
|
|
42
206
|
// a reply limit of 0 is one Trae doesn't say: magpie then takes models.dev's
|
|
43
207
|
// for the model (magpie's catalog), where a made-up 32K told agents a far
|
|
44
208
|
// smaller one than DeepSeek V4.1 Flash's (ARNO on magpie's Discord)
|
|
45
209
|
const MODEL = { attachment: false, tool_call: true, reasoning: true, temperature: true, limit: { context: 128_000, output: 0 }, modalities: { input: ["text"], output: ["text"] } }
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"kimi-k2.6": { name: "Kimi K2.6", ...MODEL, limit: { context: 256_000, output: 0 } },
|
|
51
|
-
"qwen-3.7-plus": { name: "Qwen 3.7 Plus", ...MODEL },
|
|
52
|
-
"DeepSeek-V4-Pro": { name: "DeepSeek V4 Pro", ...MODEL },
|
|
53
|
-
"DeepSeek-V4-Flash": { name: "DeepSeek V4 Flash", ...MODEL },
|
|
210
|
+
// a site's fallback list names only what differs from MODEL: the defaults go
|
|
211
|
+
// in here, where MODEL is defined (a site table above can't spread it)
|
|
212
|
+
for (const site of Object.values(SITES)) {
|
|
213
|
+
for (const [id, m] of Object.entries(site.models)) site.models[id] = { ...MODEL, ...m }
|
|
54
214
|
}
|
|
55
215
|
|
|
56
216
|
const digits = (n) => Array.from({ length: n }, (_, i) => (i === 0 ? randomInt(1, 10) : randomInt(0, 10))).join("")
|
|
@@ -99,7 +259,7 @@ const parseJSON = (s) => {
|
|
|
99
259
|
// The sign-in is kept as OpenCode keeps an OAuth one: access the
|
|
100
260
|
// Cloud-IDE-JWT, refresh the refresh token, expires the JWT's end; beside
|
|
101
261
|
// them the account, the client it was issued to and its device.
|
|
102
|
-
function toAuth(s) {
|
|
262
|
+
function toAuth(s, site) {
|
|
103
263
|
return {
|
|
104
264
|
type: "oauth",
|
|
105
265
|
access: s.token,
|
|
@@ -107,15 +267,16 @@ function toAuth(s) {
|
|
|
107
267
|
expires: s.expires || jwtExp(s.token) || Date.now() + DAY,
|
|
108
268
|
accountId: s.name || s.uid,
|
|
109
269
|
uid: s.uid,
|
|
110
|
-
clientId: s.clientId ||
|
|
270
|
+
clientId: s.clientId || site.clientId,
|
|
111
271
|
deviceId: s.deviceId,
|
|
112
272
|
machineId: s.machineId,
|
|
113
273
|
// the model host the sign-in named, if it named one
|
|
114
|
-
...(
|
|
274
|
+
...(site.modelHost(s.api) ? { api: s.api } : {}),
|
|
275
|
+
...(s.region ? { region: s.region } : {}),
|
|
115
276
|
}
|
|
116
277
|
}
|
|
117
278
|
|
|
118
|
-
function fromAuth(auth) {
|
|
279
|
+
function fromAuth(auth, site) {
|
|
119
280
|
if (auth?.type !== "oauth" || !auth.access) return null
|
|
120
281
|
return {
|
|
121
282
|
token: auth.access,
|
|
@@ -123,31 +284,24 @@ function fromAuth(auth) {
|
|
|
123
284
|
expires: Number(auth.expires) || 0,
|
|
124
285
|
uid: String(auth.uid ?? ""),
|
|
125
286
|
name: String(auth.accountId ?? ""),
|
|
126
|
-
clientId: auth.clientId ||
|
|
287
|
+
clientId: auth.clientId || site.clientId,
|
|
127
288
|
deviceId: auth.deviceId || "",
|
|
128
289
|
machineId: auth.machineId || "",
|
|
129
290
|
api: auth.api || "",
|
|
291
|
+
region: site.regionOf(auth),
|
|
130
292
|
}
|
|
131
293
|
}
|
|
132
294
|
|
|
133
|
-
// apiOf is the model host for an account
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
function apiOf(a) {
|
|
137
|
-
const h = String(a?.api ?? "").replace(/\/+$/, "")
|
|
138
|
-
return /mchost\.guru|trae-api-/i.test(h) ? h : HOSTS.api
|
|
295
|
+
// apiOf is the model host for an account
|
|
296
|
+
function apiOf(a, site) {
|
|
297
|
+
return site.apiOf(a)
|
|
139
298
|
}
|
|
140
299
|
|
|
141
|
-
// ownPage: url is one of Trae
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
function ownPage(url) {
|
|
145
|
-
|
|
146
|
-
const u = new URL(url)
|
|
147
|
-
return u.origin === new URL(HOSTS.auth).origin && u.pathname.startsWith("/trae/api/")
|
|
148
|
-
} catch {
|
|
149
|
-
return false
|
|
150
|
-
}
|
|
300
|
+
// ownPage: url is one of Trae's own JSON pages (the daily check-in,
|
|
301
|
+
// credits), which the account's fetch sends as the account rather than as
|
|
302
|
+
// a chat
|
|
303
|
+
function ownPage(url, site) {
|
|
304
|
+
return site.ownPage(url)
|
|
151
305
|
}
|
|
152
306
|
|
|
153
307
|
// ---- errors --------------------------------------------------------------------
|
|
@@ -173,9 +327,13 @@ function errorOf(v) {
|
|
|
173
327
|
}
|
|
174
328
|
|
|
175
329
|
// lapsed: Trae turned the token away. 1001 is its "not signed in".
|
|
176
|
-
|
|
330
|
+
// The realms differ in which codes they send: the international deployment
|
|
331
|
+
// also answers 20101 for a token it no longer takes.
|
|
332
|
+
const CN_LAPSED = [1001, "1001", 401, "401"]
|
|
333
|
+
const lapsedCode = (code, site) => (site?.lapsed ?? CN_LAPSED).includes(code)
|
|
177
334
|
const lapsedWords = /not ?log(ged)? ?in|unauthori[sz]ed|token (is )?(expired|invalid)|jwt|未登录|登录(已)?(过期|失效)/i
|
|
178
|
-
const
|
|
335
|
+
const CN_QUOTA = [4008, "4008", 1005, "1005"]
|
|
336
|
+
const quotaCode = (code, site) => (site?.quota ?? CN_QUOTA).includes(code)
|
|
179
337
|
const quotaWords = /quota|credit|insufficient|exceed|limit|额度|积分|次数|上限|用完/i
|
|
180
338
|
|
|
181
339
|
// errorResponse is an OpenAI-style error, as the agent reads one
|
|
@@ -190,17 +348,17 @@ const errorResponse = (status, message, signIn) => {
|
|
|
190
348
|
// exchange trades a refresh token for a new Cloud-IDE-JWT, as the IDE
|
|
191
349
|
// does: the answer's Result holds the new token pair and the account.
|
|
192
350
|
// Trae issues a new refresh token each time and spends the old one.
|
|
193
|
-
async function exchange(refresh, clientId, host =
|
|
351
|
+
async function exchange(refresh, clientId, site, host = site.hosts.auth) {
|
|
194
352
|
let res
|
|
195
353
|
try {
|
|
196
354
|
res = await fetch(host.replace(/\/+$/, "") + "/cloudide/api/v3/trae/oauth/ExchangeToken", {
|
|
197
355
|
method: "POST",
|
|
198
356
|
headers: { "Content-Type": "application/json" },
|
|
199
|
-
body: JSON.stringify({ ClientID: clientId ||
|
|
357
|
+
body: JSON.stringify({ ClientID: clientId || site.clientId, RefreshToken: refresh, ClientSecret: "-", UserID: "" }),
|
|
200
358
|
signal: AbortSignal.timeout(20_000),
|
|
201
359
|
})
|
|
202
360
|
} catch (e) {
|
|
203
|
-
throw new Error(
|
|
361
|
+
throw new Error(`${site.realm}: renewing the sign-in: ` + (e?.message ?? e))
|
|
204
362
|
}
|
|
205
363
|
const text = await res.text()
|
|
206
364
|
const v = parseJSON(text)
|
|
@@ -216,6 +374,7 @@ async function exchange(refresh, clientId, host = HOSTS.auth) {
|
|
|
216
374
|
uid: String(info.UserID ?? info.userId ?? r.UserID ?? ""),
|
|
217
375
|
name: String(info.ScreenName ?? info.screenName ?? ""),
|
|
218
376
|
api: info.Host ?? "",
|
|
377
|
+
region: site.regionOf(info),
|
|
219
378
|
}
|
|
220
379
|
}
|
|
221
380
|
const e = errorOf(text)
|
|
@@ -223,9 +382,9 @@ async function exchange(refresh, clientId, host = HOSTS.auth) {
|
|
|
223
382
|
// a refresh token Trae no longer takes (spent, revoked, past its end, or
|
|
224
383
|
// of another client) is a sign-in to make again
|
|
225
384
|
if ([400, 401, 403].includes(res.status) || /refresh|expired|invalid|not matched|revoked|unauthori/i.test(msg) || String(e.code) === "10101") {
|
|
226
|
-
throw new Expired(
|
|
385
|
+
throw new Expired(`${site.realm}'s sign-in has expired (${msg}); sign in again`)
|
|
227
386
|
}
|
|
228
|
-
throw new Error(
|
|
387
|
+
throw new Error(`${site.realm}: renewing the sign-in: ${statusLine(res.status)} ${msg}`.trim())
|
|
229
388
|
}
|
|
230
389
|
|
|
231
390
|
// ---- the browser sign-in --------------------------------------------------------
|
|
@@ -239,7 +398,7 @@ const page = (ok, title, text) => `<!doctype html><meta charset="utf-8"><meta na
|
|
|
239
398
|
// signedIn reads trae.cn's callback: userJwt (the token pair) and userInfo
|
|
240
399
|
// (the account), or, from its older page, a refresh token alone, which is
|
|
241
400
|
// exchanged for the pair
|
|
242
|
-
async function signedIn(q, device) {
|
|
401
|
+
async function signedIn(q, device, site) {
|
|
243
402
|
const jwt = parseJSON(q.get("userJwt"))
|
|
244
403
|
const info = parseJSON(q.get("userInfo"))
|
|
245
404
|
const host = q.get("host") || info.Host || ""
|
|
@@ -247,23 +406,24 @@ async function signedIn(q, device) {
|
|
|
247
406
|
token: jwt.Token ?? jwt.token ?? "",
|
|
248
407
|
refresh: jwt.RefreshToken ?? jwt.refreshToken ?? q.get("refreshToken") ?? "",
|
|
249
408
|
expires: whenOf(jwt.TokenExpireAt ?? jwt.tokenExpireAt),
|
|
250
|
-
clientId: jwt.ClientID ?? jwt.clientId ?? q.get("clientId") ??
|
|
409
|
+
clientId: jwt.ClientID ?? jwt.clientId ?? q.get("clientId") ?? site.clientId,
|
|
251
410
|
uid: String(info.UserID ?? info.userId ?? q.get("userId") ?? ""),
|
|
252
411
|
name: String(info.ScreenName ?? info.screenName ?? ""),
|
|
253
412
|
api: host,
|
|
413
|
+
region: site.regionOf(info),
|
|
254
414
|
}
|
|
255
415
|
if (!s.token && s.refresh) {
|
|
256
|
-
const x = await exchange(s.refresh, s.clientId)
|
|
257
|
-
s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api }
|
|
416
|
+
const x = await exchange(s.refresh, s.clientId, site)
|
|
417
|
+
s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region }
|
|
258
418
|
}
|
|
259
|
-
if (!s.token) throw new Error(
|
|
260
|
-
if (!s.refresh) throw new Error(
|
|
419
|
+
if (!s.token) throw new Error(`${site.webHost} sent back no token`)
|
|
420
|
+
if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
|
|
261
421
|
return { ...s, ...device }
|
|
262
422
|
}
|
|
263
423
|
|
|
264
424
|
// browserSignIn is the IDE's sign-in: trae.cn's authorization page, back
|
|
265
425
|
// to a callback on 127.0.0.1 (the page takes no other kind)
|
|
266
|
-
async function browserSignIn() {
|
|
426
|
+
async function browserSignIn(site) {
|
|
267
427
|
const device = newDevice()
|
|
268
428
|
const trace = randomUUID()
|
|
269
429
|
let over = false
|
|
@@ -275,7 +435,7 @@ async function browserSignIn() {
|
|
|
275
435
|
settle(result)
|
|
276
436
|
}
|
|
277
437
|
const server = createServer(async (req, res) => {
|
|
278
|
-
//
|
|
438
|
+
// the site's page may call the callback from script as well as open it
|
|
279
439
|
const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }
|
|
280
440
|
if (req.method === "OPTIONS") return res.writeHead(204, cors).end()
|
|
281
441
|
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
@@ -291,11 +451,11 @@ async function browserSignIn() {
|
|
|
291
451
|
finish({ type: "failed", error: msg })
|
|
292
452
|
return html(page(false, "Sign-in didn't finish", msg))
|
|
293
453
|
}
|
|
294
|
-
if (!q.get("userJwt") && !q.get("refreshToken")) return html(page(false, "Nothing to sign in with",
|
|
454
|
+
if (!q.get("userJwt") && !q.get("refreshToken")) return html(page(false, "Nothing to sign in with", `${site.webHost} sent no token; start the sign-in again.`))
|
|
295
455
|
try {
|
|
296
|
-
const s = await signedIn(q, device)
|
|
297
|
-
finish({ ...toAuth(s), type: "success" })
|
|
298
|
-
html(page(true, "You're signed in", `${s.name || s.uid ||
|
|
456
|
+
const s = await signedIn(q, device, site)
|
|
457
|
+
finish({ ...toAuth(s, site), type: "success" })
|
|
458
|
+
html(page(true, "You're signed in", `${s.name || s.uid || `Your ${site.name} account`} is signed in. You can close this tab.`))
|
|
299
459
|
} catch (e) {
|
|
300
460
|
finish({ type: "failed", error: e.message })
|
|
301
461
|
html(page(false, "Sign-in didn't finish", e.message))
|
|
@@ -315,9 +475,9 @@ async function browserSignIn() {
|
|
|
315
475
|
login_version: "1",
|
|
316
476
|
auth_from: "solo",
|
|
317
477
|
login_channel: "native_ide",
|
|
318
|
-
plugin_version:
|
|
478
|
+
plugin_version: site.pluginVersion,
|
|
319
479
|
auth_type: "local",
|
|
320
|
-
client_id:
|
|
480
|
+
client_id: site.clientId,
|
|
321
481
|
redirect: "0",
|
|
322
482
|
login_trace_id: trace,
|
|
323
483
|
auth_callback_url: callback,
|
|
@@ -325,17 +485,17 @@ async function browserSignIn() {
|
|
|
325
485
|
device_id: device.deviceId,
|
|
326
486
|
x_device_id: device.deviceId,
|
|
327
487
|
x_machine_id: device.machineId,
|
|
328
|
-
x_device_brand:
|
|
488
|
+
x_device_brand: site.brand,
|
|
329
489
|
x_device_type: "windows",
|
|
330
490
|
x_os_version: "Windows 10 Pro",
|
|
331
491
|
x_env: "",
|
|
332
|
-
x_app_version:
|
|
492
|
+
x_app_version: site.ideVersion,
|
|
333
493
|
x_app_type: "stable",
|
|
334
494
|
hide_saas_login: "true",
|
|
335
495
|
})
|
|
336
496
|
return {
|
|
337
|
-
url: `${
|
|
338
|
-
instructions:
|
|
497
|
+
url: `${site.hosts.web}/authorization?${q}`,
|
|
498
|
+
instructions: `Sign in to ${site.name} (${site.webHost}) in the browser and allow the sign-in. It finishes here by itself.`,
|
|
339
499
|
method: "auto",
|
|
340
500
|
callback: () => done,
|
|
341
501
|
}
|
|
@@ -344,24 +504,24 @@ async function browserSignIn() {
|
|
|
344
504
|
// ---- requests ---------------------------------------------------------------------
|
|
345
505
|
|
|
346
506
|
// headers are the IDE's, for the account and its device
|
|
347
|
-
function ideHeaders(a, extra = {}) {
|
|
507
|
+
function ideHeaders(a, site, extra = {}) {
|
|
348
508
|
return {
|
|
349
509
|
"Content-Type": "application/json",
|
|
350
510
|
Authorization: `Cloud-IDE-JWT ${a.token}`,
|
|
351
511
|
"X-Cloudide-Token": a.token,
|
|
352
512
|
"x-ide-token": a.token,
|
|
353
513
|
"x-uid": a.uid,
|
|
354
|
-
"x-app-id":
|
|
514
|
+
"x-app-id": site.appId,
|
|
355
515
|
"x-device-id": a.deviceId,
|
|
356
516
|
"x-machine-id": a.machineId,
|
|
357
517
|
"x-request-id": randomUUID(),
|
|
358
|
-
"x-app-version":
|
|
359
|
-
"x-app-version-code":
|
|
360
|
-
"x-ide-version":
|
|
361
|
-
"x-ide-version-code":
|
|
518
|
+
"x-app-version": site.clientVersion,
|
|
519
|
+
"x-app-version-code": site.clientVersionCode,
|
|
520
|
+
"x-ide-version": site.clientVersion,
|
|
521
|
+
"x-ide-version-code": site.clientVersionCode,
|
|
362
522
|
"x-ide-version-type": "stable",
|
|
363
523
|
"x-device-cpu": "AMD",
|
|
364
|
-
"x-device-brand":
|
|
524
|
+
"x-device-brand": site.brand,
|
|
365
525
|
"x-device-type": "windows",
|
|
366
526
|
"x-os-version": "Windows 10",
|
|
367
527
|
"x-system-type": "Windows",
|
|
@@ -369,6 +529,42 @@ function ideHeaders(a, extra = {}) {
|
|
|
369
529
|
}
|
|
370
530
|
}
|
|
371
531
|
|
|
532
|
+
// ugUA is the User-Agent a growth page (/trae/api/v2/ug/…: the daily
|
|
533
|
+
// check-in) is sent with: TRAE SOLO CN's Electron shell, as its version
|
|
534
|
+
// is named. Bun's own (Bun/1.x) reads as a script, and Trae answers a
|
|
535
|
+
// script's check-in with 9074 「当前参与用户太多,请稍后再试」 however
|
|
536
|
+
// seldom it asks (yetone/magpie#808; qilimixingkong/trae-checkin found
|
|
537
|
+
// the same).
|
|
538
|
+
const ugUA = (site) =>
|
|
539
|
+
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) TRAE-SOLO-CN/${site.clientVersion} Chrome/120.0.6099.291 Electron/28.2.10 Safari/537.36`
|
|
540
|
+
|
|
541
|
+
// ugHeaders are the headers Trae CN's IDE (3.3.104) sends a growth page
|
|
542
|
+
// with, and only those: its JWT, then its device (fb: x-device-id,
|
|
543
|
+
// x-device-brand, x-device-type, x-os-version, x-app-version), not the
|
|
544
|
+
// chat's tokens and versions.
|
|
545
|
+
function ugHeaders(a, site, extra = {}) {
|
|
546
|
+
return {
|
|
547
|
+
"Content-Type": "application/json",
|
|
548
|
+
Authorization: `Cloud-IDE-JWT ${a.token}`,
|
|
549
|
+
"User-Agent": ugUA(site),
|
|
550
|
+
"x-device-id": a.deviceId,
|
|
551
|
+
"x-device-brand": site.brand,
|
|
552
|
+
"x-device-type": "Windows",
|
|
553
|
+
"x-os-version": "10.0.22631",
|
|
554
|
+
"x-app-version": site.clientVersion,
|
|
555
|
+
...extra,
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ugPage: url is one of Trae's growth pages (the check-in)
|
|
560
|
+
const ugPage = (url) => {
|
|
561
|
+
try {
|
|
562
|
+
return new URL(url).pathname.startsWith("/trae/api/v2/ug/")
|
|
563
|
+
} catch {
|
|
564
|
+
return false
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
372
568
|
// Trae's chat takes text only and has no turn for a tool call or its
|
|
373
569
|
// result. The agent's tools are named to it natively and in a system
|
|
374
570
|
// prompt that asks for each call as a tagged block; earlier calls and
|
|
@@ -500,28 +696,36 @@ async function* sse(stream) {
|
|
|
500
696
|
yield { event: event || (v && typeof v === "object" ? String(v.event ?? v.type ?? "") : ""), data: v }
|
|
501
697
|
event = ""
|
|
502
698
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
699
|
+
try {
|
|
700
|
+
for (;;) {
|
|
701
|
+
const { value, done } = await reader.read()
|
|
702
|
+
if (done) break
|
|
703
|
+
buf += dec.decode(value, { stream: true })
|
|
704
|
+
let i
|
|
705
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
706
|
+
const line = buf.slice(0, i).replace(/\r$/, "")
|
|
707
|
+
buf = buf.slice(i + 1)
|
|
708
|
+
if (!line) {
|
|
709
|
+
yield* flush()
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (line.startsWith(":")) continue
|
|
713
|
+
if (line.startsWith("event:")) {
|
|
714
|
+
yield* flush()
|
|
715
|
+
event = line.slice(6).trim()
|
|
716
|
+
} else if (line.startsWith("data:")) data.push(line.slice(5).trimStart())
|
|
514
717
|
}
|
|
515
|
-
if (line.startsWith(":")) continue
|
|
516
|
-
if (line.startsWith("event:")) {
|
|
517
|
-
yield* flush()
|
|
518
|
-
event = line.slice(6).trim()
|
|
519
|
-
} else if (line.startsWith("data:")) data.push(line.slice(5).trimStart())
|
|
520
718
|
}
|
|
719
|
+
buf += dec.decode()
|
|
720
|
+
if (buf.startsWith("data:")) data.push(buf.slice(5).trimStart())
|
|
721
|
+
yield* flush()
|
|
722
|
+
} finally {
|
|
723
|
+
// the answer the agent goes away from (a tool call made, a request
|
|
724
|
+
// aborted) closes this generator mid-read: let Trae's connection go,
|
|
725
|
+
// or the read hangs on and the host the next ask reuses is one Trae
|
|
726
|
+
// has dropped — a stream that runs, then dies
|
|
727
|
+
reader.cancel().catch(() => {})
|
|
521
728
|
}
|
|
522
|
-
buf += dec.decode()
|
|
523
|
-
if (buf.startsWith("data:")) data.push(buf.slice(5).trimStart())
|
|
524
|
-
yield* flush()
|
|
525
729
|
}
|
|
526
730
|
|
|
527
731
|
// normalised event names: "TokenUsage", "token-usage" → token_usage
|
|
@@ -1023,17 +1227,21 @@ async function* chain(held, it) {
|
|
|
1023
1227
|
}
|
|
1024
1228
|
|
|
1025
1229
|
// failure is the response for an error Trae answered with
|
|
1026
|
-
function failure(status, code, message) {
|
|
1230
|
+
function failure(site, status, code, message) {
|
|
1027
1231
|
message = message || statusLine(status)
|
|
1028
|
-
if (status === 401 || lapsedCode(code) || (status === 403 && lapsedWords.test(message))) {
|
|
1029
|
-
return errorResponse(401,
|
|
1232
|
+
if (status === 401 || lapsedCode(code, site) || (status === 403 && lapsedWords.test(message))) {
|
|
1233
|
+
return errorResponse(401, `${site.realm}'s sign-in has expired (${message}); sign in again`, "expired")
|
|
1030
1234
|
}
|
|
1031
|
-
if (status === 429 || quotaCode(code) || (status === 403 && quotaWords.test(message))) return errorResponse(429,
|
|
1032
|
-
return errorResponse(status >= 400 ? status : 502,
|
|
1235
|
+
if (status === 429 || quotaCode(code, site) || (status === 403 && quotaWords.test(message))) return errorResponse(429, `${site.realm}: ${message}`)
|
|
1236
|
+
return errorResponse(status >= 400 ? status : 502, `${site.realm}: ${message}${code ? ` (code ${code})` : ""}`)
|
|
1033
1237
|
}
|
|
1034
1238
|
|
|
1035
1239
|
// openai is the answer as an OpenAI chat completion: a stream, or one body
|
|
1036
|
-
|
|
1240
|
+
// abort, when given, lets the answer's reader close Trae's connection the
|
|
1241
|
+
// moment the agent goes away (a tool call made, a request cancelled): a
|
|
1242
|
+
// generator's return can't break a read that is already waiting, so the
|
|
1243
|
+
// fetch itself is what has to be stopped
|
|
1244
|
+
async function openai(req, it, abort, site) {
|
|
1037
1245
|
const id = "chatcmpl-" + randomBytes(12).toString("hex")
|
|
1038
1246
|
const created = Math.floor(Date.now() / 1000)
|
|
1039
1247
|
const model = req.model
|
|
@@ -1044,7 +1252,7 @@ async function openai(req, it) {
|
|
|
1044
1252
|
let usage = null
|
|
1045
1253
|
let finish = ""
|
|
1046
1254
|
for await (const p of it) {
|
|
1047
|
-
if (p.error) return failure(502, p.code, p.error)
|
|
1255
|
+
if (p.error) return failure(site, 502, p.code, p.error)
|
|
1048
1256
|
if (p.text) text += p.text
|
|
1049
1257
|
if (p.reasoning) reasoning += p.reasoning
|
|
1050
1258
|
if (p.call) calls.push(p.call)
|
|
@@ -1106,6 +1314,7 @@ async function openai(req, it) {
|
|
|
1106
1314
|
},
|
|
1107
1315
|
cancel() {
|
|
1108
1316
|
gone = true
|
|
1317
|
+
abort?.()
|
|
1109
1318
|
it.return?.()
|
|
1110
1319
|
},
|
|
1111
1320
|
})
|
|
@@ -1121,12 +1330,16 @@ const wrongFunction = (code) => ["4001", "4023", "1005"].includes(String(code))
|
|
|
1121
1330
|
// The lists also hold the IDE's own helpers (summary, fast_apply, title
|
|
1122
1331
|
// generation: usage other than chat_completion), configs switched off, and
|
|
1123
1332
|
// the slots of custom models, which need a provider bound in the IDE.
|
|
1124
|
-
function chatModel(m) {
|
|
1333
|
+
function chatModel(m, site) {
|
|
1125
1334
|
const id = String(m?.config_name ?? "")
|
|
1126
1335
|
if (!id || /^custom_model/i.test(id)) return false
|
|
1127
1336
|
if (m.usage && m.usage !== "chat_completion") return false
|
|
1128
1337
|
if (m.config_switch === false) return false
|
|
1129
1338
|
if (m.is_custom_model === true || m.display_config?.is_custom_model === true) return false
|
|
1339
|
+
// the IDE's own picker hides these: Auto-mode routing channels and
|
|
1340
|
+
// pay-as-you-go twins of another entry (gemini-3.1-pro vs -paygo/-auto,
|
|
1341
|
+
// the search_agent family), not entries to pick by hand
|
|
1342
|
+
if (site?.hidesInvisible && m.is_invisible_to_user === true) return false
|
|
1130
1343
|
return true
|
|
1131
1344
|
}
|
|
1132
1345
|
|
|
@@ -1179,9 +1392,26 @@ function credits(v) {
|
|
|
1179
1392
|
|
|
1180
1393
|
const round = (n) => Math.round(n * 100) / 100
|
|
1181
1394
|
|
|
1395
|
+
// dollarUsageOf reads an account's entitlement pack as the dollar billing
|
|
1396
|
+
// has it (the international deployment): the period's allowance in dollars
|
|
1397
|
+
// (basic_usage_limit plus bonus_usage_limit) and what was used of each
|
|
1398
|
+
// (basic_usage_amount, bonus_usage_amount). null when the answer carries no
|
|
1399
|
+
// pack.
|
|
1400
|
+
function dollarUsageOf(v) {
|
|
1401
|
+
const pack = (v?.user_entitlement_pack_list ?? []).find((p) => p?.usage || p?.entitlement_base_info?.quota)
|
|
1402
|
+
const quota = pack?.entitlement_base_info?.quota
|
|
1403
|
+
if (!pack || !quota) return null
|
|
1404
|
+
const basic = Number(quota.basic_usage_limit) || 0
|
|
1405
|
+
const bonus = Number(quota.bonus_usage_limit) || 0
|
|
1406
|
+
const used = Number(pack.usage?.basic_usage_amount) || 0
|
|
1407
|
+
const usedBonus = Number(pack.usage?.bonus_usage_amount) || 0
|
|
1408
|
+
const end = whenOf(pack.entitlement_base_info.end_time)
|
|
1409
|
+
return { plan: String(pack.display_desc ?? ""), basic, bonus, used, usedBonus, end }
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1182
1412
|
// ---- the plugin ---------------------------------------------------------------------
|
|
1183
1413
|
|
|
1184
|
-
|
|
1414
|
+
const makePlugin = (site) => async ({ client }) => {
|
|
1185
1415
|
// renewals under way, one to a refresh token (Trae spends it once), and
|
|
1186
1416
|
// the last each account got here, so a sign-in from before it is given
|
|
1187
1417
|
// that one rather than spending the token again
|
|
@@ -1198,7 +1428,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1198
1428
|
|
|
1199
1429
|
const save = async (auth) => {
|
|
1200
1430
|
try {
|
|
1201
|
-
await client?.auth?.set?.({ path: { id:
|
|
1431
|
+
await client?.auth?.set?.({ path: { id: site.id }, body: auth })
|
|
1202
1432
|
} catch {}
|
|
1203
1433
|
}
|
|
1204
1434
|
|
|
@@ -1207,10 +1437,10 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1207
1437
|
let r = renewing.get(key)
|
|
1208
1438
|
if (!r) {
|
|
1209
1439
|
r = (async () => {
|
|
1210
|
-
const x = await exchange(a.refresh, a.clientId)
|
|
1440
|
+
const x = await exchange(a.refresh, a.clientId, site)
|
|
1211
1441
|
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 }
|
|
1212
1442
|
renewed.set(a.uid || a.name, { was: a.refresh, got })
|
|
1213
|
-
if (keep) await save(toAuth(got))
|
|
1443
|
+
if (keep) await save(toAuth(got, site))
|
|
1214
1444
|
return got
|
|
1215
1445
|
})().finally(() => renewing.delete(key))
|
|
1216
1446
|
renewing.set(key, r)
|
|
@@ -1221,8 +1451,8 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1221
1451
|
// fresh is the account with a token that has a while to go, renewed
|
|
1222
1452
|
// when it hasn't
|
|
1223
1453
|
const fresh = async (getAuth) => {
|
|
1224
|
-
const a = fromAuth(await getAuth())
|
|
1225
|
-
if (!a) throw new Expired(
|
|
1454
|
+
const a = fromAuth(await getAuth(), site)
|
|
1455
|
+
if (!a) throw new Expired(`${site.realm}: not signed in`)
|
|
1226
1456
|
const last = renewed.get(a.uid || a.name)
|
|
1227
1457
|
if (last && last.was === a.refresh) return { ...last.got, renewed: false }
|
|
1228
1458
|
if (!a.expires || a.expires - Date.now() > EARLY_MS || !a.refresh) return a
|
|
@@ -1238,7 +1468,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1238
1468
|
// refresh is magpie's auth.refresh: the token renewed LEAD_MS before its
|
|
1239
1469
|
// end, as the fields that changed
|
|
1240
1470
|
const refresh = async (auth) => {
|
|
1241
|
-
const a = fromAuth(auth)
|
|
1471
|
+
const a = fromAuth(auth, site)
|
|
1242
1472
|
if (!a || !a.refresh) return undefined
|
|
1243
1473
|
const last = renewed.get(a.uid || a.name)
|
|
1244
1474
|
if (last && last.was === a.refresh) return { access: last.got.token, refresh: last.got.refresh, expires: last.got.expires }
|
|
@@ -1248,7 +1478,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1248
1478
|
|
|
1249
1479
|
// post asks one of Trae's JSON pages with the account
|
|
1250
1480
|
const post = async (a, url, body) => {
|
|
1251
|
-
const res = await fetch(url, { method: "POST", headers: ideHeaders(a, { Accept: "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) })
|
|
1481
|
+
const res = await fetch(url, { method: "POST", headers: ideHeaders(a, site, { Accept: "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) })
|
|
1252
1482
|
const text = await res.text()
|
|
1253
1483
|
return { status: res.status, text, v: parseJSON(text) }
|
|
1254
1484
|
}
|
|
@@ -1267,18 +1497,22 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1267
1497
|
if (e instanceof Expired) return errorResponse(401, `${a0.name || a0.uid}: ${e.message}`, "expired")
|
|
1268
1498
|
throw e
|
|
1269
1499
|
}
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
// 0.1.6
|
|
1500
|
+
// a growth page (the check-in) goes as the IDE sends it: its headers,
|
|
1501
|
+
// a client's User-Agent, and {req_source: 1} for an empty body. Every
|
|
1502
|
+
// check-in sent with Bun's User-Agent came back 9074, with the chat's
|
|
1503
|
+
// headers and {} (0.1.6, 0.1.9) as with the IDE's (0.1.7)
|
|
1504
|
+
// (yetone/magpie#808). Other pages go with the chat's headers.
|
|
1505
|
+
const ug = ugPage(url)
|
|
1506
|
+
let sent = method === "GET" || method === "HEAD" ? undefined : typeof body === "string" ? body : "{}"
|
|
1507
|
+
if (ug && sent !== undefined && /^\s*(\{\s*\})?\s*$/.test(sent)) sent = JSON.stringify({ req_source: 1 })
|
|
1274
1508
|
const res = await fetch(url, {
|
|
1275
1509
|
method,
|
|
1276
|
-
headers: ideHeaders(a, { Accept: "application/json" }),
|
|
1277
|
-
body:
|
|
1510
|
+
headers: ug ? ugHeaders(a, site, { Accept: "application/json" }) : ideHeaders(a, site, { Accept: "application/json" }),
|
|
1511
|
+
body: sent,
|
|
1278
1512
|
signal: signal ?? AbortSignal.timeout(20_000),
|
|
1279
1513
|
})
|
|
1280
1514
|
const text = await res.text()
|
|
1281
|
-
const lapsed = res.status === 401 || lapsedCode(errorOf(text).code)
|
|
1515
|
+
const lapsed = res.status === 401 || lapsedCode(errorOf(text).code, site)
|
|
1282
1516
|
const headers = { "content-type": res.headers.get("content-type") ?? "application/json", "X-Magpie-Sign-In": lapsed ? "expired" : a.renewed ? "renewed" : "kept" }
|
|
1283
1517
|
return new Response(text, { status: res.status, headers })
|
|
1284
1518
|
}
|
|
@@ -1293,44 +1527,32 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1293
1527
|
const signIn = a.renewed ? "renewed" : "kept"
|
|
1294
1528
|
let r
|
|
1295
1529
|
try {
|
|
1296
|
-
r = await post(a,
|
|
1530
|
+
r = await post(a, site.usage.host(site, a) + site.usage.path, site.usage.body)
|
|
1297
1531
|
} catch (e) {
|
|
1298
|
-
return { error:
|
|
1532
|
+
return { error: `${site.realm} ${site.usage.label}: ` + (e?.message ?? e), signIn }
|
|
1299
1533
|
}
|
|
1300
1534
|
const e = errorOf(r.v)
|
|
1301
|
-
if (r.status === 401 || lapsedCode(e.code)) return { error: `${a.name || a.uid}:
|
|
1302
|
-
if (r.status !== 200 || (e.code && String(e.code) !== "0")) return { error:
|
|
1303
|
-
|
|
1304
|
-
const out = { plan: r.v.is_credits_billing ? "Credits" : "Free", user: a.name || a.uid, signIn }
|
|
1305
|
-
if (c.unlimited) out.balance = "unlimited"
|
|
1306
|
-
else if (c.limit > 0) {
|
|
1307
|
-
// the count rides on the window (amount of limit, in credits), as
|
|
1308
|
-
// WorkBuddy's does, so magpie says it in the window's row, used or
|
|
1309
|
-
// left, in its own words and number format, rather than as an
|
|
1310
|
-
// English balance line beside it (yetone/magpie#694)
|
|
1311
|
-
const w = { name: "Credits", used: Math.max(0, Math.min(100, (c.used / c.limit) * 100)), amount: round(c.used), limit: round(c.limit), unit: "credits" }
|
|
1312
|
-
if (c.until) w.resetsAt = new Date(c.until).toISOString()
|
|
1313
|
-
out.windows = [w]
|
|
1314
|
-
}
|
|
1315
|
-
return out
|
|
1535
|
+
if (r.status === 401 || lapsedCode(e.code, site)) return { error: `${a.name || a.uid}: ${site.realm}'s sign-in has expired — sign in again`, signIn: "expired" }
|
|
1536
|
+
if (r.status !== 200 || (e.code && String(e.code) !== "0")) return { error: `${site.realm} ${site.usage.label}: ${e.message || statusLine(r.status)}`, signIn, user: a.name }
|
|
1537
|
+
return site.usage.read(r.v, a, signIn, { credits, round, statusLine })
|
|
1316
1538
|
}
|
|
1317
1539
|
|
|
1318
1540
|
// batchLists is every chat function's model list in one ask, as TRAE
|
|
1319
1541
|
// SOLO CN's ai-agent asks (batch_get_detail_param): {function: entries}.
|
|
1320
1542
|
// null when Trae answers it with no lists.
|
|
1321
1543
|
const batchLists = async (a) => {
|
|
1322
|
-
const r = await post(a, apiOf(a) + "/api/ide/v1/batch_get_detail_param", {
|
|
1323
|
-
functions:
|
|
1544
|
+
const r = await post(a, apiOf(a, site) + "/api/ide/v1/batch_get_detail_param", {
|
|
1545
|
+
functions: site.functions, agent_type: "", current_config_info: { config_name: "", is_custom_model: false },
|
|
1324
1546
|
mode_type: 0, access_type: 0, ab_force_vids: "", ab_autotest_advanced_mode: 0, show_custom_model: true,
|
|
1325
1547
|
})
|
|
1326
|
-
if (r.status === 401 || lapsedCode(errorOf(r.v).code)) throw new Expired(
|
|
1327
|
-
if (r.status !== 200) throw new Error(
|
|
1548
|
+
if (r.status === 401 || lapsedCode(errorOf(r.v).code, site)) throw new Expired(`${site.realm}'s sign-in has expired; sign in again`)
|
|
1549
|
+
if (r.status !== 200) throw new Error(`${site.realm} models: ${statusLine(r.status)}`)
|
|
1328
1550
|
const groups = r.v.function_configs ?? r.v.data?.function_configs
|
|
1329
1551
|
if (!Array.isArray(groups)) return null
|
|
1330
1552
|
const out = {}
|
|
1331
1553
|
for (const g of groups) {
|
|
1332
1554
|
const fn = String(g?.function ?? "")
|
|
1333
|
-
if (!
|
|
1555
|
+
if (!site.functions.includes(fn) || !Array.isArray(g.config_info_list)) continue
|
|
1334
1556
|
out[fn] = [...(out[fn] ?? []), ...g.config_info_list.filter((m) => m?.config_name)]
|
|
1335
1557
|
}
|
|
1336
1558
|
return Object.keys(out).length ? out : null
|
|
@@ -1338,11 +1560,11 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1338
1560
|
|
|
1339
1561
|
// listOf is one function's model list, as the IDE asks for it
|
|
1340
1562
|
const listOf = async (a, fn) => {
|
|
1341
|
-
const r = await post(a, apiOf(a) + "/api/ide/v1/get_detail_param", {
|
|
1563
|
+
const r = await post(a, apiOf(a, site) + "/api/ide/v1/get_detail_param", {
|
|
1342
1564
|
function: fn, config_names: null, need_prompt: false, current_config_info: null, poly_prompt: true, mode_type: null, agent_type: null,
|
|
1343
1565
|
})
|
|
1344
|
-
if (r.status === 401 || lapsedCode(errorOf(r.v).code)) throw new Expired(
|
|
1345
|
-
if (r.status !== 200) throw new Error(
|
|
1566
|
+
if (r.status === 401 || lapsedCode(errorOf(r.v).code, site)) throw new Expired(`${site.realm}'s sign-in has expired; sign in again`)
|
|
1567
|
+
if (r.status !== 200) throw new Error(`${site.realm} models: ${statusLine(r.status)}`)
|
|
1346
1568
|
const list = r.v.config_info_list ?? r.v.data?.config_info_list ?? []
|
|
1347
1569
|
return list.filter((m) => m?.config_name)
|
|
1348
1570
|
}
|
|
@@ -1366,13 +1588,13 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1366
1588
|
if (e instanceof Expired) throw e
|
|
1367
1589
|
}
|
|
1368
1590
|
if (!lists) {
|
|
1369
|
-
const got = await Promise.allSettled(
|
|
1591
|
+
const got = await Promise.allSettled(site.functions.map((fn) => listOf(a, fn)))
|
|
1370
1592
|
const expired = got.find((g) => g.status === "rejected" && g.reason instanceof Expired)
|
|
1371
1593
|
if (expired) throw expired.reason
|
|
1372
1594
|
if (got.every((g) => g.status === "rejected")) throw got[0].reason
|
|
1373
1595
|
lists = {}
|
|
1374
1596
|
got.forEach((g, i) => {
|
|
1375
|
-
if (g.status === "fulfilled") lists[
|
|
1597
|
+
if (g.status === "fulfilled") lists[site.functions[i]] = g.value
|
|
1376
1598
|
})
|
|
1377
1599
|
}
|
|
1378
1600
|
// a function can give a model another's name: chat_v3 calls
|
|
@@ -1383,24 +1605,24 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1383
1605
|
// model's in the same list, and other lists name it otherwise.
|
|
1384
1606
|
const nameOf = (m) => String(m.display_config?.display_name || m.display_name || m.display_model_name || m.config_name)
|
|
1385
1607
|
const names = new Map() // id → the names its lists give it
|
|
1386
|
-
for (const fn of
|
|
1608
|
+
for (const fn of site.functions) {
|
|
1387
1609
|
for (const m of lists[fn] ?? []) {
|
|
1388
|
-
if (!chatModel(m)) continue
|
|
1610
|
+
if (!chatModel(m, site)) continue
|
|
1389
1611
|
const id = String(m.config_name)
|
|
1390
1612
|
names.set(id, (names.get(id) ?? new Set()).add(nameOf(m)))
|
|
1391
1613
|
}
|
|
1392
1614
|
}
|
|
1393
1615
|
const misnamed = (fn, m) => {
|
|
1394
1616
|
const id = String(m.config_name), name = nameOf(m)
|
|
1395
|
-
return names.get(id).size > 1 && (lists[fn] ?? []).some((o) => chatModel(o) && String(o.config_name) !== id && nameOf(o) === name)
|
|
1617
|
+
return names.get(id).size > 1 && (lists[fn] ?? []).some((o) => chatModel(o, site) && String(o.config_name) !== id && nameOf(o) === name)
|
|
1396
1618
|
}
|
|
1397
1619
|
// a function's entry is worth more when it names a __dev model, then
|
|
1398
1620
|
// when it isn't misnamed; the first of the best is taken
|
|
1399
1621
|
const worth = (fn, m) => (devModel(m) ? 2 : 0) + (misnamed(fn, m) ? 0 : 1)
|
|
1400
1622
|
const out = new Map()
|
|
1401
|
-
for (const fn of
|
|
1623
|
+
for (const fn of site.functions) {
|
|
1402
1624
|
for (const m of lists[fn] ?? []) {
|
|
1403
|
-
if (!chatModel(m)) continue
|
|
1625
|
+
if (!chatModel(m, site)) continue
|
|
1404
1626
|
const id = String(m.config_name)
|
|
1405
1627
|
const was = out.get(id)
|
|
1406
1628
|
if (!was || worth(fn, m) > was.worth) out.set(id, { m, fn, worth: worth(fn, m) })
|
|
@@ -1413,7 +1635,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1413
1635
|
else modelNames.delete(id)
|
|
1414
1636
|
if (out.has(id + MAX)) continue
|
|
1415
1637
|
// its Max is asked through the function whose entry names the __max model
|
|
1416
|
-
const entries = [{ m, fn }, ...
|
|
1638
|
+
const entries = [{ m, fn }, ...site.functions.flatMap((f) => (lists[f] ?? []).filter((o) => o !== m && chatModel(o, site) && String(o.config_name) === id).map((o) => ({ m: o, fn: f })))]
|
|
1417
1639
|
const max = maxModel(...entries.map((e) => e.m))
|
|
1418
1640
|
maxes.set(id, max)
|
|
1419
1641
|
if (max) {
|
|
@@ -1435,23 +1657,23 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1435
1657
|
const ctx = Number(m.context_window_tokens?.dev ?? 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
|
|
1436
1658
|
const out = Number(devModel(m)?.max_tokens) || was.limit?.output || 0
|
|
1437
1659
|
const name = m.display_config?.display_name || m.display_name || m.display_model_name || was.name || id
|
|
1438
|
-
return { ...MODEL, ...was, id, providerID:
|
|
1660
|
+
return { ...MODEL, ...was, id, providerID: site.id, name: String(name), limit: { context: ctx, output: out }, api: was.api ?? { id, url: site.hosts.api, npm: "@ai-sdk/openai-compatible" } }
|
|
1439
1661
|
}
|
|
1440
1662
|
|
|
1441
1663
|
return {
|
|
1442
1664
|
config: async (config) => {
|
|
1443
1665
|
config.provider ??= {}
|
|
1444
|
-
const was = config.provider[
|
|
1445
|
-
config.provider[
|
|
1446
|
-
name:
|
|
1666
|
+
const was = config.provider[site.id] ?? {}
|
|
1667
|
+
config.provider[site.id] = {
|
|
1668
|
+
name: site.name,
|
|
1447
1669
|
npm: "@ai-sdk/openai-compatible",
|
|
1448
|
-
api:
|
|
1670
|
+
api: site.hosts.api + "/v1",
|
|
1449
1671
|
...was,
|
|
1450
|
-
models: { ...
|
|
1672
|
+
models: { ...site.models, ...(was.models ?? {}) },
|
|
1451
1673
|
}
|
|
1452
1674
|
},
|
|
1453
1675
|
provider: {
|
|
1454
|
-
id:
|
|
1676
|
+
id: site.id,
|
|
1455
1677
|
// the account's own list, when Trae answers; else the list above
|
|
1456
1678
|
async models(provider, { auth }) {
|
|
1457
1679
|
if (auth?.type !== "oauth" || !auth.access) return provider.models
|
|
@@ -1475,7 +1697,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1475
1697
|
},
|
|
1476
1698
|
},
|
|
1477
1699
|
auth: {
|
|
1478
|
-
provider:
|
|
1700
|
+
provider: site.id,
|
|
1479
1701
|
usage,
|
|
1480
1702
|
// magpie renews the token LEAD_MS before its end, once, before its
|
|
1481
1703
|
// requests, models and usage ask; the check before each request
|
|
@@ -1483,10 +1705,10 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1483
1705
|
refreshLead: LEAD_MS,
|
|
1484
1706
|
refresh,
|
|
1485
1707
|
loader: async (getAuth) => {
|
|
1486
|
-
const a0 = fromAuth(await getAuth())
|
|
1708
|
+
const a0 = fromAuth(await getAuth(), site)
|
|
1487
1709
|
if (!a0) return {}
|
|
1488
1710
|
return {
|
|
1489
|
-
baseURL: apiOf(a0) + "/v1",
|
|
1711
|
+
baseURL: apiOf(a0, site) + "/v1",
|
|
1490
1712
|
apiKey: "trae", // the engine's placeholder; the request carries the JWT
|
|
1491
1713
|
async fetch(input, init = {}) {
|
|
1492
1714
|
const r0 = input instanceof Request ? input : null
|
|
@@ -1494,9 +1716,9 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1494
1716
|
if (body instanceof ArrayBuffer) body = new TextDecoder().decode(body)
|
|
1495
1717
|
else if (ArrayBuffer.isView(body)) body = new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength))
|
|
1496
1718
|
const url = r0 ? r0.url : input instanceof URL ? input.href : String(input)
|
|
1497
|
-
if (ownPage(url)) return ownRequest(getAuth, a0, url, init.method ?? r0?.method ?? "POST", body, init.signal ?? r0?.signal)
|
|
1719
|
+
if (ownPage(url, site)) return ownRequest(getAuth, a0, url, init.method ?? r0?.method ?? "POST", body, init.signal ?? r0?.signal)
|
|
1498
1720
|
const req = parseJSON(body)
|
|
1499
|
-
if (!Array.isArray(req.messages)) return errorResponse(400,
|
|
1721
|
+
if (!Array.isArray(req.messages)) return errorResponse(400, `${site.realm}: only chat completions are served`)
|
|
1500
1722
|
let a
|
|
1501
1723
|
try {
|
|
1502
1724
|
a = await fresh(getAuth)
|
|
@@ -1508,7 +1730,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1508
1730
|
const who = a.uid || a.name
|
|
1509
1731
|
// the function that lists the model first, then the one that
|
|
1510
1732
|
// served this account last, then the rest
|
|
1511
|
-
const fns = [...new Set([listedBy.get(String(req.model)), fnOf.get(who), ...
|
|
1733
|
+
const fns = [...new Set([listedBy.get(String(req.model)), fnOf.get(who), ...site.functions].filter(Boolean))]
|
|
1512
1734
|
let last
|
|
1513
1735
|
let named = modelNames.get(String(req.model))
|
|
1514
1736
|
if (!named && String(req.model).endsWith(MAX)) {
|
|
@@ -1518,17 +1740,27 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1518
1740
|
if (named) fns.unshift(named.fn)
|
|
1519
1741
|
}
|
|
1520
1742
|
const max = named?.max ?? null
|
|
1743
|
+
// the answer's own signal: the caller's abort (a cancelled
|
|
1744
|
+
// request) and the stream's cancel both stop Trae's fetch, so
|
|
1745
|
+
// no read is left hanging on a connection the agent walked
|
|
1746
|
+
// away from
|
|
1747
|
+
const ac = new AbortController()
|
|
1748
|
+
const outer = init.signal ?? r0?.signal ?? null
|
|
1749
|
+
if (outer) {
|
|
1750
|
+
if (outer.aborted) ac.abort()
|
|
1751
|
+
else outer.addEventListener("abort", () => ac.abort(), { once: true })
|
|
1752
|
+
}
|
|
1521
1753
|
for (const fn of [...new Set(fns)]) {
|
|
1522
|
-
const res = await fetch(apiOf(a) + "/api/agent/v3/llm_utils_chat", {
|
|
1754
|
+
const res = await fetch(apiOf(a, site) + "/api/agent/v3/llm_utils_chat", {
|
|
1523
1755
|
method: "POST",
|
|
1524
|
-
headers: ideHeaders(a, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
|
|
1756
|
+
headers: ideHeaders(a, site, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
|
|
1525
1757
|
body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max)),
|
|
1526
|
-
signal:
|
|
1758
|
+
signal: ac.signal,
|
|
1527
1759
|
})
|
|
1528
1760
|
if (!res.ok) {
|
|
1529
1761
|
const text = await res.text()
|
|
1530
1762
|
const e = errorOf(text)
|
|
1531
|
-
last = failure(res.status, e.code, e.message || text.trim().slice(0, 300))
|
|
1763
|
+
last = failure(site, res.status, e.code, e.message || text.trim().slice(0, 300))
|
|
1532
1764
|
if (res.status === 400 && wrongFunction(e.code)) continue
|
|
1533
1765
|
break
|
|
1534
1766
|
}
|
|
@@ -1536,7 +1768,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1536
1768
|
if (!ct.includes("event-stream") && ct.includes("json")) {
|
|
1537
1769
|
// an answer that isn't a stream is an error in a 200
|
|
1538
1770
|
const e = errorOf(await res.text())
|
|
1539
|
-
last = failure(502, e.code, e.message)
|
|
1771
|
+
last = failure(site, 502, e.code, e.message)
|
|
1540
1772
|
if (wrongFunction(e.code)) continue
|
|
1541
1773
|
break
|
|
1542
1774
|
}
|
|
@@ -1544,12 +1776,18 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1544
1776
|
const { held, done } = await first(it)
|
|
1545
1777
|
const err = held.find((p) => p.error)
|
|
1546
1778
|
if (err) {
|
|
1547
|
-
last = failure(502, err.code, err.error)
|
|
1548
|
-
if (wrongFunction(err.code))
|
|
1779
|
+
last = failure(site, 502, err.code, err.error)
|
|
1780
|
+
if (wrongFunction(err.code)) {
|
|
1781
|
+
// another function may take it: this answer's read goes
|
|
1782
|
+
// with the function it came from, not left hanging
|
|
1783
|
+
it.return?.()
|
|
1784
|
+
res.body.cancel?.().catch(() => {})
|
|
1785
|
+
continue
|
|
1786
|
+
}
|
|
1549
1787
|
break
|
|
1550
1788
|
}
|
|
1551
1789
|
fnOf.set(who, fn)
|
|
1552
|
-
const out = await openai(req, done ? held : chain(held, it))
|
|
1790
|
+
const out = await openai(req, done ? held : chain(held, it), () => ac.abort(), site)
|
|
1553
1791
|
out.headers.set("X-Magpie-Sign-In", signIn)
|
|
1554
1792
|
return out
|
|
1555
1793
|
}
|
|
@@ -1558,10 +1796,13 @@ export const TraeCNAuthPlugin = async ({ client }) => {
|
|
|
1558
1796
|
},
|
|
1559
1797
|
}
|
|
1560
1798
|
},
|
|
1561
|
-
methods: [{ type: "oauth", label:
|
|
1799
|
+
methods: [{ type: "oauth", label: `${site.name} account (browser)`, authorize: () => browserSignIn(site) }],
|
|
1562
1800
|
},
|
|
1563
1801
|
}
|
|
1564
1802
|
}
|
|
1565
1803
|
|
|
1566
|
-
// for tests
|
|
1567
|
-
export const
|
|
1804
|
+
// for tests: the CN realm's table as before, plus both realms' factories
|
|
1805
|
+
export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
|
|
1806
|
+
export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
|
|
1807
|
+
|
|
1808
|
+
export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, SITES }
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-trae-auth",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "OpenCode provider
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": "./index.mjs",
|
|
8
8
|
"files": ["index.mjs", "README.md"],
|
|
9
|
-
"keywords": ["opencode", "opencode-plugin", "trae", "trae-cn", "bytedance"],
|
|
9
|
+
"keywords": ["opencode", "opencode-plugin", "magpie", "trae", "trae-cn", "trae-global", "bytedance"],
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/trae" }
|
|
12
12
|
}
|