dsh-connect-trae 2.7.1 → 2.8.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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0 (2026-10-06)
4
+
5
+ > **绑定账号额度耗尽时不再是一块没有解释的空面板**([#25](https://github.com/dingminhua/dsh-connect-trae/issues/25),报告人 JiewiW)。
6
+ >
7
+ > 账号选择是**持久化**的:你一旦把某个账号绑上去,它就长期生效。于是「绑定的那个账号额度用尽」在卡片上表现为**一个永远为 0 的面板,没有任何解释**;而「刷新账号与 Token」会返回 200 和同一份账号列表,看起来和失败一模一样。报告人遇到的正是这个:TRAE SOLO CN 卸载后其凭据仍留在 `%APPDATA%`,CN 区一直绑着那个已耗尽的残留账号,而他以为绑的是自己正在用的 Trae CN。
8
+
9
+ ### Features
10
+
11
+ - **「刷新账号与 Token」现在会报告结果**(`rescanAccounts`):成功后显示「找到 N 个账号 · 仍绑定 X」,失败时显示原因并保留原错误。用户从此能区分「刷新成功但无变化」与「刷新失败」。
12
+ - **绑定账号额度耗尽且本机另有可用账号时,卡片给出明确提示并支持一键切换**(仅国内版,额度接口只存在于 CN):
13
+ - 仅当**已选账号自身额度为 0** 时才去查其他账号,账号健康时**不产生任何额外请求**;
14
+ - 提示里带上可用账号的名字、产品与可用积分数,一键写入绑定并立即刷新;
15
+ - 其他账号同样为空、或来自另一区域(国际版)的,一律不提示——避免给出「切过去也用不了」或「数字不适用本区」的建议。
16
+ - **账号下拉显示可读的产品名**:`Trae` / `Trae 国际版` / `TRAE SOLO` / `TRAE SOLO 国际版`,取代原先裸露的 `cn` / `sg` / `solo` 标记——正是这个标记让报告人分不清哪个凭据来自已卸载的产品。
17
+
18
+ ### Implementation notes
19
+
20
+ - 新增只读的 `TraeCredentialStore.credentialOf(accountId)`:查某个**指定**账号的凭据而不改绑。原先只能靠 `selectAccount` 切换来读,而那会与共用同一 store 的在途聊天请求竞争,并持久化一次用户并未做出的选择。这个方法是纯查询:只读、无刷新副作用、`accountId` 不变。
21
+ - 探测其他账号的额度用一个**临时** usage client,并复用与绑定账号**完全相同**的 `toCredits`(Work/general 分桶),避免出现「提示里的数字和旁边显示的数字算法不一致」。
22
+
23
+ ### Tests
24
+
25
+ - 全仓 419 → **423**(+4):绑定账号为空时才出现替代账号;账号健康时**一次都不探测**(断言探测次数为 0);同样为空、以及**跨区域**的账号既不被提示也不被探测;单个账号探测抛错时安全降级。
26
+ - **变异验证 3 次全部被抓**:永不触发探测 → 1 例失败;去掉「无额度」过滤(空账号也会被当作出路)→ 1 例失败;去掉区域限制(会去探测国际版账号)→ 1 例失败。
27
+
28
+ ## 2.7.2 (2026-10-05)
29
+
30
+ > **补完 #23 的确定性**:2.7.1 修好了 Max 档随机丢失,但**在 max 相等时仍保留了先到者**,于是 **dev 基础窗口**还会随货架顺序漂移。报告人 JiewiW 在 Windows 复测时发现并给了修法(`glm-5.2`:`chat_v3` 的 dev=116000 与 `solo_agent_remote` 的 dev=200000 都带 max=1000000,谁先到就取谁)。
31
+
32
+ ### Bug Fixes
33
+
34
+ - **择强规则改为逐级比较到底(字典序),不再在「max 相等」处停止**(`preferStrongerRow`):
35
+ - ① 带 Max 的行优先 → ② Max 更大者胜 → ③ **max 相等(或都无 Max)时比 dev,取更宽者** → ④ 完全相等才保留先到者。
36
+ - **为什么第 ③ 步不是多余的**:2.7.1 在 max 相等时直接保留先到者,导致**未配置预算的模型**其默认窗口仍是一次抛硬币——本机 CN 30 个模型里有 9 个如此(`glm-5.2` 就是其一)。配了 1M 预算时最终仍恒为 1M,所以用户不易察觉;但「默认窗口」决定 DSH 的显示与压缩基准,正是 #23 要消除的同一类缺陷。
37
+ - **实测(本机 CN,连刷 8 次)**:修复后**整目录指纹 8/8 完全一致**(含每个模型的 dev 与 max,30 个模型);`glm-5.2` 稳定为 `dev=200000 / max=1000000`。修复前同一份数据的两种货架顺序分别得到 dev=116000 与 dev=200000。
38
+
39
+ ### Tests
40
+
41
+ - **全仓 417 → 419**:新增 2 例(max 相等时按 dev 取宽、且与顺序无关;带 Max 的行仍胜过 dev 更宽的无 Max 行)。同时把原有的「6 种货架排列」用例的**数据改成「max 相等但 dev 不等」**——正是报告人指出的形态,断言全部排列落到同一个 dev;这条用例此前只覆盖了 dev 相同的情形,所以没能发现该问题。
42
+ - **变异验证 2 次全部被抓**:把规则退回「max 相等即保留先到者」→ 2 例失败;去掉「带 Max 优先于 dev 更宽」→ 2 例失败。
43
+
44
+ ### 致谢
45
+
46
+ 报告人 **JiewiW** 在 Windows 上完成了三项预期验证,并额外指出 `preferStrongerRow` 在 max 相等时的残留不确定性,连修法(「在 max 相等时继续落到 dev 取更宽」)一并给出——本版照此实现。
47
+
3
48
  ## 2.7.1 (2026-10-05)
4
49
 
5
50
  > **修复「Max 档随机丢失、同一模型上下文窗口在多次刷新间漂移」**([#23](https://github.com/dingminhua/dsh-connect-trae/issues/23),报告人 JiewiW)。
package/README.en.md CHANGED
@@ -40,7 +40,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
40
40
  - **Dual-region detection (CN / international)** — detects local sign-ins across Trae CN, TRAE SOLO CN, Trae, and TRAE SOLO installs. The region is derived from the credential's own `userRegion` claim (host suffix and edition label as fallbacks), with no manual switch.
41
41
  - **Region-isolated directories and selections** — CN and international each keep their own model directory, enabled picks, image opt-ins, and context budgets, and each provider reads only its own slot.
42
42
  - **Vision follows Trae's own declaration** — when Trae marks a model `multimodal`, the plugin advertises it as such with no manual tick per model (issue #16). The rule is "**models you tick ∪ models Trae marks multimodal**": models Trae marks `false`, or does not mark at all, stay text-only and can still be turned on by hand. A row enabled automatically is shown ticked and read-only, with the reason. See "Image input" below.
43
- - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings.
43
+ - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings. **Your choice is remembered and stays in effect**: the plugin will never switch accounts on its own just because the bound one ran out of credit or another usable one was found (switching bills a different person). So when the bound account is exhausted the card **says so explicitly** and lists the other accounts on this machine that still have credit, with their balances, for you to switch to in one click — instead of showing an empty panel or quietly rebinding you. "Refresh accounts & tokens" now always reports what happened (how many accounts were found, who is still bound) instead of appearing to do nothing. The dropdown names the product (`Trae` / `TRAE SOLO` / international) so a credential left behind by an uninstalled app is recognisable.
44
44
  - **Read-only usage and model management** — Work/general credits on CN accounts, subscription/trial status on international ones; enable Trae models freely. Read-only queries consume nothing.
45
45
  - **One-click test of the enabled models** — sends each **enabled** model one minimal message (a single `ping`, no tools) and reports whether it actually answers: **works** / **needs a higher plan** (Trae `1005`; your plan does not cover it) / **not served** (`4001`/`4011`; no SOLO function serves it) / **throttled** / **sign-in rejected** / **not determined**. The last one is a first-class outcome: a timeout, a dropped connection, a proxy or a 5xx is **never** reported as unusable — only an explicit upstream refusal is a negative verdict. This is the plugin's **only** operation that spends quota (check-in changes account state but spends nothing), it runs only when you click, and its results live in the current session rather than in your settings.
46
46
  - **Daily check-in claiming** — a one-click claim on the CN card (the button shows the daily reward, and reads "Claimed today" and disables itself once that account has been paid). This is the plugin's **only** operation that changes account state, and it runs only when you click. Trae allows **one check-in per device per day**: after switching accounts, if this machine's slot is already spent (by any account), the selected account cannot claim here that day — the card explains that rather than pretending the account was paid. The international side has no check-in activity, so no button is shown there.
package/README.md CHANGED
@@ -40,7 +40,7 @@
40
40
  - **国内国际双区域自动识别** —— 自动发现 Trae CN / TRAE SOLO CN / Trae / TRAE SOLO 四个本地安装的登录账号;区域由凭证自带的 `userRegion` 声明自动判定(host 后缀与 edition 标签兜底),无需手动指定。
41
41
  - **目录与勾选按区域隔离** —— 国内版与国际版各一套模型目录、勾选、图片开关与上下文预算,两个供应商各自读各自的槽位。
42
42
  - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
43
- - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。
43
+ - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
44
44
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
45
45
  - **一键测试启用模型** —— 给**已启用**的每个模型发一条最小消息(一句 `ping`、不带工具),逐个告诉你能不能真的应答:**可用** / **需要更高档位**(Trae `1005`,你的套餐不覆盖)/ **上游不提供**(`4001`/`4011`,SOLO 通道没有这个模型)/ **被限流** / **登录凭据被拒** / **本次未测出**。最后一项是一等公民:超时、断连、代理、5xx **一律不算「不可用」**——只有上游明确的拒绝才算负面结论。**这是插件里唯一消耗额度的操作**(签到改变的是账号状态,不消耗),只在你点击时运行,结果只留在当前会话里不写入设置。
46
46
  - **每日签到领取** —— 国内版卡片上一键领取当日签到积分(按钮显示每日奖励,该账号已领取则显示「今日已领取」并禁用);这是插件里**唯一会改变账号状态**的操作,由你主动点击触发。Trae 的签到按「**每台设备每天一次**」限制:切换账号后如果本机今天的名额已经用掉(无论是哪个账号用的),当前账号在本机当天无法再签,卡片会说明原因而不是谎报「已领取」。国际版没有签到活动,因此不显示该按钮。
package/lib/client.js CHANGED
@@ -264,6 +264,12 @@ window.__ModuleLoader__.load({
264
264
  /* The two head actions sit together; the probe one SPENDS quota, so it carries
265
265
  the hint tooltip rather than looking like its read-only sibling. */
266
266
  .dsm-trae-models-actions{display:flex;align-items:center;gap:8px;flex:none}
267
+ .dsm-trae-usage-alternative{display:flex;align-items:flex-start;gap:10px;margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-state-warning-border,#7a5b12);border-radius:10px;background:var(--dsw-alias-state-warning-bg,#2a2313)}
268
+ .dsm-trae-usage-alternative-text{margin:0;flex:1 1 auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e6e6)}
269
+ .dsm-trae-usage-switch-button{flex:0 0 auto;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;line-height:16px;cursor:pointer}
270
+ .dsm-trae-usage-switch-button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,#333740)}
271
+ .dsm-trae-usage-switch-button:disabled{opacity:.5;cursor:not-allowed}
272
+ .dsm-trae-usage-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
267
273
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
268
274
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
269
275
  verdict stays neutral because an inconclusive probe is not evidence against
@@ -481,6 +487,16 @@ window.__ModuleLoader__.load({
481
487
  /** Last claim refusal, shown until the next successful refresh. */
482
488
  const [claimError, setClaimError] = (0, react.useState)(void 0);
483
489
  /**
490
+ * Outcome of the last "refresh accounts" press (issue #25).
491
+ *
492
+ * The rescan answers 200 with a fresh account list whether or not anything
493
+ * changed, and the selection is PERSISTED, so a rescan of a machine whose
494
+ * bound account is still the one it was yesterday looks identical to a
495
+ * failure. Reporting the outcome is the difference between "it did nothing"
496
+ * and "it found the same 2 accounts, still on X".
497
+ */
498
+ const [rescanNote, setRescanNote] = (0, react.useState)(void 0);
499
+ /**
484
500
  * Whether this MACHINE has spent today's check-in while the selected account
485
501
  * has not been paid — the "switched account" state. Kept separate from
486
502
  * `claimError` because it is not a failure: it is the upstream's one-per-
@@ -599,15 +615,24 @@ window.__ModuleLoader__.load({
599
615
  });
600
616
  const body = await response.json();
601
617
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
602
- const selected = body.accounts.find((account) => account.selected)?.id;
618
+ const selected = body.accounts.find((account) => account.selected);
603
619
  const configured = configuredAccountsOf(settingsScope?.getSnapshot().value);
604
- if (selected !== void 0 && selected !== configured[activeRegion] && settingsScope !== void 0 && settingsScope.getSnapshot().writable === true) await writeSettingsField(settingsScope, "accounts", {
620
+ const followed = selected !== void 0 && selected.id !== configured[activeRegion] && settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
621
+ if (followed && settingsScope !== void 0) await writeSettingsField(settingsScope, "accounts", {
605
622
  ...configured,
606
- [activeRegion]: selected
607
- }, (readBack) => configuredAccountsOf(readBack)[activeRegion] === selected);
623
+ [activeRegion]: selected.id
624
+ }, (readBack) => configuredAccountsOf(readBack)[activeRegion] === selected.id);
608
625
  await refreshUsage(activeRegion);
626
+ if (mounted.current) setRescanNote(body.accounts.length === 0 ? t("row.accountsRefreshedNone") : t("row.accountsRefreshed", {
627
+ count: String(body.accounts.length),
628
+ selected: followed ? "" : ` · ${t("row.accountsKept")} ${selected?.accountName ?? selected?.id ?? "—"}`
629
+ }));
609
630
  } catch (error) {
610
- if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
631
+ if (mounted.current) {
632
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
633
+ setRescanNote(t("row.accountsRescanFailed", { message }));
634
+ setWriteError(message);
635
+ }
611
636
  } finally {
612
637
  if (mounted.current) setBusy(false);
613
638
  }
@@ -1047,30 +1072,58 @@ window.__ModuleLoader__.load({
1047
1072
  children: busy ? t("row.accountsScanning") : t("row.refreshTokens")
1048
1073
  })]
1049
1074
  }),
1050
- status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1075
+ status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1051
1076
  className: "dsm-trae-account-picker",
1052
1077
  "aria-label": t("row.accountsTitle"),
1053
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1054
- className: "dsm-trae-usage-select-wrap",
1055
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1056
- className: "dsm-trae-usage-select",
1057
- value: status.status === "signed-in" ? status.accountId : "",
1058
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1059
- onChange: (event) => {
1060
- switchAccount(event.currentTarget.value);
1061
- },
1062
- children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
1063
- value: account.id,
1078
+ children: [
1079
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1080
+ className: "dsm-trae-usage-select-wrap",
1081
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1082
+ className: "dsm-trae-usage-select",
1083
+ value: status.status === "signed-in" ? status.accountId : "",
1084
+ disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1085
+ onChange: (event) => {
1086
+ switchAccount(event.currentTarget.value);
1087
+ },
1088
+ children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
1089
+ value: account.id,
1090
+ children: [
1091
+ account.accountName,
1092
+ " · ",
1093
+ account.region === "ai" ? t("row.regionAi") : t("row.regionCn"),
1094
+ " · ",
1095
+ t(`row.edition${account.edition === "cn" ? "Cn" : account.edition === "sg" ? "Sg" : account.edition === "solo" ? "Solo" : "SoloSg"}`)
1096
+ ]
1097
+ }, account.id))
1098
+ })
1099
+ }),
1100
+ status.status === "signed-in" && status.alternatives !== void 0 && status.alternatives.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1101
+ className: "dsm-trae-usage-alternative",
1102
+ role: "status",
1103
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1104
+ className: "dsm-trae-usage-alternative-text",
1064
1105
  children: [
1065
- account.accountName,
1066
- " · ",
1067
- account.region === "ai" ? t("row.regionAi") : t("row.regionCn"),
1068
- " · ",
1069
- account.edition
1106
+ t("row.creditsExhausted", { account: status.accountName }),
1107
+ " ",
1108
+ t("row.creditsAlternative", { accounts: status.alternatives.map((alt) => `${alt.accountName}(${t(`row.edition${alt.edition === "cn" ? "Cn" : alt.edition === "sg" ? "Sg" : alt.edition === "solo" ? "Solo" : "SoloSg"}`)}:${formatNumber(alt.workAvailable + alt.generalAvailable)})`).join("、") })
1070
1109
  ]
1071
- }, account.id))
1110
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1111
+ type: "button",
1112
+ className: "dsm-trae-usage-switch-button",
1113
+ disabled: switchingAccount || settingsScope?.getSnapshot()?.writable !== true,
1114
+ onClick: () => {
1115
+ const target = status.alternatives?.[0];
1116
+ if (target !== void 0) switchAccount(target.id);
1117
+ },
1118
+ children: t("row.creditsAlternativeSwitch")
1119
+ })]
1120
+ }) : null,
1121
+ rescanNote === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1122
+ className: "dsm-trae-usage-note",
1123
+ role: "status",
1124
+ children: rescanNote
1072
1125
  })
1073
- })
1126
+ ]
1074
1127
  }) : null,
1075
1128
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1076
1129
  status.payStatus === void 0 || payPlan === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1484,6 +1537,17 @@ window.__ModuleLoader__.load({
1484
1537
  "row.modelsTestEnabled": "Test enabled models",
1485
1538
  "row.modelsTesting": "Testing {done}/{total}…",
1486
1539
  "row.modelsTestHint": "Sends one minimal message per enabled model to see which ones actually answer. Costs a small amount of quota.",
1540
+ "row.accountsRefreshed": "Accounts refreshed: {count} found{selected}",
1541
+ "row.accountsKept": "still bound to",
1542
+ "row.accountsRefreshedNone": "Accounts refreshed: no usable Trae account found",
1543
+ "row.accountsRescanFailed": "Could not refresh accounts: {message}",
1544
+ "row.editionCn": "Trae",
1545
+ "row.editionSg": "Trae (international)",
1546
+ "row.editionSolo": "TRAE SOLO",
1547
+ "row.editionSoloSg": "TRAE SOLO (international)",
1548
+ "row.creditsExhausted": "The bound account ({account}) has run out of credit",
1549
+ "row.creditsAlternative": "Another account on this machine still has credit: {accounts}",
1550
+ "row.creditsAlternativeSwitch": "Switch to it",
1487
1551
  "row.modelsTestEmpty": "Enable at least one model first.",
1488
1552
  "row.modelsTestError": "Test failed: {message}",
1489
1553
  "row.modelsTestDone": "{available} of {total} answered.",
@@ -1576,6 +1640,17 @@ window.__ModuleLoader__.load({
1576
1640
  "row.modelsTestEnabled": "一键测试启用模型",
1577
1641
  "row.modelsTesting": "正在测试 {done}/{total}…",
1578
1642
  "row.modelsTestHint": "给每个已启用模型发一条最小消息,看哪些真的能应答。会消耗少量额度。",
1643
+ "row.accountsRefreshed": "已刷新账号:找到 {count} 个账号{selected}",
1644
+ "row.accountsKept": "仍绑定",
1645
+ "row.accountsRefreshedNone": "已刷新账号:未找到可用的 Trae 账号",
1646
+ "row.accountsRescanFailed": "刷新账号失败:{message}",
1647
+ "row.editionCn": "Trae",
1648
+ "row.editionSg": "Trae 国际版",
1649
+ "row.editionSolo": "TRAE SOLO",
1650
+ "row.editionSoloSg": "TRAE SOLO 国际版",
1651
+ "row.creditsExhausted": "当前绑定账号({account})额度已用尽",
1652
+ "row.creditsAlternative": "本机还有可用账号:{accounts}",
1653
+ "row.creditsAlternativeSwitch": "切换到它",
1579
1654
  "row.modelsTestEmpty": "请先启用至少一个模型。",
1580
1655
  "row.modelsTestError": "测试失败:{message}",
1581
1656
  "row.modelsTestDone": "{total} 个里有 {available} 个正常应答。",
package/lib/index.d.ts CHANGED
@@ -483,6 +483,22 @@ declare class TraeCredentialStore {
483
483
  private preferred;
484
484
  accounts(): Promise<TraeAccountChoice[]>;
485
485
  current(): Promise<TraeCredential | undefined>;
486
+ /**
487
+ * The stored credential of one specific account, without changing which
488
+ * account this store is bound to (issue #25).
489
+ *
490
+ * The card needs to answer "the account I am bound to has run out of credits
491
+ * — is another account on this machine usable?" and {@link resolve} cannot
492
+ * help: it always returns the BOUND account, and switching the bound account
493
+ * just to read one number would race every in-flight chat request sharing
494
+ * this store, and would persist a selection the user never made.
495
+ *
496
+ * So this is a pure lookup: read-only, no refresh side effects (a stale token
497
+ * is returned as-is and the caller's request then fails honestly), and
498
+ * `accountId` is left untouched. An unknown id yields undefined rather than a
499
+ * fallback, for the same reason {@link current} refuses to fall back.
500
+ */
501
+ credentialOf(accountId: string): Promise<TraeCredential | undefined>;
486
502
  resolve(): Promise<TraeCredential>;
487
503
  status(): Promise<{
488
504
  state: 'signed-in' | 'signed-out';
@@ -1673,6 +1689,22 @@ interface TraeWebAccount {
1673
1689
  tokenExpiresAtMs: number;
1674
1690
  selected: boolean;
1675
1691
  }
1692
+ /**
1693
+ * Another account on this machine that still has credit, surfaced only while
1694
+ * the BOUND account is exhausted (issue #25).
1695
+ *
1696
+ * The account switch is persisted, so a user whose bound account ran out sees
1697
+ * a permanently zeroed card with no explanation. This carries just enough to
1698
+ * name the alternative in the UI — identity plus the two CN credit buckets —
1699
+ * and is only ever produced by a read-only upstream call.
1700
+ */
1701
+ interface TraeWebCreditAlternative {
1702
+ id: string;
1703
+ accountName: string;
1704
+ edition: TraeWebAccount['edition'];
1705
+ workAvailable: number;
1706
+ generalAvailable: number;
1707
+ }
1676
1708
  /** One probed candidate path and why it did not yield an account. */
1677
1709
  interface TraeWebSearchPath {
1678
1710
  path: string;
@@ -1789,6 +1821,13 @@ type TraeWebUsage = {
1789
1821
  };
1790
1822
  credits?: TraeWebCredits;
1791
1823
  creditsError?: string;
1824
+ /**
1825
+ * Present only when the bound account is out of credit AND another account
1826
+ * on this machine has some (issue #25). The card then offers a one-click
1827
+ * switch instead of leaving a permanently empty panel. Absent in every
1828
+ * other case so a healthy account never pays for the extra read.
1829
+ */
1830
+ alternatives?: readonly TraeWebCreditAlternative[];
1792
1831
  /**
1793
1832
  * Daily check-in state (CN only). Absent on the international region,
1794
1833
  * whose check-in surface does not exist — probed 2026-09-24: the
@@ -1817,6 +1856,12 @@ interface TraeUsageRouteOptions {
1817
1856
  store(region: TraeRegion): TraeCredentialStore;
1818
1857
  /** The region-scoped usage client (CN work credits / international pay status). */
1819
1858
  client(region: TraeRegion): TraeUsageClient;
1859
+ /**
1860
+ * CN credit balances of an account OTHER than the bound one, read without
1861
+ * changing which account is bound (issue #25). Optional: when absent the
1862
+ * exhausted-account hint is simply not offered.
1863
+ */
1864
+ creditsOfAccount?(region: TraeRegion, accountId: string): Promise<TraeWebCredits | undefined>;
1820
1865
  /** The requested region's last-refreshed raw directory (one entry per upstream model). */
1821
1866
  displayModels(region: TraeRegion): readonly TraeModelInfo[];
1822
1867
  /** The user's model selection in the requested region, stored as model id (= Trae name). */
package/lib/index.js CHANGED
@@ -1198,6 +1198,25 @@ var TraeCredentialStore = class {
1198
1198
  if (this.accountId === void 0) return this.preferred(credentials);
1199
1199
  return credentials.find((credential) => traeAccountId(credential) === this.accountId);
1200
1200
  }
1201
+ /**
1202
+ * The stored credential of one specific account, without changing which
1203
+ * account this store is bound to (issue #25).
1204
+ *
1205
+ * The card needs to answer "the account I am bound to has run out of credits
1206
+ * — is another account on this machine usable?" and {@link resolve} cannot
1207
+ * help: it always returns the BOUND account, and switching the bound account
1208
+ * just to read one number would race every in-flight chat request sharing
1209
+ * this store, and would persist a selection the user never made.
1210
+ *
1211
+ * So this is a pure lookup: read-only, no refresh side effects (a stale token
1212
+ * is returned as-is and the caller's request then fails honestly), and
1213
+ * `accountId` is left untouched. An unknown id yields undefined rather than a
1214
+ * fallback, for the same reason {@link current} refuses to fall back.
1215
+ */
1216
+ async credentialOf(accountId) {
1217
+ if (accountId === "") return void 0;
1218
+ return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
1219
+ }
1201
1220
  async resolve() {
1202
1221
  const credential = await this.current();
1203
1222
  if (credential === void 0) throw new Error(`trae: no signed-in account found (${this.candidates().map((item) => item.path).join(" or ")})`);
@@ -2856,25 +2875,32 @@ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
2856
2875
  *
2857
2876
  * The gateway groups its directory by function and reports a model's capacity
2858
2877
  * per group, with the group order varying between calls. Keeping the strongest
2859
- * row makes the published window deterministic:
2878
+ * row makes the published window deterministic. The comparison is
2879
+ * LEXICOGRAPHIC and must be applied all the way down, or ordering leaks back in
2880
+ * at the next field:
2860
2881
  *
2861
2882
  * 1. a row with a Max tier beats one without (`maxContextWindow` is only set
2862
2883
  * when the group reported `max_mode: true` with a positive max);
2863
2884
  * 2. two Max rows: the larger max wins;
2864
- * 3. no Max on either side: the wider dev window wins.
2885
+ * 3. max equal (or absent on both): the WIDER dev window wins;
2886
+ * 4. both equal: keep the incumbent, so the model order stays the gateway's.
2865
2887
  *
2866
- * Ties keep the incumbent, so the first row seen still decides equal cases and
2867
- * the model order stays the one the gateway sent.
2888
+ * Step 3 is not an afterthought. With equal `max` the first version of this
2889
+ * function stopped and kept the incumbent, which left the dev window decided by
2890
+ * arrival order — measured 2026-10-05: `glm-5.2` came back as `dev=116000` when
2891
+ * `chat_v3` won and `dev=200000` when `solo_agent_remote` did, both with
2892
+ * `max=1000000` (9 of 30 CN models behaved that way, reported by JiewiW on
2893
+ * Windows). A budgeted model still resolved to 1M either way, but the DEFAULT
2894
+ * window of an unbudgeted model — what DSH shows and compresses against — was
2895
+ * a coin flip, which is the same defect this function exists to remove.
2868
2896
  */
2869
2897
  function preferStrongerRow(candidate, incumbent) {
2870
2898
  if (incumbent === void 0) return candidate;
2871
2899
  const candidateMax = candidate.maxContextWindow;
2872
2900
  const incumbentMax = incumbent.maxContextWindow;
2873
- if (candidateMax !== void 0 || incumbentMax !== void 0) {
2874
- if (incumbentMax === void 0) return candidate;
2875
- if (candidateMax === void 0) return incumbent;
2876
- return candidateMax > incumbentMax ? candidate : incumbent;
2877
- }
2901
+ if (candidateMax !== void 0 && incumbentMax === void 0) return candidate;
2902
+ if (candidateMax === void 0 && incumbentMax !== void 0) return incumbent;
2903
+ if (candidateMax !== void 0 && incumbentMax !== void 0 && candidateMax !== incumbentMax) return candidateMax > incumbentMax ? candidate : incumbent;
2878
2904
  return (candidate.contextWindow ?? 0) > (incumbent.contextWindow ?? 0) ? candidate : incumbent;
2879
2905
  }
2880
2906
  /**
@@ -4074,6 +4100,8 @@ function json(res, status, body) {
4074
4100
  });
4075
4101
  res.end(payload);
4076
4102
  }
4103
+ /** Bound on how many OTHER accounts one status read may probe (issue #25). */
4104
+ const TRAE_CREDIT_ALTERNATIVE_LIMIT = 8;
4077
4105
  /**
4078
4106
  * Read the ids to test from a POST body.
4079
4107
  *
@@ -4118,7 +4146,14 @@ function loopbackOrigin(req) {
4118
4146
  return false;
4119
4147
  }
4120
4148
  }
4121
- /** Map the credit snapshot to the card's compact credit document. */
4149
+ /**
4150
+ * Map the credit snapshot to the card's compact credit document.
4151
+ *
4152
+ * Exported so the "is another account usable?" probe (issue #25) derives its
4153
+ * two buckets through the SAME endpoint classification the bound account uses
4154
+ * — a second implementation of the Work/general split would be free to disagree
4155
+ * with the number shown next to it.
4156
+ */
4122
4157
  function toCredits(snapshot) {
4123
4158
  const { totalAmount, consumedAmount } = snapshot.summary;
4124
4159
  const credit = (value) => Math.round(value * 1e4) / 1e4;
@@ -4214,14 +4249,51 @@ async function traeWebUsage(deps, region) {
4214
4249
  };
4215
4250
  }
4216
4251
  const [snapshotResult, checkinResult] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
4252
+ const credits = snapshotResult.status === "fulfilled" ? toCredits(snapshotResult.value) : void 0;
4253
+ const alternatives = credits !== void 0 && credits.workAvailable <= 0 && credits.generalAvailable <= 0 ? await creditAlternatives(deps, region, accounts) : void 0;
4217
4254
  return {
4218
4255
  status: "signed-in",
4219
4256
  ...account,
4220
- ...snapshotResult.status === "fulfilled" ? { credits: toCredits(snapshotResult.value) } : { creditsError: safeMessage(snapshotResult.reason) },
4221
- ...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) }
4257
+ ...credits === void 0 ? { creditsError: snapshotResult.status === "rejected" ? safeMessage(snapshotResult.reason) : "unknown" } : { credits },
4258
+ ...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) },
4259
+ ...alternatives === void 0 ? {} : { alternatives }
4222
4260
  };
4223
4261
  }
4224
4262
  /**
4263
+ * Other accounts on this machine that still have credit, when the bound one has
4264
+ * none (issue #25).
4265
+ *
4266
+ * Only ever called for a bound account whose own credits came back at zero, so
4267
+ * a healthy setup pays nothing for it. Each candidate is read through
4268
+ * `creditsOfAccount`, which must not disturb the bound account; a candidate
4269
+ * that fails is simply absent from the answer rather than reported as a broken
4270
+ * account, because a stale token on an unrelated login is not this card's
4271
+ * problem to explain. The list is capped so a machine with many stale
4272
+ * credentials cannot turn one status read into a burst of upstream calls.
4273
+ */
4274
+ async function creditAlternatives(deps, region, accounts) {
4275
+ if (deps.creditsOfAccount === void 0) return void 0;
4276
+ const candidates = accounts.filter((account) => !account.selected && account.region === region).slice(0, TRAE_CREDIT_ALTERNATIVE_LIMIT);
4277
+ if (candidates.length === 0) return void 0;
4278
+ const alternatives = (await Promise.all(candidates.map(async (account) => {
4279
+ try {
4280
+ const credits = await deps.creditsOfAccount(region, account.id);
4281
+ if (credits === void 0) return void 0;
4282
+ if (credits.workAvailable <= 0 && credits.generalAvailable <= 0) return void 0;
4283
+ return {
4284
+ id: account.id,
4285
+ accountName: account.accountName,
4286
+ edition: account.edition,
4287
+ workAvailable: credits.workAvailable,
4288
+ generalAvailable: credits.generalAvailable
4289
+ };
4290
+ } catch {
4291
+ return;
4292
+ }
4293
+ }))).filter((entry) => entry !== void 0);
4294
+ return alternatives.length === 0 ? void 0 : alternatives;
4295
+ }
4296
+ /**
4225
4297
  * The region a request addresses, or a 400 answer. Absent parameter means the
4226
4298
  * domestic tab; an unknown value is refused rather than guessed.
4227
4299
  */
@@ -4886,6 +4958,22 @@ function apply(ctx, config) {
4886
4958
  credential: () => store.resolve(),
4887
4959
  deviceId: async () => (await identity()).deviceId
4888
4960
  }),
4961
+ /**
4962
+ * Credit balance of one named account, WITHOUT rebinding the store
4963
+ * (issue #25). The card asks this only after it saw the bound account
4964
+ * come back empty, so it can point at a login that still works.
4965
+ *
4966
+ * A throwaway client is used on purpose: the shared `usageClient` reads
4967
+ * `store.resolve()`, which is bound to the selected account, and calling
4968
+ * `selectAccount` to look at a neighbour would both race concurrent chat
4969
+ * requests and persist a switch the user never made. A stale token simply
4970
+ * fails here and the account is left out of the answer.
4971
+ */
4972
+ creditsOfAccount: async (accountId) => {
4973
+ const credential = await store.credentialOf(accountId);
4974
+ if (credential === void 0) return void 0;
4975
+ return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
4976
+ },
4889
4977
  discoverModels: async (signal) => {
4890
4978
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
4891
4979
  wire.callableKeys.clear();
@@ -4944,7 +5032,8 @@ function apply(ctx, config) {
4944
5032
  regionEnabled: (region) => regionEnabled(current(), region),
4945
5033
  discoverModels: (region, signal) => stacks[region].discoverModels(signal),
4946
5034
  probeModels: (region, ids) => stacks[region].probeModels(ids),
4947
- rawDiagnostic: (region) => stacks[region].rawDiagnostic()
5035
+ rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5036
+ creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId)
4948
5037
  }));
4949
5038
  /**
4950
5039
  * Live registration handles per region, filled once the shim is listening.
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": "2.7.1",
5
+ "version": "2.8.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",