@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.
Files changed (3) hide show
  1. package/README.md +88 -30
  2. package/index.mjs +422 -181
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,29 +1,60 @@
1
1
  # @magpie-community/opencode-trae-auth
2
2
 
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.
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)**. 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
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 models Trae CN's `chat_v3` is
98
- known to serve: GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus, DeepSeek V4 Pro
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
- `api.trae.cn/cloudide/api/v3/trae/oauth/ExchangeToken`. Trae issues a new
105
- refresh token each time and spends the old one, so only one renewal runs at
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 1001.
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 credits:
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 (每日签到). magpie can press it
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: its Cloud-IDE-JWT
145
- (renewed first when near its end) and its device id.
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 `{}`: `enable`, `checked_in`, `credits`.
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
- body `{}`, only while it is on and today's isn't in.
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 ID = "trae-cn"
16
- // where each part of Trae CN is served; tests point them at a fake
17
- const HOSTS = {
18
- web: "https://www.trae.cn", // the authorization page
19
- auth: "https://api.trae.cn", // tokens, the account, credits
20
- api: "https://trae-api-cn.mchost.guru", // the models
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
- const CLIENT_ID = "ono9krqynydwx5" // Trae CN's IDE
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
- // what Trae CN's chat_v3 is known to serve; the live list replaces it
47
- const MODELS = {
48
- "glm-5.2": { name: "GLM-5.2", ...MODEL, limit: { context: 200_000, output: 32_000 } },
49
- "glm-5": { name: "GLM-5", ...MODEL },
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 || CLIENT_ID,
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
- ...(/mchost\.guru|trae-api-/i.test(s.api ?? "") ? { api: s.api } : {}),
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 || CLIENT_ID,
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: the one its sign-in named when
134
- // that is a model host (trae.cn's own hosts serve no models), else Trae
135
- // CN's
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 CN's own JSON pages on api.trae.cn
142
- // (/trae/api/…: the daily check-in, credits), which the account's fetch
143
- // sends as the account rather than as a chat
144
- function ownPage(url) {
145
- try {
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
- const lapsedCode = (code) => [1001, "1001", 401, "401"].includes(code)
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 quotaCode = (code) => [4008, "4008", 1005, "1005"].includes(code)
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 = HOSTS.auth) {
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 || CLIENT_ID, RefreshToken: refresh, ClientSecret: "-", UserID: "" }),
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("Trae CN: renewing the sign-in: " + (e?.message ?? e))
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(`Trae CN's sign-in has expired (${msg}); sign in again`)
385
+ throw new Expired(`${site.realm}'s sign-in has expired (${msg}); sign in again`)
227
386
  }
228
- throw new Error(`Trae CN: renewing the sign-in: ${statusLine(res.status)} ${msg}`.trim())
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") ?? CLIENT_ID,
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("trae.cn sent back no token")
260
- if (!s.refresh) throw new Error("trae.cn sent back no refresh token")
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
- // trae.cn's page may call the callback from script as well as open it
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", "trae.cn sent no token; start the sign-in again."))
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 || "Your Trae CN account"} is signed in. You can close this tab.`))
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: PLUGIN_VERSION,
478
+ plugin_version: site.pluginVersion,
319
479
  auth_type: "local",
320
- client_id: 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: 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: IDE_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: `${HOSTS.web}/authorization?${q}`,
338
- instructions: "Sign in to Trae CN (trae.cn) in the browser and allow the sign-in. It finishes here by itself.",
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": 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": CLIENT_VERSION,
359
- "x-app-version-code": CLIENT_VERSION_CODE,
360
- "x-ide-version": CLIENT_VERSION,
361
- "x-ide-version-code": CLIENT_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": 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
- for (;;) {
504
- const { value, done } = await reader.read()
505
- if (done) break
506
- buf += dec.decode(value, { stream: true })
507
- let i
508
- while ((i = buf.indexOf("\n")) >= 0) {
509
- const line = buf.slice(0, i).replace(/\r$/, "")
510
- buf = buf.slice(i + 1)
511
- if (!line) {
512
- yield* flush()
513
- continue
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, `Trae CN's sign-in has expired (${message}); sign in again`, "expired")
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, `Trae CN: ${message}`)
1032
- return errorResponse(status >= 400 ? status : 502, `Trae CN: ${message}${code ? ` (code ${code})` : ""}`)
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
- async function openai(req, it) {
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
- export const TraeCNAuthPlugin = async ({ client }) => {
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: ID }, body: auth })
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("Trae CN: not signed in")
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
- // Trae's pages, the check-in among them, go with the chat's headers and
1271
- // the body given ({}): 0.1.7 sent the check-in with the IDE's growth
1272
- // headers and {req_source: 1} instead, and a check-in that worked on
1273
- // 0.1.6 failed from then on (yetone/magpie#808)
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: method === "GET" || method === "HEAD" ? undefined : typeof body === "string" ? 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, HOSTS.auth + "/trae/api/v2/pay/ide_user_ent_usage", { require_usage: true, req_source: 0 })
1530
+ r = await post(a, site.usage.host(site, a) + site.usage.path, site.usage.body)
1297
1531
  } catch (e) {
1298
- return { error: "Trae CN credits: " + (e?.message ?? e), signIn }
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}: Trae CN's sign-in has expired — sign in again`, signIn: "expired" }
1302
- if (r.status !== 200 || (e.code && String(e.code) !== "0")) return { error: `Trae CN credits: ${e.message || statusLine(r.status)}`, signIn, user: a.name }
1303
- const c = credits(r.v)
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: FUNCTIONS, agent_type: "", current_config_info: { config_name: "", is_custom_model: false },
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("Trae CN's sign-in has expired; sign in again")
1327
- if (r.status !== 200) throw new Error(`Trae CN models: ${statusLine(r.status)}`)
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 (!FUNCTIONS.includes(fn) || !Array.isArray(g.config_info_list)) continue
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("Trae CN's sign-in has expired; sign in again")
1345
- if (r.status !== 200) throw new Error(`Trae CN models: ${statusLine(r.status)}`)
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(FUNCTIONS.map((fn) => listOf(a, fn)))
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[FUNCTIONS[i]] = g.value
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 FUNCTIONS) {
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 FUNCTIONS) {
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 }, ...FUNCTIONS.flatMap((f) => (lists[f] ?? []).filter((o) => o !== m && chatModel(o) && String(o.config_name) === id).map((o) => ({ m: o, fn: f })))]
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: ID, name: String(name), limit: { context: ctx, output: out }, api: was.api ?? { id, url: HOSTS.api, npm: "@ai-sdk/openai-compatible" } }
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[ID] ?? {}
1445
- config.provider[ID] = {
1446
- name: "Trae CN",
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: HOSTS.api + "/v1",
1670
+ api: site.hosts.api + "/v1",
1449
1671
  ...was,
1450
- models: { ...MODELS, ...(was.models ?? {}) },
1672
+ models: { ...site.models, ...(was.models ?? {}) },
1451
1673
  }
1452
1674
  },
1453
1675
  provider: {
1454
- id: 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: ID,
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, "Trae CN: only chat completions are served")
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), ...FUNCTIONS].filter(Boolean))]
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: init.signal ?? r0?.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)) continue
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: "Trae CN account (browser)", authorize: browserSignIn }],
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 _internal = { HOSTS, MODELS, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, whenOf, newDevice }
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.15",
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",
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
  }