dsh-connect-trae 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.0 (2026-10-21)
4
+
5
+ > **受限网络(企业网只放行少数域名)下「刷新模型 500 / 聊天 4001」的完整修复。**
6
+ >
7
+ > 链路是三段式的:`solo.trae.cn` 的 CONNECT 被代理拒绝(403)→ 目录 `fetch failed`(刷新 500)→
8
+ > 模型丢失 `wireFunction` → 聊天按默认 `solo_work_lite` 发请求 → 模型不在该 function 的 config 名单里就
9
+ > 4001 `config_name rejected`。本版对三段各有一处改动:目录 host 回退到聊天网关的同路径(带冷却重试)、
10
+ > 目录分组的 function 兜底 `wireFunction`(只认可聊分组)、bridge 对「只带 function」的目录行保留
11
+ > resolver 的 `config_name` 大小写(issue #38 的坑)。
12
+ >
13
+ > 合并 PR #43 前做了对抗性复现,修掉了四处会伤到真实用户的回归——**最关键的一处**:原实现把功能名
14
+ > 属于 `solo_agent`/`builder_v3` 的目录行整行丢弃,而受限网络下那正是 9 个只在 `solo_agent` 分组里
15
+ > 出现的模型(`gpt-5.5`、`gpt-5.6-*`、`gpt-6-astra`、`glm-5.2`、`kimi-k2.7-code`、`Dola-Seed-2.0-Code`、
16
+ > `deepseek-v4-flash-0731`),它们正是 issue #19 修复要暴露的那批、且实测在默认 chat function 下
17
+ > 都能正常应答。现在只把非可聊 function **降级清空**,行保留。
18
+
19
+ ### 修复
20
+
21
+ - **受限网络下模型目录走聊天网关备用 host,目录分组 function 兜底 `wireFunction`**(`fix(cn)`)。修复分三层:
22
+ - **目录 host 回退**:官方 remote host 网络错误 / 超时 / 5xx 时,按序尝试聊天网关的同一路径
23
+ (CN 为 `trae-api-cn.mchost.guru/api/remote/v1`,实测同路径同响应结构);401/403/404 等语义性拒绝**不回退**。
24
+ 进程内记住最近一次**给出了非空目录**的 host。官方 host 失败后进入 5 分钟冷却:冷却期内先走记住的备用
25
+ host、官方 host 排在链尾(备用也挂了时仍有一次机会),冷却到期后官方 host 重新排第一 —— 只把官方 host
26
+ 「排在第二」并不能让它被重试,因为循环在第一个成功处返回,所以必须显式冷却重试,恢复的网络才会在
27
+ 一个冷却窗口内重新接管。空目录(200-but-empty)既不算成功(不记忆)也不终止链路(继续试下一个 base),
28
+ 否则一个还没同步的镜像会永久堵死官方 host。回退只作用于 remote/v1 目录这一个 base;
29
+ `get_detail_param`、`llm_utils_chat` 照旧走各自原有 host。
30
+ - **目录分组 function 归因**:`fetchModels` 不再丢弃 `data.list[].function`——解析时把**可聊**分组的 function
31
+ 附到每个模型(同一模型出现于多个分组时按 `TRAE_DIRECTORY_FUNCTIONS` 的实测优先级取,而非请求参数
32
+ `functions=` 的顺序;后者是历史列表,与 wire roster 自己的收集顺序不同,混用会让同一模型在「roster 有没有
33
+ 返回」两种网络下拿到不同 function,`solo_agent_remote` 在 Remote 付费门后,优先它会多出 1005)。
34
+ `mergeTraeModelSources` 的 `wireFunction` 优先取 `get_detail_param` 的结论,目录分组兜底(roster 知道
35
+ 账号的付费/可用状态,目录不知道)。同一 function 名重复出现时按名分桶保留全部,不再只留最后一组。
36
+ - **非可聊 function 只降级、不丢行**:`solo_agent`、`builder_v3` 是 IDE/Remote-agent roster,聊天通道实测
37
+ 4011/4001,所以**不把这两个 function 写进 `wireFunction`**;模型本身**保留**,改由默认 chat function
38
+ 调用。把它们整行丢掉等于在本次修复针对的受限网络里重新藏起 issue #19 的成果(详见上方导语)。
39
+ 仅带 `wireFunction`(无 `wireConfigName`)的目录行,聊天请求会带上该 function,但 `config_name` 仍由
40
+ resolver 提供(目录 id 只是展示名,网关 `config_name` 大小写敏感,见 issue #38)。默认 function 与
41
+ fallback 模型常量未动。
42
+ - 已知取舍:模型被目录放在 `chat_v3` 分组下、但实际不可聊时,插件仅凭目录数据无法区分,仍会展示,
43
+ 聊天失败走现有 4001 提示翻译(诊断入口见 `docs/ISSUE19_DIAGNOSIS.md`)。
44
+
45
+ ### 测试
46
+
47
+ - 回归用例:host 回退(官方网络错误 → 备用 host 同路径同查询;401 不回退;5xx 回退;**空目录继续尝试下一个
48
+ base 且不被记住**;记住最近成功 host;**冷却到期后官方 host 重新排第一**;显式 baseUrl 钉住单 host)、
49
+ 分组 function 归因(可聊分组优先;**同一 function 名重复出现时不丢模型**;容量来自后分组时保留先到先得的
50
+ function)、合并兜底与 `withChatCallableFunctions` 的**降级而非丢弃**、bridge 对 wireFunction-only 目录行
51
+ 保留 resolver 的 `config_name`(大小写)、以及受限网络端到端(wire roster 为空时 9 个 `solo_agent`-only
52
+ 模型仍然在目录里)。
53
+ - 全仓 **921** 例通过,0 skipped;`tsc` 两个工程干净;`build` 通过;CI(ubuntu + windows)全绿。
54
+
55
+ ## 4.0.1 (2026-10-19)
56
+
57
+ > CN 区模型卡「最大输出」整列空白(issue #42)修复:上游**是**发布输出上限的——`get_detail_param` 的
58
+ > CN 80 个 config **80/80** 都带 `model_detail_list[].max_tokens`——但目录合并 `mergeTraeModelSources()`
59
+ > 把 wire 侧的 `maxTokens` 丢掉了。这个字段从合并函数诞生那天(1.1.0)起就没被复制过,卡片、
60
+ > `/models/refresh` 与 DSH 模型注册因此永远收到「未知」。附带落地同一 issue 的批量请求:整区上下文
61
+ > 一键切换两个按钮。
62
+
63
+ ### 修复
64
+
65
+ - **目录合并带上 wire 的 `max_tokens`**(`fix(catalog)`)。合并时其余字段(contextWindow / maxContextWindow /
66
+ 倍率 / reasoning / multimodal / config_name)一路都复制了,唯独漏了 `maxTokens` 一行。修复后 CN 实测
67
+ 32 行目录 30 行带值;**仍空白的行是诚实的**——`kimi-k2.8-preview`、`qwen3.8-flash` 这类 TraeCode 通道行
68
+ 在 SOLO 名单里没有对应 config,拿不到候选值,宁空白不编造。取值链其余部分不动:`collectModels` 读
69
+ `model_detail_list[0]` 实测无损(71/80 config 的双项 `__dev`/`__max` 上限恒等)。该值是**元数据**,
70
+ 请求体照旧不下发 `max_tokens`(上限由 Trae 服务端决定),行为无变化。
71
+ - 报告人「想确认的一点」(输出上限是否挂在后面的 detail 项或别的键)已逐项 dump 排除,全文见
72
+ `docs/ISSUE42_DIAGNOSIS.md`,取证脚本 `scripts/probe-cn-maxtokens.mjs`(只读,随仓库提交)。
73
+
74
+ ### 新增
75
+
76
+ - **卡片模型管理区两个批量上下文按钮**(`feat(card)`,issue #42 附带需求):「**整区切到 Max**」给本区
77
+ 每个带 Max 档的模型一次性写入其 `maxContextWindow`(正是 `applyContextBudgets` 认的精确值),
78
+ 没有 Max 档的行不动、已有手动预算保留;「**全部恢复默认上下文**」清空本区预算。二者作用于草稿、
79
+ 按「保存」生效,落盘形状与逐行单选完全一致——因此**没有**新增 `contextBudgetMode` 配置键,
80
+ 运行时零新分支、旧配置零迁移。报告人数的 18 次点击现在是一次。
81
+ - README 新增「最大输出与上下文预算」一节(issue #42 建议 1 换内容采纳:事实已变,文档写明上游发布该值、
82
+ 空白行另有其因)。
83
+
84
+ ### 测试
85
+
86
+ - 新增合并回归用例(修复前必红:wire 行带 `maxTokens` → 合并行必须保留;join-miss 行必须缺席)与
87
+ 5 例卡片用例(批量按钮的落盘内容、既有手动预算的保留、两按钮各自禁用条件)。
88
+ - 全仓 **901** 例通过,0 skipped;`tsc` 两个工程干净;`build` 通过。修复前后各跑一次实时探针对照
89
+ (0/32 → 30/32)。
90
+
3
91
  ## 4.0.0 (2026-10-19)
4
92
 
5
93
  > 移除 issue #29 的「跟随 Trae 桌面端当前登录」整套机制,连同它派生的两处 UI。
package/README.en.md CHANGED
@@ -127,6 +127,12 @@ That is the upstream refusal stated plainly, rather than a silent failure or som
127
127
 
128
128
  To use these three models today, use the **Trae IDE itself**.
129
129
 
130
+ ## Max output and context budgets
131
+
132
+ The "最大输出" (max output) on the model card comes from `model_detail_list[].max_tokens` in Trae's `get_detail_param` — upstream **does** publish it (live-verified: 80/80 CN configs carry it). As of 4.0.1 the value travels through the catalog merge all the way to the card, `/models/refresh` and DSH model registration; before that the merge step dropped the field, so the whole region rendered blank (issue #42 — full diagnosis in `docs/ISSUE42_DIAGNOSIS.md`). **Rows that are still blank are being honest**: models the directory lists but the SOLO roster has no config for (the three TraeCode rows above) have no candidate value to show — we prefer a blank over an invented cap. The value is metadata only; the plugin never puts `max_tokens` into request bodies (the server decides the real cap).
133
+
134
+ Context windows are a per-model choice between Trae's default (dev) window and its Max window, stored per panel as `contextBudgets`. For a whole-region switch, the model section now has two buttons: "**Switch all to Max**" (writes each Max-capable row's `maxContextWindow`, leaves no-Max rows and existing manual budgets alone) and "**Restore default context**" (clears the region's budget dict). Both act on the draft and take effect on Save, producing exactly the same stored shape as the per-row radios (issue #42 side request — 18 Max rows in CN used to mean 18 clicks).
135
+
130
136
  ## Install
131
137
 
132
138
  ```sh
package/README.md CHANGED
@@ -140,6 +140,12 @@ Trae does not serve this model under the SOLO function the request used
140
140
 
141
141
  如果你现在就要用这 3 个模型,请直接在 **Trae IDE 本体**中使用。
142
142
 
143
+ ## 最大输出与上下文预算
144
+
145
+ 模型卡上的「最大输出」来自 Trae `get_detail_param` 的 `model_detail_list[].max_tokens` —— 上游**是**发布这个值的(CN 实测 80/80 config 都带)。4.0.1 起它随目录合并一路带到卡片、`/models/refresh` 与 DSH 模型注册;此前合并层漏传了这个字段,整区模型全部显示空白(issue #42,诊断见 `docs/ISSUE42_DIAGNOSIS.md`)。**仍然空白的行是诚实的**:目录里有、但 SOLO 名单里没有对应 config 的模型(上面那 3 个 TraeCode 通道行)拿不到任何候选值,宁可空白也不编一个上限。这个值是元数据,插件不把它写进请求体(输出上限由 Trae 服务端决定)。
146
+
147
+ 上下文窗口在 Trae 公布的默认(dev)档与 Max 档之间二选一,按面板逐模型存成 `contextBudgets`。想整区一次切换:模型管理区有「**整区切到 Max**」(给每个带 Max 档的行写入其 `maxContextWindow`,没有 Max 档的行不动)和「**全部恢复默认上下文**」(清空本区预算)两个按钮,作用在草稿上、按「保存」生效,与逐行单选落盘的结果完全一致(issue #42 附带需求——CN 有 18 个 Max 模型,以前要点 18 次)。
148
+
143
149
  ## 安装
144
150
 
145
151
  推荐使用 DSH 插件命令安装 npm 已发布版本:
package/lib/client.js CHANGED
@@ -679,6 +679,10 @@ window.__ModuleLoader__.load({
679
679
  color:var(--dsw-alias-brand-primary,#2563eb);font-size:11px;line-height:16px;
680
680
  }
681
681
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
682
+ /* Bulk context-budget row (issue #42): under the model list, above the
683
+ capability note. Wraps on narrow cards; keeps the two buttons visually a
684
+ pair and distinct from the save footer below. */
685
+ .dsm-trae-context-bulk{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
682
686
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
683
687
  verdict stays neutral because an inconclusive probe is not evidence against
684
688
  the model. */
@@ -3879,6 +3883,39 @@ window.__ModuleLoader__.load({
3879
3883
  };
3880
3884
  });
3881
3885
  };
3886
+ /**
3887
+ * Bulk context-budget switch (issue #42 side request). The per-model radios
3888
+ * stay exactly as they are; these two act on the whole region draft at once.
3889
+ * The reporter counted 18 CN models whose Max window differs from the
3890
+ * default and described clicking 18 times per region — 「整区切到 Max」 is
3891
+ * those 18 clicks as one button. It writes `maxContextWindow` per row, which
3892
+ * is precisely the spelling `applyContextBudgets` recognizes (a budget equal
3893
+ * to the native Max window widens the effective window), so bulk and per-row
3894
+ * paths land on identical saved state. Models without a Max window are left
3895
+ * untouched — inventing a budget for them would be filtered out anyway.
3896
+ */
3897
+ const setAllContextMax = () => {
3898
+ editDraft((current) => {
3899
+ const next = { ...current.contextBudgets };
3900
+ for (const model of current.models) if (model.maxContextWindow !== void 0) next[model.id] = model.maxContextWindow;
3901
+ return {
3902
+ ...current,
3903
+ contextBudgets: next
3904
+ };
3905
+ });
3906
+ };
3907
+ /**
3908
+ * 「恢复默认上下文」 clears every override in this region's draft. Empty
3909
+ * dict = every row falls back to its default window (the same state as a
3910
+ * region that was never configured), and clearing is what makes the bulk
3911
+ * action reversible without 18 more clicks.
3912
+ */
3913
+ const resetAllContextBudgets = () => {
3914
+ editDraft((current) => ({
3915
+ ...current,
3916
+ contextBudgets: {}
3917
+ }));
3918
+ };
3882
3919
  const discardModels = () => {
3883
3920
  setDrafts((prev) => {
3884
3921
  const next = { ...prev };
@@ -4589,6 +4626,24 @@ window.__ModuleLoader__.load({
4589
4626
  })]
4590
4627
  }, model.id))
4591
4628
  }),
4629
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4630
+ className: "dsm-trae-context-bulk",
4631
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4632
+ type: "button",
4633
+ className: "dsm-btn dsm-btn-outline",
4634
+ disabled: busy || saving || settingsScope?.getSnapshot().writable !== true || !visibleModels.some((model) => model.maxContextWindow !== void 0),
4635
+ title: t("row.contextAllMaxHint"),
4636
+ onClick: setAllContextMax,
4637
+ children: t("row.contextAllMax")
4638
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4639
+ type: "button",
4640
+ className: "dsm-btn dsm-btn-outline",
4641
+ disabled: busy || saving || settingsScope?.getSnapshot().writable !== true || Object.keys(activeContextBudgets).length === 0,
4642
+ title: t("row.contextAllDefaultHint"),
4643
+ onClick: resetAllContextBudgets,
4644
+ children: t("row.contextAllDefault")
4645
+ })]
4646
+ }),
4592
4647
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4593
4648
  className: "dsm-trae-model-capability-note",
4594
4649
  children: t("row.modelCapabilityPending")
@@ -5507,6 +5562,10 @@ window.__ModuleLoader__.load({
5507
5562
  "row.expiryEarliest": "Earliest: {time}",
5508
5563
  "row.settingsDirty": "Unsaved changes",
5509
5564
  "row.modelsTestEmpty": "Enable at least one model first.",
5565
+ "row.contextAllMax": "Switch all to Max",
5566
+ "row.contextAllMaxHint": "Sets the DSH context budget of every model in this region that has a Max window to its Max window at once. Still a draft — press Save to apply. Models without a Max window are left alone.",
5567
+ "row.contextAllDefault": "Restore default context",
5568
+ "row.contextAllDefaultHint": "Clears every context-budget override in this region, so all models go back to Trae's default window. Still a draft — press Save to apply.",
5510
5569
  "row.modelsTestError": "Test failed: {message}",
5511
5570
  "row.modelsTestDone": "{available} of {total} answered.",
5512
5571
  "row.probeAvailable": "works",
@@ -5823,6 +5882,10 @@ window.__ModuleLoader__.load({
5823
5882
  "row.expiryEarliest": "最早到期:{time}",
5824
5883
  "row.settingsDirty": "有未保存的修改",
5825
5884
  "row.modelsTestEmpty": "请先启用至少一个模型。",
5885
+ "row.contextAllMax": "整区切到 Max",
5886
+ "row.contextAllMaxHint": "把本区所有带 Max 档的模型的 DSH 上下文预算一次性设为各自的 Max 档。这只是草稿——要按「保存」才生效。没有 Max 档的模型不会被改动。",
5887
+ "row.contextAllDefault": "全部恢复默认上下文",
5888
+ "row.contextAllDefaultHint": "清掉本区所有上下文预算覆盖,让每个模型都回到 Trae 的默认窗口。这只是草稿——要按「保存」才生效。",
5826
5889
  "row.modelsTestError": "测试失败:{message}",
5827
5890
  "row.modelsTestDone": "{total} 个里有 {available} 个正常应答。",
5828
5891
  "row.probeAvailable": "可用",
package/lib/index.d.ts CHANGED
@@ -30,9 +30,29 @@ interface TraeDiscoveredModel {
30
30
  creditMultiplier?: number;
31
31
  reasoningSupported: boolean;
32
32
  reasoning?: TraeDiscoveredReasoning;
33
+ /**
34
+ * The directory group the model was seen under (`data.list[].function`).
35
+ *
36
+ * The Remote `/models` answer is grouped by SOLO function and the group is
37
+ * the authoritative statement of which function the model belongs to — the
38
+ * `config_name` the gateway accepts is isolated per function, so a model in
39
+ * `solo_work_remote` answers 4001 when asked through `solo_work_lite`. The
40
+ * wire join (see `mergeTraeModelSources`) prefers its own
41
+ * `get_detail_param` function and uses this as the fallback when the roster
42
+ * has nothing to say (restricted networks often return an empty
43
+ * `config_info_list` for the chat functions).
44
+ */
45
+ function?: string;
33
46
  }
34
- /** Parse only capabilities explicitly advertised by Trae's remote model API. */
35
- declare function parseTraeRemoteModel(value: unknown): TraeDiscoveredModel | undefined;
47
+ /**
48
+ * Parse only capabilities explicitly advertised by Trae's remote model API.
49
+ *
50
+ * @param groupFunction The `data.list[].function` of the group this raw row
51
+ * was read from; attached verbatim so downstream can replay the model's own
52
+ * function instead of the `llm_utils_chat` default (see
53
+ * {@link TraeDiscoveredModel.function}).
54
+ */
55
+ declare function parseTraeRemoteModel(value: unknown, groupFunction?: string): TraeDiscoveredModel | undefined;
36
56
  //#endregion
37
57
  //#region src/paths.d.ts
38
58
  type TraeEdition = 'cn' | 'sg' | 'solo' | 'solo-sg';
@@ -2810,6 +2830,13 @@ interface TraeSoloRemoteCatalogOptions {
2810
2830
  credential(): Promise<TraeCredential>;
2811
2831
  fetchImpl?: typeof fetch;
2812
2832
  baseUrl?: string;
2833
+ /**
2834
+ * Diagnostics sink (wired to `ctx.logger.warn` by the plugin). Called only
2835
+ * on the interesting path — when a NON-official host served the directory
2836
+ * or is reused as the remembered first candidate — never on the happy path
2837
+ * of official-host-first.
2838
+ */
2839
+ log?: (message: string, detail?: unknown) => void;
2813
2840
  }
2814
2841
  /**
2815
2842
  * Read-only model catalog client for the SOLO Web API.
@@ -2821,9 +2848,118 @@ declare class TraeSoloRemoteCatalogClient {
2821
2848
  private readonly options;
2822
2849
  private readonly fetchImpl;
2823
2850
  private readonly baseUrl;
2851
+ /**
2852
+ * The last base that answered the directory with a NON-EMPTY catalog in this
2853
+ * process.
2854
+ *
2855
+ * A proxy-blocked network pays the failing official attempt on EVERY refresh
2856
+ * otherwise, so a backup that has served the catalog is tried first on later
2857
+ * refreshes. Putting the official base "second" is NOT enough to re-probe it:
2858
+ * the loop returns on the first success, so a backup that keeps answering
2859
+ * means the official base is never asked again. The official host is
2860
+ * therefore re-probed on a COOLDOWN instead (see {@link OFFICIAL_RETRY_MS}):
2861
+ * while the cooldown runs the backup is used and the official host is
2862
+ * skipped; when it expires the official host is tried FIRST again, so a
2863
+ * healed network takes over within one cooldown window rather than never.
2864
+ */
2865
+ private lastSuccessfulBase;
2866
+ /**
2867
+ * When the official base may be probed again after a transport/5xx failure,
2868
+ * as epoch milliseconds (0 = probe it on the next refresh).
2869
+ */
2870
+ private officialRetryAtMs;
2871
+ /**
2872
+ * How long a failing official host is skipped before it is re-probed.
2873
+ *
2874
+ * Long enough that a blocked network stops paying the failing attempt on
2875
+ * every refresh (the point of the fallback), short enough that a healed
2876
+ * network takes over without a restart. Each probe costs one connect timeout
2877
+ * (30 s) in the worst case, so 5 minutes bounds that to ~1 attempt per 5 min.
2878
+ */
2879
+ private static readonly OFFICIAL_RETRY_MS;
2824
2880
  constructor(options: TraeSoloRemoteCatalogOptions);
2825
2881
  private headers;
2826
2882
  fetchModels(signal?: AbortSignal): Promise<TraeDiscoveredModel[]>;
2883
+ /**
2884
+ * Bases to try for one refresh, in order.
2885
+ *
2886
+ * While the official host is in its post-failure cooldown the backup hosts go
2887
+ * first and the official base is kept LAST as a final chance (so a still-down
2888
+ * backup does not fail the whole refresh while the official host happens to
2889
+ * be reachable). Once the cooldown expires the official base is first again:
2890
+ * that is what lets a healed network take over — the loop returns on the
2891
+ * first success, so simply placing the official base second never re-probes
2892
+ * it while a backup keeps answering.
2893
+ */
2894
+ private candidateBases;
2895
+ /**
2896
+ * Union every returned group into one deduplicated list, attributing each
2897
+ * model to the CHAT function the directory listed it under.
2898
+ *
2899
+ * Each group is a roster the gateway is willing to serve; a model listed by
2900
+ * any of them is a model the IDE can offer, and the wire join downstream (see
2901
+ * mergeTraeModelSources) is what decides whether it is actually callable.
2902
+ * Restricting this to `solo_agent_remote` dropped the nine models issue #19
2903
+ * was opened about.
2904
+ *
2905
+ * FUNCTION ATTRIBUTION (2026-10-20): a group is the authoritative statement
2906
+ * of which SOLO function a model belongs to — the accepted `config_name` is
2907
+ * isolated per function (glm-5.3 answers `solo_work_remote` and 4001s under
2908
+ * `solo_work_lite`). The wire join prefers its own `get_detail_param`
2909
+ * function and falls back to this one when the roster says nothing, which
2910
+ * restricted networks often do (measured: an empty `config_info_list` for
2911
+ * every chat function but `chat_v3`).
2912
+ *
2913
+ * ONLY CHAT-CAPABLE GROUPS ARE ATTRIBUTED. `solo_agent` and `builder_v3` are
2914
+ * IDE/Remote-agent rosters: asking `llm_utils_chat` with either answers
2915
+ * 4011/4001 (measured 2026-10-01 on a live credential, issue #19). A model
2916
+ * that appears ONLY there is still not uncallable, though — the SAME models
2917
+ * answered normally under `solo_work_remote` / `solo_work_lite` /
2918
+ * `solo_agent_remote` (measured the same day, see `TRAE_DIRECTORY_FUNCTIONS`
2919
+ * in solo.ts), and the default function is what has always reached them:
2920
+ * they are exactly the nine models issue #19's fix exposes (`gpt-5.5`,
2921
+ * `gpt-5.6-*`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code`,
2922
+ * `Dola-Seed-2.0-Code`, `deepseek-v4-flash-0731` — docs/ISSUE19_DIAGNOSIS.md).
2923
+ * Stamping `solo_agent` onto such a row makes every call 4011; DROPPING it
2924
+ * undoes issue #19 for exactly the restricted-network users this fallback
2925
+ * exists for. It therefore keeps NO function, the bridge falls back to the
2926
+ * default chat function exactly as before this change, and a refusal (if the
2927
+ * model really is unreachable) comes back as a readable upstream message
2928
+ * instead of a silent absence.
2929
+ *
2930
+ * CHAT GROUPS ARE WALKED IN `TRAE_DIRECTORY_FUNCTIONS` ORDER, not in the
2931
+ * request's `functions=` order. That list is the measured precedence of which
2932
+ * function serves a shared config, and it is the order the wire roster itself
2933
+ * collects in (`solo.ts`) — walking a different order would attribute the
2934
+ * same model differently depending on whether the roster answered, and the
2935
+ * difference is not cosmetic: `solo_agent_remote` sits after the two
2936
+ * `solo_work_*` functions because it is behind the Remote plan gate, so
2937
+ * preferring it can turn a free-tier model into a `1005 requires a paid
2938
+ * plan` refusal.
2939
+ *
2940
+ * A model appears in SEVERAL groups and the groups disagree about its
2941
+ * capacity, so dedupe keeps the MOST CAPABLE row rather than the first one
2942
+ * (issue #23). "First wins" assumed the group order was stable; measured
2943
+ * 2026-10-05 on a live CN credential it is not — five calls returned five
2944
+ * different orders — so the published window became a coin flip:
2945
+ * `Doubao-Seed-2.1-Pro` came back as `116000`/no-Max (`builder_v3`), or
2946
+ * `256000`/no-Max (`solo_work_*`), or `256000`/`1000000` (`chat_v3`,
2947
+ * `solo_agent*`) depending on which group answered first. The Max tier is
2948
+ * the one users enable in the IDE, and `applyContextBudgets` can only raise
2949
+ * a window to `maxContextWindow` — so losing that field silently capped
2950
+ * every configured 1M model at 256K or less, and DSH compressed far earlier
2951
+ * than the chosen budget implied.
2952
+ *
2953
+ * Ordering rule: a row advertising a Max tier beats one that does not, a
2954
+ * larger Max wins over a smaller one, and with no Max on either side the
2955
+ * wider dev window wins. Every row for one id carries the same
2956
+ * `multimodal` / `reasoning` / credit facts (verified the same day), so
2957
+ * choosing on capacity loses no capability. The result no longer depends on
2958
+ * the gateway's group order — and the FUNCTION does not either: the winning
2959
+ * row's capacity may come from a later group, but the separately-tracked
2960
+ * attribution is re-asserted on it.
2961
+ */
2962
+ private collectModels;
2827
2963
  }
2828
2964
  //#endregion
2829
2965
  //#region src/delegating-upstream.d.ts
package/lib/index.js CHANGED
@@ -235,6 +235,7 @@ function mergeTraeModelSources(remote, wire) {
235
235
  for (const model of remote) {
236
236
  const wireModel = wireById.get(displayKey(model.id)) ?? wireByName.get(displayKey(model.name));
237
237
  const creditMultiplier = wireModel?.creditMultiplier ?? model.creditMultiplier;
238
+ const wireFunction = wireModel?.function ?? model.function;
238
239
  result.push({
239
240
  id: model.id,
240
241
  name: model.name,
@@ -242,6 +243,7 @@ function mergeTraeModelSources(remote, wire) {
242
243
  ...model.maxContextWindow === void 0 ? {} : { maxContextWindow: model.maxContextWindow },
243
244
  ...creditMultiplier === void 0 ? {} : { creditMultiplier },
244
245
  input: ["text"],
246
+ ...wireModel?.maxTokens === void 0 ? {} : { maxTokens: wireModel.maxTokens },
245
247
  ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
246
248
  reasoningSupported: model.reasoningSupported,
247
249
  ...model.reasoning === void 0 ? {} : {
@@ -249,12 +251,45 @@ function mergeTraeModelSources(remote, wire) {
249
251
  reasoningEfforts: Object.fromEntries(model.reasoning.supported.map((effort) => [effort, effort === "low" ? "light" : effort === "xhigh" ? "extra_high" : "high"]))
250
252
  },
251
253
  ...wireModel !== void 0 && wireModel.id !== "" && wireModel.id !== model.id ? { wireConfigName: wireModel.id } : {},
252
- ...wireModel?.function === void 0 ? {} : { wireFunction: wireModel.function }
254
+ ...wireFunction === void 0 ? {} : { wireFunction }
253
255
  });
254
256
  }
255
257
  return result;
256
258
  }
257
259
  /**
260
+ * Demote a NON-chat `wireFunction` to "no function", keeping the row.
261
+ *
262
+ * A row pinned to an IDE/Remote-agent roster — `solo_agent`, `builder_v3` — is
263
+ * not callable through `llm_utils_chat` by THAT function: asking answers
264
+ * 4011/4001 (measured 2026-10-01, issue #19). Dropping the row is not the fix,
265
+ * though, and was a regression: a model can carry a non-chat function for a
266
+ * reason that says nothing about its chat availability. The directory lists
267
+ * `gpt-5.5`, `gpt-5.6-*`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code`,
268
+ * `Dola-Seed-2.0-Code` and `deepseek-v4-flash-0731` under `solo_agent` ALONE
269
+ * (docs/ISSUE19_DIAGNOSIS.md), yet the same models answer normally under
270
+ * `solo_work_remote` / `solo_work_lite` / `solo_agent_remote` — they are
271
+ * exactly the models issue #19's fix was opened to expose. Dropping them
272
+ * re-hid the whole set on the restricted networks this fallback targets, where
273
+ * the wire roster is empty and the directory group is the only attribution.
274
+ *
275
+ * Keeping the row with the function CLEARED preserves both facts: the model
276
+ * stays visible (issue #19), and the bridge sends the region's DEFAULT chat
277
+ * function instead of one that is guaranteed to refuse. That is the pre-change
278
+ * behaviour for these rows — a genuinely unreachable model still fails with a
279
+ * readable upstream message rather than a silent absence.
280
+ *
281
+ * `chatFunctions` must be the region's own `TRAE_DIRECTORY_FUNCTIONS` list —
282
+ * the functions `llm_utils_chat` accepts. Callers should log the demoted rows:
283
+ * "which function did it lose, and why" deserves an answer.
284
+ */
285
+ function withChatCallableFunctions(rows, chatFunctions) {
286
+ return rows.map((model) => {
287
+ if (model.wireFunction === void 0 || chatFunctions.includes(model.wireFunction)) return model;
288
+ const { wireFunction: _demoted, ...withoutFunction } = model;
289
+ return withoutFunction;
290
+ });
291
+ }
292
+ /**
258
293
  * Apply the saved local budget. Trae advertises two windows per model (dev and
259
294
  * Max), so the budget may only switch a model to its own advertised Max value —
260
295
  * never to a fabricated number. Everything else keeps the dev window.
@@ -276,6 +311,7 @@ function discoveredCatalog(models) {
276
311
  input: ["text"],
277
312
  ...model.creditMultiplier === void 0 ? {} : { creditMultiplier: model.creditMultiplier },
278
313
  ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
314
+ ...model.function === void 0 ? {} : { wireFunction: model.function },
279
315
  reasoningSupported: model.reasoningSupported,
280
316
  ...model.reasoning === void 0 ? {} : {
281
317
  reasoning: model.reasoning,
@@ -4701,8 +4737,9 @@ var TraeSoloBridge = class {
4701
4737
  if (typeof input["model"] === "string" && input["model"] !== "") model = input["model"];
4702
4738
  const entryKey = model.trim().toLowerCase();
4703
4739
  const entry = this.catalog?.current().find((item) => item.id === model || item.id.trim().toLowerCase() === entryKey);
4704
- const fromCatalog = entry?.wireConfigName === void 0 ? void 0 : {
4705
- configName: entry.wireConfigName,
4740
+ const catalogConfigName = entry?.wireConfigName;
4741
+ const fromCatalog = entry === void 0 || catalogConfigName === void 0 && entry.wireFunction === void 0 ? void 0 : {
4742
+ configName: catalogConfigName ?? entry.id,
4706
4743
  ...entry.wireFunction === void 0 ? {} : { function: entry.wireFunction }
4707
4744
  };
4708
4745
  const fromResolver = this.wireResolver?.(model) ?? this.wireResolver?.(entry?.name ?? "");
@@ -4717,11 +4754,28 @@ var TraeSoloBridge = class {
4717
4754
  * directory, so borrowing its function is safe exactly when both sources
4718
4755
  * agree on the wire config_name; a disagreement means the resolver found
4719
4756
  * a different row and its function would be wrong.
4757
+ *
4758
+ * The MIRROR case is equally load-bearing and is the reason the row's own
4759
+ * id is not accepted as its wire spelling: a row that carries only the
4760
+ * FUNCTION (`wireConfigName` absent) has no config_name of its own, and
4761
+ * its display id is the wrong-cased spelling of the wire name in exactly
4762
+ * the cases issue #38 is about. The resolver's config_name is then the
4763
+ * only correct one, so it must win for the NAME while the row keeps
4764
+ * winning for the FUNCTION.
4720
4765
  */
4721
4766
  const sameWireConfig = fromCatalog !== void 0 && fromResolver !== void 0 && fromCatalog.configName.toLowerCase() === fromResolver.configName.toLowerCase();
4767
+ /**
4768
+ * Whether the catalog row knows its own wire `config_name` (as opposed to
4769
+ * only carrying the display id plus a function). Only such a row may
4770
+ * decide the name on its own.
4771
+ */
4772
+ const catalogKnowsConfigName = catalogConfigName !== void 0;
4722
4773
  const target = fromCatalog !== void 0 && fromCatalog.function === void 0 && sameWireConfig && fromResolver !== void 0 ? {
4723
4774
  configName: fromCatalog.configName,
4724
4775
  ...fromResolver.function === void 0 ? {} : { function: fromResolver.function }
4776
+ } : fromCatalog !== void 0 && !catalogKnowsConfigName && fromResolver !== void 0 ? {
4777
+ configName: fromResolver.configName,
4778
+ ...fromCatalog.function === void 0 ? {} : { function: fromCatalog.function }
4725
4779
  } : fromCatalog ?? fromResolver;
4726
4780
  /**
4727
4781
  * Last line of defence: the discovered row's own id.
@@ -4785,8 +4839,15 @@ const EFFORT_MAP = {
4785
4839
  high: "high",
4786
4840
  extra_high: "xhigh"
4787
4841
  };
4788
- /** Parse only capabilities explicitly advertised by Trae's remote model API. */
4789
- function parseTraeRemoteModel(value) {
4842
+ /**
4843
+ * Parse only capabilities explicitly advertised by Trae's remote model API.
4844
+ *
4845
+ * @param groupFunction The `data.list[].function` of the group this raw row
4846
+ * was read from; attached verbatim so downstream can replay the model's own
4847
+ * function instead of the `llm_utils_chat` default (see
4848
+ * {@link TraeDiscoveredModel.function}).
4849
+ */
4850
+ function parseTraeRemoteModel(value, groupFunction) {
4790
4851
  const raw = record(value);
4791
4852
  if (raw === void 0 || typeof raw.name !== "string" || raw.name === "") return void 0;
4792
4853
  const context = record(raw.context_window_tokens);
@@ -4817,7 +4878,8 @@ function parseTraeRemoteModel(value) {
4817
4878
  ...supported.length === 0 ? {} : { reasoning: {
4818
4879
  supported,
4819
4880
  ...defaultEffort === void 0 ? {} : { defaultEffort }
4820
- } }
4881
+ } },
4882
+ ...groupFunction === void 0 || groupFunction === "" ? {} : { function: groupFunction }
4821
4883
  };
4822
4884
  }
4823
4885
  //#endregion
@@ -4884,6 +4946,23 @@ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
4884
4946
  ]
4885
4947
  };
4886
4948
  /**
4949
+ * Backup bases for the model directory, in try order after the official base.
4950
+ *
4951
+ * In restricted (proxy-sandboxed) networks the official catalog host
4952
+ * `solo.trae.cn` is behind a CONNECT rejection while the region's chat gateway
4953
+ * stays reachable — and that gateway serves the SAME `/api/remote/v1/models`
4954
+ * endpoint with an identical response shape (verified against
4955
+ * `https://trae-api-cn.mchost.guru/api/remote/v1` 2026-10-20; `chat` already
4956
+ * carries `/api/remote/v1`'s parent in {@link REGION_GATEWAYS}). The backup is
4957
+ * therefore derived from the region's chat base, mirrored on the remote path;
4958
+ * `ai` shares a single host for chat and remote, so it yields no candidate and
4959
+ * keeps its single-host behavior. An explicit `baseUrl` disables the chain.
4960
+ */
4961
+ function traeSoloRemoteFallbackBases(region, officialBase) {
4962
+ const backupBase = `${REGION_GATEWAYS[region].chat}/api/remote/v1`;
4963
+ return backupBase === officialBase ? [] : [backupBase];
4964
+ }
4965
+ /**
4887
4966
  * Of two rows for the SAME model id, the one that advertises the stronger
4888
4967
  * capacity (issue #23).
4889
4968
  *
@@ -4941,10 +5020,39 @@ function remoteDressing(region) {
4941
5020
  * This deliberately has no chat/session method: the Remote session protocol
4942
5021
  * only exposes a final answer and cannot preserve DSH's structured tool loop.
4943
5022
  */
4944
- var TraeSoloRemoteCatalogClient = class {
5023
+ var TraeSoloRemoteCatalogClient = class TraeSoloRemoteCatalogClient {
4945
5024
  options;
4946
5025
  fetchImpl;
4947
5026
  baseUrl;
5027
+ /**
5028
+ * The last base that answered the directory with a NON-EMPTY catalog in this
5029
+ * process.
5030
+ *
5031
+ * A proxy-blocked network pays the failing official attempt on EVERY refresh
5032
+ * otherwise, so a backup that has served the catalog is tried first on later
5033
+ * refreshes. Putting the official base "second" is NOT enough to re-probe it:
5034
+ * the loop returns on the first success, so a backup that keeps answering
5035
+ * means the official base is never asked again. The official host is
5036
+ * therefore re-probed on a COOLDOWN instead (see {@link OFFICIAL_RETRY_MS}):
5037
+ * while the cooldown runs the backup is used and the official host is
5038
+ * skipped; when it expires the official host is tried FIRST again, so a
5039
+ * healed network takes over within one cooldown window rather than never.
5040
+ */
5041
+ lastSuccessfulBase;
5042
+ /**
5043
+ * When the official base may be probed again after a transport/5xx failure,
5044
+ * as epoch milliseconds (0 = probe it on the next refresh).
5045
+ */
5046
+ officialRetryAtMs = 0;
5047
+ /**
5048
+ * How long a failing official host is skipped before it is re-probed.
5049
+ *
5050
+ * Long enough that a blocked network stops paying the failing attempt on
5051
+ * every refresh (the point of the fallback), short enough that a healed
5052
+ * network takes over without a restart. Each probe costs one connect timeout
5053
+ * (30 s) in the worst case, so 5 minutes bounds that to ~1 attempt per 5 min.
5054
+ */
5055
+ static OFFICIAL_RETRY_MS = 3e5;
4948
5056
  constructor(options) {
4949
5057
  this.options = options;
4950
5058
  this.fetchImpl = options.fetchImpl ?? fetch;
@@ -4965,24 +5073,183 @@ var TraeSoloRemoteCatalogClient = class {
4965
5073
  }
4966
5074
  async fetchModels(signal) {
4967
5075
  const region = regionOfCredential(await this.options.credential());
4968
- const base = this.baseUrl ?? REGION_GATEWAYS[region].remote;
5076
+ const officialBase = this.baseUrl ?? REGION_GATEWAYS[region].remote;
4969
5077
  const headers = await this.headers(region);
4970
5078
  const functions = TRAE_REMOTE_DIRECTORY_FUNCTIONS[region].join(",");
4971
- const response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
4972
- headers,
4973
- signal: signal ?? AbortSignal.timeout(3e4)
4974
- });
4975
- if (!response.ok) throw new Error(`SOLO remote models returned HTTP ${response.status}`);
4976
- const groups = (await response.json()).data?.list ?? [];
5079
+ let lastError;
5080
+ let emptyError;
5081
+ for (const base of this.candidateBases(region, officialBase)) {
5082
+ let response;
5083
+ try {
5084
+ response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
5085
+ headers,
5086
+ signal: signal ?? AbortSignal.timeout(3e4)
5087
+ });
5088
+ } catch (error) {
5089
+ if (signal?.aborted) throw error;
5090
+ if (base === officialBase) this.officialRetryAtMs = Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS;
5091
+ lastError = error;
5092
+ continue;
5093
+ }
5094
+ if (!response.ok) {
5095
+ if (response.status < 500) throw new Error(`SOLO remote models returned HTTP ${response.status}`);
5096
+ if (base === officialBase) this.officialRetryAtMs = Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS;
5097
+ lastError = /* @__PURE__ */ new Error(`SOLO remote models returned HTTP ${response.status}`);
5098
+ continue;
5099
+ }
5100
+ const json = await response.json();
5101
+ const models = this.collectModels(region, json.data?.list ?? []);
5102
+ if (models.length === 0) {
5103
+ emptyError = /* @__PURE__ */ new Error("SOLO remote models response contained no models");
5104
+ lastError = emptyError;
5105
+ continue;
5106
+ }
5107
+ 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 });
5108
+ else this.officialRetryAtMs = 0;
5109
+ this.lastSuccessfulBase = base;
5110
+ return models;
5111
+ }
5112
+ if (emptyError !== void 0) throw emptyError;
5113
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
5114
+ }
5115
+ /**
5116
+ * Bases to try for one refresh, in order.
5117
+ *
5118
+ * While the official host is in its post-failure cooldown the backup hosts go
5119
+ * first and the official base is kept LAST as a final chance (so a still-down
5120
+ * backup does not fail the whole refresh while the official host happens to
5121
+ * be reachable). Once the cooldown expires the official base is first again:
5122
+ * that is what lets a healed network take over — the loop returns on the
5123
+ * first success, so simply placing the official base second never re-probes
5124
+ * it while a backup keeps answering.
5125
+ */
5126
+ candidateBases(region, officialBase) {
5127
+ if (this.baseUrl !== void 0) return [officialBase];
5128
+ const fallbacks = traeSoloRemoteFallbackBases(region, officialBase);
5129
+ if (this.officialRetryAtMs <= Date.now()) return [officialBase, ...fallbacks];
5130
+ const remembered = this.lastSuccessfulBase;
5131
+ return [
5132
+ ...remembered === void 0 ? [] : [remembered],
5133
+ ...fallbacks.filter((base) => base !== remembered),
5134
+ officialBase
5135
+ ];
5136
+ }
5137
+ /**
5138
+ * Union every returned group into one deduplicated list, attributing each
5139
+ * model to the CHAT function the directory listed it under.
5140
+ *
5141
+ * Each group is a roster the gateway is willing to serve; a model listed by
5142
+ * any of them is a model the IDE can offer, and the wire join downstream (see
5143
+ * mergeTraeModelSources) is what decides whether it is actually callable.
5144
+ * Restricting this to `solo_agent_remote` dropped the nine models issue #19
5145
+ * was opened about.
5146
+ *
5147
+ * FUNCTION ATTRIBUTION (2026-10-20): a group is the authoritative statement
5148
+ * of which SOLO function a model belongs to — the accepted `config_name` is
5149
+ * isolated per function (glm-5.3 answers `solo_work_remote` and 4001s under
5150
+ * `solo_work_lite`). The wire join prefers its own `get_detail_param`
5151
+ * function and falls back to this one when the roster says nothing, which
5152
+ * restricted networks often do (measured: an empty `config_info_list` for
5153
+ * every chat function but `chat_v3`).
5154
+ *
5155
+ * ONLY CHAT-CAPABLE GROUPS ARE ATTRIBUTED. `solo_agent` and `builder_v3` are
5156
+ * IDE/Remote-agent rosters: asking `llm_utils_chat` with either answers
5157
+ * 4011/4001 (measured 2026-10-01 on a live credential, issue #19). A model
5158
+ * that appears ONLY there is still not uncallable, though — the SAME models
5159
+ * answered normally under `solo_work_remote` / `solo_work_lite` /
5160
+ * `solo_agent_remote` (measured the same day, see `TRAE_DIRECTORY_FUNCTIONS`
5161
+ * in solo.ts), and the default function is what has always reached them:
5162
+ * they are exactly the nine models issue #19's fix exposes (`gpt-5.5`,
5163
+ * `gpt-5.6-*`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code`,
5164
+ * `Dola-Seed-2.0-Code`, `deepseek-v4-flash-0731` — docs/ISSUE19_DIAGNOSIS.md).
5165
+ * Stamping `solo_agent` onto such a row makes every call 4011; DROPPING it
5166
+ * undoes issue #19 for exactly the restricted-network users this fallback
5167
+ * exists for. It therefore keeps NO function, the bridge falls back to the
5168
+ * default chat function exactly as before this change, and a refusal (if the
5169
+ * model really is unreachable) comes back as a readable upstream message
5170
+ * instead of a silent absence.
5171
+ *
5172
+ * CHAT GROUPS ARE WALKED IN `TRAE_DIRECTORY_FUNCTIONS` ORDER, not in the
5173
+ * request's `functions=` order. That list is the measured precedence of which
5174
+ * function serves a shared config, and it is the order the wire roster itself
5175
+ * collects in (`solo.ts`) — walking a different order would attribute the
5176
+ * same model differently depending on whether the roster answered, and the
5177
+ * difference is not cosmetic: `solo_agent_remote` sits after the two
5178
+ * `solo_work_*` functions because it is behind the Remote plan gate, so
5179
+ * preferring it can turn a free-tier model into a `1005 requires a paid
5180
+ * plan` refusal.
5181
+ *
5182
+ * A model appears in SEVERAL groups and the groups disagree about its
5183
+ * capacity, so dedupe keeps the MOST CAPABLE row rather than the first one
5184
+ * (issue #23). "First wins" assumed the group order was stable; measured
5185
+ * 2026-10-05 on a live CN credential it is not — five calls returned five
5186
+ * different orders — so the published window became a coin flip:
5187
+ * `Doubao-Seed-2.1-Pro` came back as `116000`/no-Max (`builder_v3`), or
5188
+ * `256000`/no-Max (`solo_work_*`), or `256000`/`1000000` (`chat_v3`,
5189
+ * `solo_agent*`) depending on which group answered first. The Max tier is
5190
+ * the one users enable in the IDE, and `applyContextBudgets` can only raise
5191
+ * a window to `maxContextWindow` — so losing that field silently capped
5192
+ * every configured 1M model at 256K or less, and DSH compressed far earlier
5193
+ * than the chosen budget implied.
5194
+ *
5195
+ * Ordering rule: a row advertising a Max tier beats one that does not, a
5196
+ * larger Max wins over a smaller one, and with no Max on either side the
5197
+ * wider dev window wins. Every row for one id carries the same
5198
+ * `multimodal` / `reasoning` / credit facts (verified the same day), so
5199
+ * choosing on capacity loses no capability. The result no longer depends on
5200
+ * the gateway's group order — and the FUNCTION does not either: the winning
5201
+ * row's capacity may come from a later group, but the separately-tracked
5202
+ * attribution is re-asserted on it.
5203
+ */
5204
+ collectModels(region, groups) {
4977
5205
  const byId = /* @__PURE__ */ new Map();
4978
- for (const group of groups) for (const raw of group.models ?? []) {
4979
- const model = parseTraeRemoteModel(raw);
4980
- if (model === void 0) continue;
4981
- byId.set(model.id, preferStrongerRow(model, byId.get(model.id)));
5206
+ const attributedFunction = /* @__PURE__ */ new Map();
5207
+ /**
5208
+ * The function a row may be stamped with: only a CHAT function. A non-chat
5209
+ * roster is not "the function this model belongs to" in any sense the chat
5210
+ * route can use (see the method note above), so it is not recorded at all.
5211
+ */
5212
+ const chatFunctions = new Set(TRAE_DIRECTORY_FUNCTIONS[region]);
5213
+ const attribution = (groupFunction) => groupFunction !== void 0 && chatFunctions.has(groupFunction) ? groupFunction : void 0;
5214
+ const addGroup = (groupFunction, raws) => {
5215
+ const fn = attribution(groupFunction);
5216
+ for (const raw of raws) {
5217
+ const model = parseTraeRemoteModel(raw, fn);
5218
+ if (model === void 0) continue;
5219
+ if (fn !== void 0 && !attributedFunction.has(model.id)) attributedFunction.set(model.id, fn);
5220
+ byId.set(model.id, preferStrongerRow(model, byId.get(model.id)));
5221
+ }
5222
+ };
5223
+ /**
5224
+ * Every group the gateway sent, bucketed BY FUNCTION NAME and kept as a
5225
+ * LIST.
5226
+ *
5227
+ * A name→group `Map` would keep only the LAST group of a name and silently
5228
+ * drop the models of every earlier one: nothing documents that the gateway
5229
+ * sends a function at most once, and the pre-change code unioned every
5230
+ * element of `data.list`, so a repeated name lost real models. Bucketing
5231
+ * keeps that union while the walk below still imposes a deterministic
5232
+ * order.
5233
+ */
5234
+ const byFunction = /* @__PURE__ */ new Map();
5235
+ for (const group of groups) {
5236
+ const key = group.function ?? "";
5237
+ const bucket = byFunction.get(key);
5238
+ if (bucket === void 0) byFunction.set(key, [group]);
5239
+ else bucket.push(group);
4982
5240
  }
4983
- const models = [...byId.values()];
4984
- if (models.length === 0) throw new Error("SOLO remote models response contained no models");
4985
- return models;
5241
+ for (const name of TRAE_DIRECTORY_FUNCTIONS[region]) for (const group of byFunction.get(name) ?? []) addGroup(name, group.models ?? []);
5242
+ for (const [name, bucket] of byFunction) {
5243
+ if (chatFunctions.has(name)) continue;
5244
+ for (const group of bucket) addGroup(name, group.models ?? []);
5245
+ }
5246
+ return [...byId.values()].map((model) => {
5247
+ const fn = attributedFunction.get(model.id);
5248
+ return fn === void 0 || fn === model.function ? model : {
5249
+ ...model,
5250
+ function: fn
5251
+ };
5252
+ });
4986
5253
  }
4987
5254
  };
