dsh-connect-workbuddy 4.5.1 → 4.5.2

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.2 (2026-10-14)
4
+
5
+ > 本节含三件事,**一次发布**:先修了「每行下拉框挤坏布局 + 命名看不懂」,随后维护者判定**把这个下拉框连同逐账号设置整体撤掉**;并**新增两个一键遍历按钮**(对齐同族 trae 插件)——撤掉的是「逐账号指定测试哪一个模型」,加回的是「一次把模型测一遍」,两者不冲突。4.4.6 之后没有任何以 4.5.2 发布过的产物,所以合并为一版。
6
+
7
+ ### Features
8
+
9
+ - **新增两个一键遍历按钮:「一键测试启用模型」与「一键测试所有模型」**(维护者要求在对比同族 `trae` 插件后补齐;`trae` 3.5.0 提供同一对按钮)
10
+ - **为什么这两个按钮与「3.0.0 删掉批量按钮」不矛盾**:3.0.0 删它是因为「一次覆盖整个已勾选列表就是一次不可控支出」,并**在路由层加固**(多于 1 个模型直接 400,防止陈旧 bundle 或手写客户端把按钮变成消费循环)。这一版**保留那道闸门**,把循环放在**卡片端**:一个模型一次请求、串行、每个结果一出来就显示 —— 也就是 `trae` 的做法。路由仍然只接受 1 个模型。
11
+ - **成本按维护者的选择写在提示里(不做二次确认)**:每次探测都发真实体积的请求,真花积分。**本机实测**(默认 25000 token 体积)每次约 **0.01 积分**,目录 16~20 个模型 → 「所有模型」一轮约 **0.16~0.2 积分**。历史实测曾达 0.02~0.72/次(体积不同),所以提示里写明「含未启用的模型,明显更贵」而不是只给一个数。
12
+ - **两个按钮的范围不同,这一点是成本控制而不是措辞**:「启用模型」= 用户勾选的那几个(尊重他刚做的选择,不花在他关掉的模型上);「所有模型」= 本区域 live 目录里的每一个。取的是**可见目录**而不是配置里的任意 id,所以不可能花在只存在于配置文件里的名字上。
13
+ - **串行而非并发**:探测的目的就是发现限流,一次把整个名册打出去是最容易制造限流的做法 —— 与宿主自己那条循环同样的理由。
14
+ - **凭据被拒时立刻停止并明说**:被拒的凭据对剩下每个模型都会给出同一个答案,继续跑只是花钱重复收集同一个答案。**模型级失败(如某模型被限流)不停** —— 那是关于那个模型的信息,停下来会把它藏掉。汇总只统计**实际测到**的数量,所以提前停止的一次遍历不会读成「整册全绿」。
15
+ - **顺带修掉一处过期注释**:`probeModels` 的文档还在描述「宿主端一次跑完整个批量」(那是 3.0.0 之前的形态),与新的事实(路由只收 1 个、由卡片端循环)相反,已更正。
16
+ - **守卫**:`tests/card-model-sweep.spec.tsx`(10 例)钉住三件事:**一个请求一个模型**(断言每次 `/probe` 请求体的 `modelIds` 长度为 1)、**两个按钮范围不同**(「所有」= 全部三个;「启用」= 只有勾选的,且断言**不含**未启用的那个)、**汇总只算测到的**(一个模型被限流时不得读成全绿)。另含:无启用项时「启用」按钮置灰而「所有」仍可用、凭据被拒只发一次请求、模型级失败不中断、跑完按钮恢复可用。**变异验证**:把「所有模型」改成只遍历启用项 → 2 例红;去掉凭据被拒的提前停止 → 2 例红。
17
+ - **布局**:模型区头部现在有三个按钮(刷新 + 两个遍历),中文标签较长(「一键测试所有模型」),所以按钮组允许换行且标签 `nowrap` —— 与之前账号池行那个「测试按钮被拆成两行」是同一类失败,这次先防住。
18
+
19
+
20
+ ### Fixes(同一次发布,见上)
21
+
22
+ - **(已随本节撤除)账号池每行那个「测试模型」下拉框把行挤坏了:「测试」按钮被压成一列一个字(「测」/「试」),选项名也让人看不懂。**(维护者看截图发现,属 4.5.0 的 #61 改动引入)
23
+ - **布局**:那一行的动作列是按「只有两个按钮」的宽度设计的网格轨道(有签到列时仅 `.8fr`),4.5.0 往里塞了一个**约 150px、不可再压缩**的下拉框,于是按钮被挤到只剩一个字符宽 —— 每一行都变成两行「测 / 试」。**注意:这个缺陷的修法(加最小宽度轨道等)随后连同下拉框一起被撤掉了** —— 保留下来的是 `white-space:nowrap`,它独立成立:这个动作列的宽度是表格的一个分数轨,一个固定的两字标签(测试)不该被拆行,窄窗口下**裁剪**才是正确的失败方式。
24
+ - **文案**:那个下拉框的第一个选项原本叫**「区域目标」** —— 维护者看到它直接问「这目标区域是做什么用的?」,这本身就是命名失败的证据:它读起来像一个**区域**,而它表达的是「**跟随账号池的目标模型**」。当时先改为**「同账号池」**(en: `same as pool`)并把 `title` 重写为**先回答「它是做什么的」**;随后整个下拉框被撤除,这两个字符串也一并删掉了。**留下的教训是命名那条**:一个选项名如果让人需要问「这是做什么用的」,那它写的就是「值从哪来」而不是「它做什么」。
25
+ - **为什么这件事值得记**:这两处都不是逻辑错,而是**只有人看界面才会发现**的错 —— 自动测试全绿、CI 全绿、发布会成功。它提醒本仓库的验证手段(源码级守卫 + 变异验证)覆盖不了「读起来对不对、排得下排不下」,而这两类恰恰需要真机截图或使用者提问才能暴露。
26
+ - **守卫**:`tests/client-host-contract.spec.ts` 新增一例钉住 `nowrap`;随撤除一并改写为「不再为已移除的下拉框预留宽度」(见下)。**变异验证**:删掉 `nowrap` → 该例变红。
27
+
28
+ ### 撤除
29
+
30
+ - **撤掉「逐账号测试模型」(`pool.targetModelIdByAccount`)—— 它是 4.4.6 采纳报告者提议时加进来的,维护者判定应撤。**(issue #61 的后续)
31
+ - **为什么撤**:**池设置里本来就有统一的测试模型**(`pool.targetModelId`),而报告的诉求(「你用 `step5` 就要测这个模型」)用它就能达成 —— 报告者当时把它配成 `hy4-preview-f`(实际在用 `step-5-preview`),**根因是那个统一设置指错了模型,不是缺少逐账号能力**。
32
+ - **更重要的理由**:逐账号让**同一张表的「全绿」不再可比** —— A 的 `ok` 是模型 X 上的、B 的 `ok` 是模型 Y 上的,而「全绿不代表可用」正是这个 issue 抱怨的东西。为了「方便对比某个号」引入一个削弱「整表可比」的设置,方向是反的。
33
+ - **撤掉的东西**:配置字段 `pool.targetModelIdByAccount`(含 schema)、`targetModelPreference()`、批量路由的**逐账号解析器**(`testAllAccounts` 回到「整批一个模型」的 `string` 形态,`WorkBuddyPoolDeps.test` 的联合类型一并收窄)、池文件与 usage 载荷里的逐账号映射、每行那个「测试模型」下拉框(含 `setRowModel`、draft 比较、写入与**写入后校验**、三个 locale 键)、以及为容纳它而加的网格最小宽度。
34
+ - **完整保留的东西(#61 里真正解决问题的那一半,不需要任何新配置)**:
35
+ 1. 探针记录**写入 `modelId`**(`probeModel()` 一直返回它,此前落盘被丢掉),所以每行的结论**自带适用范围**;批量与实时聊天失败两条路径都写;
36
+ 2. 每行**显示**「模型 X」(`poolProbeModel`),以及未记录时的显式说明;
37
+ 3. `resolveTargetModel` 的**陈旧目标如实报告、绝不静默替换**(指向已下架模型时报 `stale`,不换一个用户没选的模型去花积分)。
38
+ - **升级影响**:老配置里若已写了 `targetModelIdByAccount`,升级后**被忽略**(未知字段),池的实际测试模型回到 `targetModelId` —— 也就是「所有号测同一个模型」,这正是撤除的目的。**不影响聊天用哪个模型**(那个设置从来不参与选路)。
39
+ - **守卫**:`tests/issue61-probe-model-scope.spec.ts` 重写为钉住「保留的那一半」+「整批只能一个模型」(`probes every account on the SAME model` 直接断言两次探测拿到同一个 id,若有人把它改回逐账号,这条会红);`tests/pool-route.spec.ts` 删掉逐账号解析器的路由断言与 `askedModel()` 联合类型收窄辅助函数;`tests/client-host-contract.spec.ts` 的布局守卫改写为「不再为已移除的下拉框预留宽度」(并保留 `nowrap`,它与下拉框无关、独立成立)。
40
+ - 全量 **1249 通过 / 2 跳过**。
41
+
3
42
  ## 4.5.1 (2026-10-14)
4
43
 
5
44
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Vd2tNdsv.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-DcUM0cVn.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -692,8 +692,6 @@ window.__ModuleLoader__.load({
692
692
  targetModelId: preferences.targetModelId,
693
693
  memberAccountIds: preferences.memberAccountIds
694
694
  };
695
- const overrides = Object.entries(preferences.targetModelIdByAccount ?? {}).filter(([, modelId]) => modelId !== "");
696
- if (overrides.length > 0) slot["targetModelIdByAccount"] = Object.fromEntries(overrides);
697
695
  if (preferences.probeInputTokens !== void 0) slot["probeInputTokens"] = preferences.probeInputTokens;
698
696
  const next = nextRegionPool(scope.getSnapshot().value, region, slot)[region];
699
697
  await writeField(scope, "regions", region, next, (readBack) => {
@@ -706,11 +704,7 @@ window.__ModuleLoader__.load({
706
704
  if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
707
705
  const wanted = new Set(preferences.memberAccountIds);
708
706
  if (!stored.every((id) => typeof id === "string" && wanted.has(id))) return false;
709
- const storedModels = pool["targetModelIdByAccount"];
710
- if (overrides.length === 0) return storedModels === void 0 || typeof storedModels === "object" && storedModels !== null && !Array.isArray(storedModels) && Object.keys(storedModels).length === 0;
711
- if (typeof storedModels !== "object" || storedModels === null || Array.isArray(storedModels)) return false;
712
- const storedMap = storedModels;
713
- return Object.keys(storedMap).length === overrides.length && overrides.every(([accountId, modelId]) => storedMap[accountId] === modelId);
707
+ return true;
714
708
  });
715
709
  }
716
710
  //#endregion
@@ -1297,21 +1291,6 @@ window.__ModuleLoader__.load({
1297
1291
  const wanted = new Set(right);
1298
1292
  return left.every((id) => wanted.has(id));
1299
1293
  }
1300
- /**
1301
- * Whether two per-account test-model maps are equal (issue #61).
1302
- *
1303
- * By CONTENT, not by size: moving an account from one model to another keeps the
1304
- * count and changes the meaning, and clearing an entry is a change a size
1305
- * comparison would miss entirely when another account was added at the same
1306
- * time. Order is irrelevant for the same reason it is in {@link sameIds} — the
1307
- * map is written by key, not by click sequence.
1308
- */
1309
- function sameModelOverrides(left, right) {
1310
- const leftKeys = Object.keys(left);
1311
- const rightKeys = Object.keys(right);
1312
- if (leftKeys.length !== rightKeys.length) return false;
1313
- return leftKeys.every((key) => left[key] === right[key]);
1314
- }
1315
1294
  /** Whether a probe outcome means the model answered. */
1316
1295
  function outcomeOk(outcome) {
1317
1296
  return outcome === "ok";
@@ -1398,8 +1377,7 @@ window.__ModuleLoader__.load({
1398
1377
  enabled: pool?.enabled ?? false,
1399
1378
  targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
1400
1379
  probeInputTokens: pool?.probeInputTokens ?? 0,
1401
- memberAccountIds: pool?.memberAccountIds ?? [],
1402
- targetModelIdByAccount: { ...pool?.targetModelIdByAccount }
1380
+ memberAccountIds: pool?.memberAccountIds ?? []
1403
1381
  };
1404
1382
  const active = draftRegion === region && draft !== null ? draft : saved;
1405
1383
  /**
@@ -1420,7 +1398,7 @@ window.__ModuleLoader__.load({
1420
1398
  * the pending edit.
1421
1399
  */
1422
1400
  const poolRunning = saved.enabled;
1423
- const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameModelOverrides(draft.targetModelIdByAccount, saved.targetModelIdByAccount) || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1401
+ const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1424
1402
  /**
1425
1403
  * Membership that actually resolves to a local sign-in — what a batch runs on.
1426
1404
  *
@@ -1581,25 +1559,6 @@ window.__ModuleLoader__.load({
1581
1559
  };
1582
1560
  });
1583
1561
  }, [editDraft]);
1584
- /**
1585
- * Set ONE account's test model (issue #61).
1586
- *
1587
- * `''` REMOVES the entry rather than storing an empty string: "follow the
1588
- * region target" is the absence of a choice, which is the same shape the host
1589
- * reads and the same shape an untouched profile has — so nothing about an
1590
- * older profile changes when this setting is never used.
1591
- */
1592
- const setRowModel = (0, react.useCallback)((accountId, modelId) => {
1593
- editDraft((current) => {
1594
- const next = { ...current.targetModelIdByAccount };
1595
- if (modelId === "") delete next[accountId];
1596
- else next[accountId] = modelId;
1597
- return {
1598
- ...current,
1599
- targetModelIdByAccount: next
1600
- };
1601
- });
1602
- }, [editDraft]);
1603
1562
  /** Check every listed account, or none of them. */
1604
1563
  const selectAll = (0, react.useCallback)((next) => {
1605
1564
  editDraft((current) => ({
@@ -1920,10 +1879,7 @@ window.__ModuleLoader__.load({
1920
1879
  },
1921
1880
  onRemove: () => {
1922
1881
  runRowRemove(account.accountId, account.accountName);
1923
- },
1924
- modelOverride: active.targetModelIdByAccount[account.accountId] ?? "",
1925
- catalog: pool.catalog ?? [],
1926
- onModelChange: (modelId) => setRowModel(account.accountId, modelId)
1882
+ }
1927
1883
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1928
1884
  className: "dsm-workbuddy-pool-warn",
1929
1885
  role: "status"
@@ -1991,7 +1947,6 @@ window.__ModuleLoader__.load({
1991
1947
  /** One account's row. */
1992
1948
  function renderAccountRow(input) {
1993
1949
  const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
1994
- const { modelOverride, catalog, onModelChange } = input;
1995
1950
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1996
1951
  const excluded = exclusionText(t, account.excludedBy);
1997
1952
  const probe = account.probe;
@@ -2032,20 +1987,7 @@ window.__ModuleLoader__.load({
2032
1987
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
2033
1988
  className: "dsm-workbuddy-pool-unknown",
2034
1989
  title: t("row.poolNeverTested")
2035
- }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("select", {
2036
- className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-row-model",
2037
- value: modelOverride,
2038
- disabled: !canEdit,
2039
- title: t("row.poolRowModelHint"),
2040
- "aria-label": t("row.poolRowModelAria", { accountName: name }),
2041
- onChange: (event) => onModelChange(event.currentTarget.value)
2042
- }, (0, react.createElement)("option", { value: "" }, t("row.poolRowModelInherit")), modelOverride !== "" && !catalog.some((model) => model.id === modelOverride) ? (0, react.createElement)("option", {
2043
- value: modelOverride,
2044
- disabled: true
2045
- }, t("row.poolTargetStaleOption", { model: modelOverride })) : null, ...catalog.map((model) => (0, react.createElement)("option", {
2046
- value: model.id,
2047
- key: model.id
2048
- }, model.name))), (0, react.createElement)("button", {
1990
+ }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
2049
1991
  type: "button",
2050
1992
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
2051
1993
  disabled: busy || testDisabled,
@@ -3160,7 +3102,16 @@ window.__ModuleLoader__.load({
3160
3102
  meta line, because a cooldown sentence ("rate limited - the upstream gave no
3161
3103
  time") does not fit next to the context/output chips, and truncating it would
3162
3104
  hide exactly the part the user needs. */
3163
- .dsm-workbuddy-models-head-actions{display:flex;align-items:center;gap:8px;flex:none}
3105
+ /* Three buttons now live here (refresh + the two sweeps), and the sweep labels are
3106
+ long in Chinese (一键测试所有模型). flex:none alone let the group run past the
3107
+ header on a narrow card, so the group may WRAP and each label refuses to break
3108
+ mid-word — the same failure the pool row's action buttons had. */
3109
+ .dsm-workbuddy-models-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
3110
+ .dsm-workbuddy-models-head-actions .dsm-btn{white-space:nowrap}
3111
+ /* The sweep buttons keep the outline weight of the refresh button beside them, so
3112
+ the header reads as one group of equal actions. */
3113
+ .dsm-workbuddy-models-sweep{white-space:nowrap}
3114
+ .dsm-workbuddy-models-sweep-summary{margin:6px 0 0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3164
3115
  .dsm-workbuddy-model-probe{flex:none;padding:3px 10px;font-size:11px;line-height:16px}
3165
3116
  .dsm-workbuddy-model-probe-result{margin:0;font-size:12px;line-height:18px;word-break:break-word}
3166
3117
  .dsm-workbuddy-model-probe-result-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
@@ -3301,11 +3252,16 @@ window.__ModuleLoader__.load({
3301
3252
  .dsm-workbuddy-pool-log-info{color:var(--dsw-alias-label-secondary,#c6c9d0)}
3302
3253
  .dsm-workbuddy-pool-log-empty{padding:14px;font-size:12px;color:var(--dsw-alias-label-tertiary,#999)}
3303
3254
  /* Per-row actions (test / remove), right side of each pool row. */
3255
+ /* Per-row actions (test / remove), right side of each pool row.
3256
+ The nowrap is NOT cosmetic: a two-character label (测试) belongs on one line,
3257
+ and this row's track is a fraction of the table — in a narrow window it let
3258
+ the label break into 测 / 试. Clipping is the right failure for a button
3259
+ whose label is fixed and tiny. */
3304
3260
  .dsm-workbuddy-pool-row-actions{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex:none}
3261
+ .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{white-space:nowrap}
3305
3262
  .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{padding:2px 10px;font-size:12px}
3306
3263
  /* This account's own test model (issue #61). Narrower than the section-level
3307
3264
  picker so a long model name cannot push the two buttons out of the row. */
3308
- .dsm-workbuddy-pool-row-model{max-width:150px;padding:2px 6px;font-size:12px}
3309
3265
  @media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
3310
3266
  /* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
3311
3267
  .dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
@@ -3939,6 +3895,19 @@ window.__ModuleLoader__.load({
3939
3895
  const [probing, setProbing] = (0, react.useState)({});
3940
3896
  /** Probe failure that is not attributable to one model (a dead route, say). */
3941
3897
  const [probeError, setProbeError] = (0, react.useState)(void 0);
3898
+ /**
3899
+ * The active region's probe results, mirrored into a ref.
3900
+ *
3901
+ * The sweep loop reads each verdict as soon as it lands, and state read inside
3902
+ * an async loop is the value captured when the loop STARTED — so counting from
3903
+ * `probes` would tally the previous run's answers. The ref is updated in the
3904
+ * same place the state is, so the two cannot drift.
3905
+ */
3906
+ const probesRef = (0, react.useRef)({});
3907
+ /** Progress of an in-flight sweep, or undefined when none is running. */
3908
+ const [sweeping, setSweeping] = (0, react.useState)(void 0);
3909
+ /** The last sweep's tally, cleared when the next one starts. */
3910
+ const [sweepSummary, setSweepSummary] = (0, react.useState)(void 0);
3942
3911
  const mounted = (0, react.useRef)(true);
3943
3912
  (0, react.useEffect)(() => {
3944
3913
  mounted.current = true;
@@ -4367,13 +4336,16 @@ window.__ModuleLoader__.load({
4367
4336
  });
4368
4337
  };
4369
4338
  /**
4370
- * Probe one or more models with a single minimal request each.
4339
+ * Probe ONE model (the route refuses more than one — see `web-status.ts`,
4340
+ * where the cap is the gate that keeps a whole-roster sweep from becoming a
4341
+ * single unaccounted spend). The HOST owns the request: the browser half sends
4342
+ * only a model id and gets an outcome back, because the credential must never
4343
+ * cross to the page.
4371
4344
  *
4372
- * The Host owns the request: the browser half sends only model ids and gets
4373
- * outcomes back, because the credential must never cross to the page. The
4374
- * route runs the batch sequentially and this waits for the whole answer, which
4375
- * is what lets the card report every row at once — a per-row response would
4376
- * race the shared `probing` map and make the batch button's state unknowable.
4345
+ * The plural name is historical: this used to post a whole list in one request
4346
+ * before 3.0.0 removed the batch button. It is kept because callers that
4347
+ * already hold a list (the two sweep buttons below) can still express "test
4348
+ * these" without a second code path — it just sends them one at a time.
4377
4349
  *
4378
4350
  * Probing is deliberately independent of `dirty`: a test answers "does this
4379
4351
  * model work right now", which is true of the SAVED selection and the draft
@@ -4401,9 +4373,12 @@ window.__ModuleLoader__.load({
4401
4373
  const body = await response.json().catch(() => void 0);
4402
4374
  if (!response.ok || !Array.isArray(body?.results)) throw new Error(body?.error ?? `HTTP ${String(response.status)}`);
4403
4375
  if (!mounted.current) return;
4376
+ const landedResults = body.results;
4377
+ probesRef.current = { ...probesRef.current };
4378
+ for (const result of landedResults) probesRef.current[result.modelId] = result;
4404
4379
  setProbes((previous) => {
4405
4380
  const regionResults = { ...previous[activeRegion] ?? {} };
4406
- for (const result of body.results) regionResults[result.modelId] = result;
4381
+ for (const result of landedResults) regionResults[result.modelId] = result;
4407
4382
  return {
4408
4383
  ...previous,
4409
4384
  [activeRegion]: regionResults
@@ -4419,6 +4394,86 @@ window.__ModuleLoader__.load({
4419
4394
  });
4420
4395
  }
4421
4396
  };
4397
+ /**
4398
+ * ONE-CLICK availability sweep over several models (issue #61 follow-up, by
4399
+ * request of the maintainer after comparing with the sibling `trae` plugin).
4400
+ *
4401
+ * WHY IT IS A CLIENT-SIDE LOOP. The route accepts exactly ONE model id and
4402
+ * refuses more, and that cap is deliberate: it is what stops a stale bundle or
4403
+ * a hand-rolled client from turning one click into an unaccounted spend over a
4404
+ * whole roster (3.0.0's reason for deleting the original batch button). A
4405
+ * sweep is therefore expressed the way trae expresses it — one model per
4406
+ * request, from here — which keeps that gate intact and has a second benefit
4407
+ * the host-side batch never had: each row's verdict appears the moment it is
4408
+ * known, instead of the card sitting blank until the slowest model answers.
4409
+ *
4410
+ * COST is stated in the button's own hint rather than in a confirmation
4411
+ * dialog: a probe sends a real-volume request, so a sweep over a whole catalog
4412
+ * spends real credit (measured 0.01 per probe at the current default size, on
4413
+ * this machine — see the hint text). The user asked for the hint form, and it
4414
+ * matches the sibling plugin.
4415
+ *
4416
+ * SEQUENTIAL, not parallel: a probe exists to discover rate limits, and firing
4417
+ * a roster's worth of requests at once is the surest way to create one — the
4418
+ * same reason the host's own loop was serial.
4419
+ *
4420
+ * STOPS ON THE FIRST AUTH FAILURE: a rejected credential answers identically
4421
+ * for every remaining model, so continuing would only spend credit to collect
4422
+ * the same answer again. Other failures (a rate limit on one model) do NOT
4423
+ * stop the sweep — that is information about that model, and stopping would
4424
+ * hide it.
4425
+ *
4426
+ * The summary counts what the run actually established, so a stopped or
4427
+ * failed sweep cannot read as a complete green roster.
4428
+ */
4429
+ const sweepModels = async (modelIds) => {
4430
+ if (modelIds.length === 0) return;
4431
+ setProbeError(void 0);
4432
+ setSweepSummary(void 0);
4433
+ setSweeping({
4434
+ done: 0,
4435
+ total: modelIds.length
4436
+ });
4437
+ let available = 0;
4438
+ let measured = 0;
4439
+ try {
4440
+ for (const [index, modelId] of modelIds.entries()) {
4441
+ if (!mounted.current) return;
4442
+ await probeModels([modelId]);
4443
+ const result = probesRef.current[modelId];
4444
+ measured += 1;
4445
+ if (result?.outcome === "ok") available += 1;
4446
+ if (result?.outcome === "credential-rejected") {
4447
+ if (mounted.current) setProbeError(t("row.modelsTestAuth"));
4448
+ break;
4449
+ }
4450
+ if (mounted.current) setSweeping({
4451
+ done: index + 1,
4452
+ total: modelIds.length
4453
+ });
4454
+ }
4455
+ if (mounted.current) setSweepSummary(t("row.modelsTestDone", {
4456
+ available,
4457
+ total: measured
4458
+ }));
4459
+ } finally {
4460
+ if (mounted.current) setSweeping(void 0);
4461
+ }
4462
+ };
4463
+ /** Sweep the models this region has ENABLED (the current draft's selection). */
4464
+ const sweepEnabledModels = () => {
4465
+ sweepModels(visibleModels.filter((model) => activeEnabledIds.has(model.id)).map((model) => model.id));
4466
+ };
4467
+ /**
4468
+ * Sweep EVERY model in the region's live catalog, enabled or not.
4469
+ *
4470
+ * Deliberately the whole VISIBLE catalog and nothing else: those are the ids
4471
+ * the host can validate against its own catalog, so this cannot spend credit
4472
+ * on an id that only exists in a config file.
4473
+ */
4474
+ const sweepAllModels = () => {
4475
+ sweepModels(visibleModels.map((model) => model.id));
4476
+ };
4422
4477
  const saveModels = async () => {
4423
4478
  if (settingsScope === void 0) return;
4424
4479
  if (status.status !== "signed-in") return;
@@ -4731,17 +4786,41 @@ window.__ModuleLoader__.load({
4731
4786
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4732
4787
  className: "dsm-workbuddy-models-summary",
4733
4788
  children: t("row.modelsSummary", { count: activeEnabledIds.size })
4734
- })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4789
+ })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4735
4790
  className: "dsm-workbuddy-models-head-actions",
4736
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4737
- type: "button",
4738
- className: "dsm-btn dsm-btn-outline",
4739
- disabled: busy,
4740
- onClick: () => {
4741
- refreshModels();
4742
- },
4743
- children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
4744
- })
4791
+ children: [
4792
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4793
+ type: "button",
4794
+ className: "dsm-btn dsm-btn-outline",
4795
+ disabled: busy,
4796
+ onClick: () => {
4797
+ refreshModels();
4798
+ },
4799
+ children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
4800
+ }),
4801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4802
+ type: "button",
4803
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-models-sweep",
4804
+ disabled: busy || sweeping !== void 0 || activeEnabledIds.size === 0,
4805
+ title: t("row.modelsTestEnabledHint"),
4806
+ onClick: sweepEnabledModels,
4807
+ children: sweeping === void 0 ? t("row.modelsTestEnabled") : t("row.modelsTesting", {
4808
+ done: sweeping.done,
4809
+ total: sweeping.total
4810
+ })
4811
+ }),
4812
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4813
+ type: "button",
4814
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-models-sweep",
4815
+ disabled: busy || sweeping !== void 0 || visibleModels.length === 0,
4816
+ title: t("row.modelsTestAllHint"),
4817
+ onClick: sweepAllModels,
4818
+ children: sweeping === void 0 ? t("row.modelsTestAll") : t("row.modelsTesting", {
4819
+ done: sweeping.done,
4820
+ total: sweeping.total
4821
+ })
4822
+ })
4823
+ ]
4745
4824
  })]
4746
4825
  }),
4747
4826
  probeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -4749,6 +4828,11 @@ window.__ModuleLoader__.load({
4749
4828
  role: "alert",
4750
4829
  children: t("row.probeError", { message: probeError })
4751
4830
  }),
4831
+ sweepSummary === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4832
+ className: "dsm-workbuddy-models-sweep-summary",
4833
+ role: "status",
4834
+ children: sweepSummary
4835
+ }),
4752
4836
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4753
4837
  className: "dsm-workbuddy-model-list",
4754
4838
  children: visibleModels.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5739,6 +5823,13 @@ window.__ModuleLoader__.load({
5739
5823
  "row.modelReasoning": "Reasoning: {efforts}",
5740
5824
  "row.modelUnknown": "Unknown",
5741
5825
  "row.modelCapabilityPending": "Only capabilities advertised by WorkBuddy are shown.",
5826
+ "row.modelsTestEnabled": "Test enabled models",
5827
+ "row.modelsTestAll": "Test all models",
5828
+ "row.modelsTesting": "Testing {done}/{total}…",
5829
+ "row.modelsTestEnabledHint": "Tests every model you have ENABLED, one request each — each probe sends a real-volume request, so this spends real credit (about 0.01 per model at the default 25000-token size). Rows show their verdict as it lands. Stops early if the credential is rejected.",
5830
+ "row.modelsTestAllHint": "Tests EVERY model in this region's catalog, including the ones you have NOT enabled — so it costs noticeably more than \"test enabled models\". Same per-probe cost, same early stop on a rejected credential.",
5831
+ "row.modelsTestDone": "Done: {available} of {total} available",
5832
+ "row.modelsTestAuth": "Stopped: the credential was rejected, so every remaining model would answer the same way. Sign in again, then re-run.",
5742
5833
  "row.probe": "Test",
5743
5834
  "row.probing": "Testing…",
5744
5835
  "row.probeHint": "Send a real-volume request (~25k input tokens) to this model and report whether it answers right now. The upstream rate limit fires on request SIZE — measured: about 20k tokens pass, about 30k are refused — so a small request can look fine while every long-conversation request is refused. This is why every test costs real credits (about 0.7) and runs one model at a time.",
@@ -5847,9 +5938,6 @@ window.__ModuleLoader__.load({
5847
5938
  "row.poolProbeSourceUnknown": "source unknown",
5848
5939
  "row.poolProbeModel": "model {model}",
5849
5940
  "row.poolProbeModelUnknown": "model not recorded",
5850
- "row.poolRowModelHint": "Which model this account's test uses. Leave it on \"region target\" to follow the pool's model; pick one when this account must be tested on the model you actually chat with.",
5851
- "row.poolRowModelAria": "Test model for {accountName}",
5852
- "row.poolRowModelInherit": "region target",
5853
5941
  "row.poolSettingsTitle": "Account pool settings",
5854
5942
  "row.poolSettingsHint": "These are preferences: they take effect when you save (same as the model list). Test results are written automatically and are not affected by this button.",
5855
5943
  "row.poolEnabled": "Enable the account pool",
@@ -6018,6 +6106,13 @@ window.__ModuleLoader__.load({
6018
6106
  "row.modelReasoning": "推理强度:{efforts}",
6019
6107
  "row.modelUnknown": "未知",
6020
6108
  "row.modelCapabilityPending": "仅展示 WorkBuddy 接口明确公布的模型能力。",
6109
+ "row.modelsTestEnabled": "一键测试启用模型",
6110
+ "row.modelsTestAll": "一键测试所有模型",
6111
+ "row.modelsTesting": "测试中 {done}/{total}…",
6112
+ "row.modelsTestEnabledHint": "逐个测试你**已启用**的模型,一个模型一次请求 —— 每次探测都发真实体积的请求,真花积分(默认 25000 token 体积下约 0.01/个)。每行的结果一出来就显示。凭据被拒时提前停止。",
6113
+ "row.modelsTestAllHint": "测试本区域目录里的**每一个**模型,含**未启用**的 —— 所以比「仅测试启用模型」明显更贵。单次探测成本相同,凭据被拒时同样提前停止。",
6114
+ "row.modelsTestDone": "完成:{total} 个里 {available} 个可用",
6115
+ "row.modelsTestAuth": "已停止:凭据被拒,剩下的模型都会得到同一个答案。请重新登录后再跑一次。",
6021
6116
  "row.probe": "测试",
6022
6117
  "row.probing": "测试中…",
6023
6118
  "row.probeHint": "对该模型发一条真实体积的请求(约 25k 输入 token),报告它现在能不能用。上游的限流按请求体积触发——实测约 20k 能过、约 30k 被拒——所以小请求看着正常、长对话里却可能每次都被拒。这也是为什么每次测试都会消耗真实积分(约 0.7),且一次只测一个模型。",
@@ -6126,9 +6221,6 @@ window.__ModuleLoader__.load({
6126
6221
  "row.poolProbeSourceUnknown": "来源未知",
6127
6222
  "row.poolProbeModel": "模型 {model}",
6128
6223
  "row.poolProbeModelUnknown": "未记录模型",
6129
- "row.poolRowModelHint": "这个账号测试用哪个模型。留「区域目标」就跟随账号池的目标模型;当某个号必须用你实际在聊的模型来测时,在这里单独指定。",
6130
- "row.poolRowModelAria": "{accountName} 的测试模型",
6131
- "row.poolRowModelInherit": "区域目标",
6132
6224
  "row.poolSettingsTitle": "账号池设置",
6133
6225
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
6134
6226
  "row.poolEnabled": "启用账号池",
@@ -3825,7 +3825,7 @@ function generationOf(version) {
3825
3825
  //#endregion
3826
3826
  //#region src/version.ts
3827
3827
  /** The npm package version this build was produced from. */
3828
- const WORKBUDDY_CONNECT_VERSION = "4.5.1";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.5.2";
3829
3829
  //#endregion
3830
3830
  //#region src/host-heartbeat.ts
3831
3831
  /**
package/lib/index.d.ts CHANGED
@@ -2660,7 +2660,6 @@ interface WorkBuddyWebPool {
2660
2660
  * an id whose model has left the catalog must survive a save so the user's
2661
2661
  * record of what they chose is not silently destroyed.
2662
2662
  */
2663
- targetModelIdByAccount?: Readonly<Record<string, string>>;
2664
2663
  /**
2665
2664
  * Where the target came from. `'stale'` means the SAVED id is no longer in
2666
2665
  * the catalog — a distinct state from `'none'` (no free model exists),
@@ -2953,7 +2952,6 @@ interface WorkBuddyPoolDeps {
2953
2952
  enabled: boolean;
2954
2953
  targetModelId: string;
2955
2954
  /** Per-account override of `targetModelId` (issue #61). */
2956
- targetModelIdByAccount?: Readonly<Record<string, string>>;
2957
2955
  /**
2958
2956
  * Tokens each test probe sends as saved; `0` when the user never chose.
2959
2957
  *
@@ -3051,16 +3049,11 @@ interface WorkBuddyPoolDeps {
3051
3049
  */
3052
3050
  test?(region: WorkBuddyRegion,
3053
3051
  /**
3054
- * One model for the whole batch, or a per-account resolver (issue #61).
3055
- *
3056
- * The resolver form is what lets a batch honour a per-account test model; a
3057
- * resolver that names no model yields an `unavailable` row for that account
3058
- * rather than silently testing the region default.
3052
+ * The model the whole batch is measured on — ONE value, by design: every
3053
+ * member of the batch must be probed on the same model or a green batch
3054
+ * stops meaning a single thing (see the removal note in `account-pool-run.ts`).
3059
3055
  */
3060
- modelId: string | ((accountId: string) => {
3061
- modelId?: string;
3062
- staleModelId?: string;
3063
- }), onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
3056
+ modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
3064
3057
  /** The region's live catalog, used to resolve the free target model. */
3065
3058
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
3066
3059
  }
@@ -3200,7 +3193,6 @@ interface WorkBuddyPoolPreferences {
3200
3193
  * chatting with. An id that is no longer in the catalog is reported as stale
3201
3194
  * rather than replaced, so no credits are spent on a model nobody chose.
3202
3195
  */
3203
- targetModelIdByAccount?: Record<string, string>;
3204
3196
  /**
3205
3197
  * How much input a probe sends, in tokens — the pool's biggest single knob.
3206
3198
  *
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-Vd2tNdsv.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-DcUM0cVn.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -2752,22 +2752,7 @@ async function testAllAccounts(targets, modelId, deps, onRow) {
2752
2752
  const rows = [];
2753
2753
  for (const [index, target] of targets.entries()) {
2754
2754
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2755
- const wanted = typeof modelId === "function" ? modelId(target.accountId) : { modelId };
2756
- if (wanted.modelId === void 0) {
2757
- const row = {
2758
- accountId: target.accountId,
2759
- accountName: target.accountName,
2760
- result: {
2761
- modelId: wanted.staleModelId ?? "",
2762
- outcome: "unavailable",
2763
- message: wanted.staleModelId === void 0 ? "this region offers no model to test" : `the test model "${wanted.staleModelId}" is no longer offered in this region`
2764
- }
2765
- };
2766
- rows.push(row);
2767
- onRow?.(row);
2768
- continue;
2769
- }
2770
- const row = await testOne(target, wanted.modelId, deps);
2755
+ const row = await testOne(target, modelId, deps);
2771
2756
  rows.push(row);
2772
2757
  onRow?.(row);
2773
2758
  }
@@ -3074,30 +3059,6 @@ function pickFreeModel(catalog) {
3074
3059
  * That is the safe direction: no credits are spent while the roster is unknown,
3075
3060
  * and the card offers to switch back to automatic.
3076
3061
  */
3077
- /**
3078
- * The model ONE account's test should target (issue #61).
3079
- *
3080
- * The region-wide `targetModelId` is one value, but `6004` is an ACCOUNT × MODEL
3081
- * condition: the reporter measured two accounts where `hy4-preview-f` answered
3082
- * `ok` and `step-5-preview` returned `429 / 6004` at the same moment. Testing
3083
- * only the region's model therefore cannot answer "can this account serve the
3084
- * model I actually use", which is the question the pool's test button is read as
3085
- * answering.
3086
- *
3087
- * A per-account override is the smallest thing that makes the question
3088
- * answerable, and it is additive: an account with no entry keeps using
3089
- * `targetModelId`, so every existing profile behaves identically.
3090
- *
3091
- * Returns a PREFERENCE, not a resolved target — `resolveTargetModel` still
3092
- * validates it against the catalog, so an override pointing at a model this
3093
- * region no longer offers is reported as stale rather than silently swapped for
3094
- * one the user did not choose.
3095
- */
3096
- function targetModelPreference(preferences, accountId) {
3097
- const perAccount = accountId === void 0 ? void 0 : preferences.targetModelIdByAccount?.[accountId];
3098
- if (perAccount !== void 0 && perAccount !== "") return perAccount;
3099
- return preferences.targetModelId;
3100
- }
3101
3062
  function resolveTargetModel(catalog, preferredId) {
3102
3063
  if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
3103
3064
  modelId: preferredId,
@@ -4349,7 +4310,7 @@ async function workBuddyWebPool(deps, region) {
4349
4310
  ...measured.probe.modelId === void 0 ? {} : { modelId: measured.probe.modelId }
4350
4311
  } },
4351
4312
  ...(() => {
4352
- const perRow = resolveTargetModel(catalog, targetModelPreference(preferences, account.id));
4313
+ const perRow = resolveTargetModel(catalog, preferences.targetModelId);
4353
4314
  return {
4354
4315
  ...perRow.modelId === void 0 ? {} : { testModelId: perRow.modelId },
4355
4316
  testModelSource: perRow.source,
@@ -4368,7 +4329,6 @@ async function workBuddyWebPool(deps, region) {
4368
4329
  ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
4369
4330
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
4370
4331
  targetModelSource: target.source,
4371
- targetModelIdByAccount: preferences.targetModelIdByAccount ?? {},
4372
4332
  memberAccountIds: preferences.memberAccountIds,
4373
4333
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4374
4334
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
@@ -4613,19 +4573,17 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4613
4573
  error: "pool test unavailable"
4614
4574
  });
4615
4575
  const preferences = pool.preferences(region);
4616
- const catalog = pool.catalog?.(region) ?? [];
4617
4576
  /**
4618
4577
  * The model ONE member is tested on (issue #61).
4619
4578
  *
4620
- * Per account, because a test model is a per-account choice: `6004` is
4621
- * an account × model condition, so a batch that tested every member on
4622
- * one model would report "可用" for an account whose own model is the
4623
- * one that is limited. Resolution still runs through
4624
- * `resolveTargetModel`, so a stale override is reported, never
4625
- * silently swapped.
4579
+ * ONE model for the whole batch, and it is also the gate: if the region
4580
+ * cannot name a target model the card is told to pick one before any
4581
+ * credits are spent. Every member is therefore measured on the SAME
4582
+ * model, which is what makes a green batch mean one thing. Resolution
4583
+ * runs through `resolveTargetModel`, so a stale target is reported,
4584
+ * never silently swapped for another model.
4626
4585
  */
4627
- const targetFor = (accountId) => resolveTargetModel(catalog, targetModelPreference(preferences, accountId));
4628
- const target = resolveTargetModel(catalog, targetModelPreference(preferences, void 0));
4586
+ const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
4629
4587
  if (target.modelId === void 0) return json(res, 409, {
4630
4588
  action: "test",
4631
4589
  modelId: void 0,
@@ -4647,7 +4605,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4647
4605
  modelId: target.modelId
4648
4606
  });
4649
4607
  try {
4650
- await pool.test(region, targetFor, (row) => {
4608
+ await pool.test(region, target.modelId, (row) => {
4651
4609
  writeLine({ row });
4652
4610
  });
4653
4611
  writeLine({ done: true });
@@ -4985,7 +4943,6 @@ const modelConfig = z.object({
4985
4943
  const poolConfig = z.object({
4986
4944
  enabled: z.boolean().default(false).description("Whether this region's account pool is active (opt-in). While on, a failed chat request is retried against the other usable pool members before the error is reported."),
4987
4945
  targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
4988
- targetModelIdByAccount: z.dict(z.string()).default({}).description("Per-account test model, keyed by account id; an account with no entry uses targetModelId. Use this when an account must be tested on the model you actually chat with, because 6004 is an account-by-model condition."),
4989
4946
  /**
4990
4947
  * Sizing of the test probe, as a token count the user picks from a fixed set.
4991
4948
  *
@@ -5587,7 +5544,7 @@ function apply(ctx, config) {
5587
5544
  * rather than pretending a test ran.
5588
5545
  */
5589
5546
  testOne: async (region, accountId) => {
5590
- const target = regionTargetModel(region, accountId);
5547
+ const target = regionTargetModel(region);
5591
5548
  if (target === void 0) return void 0;
5592
5549
  const store = stacks[region].store;
5593
5550
  const credential = await store.credentialFor(accountId).catch(() => void 0);
@@ -5957,8 +5914,8 @@ function apply(ctx, config) {
5957
5914
  * issue #61), so a row test and a batch test never measure different models.
5958
5915
  * Undefined mirrors the route's 409 states.
5959
5916
  */
5960
- const regionTargetModel = (region, accountId) => {
5961
- return resolveTargetModel(displayModels(current(), region), targetModelPreference(poolPreferencesOf(current(), region), accountId)).modelId;
5917
+ const regionTargetModel = (region) => {
5918
+ return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5962
5919
  };
5963
5920
  /** One region's pool preferences, with the schema's defaults applied. */
5964
5921
  /**
@@ -5975,7 +5932,6 @@ function apply(ctx, config) {
5975
5932
  return {
5976
5933
  enabled: pool?.enabled === true,
5977
5934
  targetModelId: pool?.targetModelId ?? "",
5978
- targetModelIdByAccount: { ...pool?.targetModelIdByAccount },
5979
5935
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
5980
5936
  memberAccountIds: pool?.memberAccountIds ?? []
5981
5937
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.5.1",
5
+ "version": "4.5.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",