dsh-connect-trae 4.5.0 → 4.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.1 (2026-10-25)
4
+
5
+ > **只在租户池上架的模型不再隐形。** 插件的模型目录只问了 `solo.trae.cn` 一处,
6
+ > 而它并不发布租户池的完整名单——`DeepSeek-V4.1-Flash-1017` 就在其中。这一版把
7
+ > `console.enterprise.trae.cn` 的目录**并入**主目录,两个池的模型都保留。
8
+ >
9
+ > 成因不是合并层丢行,而是**读错了 host**:目录基址循环在第一个答非空的 host 就
10
+ > `return`,而主目录总会答——所以只在租户池上架的模型,即便拿着租户凭据也永远看不到
11
+ > (#46)。
12
+
13
+ ### 修复
14
+
15
+ - **模型目录并入租户池,只在租户侧上架的模型不再隐形**(#46)。
16
+ - **实测**(2026-10-24,真实 CN 租户凭据):`solo.trae.cn` 答 36 个 id、**没有**
17
+ `DeepSeek-V4.1-Flash-1017`;`console.enterprise.trae.cn` 答 24 个、**有**。
18
+ 4.4.7 与 4.5.0 表现一致,所以升级修不了。
19
+ - **并集而非替换**:两侧没有一方包含另一方——主目录的 36 个里有 **19** 个不在租户
20
+ 应答中,其中就包括 `deepseek-v4.1-flash`。换 host 等于用 1 个模型换掉 19 个。
21
+ - **也不能做成兜底候选**:基址循环在第一个答非空的 host 就返回,而主目录总会答,
22
+ 候选恰好在唯一会出错的情形下被跳过。因此额外 host 单独读取后再并入
23
+ (`TraeSoloRemoteCatalogOptions.unionBaseUrls`)。
24
+ - **冲突保留主目录的拼写**,按不区分大小写比较。这里不能用 `preferStrongerRow`
25
+ 打擂台:`deriveCatalog` 用 `enabled.has(model.id)` **精确匹配**,改名会让用户
26
+ 已存进 `enabledModelIds` 的那一项静默失配、模型从选择里消失。
27
+ - **逐 host 尽力而为**:传输失败、非 2xx、响应体读不出、应答为空,都记日志后跳过,
28
+ 只有调用方 abort 才向上抛。某个额外 host 坏掉,损失的是它的模型,而不是整份目录。
29
+ - **门控按凭据、不按面板**:只登录租户账号的机器,在 Trae(消费端)面板下
30
+ `store.resolve()` 依然会返回租户凭据(`offered()` 是「藏光了就回退」),所以按
31
+ 面板判会漏掉这类用户。现在判「解析出的凭据 `tenantId` 非空」,个人凭据在发出请求
32
+ 前就跳过。
33
+ - **`unionRegion` 挡住跨区域请求**:`DSH_TRAE_TENANT_IN_CONSUMER` 是环境变量、不分
34
+ 区域,原先国际版面板也会挂上 CN 租户 host(用 SG 凭据实测确实发出、拿 401 后被
35
+ 跳过)。现在区域不符即跳过。
36
+ - **额外 host 有负缓存**:`discoverModels()` 不传 signal、超时回落到 30 秒,原先企业
37
+ host 不可达时每次刷新都要多等。失败后与主 host 同款冷却 5 分钟。
38
+ - **它不是别名**:用插件自己的 bridge 驱动真实网关,两个 id 派往不同后端
39
+ (`DeepSeek-V4.1-Flash-1017` → `deepseek-v4.1-flash-economic-ark`,
40
+ `DeepSeek-V4.1-Flash` → `deepseek-v4.1-flash-ark`)。
41
+ - 测试:`tests/solo-remote.spec.ts` 新增 8 例,覆盖并集、仅大小写不同的冲突、
42
+ 额外 host 不可达/被拒/应答为空、个人凭据跳过、跨区域跳过、冷却、已经是主 host 时
43
+ 不重复请求,以及未配置 `unionBaseUrls` 时行为与改动前完全一致。
44
+ - 贡献者:[@yuantao313](https://github.com/yuantao313)(PR #59)。
45
+
3
46
  ## 4.5.0 (2026-10-24)
4
47
 
5
48
  > **宽窗模型多一个 500K 上下文中间档。** Trae 每个模型公布两个窗口——默认(dev)档和
package/lib/index.d.ts CHANGED
@@ -3486,6 +3486,51 @@ interface TraeSoloRemoteCatalogOptions {
3486
3486
  * of official-host-first.
3487
3487
  */
3488
3488
  log?: (message: string, detail?: unknown) => void;
3489
+ /**
3490
+ * Extra directory hosts whose rows are UNIONED into the primary answer.
3491
+ *
3492
+ * The base loop below stops at the FIRST host that answers non-empty. That is
3493
+ * the right rule for a FALLBACK chain (one host, mirrored), but it cannot
3494
+ * express "these hosts serve DIFFERENT pools" — and the primary host
3495
+ * answering successfully is exactly the case where another host's models
3496
+ * stay invisible. So these hosts are read SEPARATELY and merged, never added
3497
+ * as another candidate base: a candidate is skipped precisely when the
3498
+ * primary succeeds, which is always in the scenario that fails today.
3499
+ *
3500
+ * Measured 2026-10-24 on a live CN tenant credential:
3501
+ * `solo.trae.cn` (the primary) answered 36 ids and does NOT list
3502
+ * `DeepSeek-V4.1-Flash-1017`, while `console.enterprise.trae.cn` answered 24
3503
+ * and DOES list it (issue #46). Neither host is a superset of the other: 19
3504
+ * of the primary's ids are absent from the tenant answer, so REPLACING the
3505
+ * primary would trade one missing model for nineteen — a union is the only
3506
+ * shape that loses nothing.
3507
+ *
3508
+ * The primary answer wins every id collision, compared case-insensitively, and
3509
+ * a colliding extra row is dropped rather than capacity-competed: a
3510
+ * `preferStrongerRow` contest could rename a model the user has already saved
3511
+ * in `enabledModelIds` (`deriveCatalog` matches those ids EXACTLY), silently
3512
+ * dropping the model out of the selection.
3513
+ *
3514
+ * An extra host is asked ONLY when the credential being used can call that
3515
+ * pool at all (`region` must match the host, and a tenant host additionally
3516
+ * requires a tenant credential). The gate lives here rather than at the
3517
+ * construction site because the deciding fact — "is this credential a tenant
3518
+ * one?" — only exists once `credential()` has resolved, which is per refresh.
3519
+ * Gating on the PANEL instead missed the case that matters: a consumer store
3520
+ * does not hard-exclude tenant accounts (`auth.ts`'s `offered()` backs off
3521
+ * when its filter would hide everything), so a machine whose only login is a
3522
+ * tenant account resolves a tenant credential under the Trae panel too.
3523
+ */
3524
+ unionBaseUrls?: readonly string[];
3525
+ /**
3526
+ * Region an extra host serves. An `ai` credential never asks a `cn` host.
3527
+ *
3528
+ * `unionBaseUrls` used to be ungated by region, so a `DSH_TRAE_TENANT_IN_CONSUMER`
3529
+ * machine on the international panel issued the CN tenant request on every
3530
+ * refresh — a 401 the union skipped, but one wasted request and one warn log
3531
+ * per refresh (measured with an SG credential 2026-10-24).
3532
+ */
3533
+ unionRegion?: TraeRegion;
3489
3534
  }
3490
3535
  /**
3491
3536
  * Read-only model catalog client for the SOLO Web API.
@@ -3526,9 +3571,42 @@ declare class TraeSoloRemoteCatalogClient {
3526
3571
  * (30 s) in the worst case, so 5 minutes bounds that to ~1 attempt per 5 min.
3527
3572
  */
3528
3573
  private static readonly OFFICIAL_RETRY_MS;
3574
+ /**
3575
+ * When each extra union host may be asked again after it failed, as epoch
3576
+ * milliseconds. Absent = ask it on the next refresh.
3577
+ *
3578
+ * The official host has {@link OFFICIAL_RETRY_MS} because a blocked network
3579
+ * would otherwise pay its failing connect on every refresh; an extra host has
3580
+ * the same problem and, since discovery (`discoverModels()`) passes no signal,
3581
+ * the same 30 s ceiling. A tenant host that is unreachable from this network
3582
+ * would therefore stall every refresh for up to 30 s. Same cooldown, same
3583
+ * reasoning — an unhelpful host is skipped for a while instead of retried
3584
+ * immediately.
3585
+ */
3586
+ private readonly extraRetryAtMs;
3587
+ private readonly unionBaseUrls;
3588
+ private readonly unionRegion;
3529
3589
  constructor(options: TraeSoloRemoteCatalogOptions);
3530
3590
  private headers;
3531
3591
  fetchModels(signal?: AbortSignal): Promise<TraeDiscoveredModel[]>;
3592
+ /**
3593
+ * Union the extra directory hosts' rows into the primary answer (issue #46).
3594
+ *
3595
+ * The base loop above returns on the FIRST host that answers non-empty, which
3596
+ * is correct for a FALLBACK chain but cannot express "these hosts serve
3597
+ * different pools" — the primary host answering is exactly the case where the
3598
+ * extra host's models would stay invisible forever. Hence a separate read and
3599
+ * a merge, not another candidate base (see `unionBaseUrls`).
3600
+ *
3601
+ * Every extra host is BEST-EFFORT: transport failure, non-2xx, an unreadable
3602
+ * body and an empty answer are each logged and skipped. Only an aborted
3603
+ * `signal` propagates — that is the caller cancelling, not a host being
3604
+ * unhelpful. Failing here must never fail a refresh the primary host already
3605
+ * satisfied, which is why a broken extra host costs models, not the catalog.
3606
+ * A failure also puts that host on {@link extraRetryAtMs} so a network that
3607
+ * cannot reach it stops paying its connect timeout on every refresh.
3608
+ */
3609
+ private unionWithExtraHosts;
3532
3610
  /**
3533
3611
  * Bases to try for one refresh, in order.
3534
3612
  *
package/lib/index.js CHANGED
@@ -6025,6 +6025,17 @@ function traeSoloRemoteFallbackBases(region, officialBase) {
6025
6025
  return backupBase === officialBase ? [] : [backupBase];
6026
6026
  }
6027
6027
  /**
6028
+ * Case-folded identity for a directory id.
6029
+ *
6030
+ * A local copy of `catalog.ts`'s private `displayKey`: the two hosts of the
6031
+ * union spell the same model differently (they disagree on at least 3 ids), and
6032
+ * ids that differ only by case MUST be treated as the same model — otherwise a
6033
+ * union would list one model twice under two spellings.
6034
+ */
6035
+ function directoryKey(id) {
6036
+ return id.trim().toLowerCase();
6037
+ }
6038
+ /**
6028
6039
  * Of two rows for the SAME model id, the one the catalog should keep.
6029
6040
  *
6030
6041
  * The gateway groups its directory by function and reports a model's capacity
@@ -6163,10 +6174,27 @@ var TraeSoloRemoteCatalogClient = class TraeSoloRemoteCatalogClient {
6163
6174
  * (30 s) in the worst case, so 5 minutes bounds that to ~1 attempt per 5 min.
6164
6175
  */
6165
6176
  static OFFICIAL_RETRY_MS = 3e5;
6177
+ /**
6178
+ * When each extra union host may be asked again after it failed, as epoch
6179
+ * milliseconds. Absent = ask it on the next refresh.
6180
+ *
6181
+ * The official host has {@link OFFICIAL_RETRY_MS} because a blocked network
6182
+ * would otherwise pay its failing connect on every refresh; an extra host has
6183
+ * the same problem and, since discovery (`discoverModels()`) passes no signal,
6184
+ * the same 30 s ceiling. A tenant host that is unreachable from this network
6185
+ * would therefore stall every refresh for up to 30 s. Same cooldown, same
6186
+ * reasoning — an unhelpful host is skipped for a while instead of retried
6187
+ * immediately.
6188
+ */
6189
+ extraRetryAtMs = /* @__PURE__ */ new Map();
6190
+ unionBaseUrls;
6191
+ unionRegion;
6166
6192
  constructor(options) {
6167
6193
  this.options = options;
6168
6194
  this.fetchImpl = options.fetchImpl ?? fetch;
6169
6195
  this.baseUrl = options.baseUrl;
6196
+ this.unionBaseUrls = options.unionBaseUrls ?? [];
6197
+ this.unionRegion = options.unionRegion;
6170
6198
  }
6171
6199
  async headers(region) {
6172
6200
  const credential = await this.options.credential();
@@ -6182,7 +6210,8 @@ var TraeSoloRemoteCatalogClient = class TraeSoloRemoteCatalogClient {
6182
6210
  };
6183
6211
  }
6184
6212
  async fetchModels(signal) {
6185
- const region = regionOfCredential(await this.options.credential());
6213
+ const credential = await this.options.credential();
6214
+ const region = regionOfCredential(credential);
6186
6215
  const officialBase = this.baseUrl ?? REGION_GATEWAYS[region].remote;
6187
6216
  const headers = await this.headers(region);
6188
6217
  const functions = TRAE_REMOTE_DIRECTORY_FUNCTIONS[region].join(",");
@@ -6217,12 +6246,88 @@ var TraeSoloRemoteCatalogClient = class TraeSoloRemoteCatalogClient {
6217
6246
  if (base !== officialBase) this.options.log?.(this.lastSuccessfulBase === base ? `model directory served by the remembered fallback host ${base}; the official host (${officialBase}) is re-probed every ${TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS / 6e4} minutes` : `model directory served by fallback host ${base}; the official host (${officialBase}) failed this refresh`, { officialBase });
6218
6247
  else this.officialRetryAtMs = 0;
6219
6248
  this.lastSuccessfulBase = base;
6220
- return models;
6249
+ return await this.unionWithExtraHosts(region, credential, headers, models, signal);
6221
6250
  }
6222
6251
  if (emptyError !== void 0) throw emptyError;
6223
6252
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
6224
6253
  }
6225
6254
  /**
6255
+ * Union the extra directory hosts' rows into the primary answer (issue #46).
6256
+ *
6257
+ * The base loop above returns on the FIRST host that answers non-empty, which
6258
+ * is correct for a FALLBACK chain but cannot express "these hosts serve
6259
+ * different pools" — the primary host answering is exactly the case where the
6260
+ * extra host's models would stay invisible forever. Hence a separate read and
6261
+ * a merge, not another candidate base (see `unionBaseUrls`).
6262
+ *
6263
+ * Every extra host is BEST-EFFORT: transport failure, non-2xx, an unreadable
6264
+ * body and an empty answer are each logged and skipped. Only an aborted
6265
+ * `signal` propagates — that is the caller cancelling, not a host being
6266
+ * unhelpful. Failing here must never fail a refresh the primary host already
6267
+ * satisfied, which is why a broken extra host costs models, not the catalog.
6268
+ * A failure also puts that host on {@link extraRetryAtMs} so a network that
6269
+ * cannot reach it stops paying its connect timeout on every refresh.
6270
+ */
6271
+ async unionWithExtraHosts(region, credential, headers, primary, signal) {
6272
+ if (this.unionBaseUrls.length === 0) return primary;
6273
+ if (this.unionRegion !== void 0 && this.unionRegion !== region) return primary;
6274
+ if (!isEnterpriseAccount(credential)) return primary;
6275
+ const functions = TRAE_REMOTE_DIRECTORY_FUNCTIONS[region].join(",");
6276
+ const byKey = /* @__PURE__ */ new Map();
6277
+ for (const model of primary) byKey.set(directoryKey(model.id), model);
6278
+ for (const base of this.unionBaseUrls) {
6279
+ if (base === this.lastSuccessfulBase) continue;
6280
+ if ((this.extraRetryAtMs.get(base) ?? 0) > Date.now()) continue;
6281
+ let response;
6282
+ try {
6283
+ response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
6284
+ headers,
6285
+ signal: signal ?? AbortSignal.timeout(3e4)
6286
+ });
6287
+ } catch (error) {
6288
+ if (signal?.aborted) throw error;
6289
+ this.extraRetryAtMs.set(base, Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS);
6290
+ this.options.log?.(`model directory union host ${base} was unreachable; its models are missing from this refresh`, {
6291
+ base,
6292
+ error: String(error)
6293
+ });
6294
+ continue;
6295
+ }
6296
+ if (!response.ok) {
6297
+ this.extraRetryAtMs.set(base, Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS);
6298
+ this.options.log?.(`model directory union host ${base} answered HTTP ${response.status}; its models are missing from this refresh`, { base });
6299
+ continue;
6300
+ }
6301
+ let extra;
6302
+ try {
6303
+ const json = await response.json();
6304
+ extra = this.collectModels(region, json.data?.list ?? []);
6305
+ } catch (error) {
6306
+ this.extraRetryAtMs.set(base, Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS);
6307
+ this.options.log?.(`model directory union host ${base} answered a body that could not be read; its models are missing from this refresh`, {
6308
+ base,
6309
+ error: String(error)
6310
+ });
6311
+ continue;
6312
+ }
6313
+ this.extraRetryAtMs.delete(base);
6314
+ let added = 0;
6315
+ for (const model of extra) {
6316
+ const key = directoryKey(model.id);
6317
+ if (byKey.has(key)) continue;
6318
+ byKey.set(key, model);
6319
+ added += 1;
6320
+ }
6321
+ const collided = extra.length - added;
6322
+ this.options.log?.(`model directory union host ${base} contributed ${added} model(s) the primary directory did not list${collided > 0 ? ` (${collided} collided and kept the primary spelling)` : ""}`, {
6323
+ base,
6324
+ served: extra.length,
6325
+ added
6326
+ });
6327
+ }
6328
+ return [...byKey.values()];
6329
+ }
6330
+ /**
6226
6331
  * Bases to try for one refresh, in order.
6227
6332
  *
6228
6333
  * While the official host is in its post-failure cooldown the backup hosts go
@@ -10030,7 +10135,57 @@ function apply(ctx, config) {
10030
10135
  });
10031
10136
  const remoteCatalog = new TraeSoloRemoteCatalogClient({
10032
10137
  credential: () => store.resolve(),
10033
- log: (message, detail) => ctx.logger.warn(message, detail)
10138
+ log: (message, detail) => ctx.logger.warn(message, detail),
10139
+ /**
10140
+ * The TENANT directory, UNIONED into the consumer one (issue #46).
10141
+ *
10142
+ * This is the "reads both pools" half of the routing note below, which
10143
+ * was intent without an implementation: only the consumer host was ever
10144
+ * asked. The `Solo` client above is routed, but THIS one is not — and
10145
+ * `solo.trae.cn`, the primary it lands on, does not publish every model a
10146
+ * tenant credential can call.
10147
+ *
10148
+ * Measured 2026-10-24 on the reporter's live CN tenant credential:
10149
+ * `solo.trae.cn` answered 36 ids with no `DeepSeek-V4.1-Flash-1017`;
10150
+ * `console.enterprise.trae.cn` answered 24 that include it, and the
10151
+ * gateway really dispatching a distinct backend for it
10152
+ * (`deepseek-v4.1-flash-economic-ark`, vs `deepseek-v4.1-flash-ark` for
10153
+ * the plain id) is how issue #46's report was confirmed.
10154
+ *
10155
+ * UNION, never replace, and never a fallback candidate. The primary is
10156
+ * the richer host — 19 of its ids are absent from the tenant answer,
10157
+ * including the `deepseek-v4.1-flash` the reporter has saved — so
10158
+ * replacing it would trade one missing model for nineteen. And a
10159
+ * candidate base cannot work at all: the base loop returns on the first
10160
+ * host that answers, and the primary always answers.
10161
+ *
10162
+ * Gated on a tenant pool being bindable here (an enterprise panel, or a
10163
+ * consumer panel under `DSH_TRAE_TENANT_IN_CONSUMER`). A personal
10164
+ * consumer account has nothing to gain from the tenant host, so it is not
10165
+ * made to pay the extra request.
10166
+ *
10167
+ * The fix is not about 1017 specifically: the tenant host is a second
10168
+ * pool, and every model it alone serves was invisible for the same
10169
+ * reason. Verified against the live hosts: 0 rows lost, 0 duplicated.
10170
+ *
10171
+ * The client decides WHEN to ask it, not this construction site: the
10172
+ * deciding fact is whether the credential being resolved is a tenant one,
10173
+ * which does not exist until `credential()` runs. Gating on the panel here
10174
+ * missed the reporters most likely to hit #46 in the first place — a
10175
+ * consumer store does not hard-exclude tenant accounts (`auth.ts`'s
10176
+ * `offered()` backs off when the filter would hide everything), so a
10177
+ * machine whose only login is a tenant account resolves one under the
10178
+ * Trae panel too, and an enterprise-panel-only gate never opened the
10179
+ * union for it. `unionRegion` additionally keeps an `ai` credential from
10180
+ * asking a `cn` host, which the old `DSH_TRAE_TENANT_IN_CONSUMER` gate on
10181
+ * its own did not.
10182
+ *
10183
+ * Providing the host unconditionally is therefore safe: a personal
10184
+ * credential and an `ai` credential both skip it before any request is
10185
+ * made, so neither pays for a pool it cannot use.
10186
+ */
10187
+ unionBaseUrls: [`${TRAE_ENTERPRISE_HOST}/api/remote/v1`],
10188
+ unionRegion: "cn"
10034
10189
  });
10035
10190
  /**
10036
10191
  * The IDE's own roster: which models the vendor's picker shows, per
@@ -10059,7 +10214,10 @@ function apply(ctx, config) {
10059
10214
  * gateway and stamps the `session_id` it requires. The discovery client
10060
10215
  * below stays UNROUTED on purpose — it owns the roster/probe path and, for
10061
10216
  * a tenant credential, reads both pools so the shared catalogue keeps every
10062
- * model either one serves.
10217
+ * model either one serves. That second pool is supplied by `unionBaseUrls`
10218
+ * at the catalog client above, which is what makes this paragraph true;
10219
+ * routing THIS client instead would have read one pool and dropped the
10220
+ * other (see the note there, and issue #46).
10063
10221
  */
10064
10222
  /**
10065
10223
  * Latest successful discovery for this panel, mutated by discoverModels
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "4.5.0",
5
+ "version": "4.5.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",