4988
5255
  //#endregion
@@ -8755,7 +9022,10 @@ function apply(ctx, config) {
8755
9022
  ...panel === "enterprise" ? { route: "enterprise" } : {},
8756
9023
  log: (message, detail) => ctx.logger.warn(message, detail)
8757
9024
  });
8758
- const remoteCatalog = new TraeSoloRemoteCatalogClient({ credential: () => store.resolve() });
9025
+ const remoteCatalog = new TraeSoloRemoteCatalogClient({
9026
+ credential: () => store.resolve(),
9027
+ log: (message, detail) => ctx.logger.warn(message, detail)
9028
+ });
8759
9029
  const wireResolver = (displayId) => wire.byId.get(displayId) ?? wire.byName.get(displayId.trim().toLowerCase());
8760
9030
  /**
8761
9031
  * One client + bridge per provider id.
@@ -8908,18 +9178,24 @@ function apply(ctx, config) {
8908
9178
  },
8909
9179
  discoverModels: async (signal) => {
8910
9180
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
8911
- wire.callableKeys.clear();
9181
+ const callable = withChatCallableFunctions(merged, TRAE_DIRECTORY_FUNCTIONS[region]);
9182
+ const chatFunctions = TRAE_DIRECTORY_FUNCTIONS[region];
8912
9183
  for (const model of merged) {
9184
+ if (model.wireFunction === void 0 || chatFunctions.includes(model.wireFunction)) continue;
9185
+ ctx.logger.warn(`model directory row is pinned to a non-chat function and will be called through the default chat function instead: ${model.id} (pinned to function ${model.wireFunction})`);
9186
+ }
9187
+ wire.callableKeys.clear();
9188
+ for (const model of callable) {
8913
9189
  wire.callableKeys.add(model.id.trim().toLowerCase());
8914
9190
  wire.callableKeys.add(model.name.trim().toLowerCase());
8915
9191
  }
8916
- wire.resolved = merged.length > 0;
8917
- const wireTargets = wireTargetsOf(merged);
9192
+ wire.resolved = callable.length > 0;
9193
+ const wireTargets = wireTargetsOf(callable);
8918
9194
  wire.byId = wireTargets.byId;
8919
9195
  wire.byName = wireTargets.byName;
8920
9196
  latestDiscovery.length = 0;
8921
- latestDiscovery.push(...merged);
8922
- return merged;
9197
+ latestDiscovery.push(...callable);
9198
+ return callable;
8923
9199
  },
8924
9200
  /**
8925
9201
  * One-click availability test. The ids come from the card, so each one is
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.0.0",
5
+ "version": "4.1.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",