dsh-connect-workbuddy 4.4.5 → 4.4.6

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.6 (2026-10-14)
4
+
5
+ ### Fixes
6
+
7
+ - **面板的「测试」只测一个模型,却把结果当成账号级结论:账号池说「可用」,一切到实际在用的模型就撞 `429 / 6004`。**(issue #61 报告者报告,维护者在 issue 下确认「测试的是某一个模型……不能换其他模型」)
8
+ - **现象与证据**:报告者同一时刻、同一脚本测两个国内账号 —— `hy4-preview-f`(当时的 `pool.targetModelId`,面板测的就是它)两个号都可用,而 `step-5-preview`(当时实际在用的模型)两个号都 `429 / 6004`。池文件里这两个号还被更早的一次批量测试记成 `outcome=ok source=test-batch`,而池开着时那份「ok」正是选号依据。**`6004` 是「账号 × 模型」维度的条件**(本项目 10-13 也在三个国际账号上量到 `deepseek-v4.1-flash` 挂、其余模型全通),所以单一模型探测在结构上无法回答「这个账号能不能服务」。
9
+ - **根因(报告者给出代码位置,本地逐条复核属实)**:`resolveTargetModel()` 只解析出**一个** `modelId`(来自区域级 `pool.targetModelId`),一条路径同时服务批量与逐行测试;而 `probeModel()` 的三个返回分支**早就带着 `modelId`**,落盘时被丢掉了 —— 记录里只有 `outcome/atMs/source/retryAtMs/message`,没有任何字段说明这条 `ok` 是**哪个模型**上的。
10
+ - **修法(三处,各自成立)**:
11
+ 1. **测量记录记住模型**:`WorkBuddyPoolProbe` 新增可选 `modelId`,两个写入方(批量测试、实时聊天失败)在拿到模型的那一刻写下它。
12
+ 2. **每个账号可单独指定测试模型**:新增设置 `pool.targetModelIdByAccount`(账号 id → 模型 id),读取时先查 map、查不到回落 `targetModelId`。**批量测试与逐行测试走同一条解析**(报告者指出这正是「改一处两处同时生效」的地方),所以「测试所有账号」也会按每个号自己的模型去测。解析仍然过 `resolveTargetModel`:指向本区域已下架模型时**报 `stale`、绝不静默换成另一个模型**(换一个等于花积分测一个用户没选的模型,还把它当作这个号的结论)。
13
+ 3. **卡片显示适用范围**:池表每行的测量结论旁写上「模型 {model}」(记录里没有该字段的旧数据写「未记录模型」,不猜);每行新增一个测试模型下拉框(留空 = 跟随区域目标),随「保存」一起写入。
14
+ - **为什么 `modelId` 落盘时不继承**:`accountName` 与 `retryAtMs` 描述的是**账号**(后来者看不到的名字仍然是真的,未来的刷新时刻仍然在未来),而模型描述的是**这一次测量**。把旧的 model id 继承到一次新测量上,等于用不是它的模型给它贴标签 —— 正是这个字段要终结的那种混淆。所以**缺失即「范围未知」,不是猜测**。
15
+ - **配置向后兼容**:`targetModelIdByAccount` 默认空对象,且**只有非空时才写入** settings(与 `probeInputTokens` 同一条规则)—— 从未用过这个设置的 profile 写出来的槽位与升级前逐字节相同,升级不会看起来像用户改过设置。
16
+ - **守卫**:`tests/issue61-probe-model-scope.spec.ts`(9 例)覆盖:逐账号优先/回落、空串视为「跟随区域目标」、override 下架时解析为 `stale` 且不带 `modelId`、批量按各账号自己的模型探测、单一模型的旧调用形态不变、解析不出模型时产出 `unavailable` 行并指名那个模型(**没有**回退到别的模型)、批量写入 `modelId`、**不继承**、新测量覆盖旧模型。`tests/pool-route.spec.ts` 新增一条路由级断言:批量路由交给 Host 的是**逐账号解析器**(`a` 用区域目标、`b` 用 `b` 自己的)。**变异验证**:记录不再写 `modelId` → 1 例红;合并改成继承 → 1 例红;批量改回「整批一个模型」→ 1 例红;路由改回传单一 modelId → 路由断言红。
17
+
18
+ ### Features
19
+
20
+ - **新增「上游请求体构成」诊断(`WORKBUDDY_BODY_REPORT`),用于定案 issue #59:两份请求体到底是不是同一份历史。**(issue #59)
21
+ - **为什么需要**:报告里同一份历史被国内网关算约 70.8 万 token、国际网关算约 137.4 万(1.94 倍),切区域即 `code 11115 prompt is too long`。两条独立测量已经把范围压到唯一一个问题:报告者在**两个区域**跑本仓库的内容类别探针,逐字节相同的散文/代码/JSON 六格比值全在 **0.98~1.06**(同一段字节两边计价一致);代码链路核对又表明**历史 reasoning 根本没进请求体**(pi-ai 只在 `compat.requiresThinkingAsText` 为真时把 `thinking` 当文本转发,本插件从未设置;`reasoning_details` 那条路需要流式解析从不写出的签名)。**剩下唯一可能是两份请求体不同**,而这件事**不需要任何区域有积分**就能查:请求体是本地拼好的,`14018 Credits exhausted` 是发出去之后才可能发生的。
22
+ - **做法**:`src/body-report.ts` 在 shim 把 body 交给上游**之前**记录它的**构成** —— 每条消息的 `role`、逐块的类型(`system`/`text`/`tool-call`/`tool-result`/`reasoning`/`image`)、字符数与其中**中日韩字符**数(实测中文 0.669、英文 0.224 token/字符,差 3 倍,混在一起看不出来)、`tools` 数组、`messages.length` 与 body 总字节数。按区域写 `<dir>/<region>.jsonl`,一次请求一行(一轮对话有很多次上游调用,**最后一次**才是要比的那份)。
23
+ - **只记计数、不记文本**:不写任何消息内容、账号名或令牌 —— 一个要被贴进公开 issue 的诊断不能有泄漏用户会话的能力。`tests/issue59-body-report.spec.ts` 里有一条专门的断言:用带特征串的 body 生成报告后,序列化结果**不含**该特征串(变异验证:让 `block()` 把 `text` 一起带上 → 该例与 reasoning 那例同时变红)。
24
+ - **开关**:环境变量 `WORKBUDDY_BODY_REPORT` 未设置或为空白即**关闭**;`1`/`true` 写到 `<DSH home>/body-reports/`;其他值按**目录路径**解释。写文件失败只走日志,**绝不影响聊天请求**(有专门断言)。
25
+ - **守卫**:`tests/issue59-body-report.spec.ts`(16 例)。
26
+ - **配套**:`scripts/probe-region-token-accounting.mjs` 新增 `--language en|zh` 与 `--kind prose|code|json`(六种组合确定性填充、跨区域逐字节相同),并补上国内侧实测基线:prose 214.0 / code 287.0 / json 288.3(en),prose 671.0 / code 480.7 / json 397.3(zh),单位 tokens/1k chars。
27
+
3
28
  ## 4.4.5 (2026-10-14)
4
29
 
5
30
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Bj7JvSYJ.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-B2jrUDhg.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
@@ -661,6 +661,8 @@ window.__ModuleLoader__.load({
661
661
  targetModelId: preferences.targetModelId,
662
662
  memberAccountIds: preferences.memberAccountIds
663
663
  };
664
+ const overrides = Object.entries(preferences.targetModelIdByAccount ?? {}).filter(([, modelId]) => modelId !== "");
665
+ if (overrides.length > 0) slot["targetModelIdByAccount"] = Object.fromEntries(overrides);
664
666
  if (preferences.probeInputTokens !== void 0) slot["probeInputTokens"] = preferences.probeInputTokens;
665
667
  const next = nextRegionPool(scope.getSnapshot().value, region, slot)[region];
666
668
  await writeField(scope, "regions", region, next, (readBack) => {
@@ -672,7 +674,12 @@ window.__ModuleLoader__.load({
672
674
  const stored = pool["memberAccountIds"];
673
675
  if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
674
676
  const wanted = new Set(preferences.memberAccountIds);
675
- return stored.every((id) => typeof id === "string" && wanted.has(id));
677
+ if (!stored.every((id) => typeof id === "string" && wanted.has(id))) return false;
678
+ const storedModels = pool["targetModelIdByAccount"];
679
+ if (overrides.length === 0) return storedModels === void 0 || typeof storedModels === "object" && storedModels !== null && !Array.isArray(storedModels) && Object.keys(storedModels).length === 0;
680
+ if (typeof storedModels !== "object" || storedModels === null || Array.isArray(storedModels)) return false;
681
+ const storedMap = storedModels;
682
+ return Object.keys(storedMap).length === overrides.length && overrides.every(([accountId, modelId]) => storedMap[accountId] === modelId);
676
683
  });
677
684
  }
678
685
  //#endregion
@@ -1259,6 +1266,21 @@ window.__ModuleLoader__.load({
1259
1266
  const wanted = new Set(right);
1260
1267
  return left.every((id) => wanted.has(id));
1261
1268
  }
1269
+ /**
1270
+ * Whether two per-account test-model maps are equal (issue #61).
1271
+ *
1272
+ * By CONTENT, not by size: moving an account from one model to another keeps the
1273
+ * count and changes the meaning, and clearing an entry is a change a size
1274
+ * comparison would miss entirely when another account was added at the same
1275
+ * time. Order is irrelevant for the same reason it is in {@link sameIds} — the
1276
+ * map is written by key, not by click sequence.
1277
+ */
1278
+ function sameModelOverrides(left, right) {
1279
+ const leftKeys = Object.keys(left);
1280
+ const rightKeys = Object.keys(right);
1281
+ if (leftKeys.length !== rightKeys.length) return false;
1282
+ return leftKeys.every((key) => left[key] === right[key]);
1283
+ }
1262
1284
  /** Whether a probe outcome means the model answered. */
1263
1285
  function outcomeOk(outcome) {
1264
1286
  return outcome === "ok";
@@ -1345,7 +1367,8 @@ window.__ModuleLoader__.load({
1345
1367
  enabled: pool?.enabled ?? false,
1346
1368
  targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
1347
1369
  probeInputTokens: pool?.probeInputTokens ?? 0,
1348
- memberAccountIds: pool?.memberAccountIds ?? []
1370
+ memberAccountIds: pool?.memberAccountIds ?? [],
1371
+ targetModelIdByAccount: { ...pool?.targetModelIdByAccount }
1349
1372
  };
1350
1373
  const active = draftRegion === region && draft !== null ? draft : saved;
1351
1374
  /**
@@ -1366,7 +1389,7 @@ window.__ModuleLoader__.load({
1366
1389
  * the pending edit.
1367
1390
  */
1368
1391
  const poolRunning = saved.enabled;
1369
- const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1392
+ 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));
1370
1393
  /**
1371
1394
  * Membership that actually resolves to a local sign-in — what a batch runs on.
1372
1395
  *
@@ -1527,6 +1550,25 @@ window.__ModuleLoader__.load({
1527
1550
  };
1528
1551
  });
1529
1552
  }, [editDraft]);
1553
+ /**
1554
+ * Set ONE account's test model (issue #61).
1555
+ *
1556
+ * `''` REMOVES the entry rather than storing an empty string: "follow the
1557
+ * region target" is the absence of a choice, which is the same shape the host
1558
+ * reads and the same shape an untouched profile has — so nothing about an
1559
+ * older profile changes when this setting is never used.
1560
+ */
1561
+ const setRowModel = (0, react.useCallback)((accountId, modelId) => {
1562
+ editDraft((current) => {
1563
+ const next = { ...current.targetModelIdByAccount };
1564
+ if (modelId === "") delete next[accountId];
1565
+ else next[accountId] = modelId;
1566
+ return {
1567
+ ...current,
1568
+ targetModelIdByAccount: next
1569
+ };
1570
+ });
1571
+ }, [editDraft]);
1530
1572
  /** Check every listed account, or none of them. */
1531
1573
  const selectAll = (0, react.useCallback)((next) => {
1532
1574
  editDraft((current) => ({
@@ -1847,7 +1889,10 @@ window.__ModuleLoader__.load({
1847
1889
  },
1848
1890
  onRemove: () => {
1849
1891
  runRowRemove(account.accountId, account.accountName);
1850
- }
1892
+ },
1893
+ modelOverride: active.targetModelIdByAccount[account.accountId] ?? "",
1894
+ catalog: pool.catalog ?? [],
1895
+ onModelChange: (modelId) => setRowModel(account.accountId, modelId)
1851
1896
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1852
1897
  className: "dsm-workbuddy-pool-warn",
1853
1898
  role: "status"
@@ -1915,6 +1960,7 @@ window.__ModuleLoader__.load({
1915
1960
  /** One account's row. */
1916
1961
  function renderAccountRow(input) {
1917
1962
  const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
1963
+ const { modelOverride, catalog, onModelChange } = input;
1918
1964
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1919
1965
  const excluded = exclusionText(t, account.excludedBy);
1920
1966
  const probe = account.probe;
@@ -1952,10 +1998,23 @@ window.__ModuleLoader__.load({
1952
1998
  }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
1953
1999
  className: "dsm-workbuddy-pool-probe",
1954
2000
  ...probe?.message === void 0 ? {} : { title: probe.message }
1955
- }, (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))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
2001
+ }, (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", {
1956
2002
  className: "dsm-workbuddy-pool-unknown",
1957
2003
  title: t("row.poolNeverTested")
1958
- }, 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", {
2004
+ }, 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", {
2005
+ className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-row-model",
2006
+ value: modelOverride,
2007
+ disabled: !canEdit,
2008
+ title: t("row.poolRowModelHint"),
2009
+ "aria-label": t("row.poolRowModelAria", { accountName: name }),
2010
+ onChange: (event) => onModelChange(event.currentTarget.value)
2011
+ }, (0, react.createElement)("option", { value: "" }, t("row.poolRowModelInherit")), modelOverride !== "" && !catalog.some((model) => model.id === modelOverride) ? (0, react.createElement)("option", {
2012
+ value: modelOverride,
2013
+ disabled: true
2014
+ }, t("row.poolTargetStaleOption", { model: modelOverride })) : null, ...catalog.map((model) => (0, react.createElement)("option", {
2015
+ value: model.id,
2016
+ key: model.id
2017
+ }, model.name))), (0, react.createElement)("button", {
1959
2018
  type: "button",
1960
2019
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1961
2020
  disabled: busy || testDisabled,
@@ -3140,6 +3199,10 @@ window.__ModuleLoader__.load({
3140
3199
  .dsm-workbuddy-pool-prov-unknown{background:var(--dsw-alias-bg-layer-3,#2a2c33);border:1px solid var(--dsw-alias-border-l1,#2c2d31);color:var(--dsw-alias-label-secondary,#c6c9d0)}
3141
3200
  .dsm-workbuddy-pool-prov-sep{color:var(--dsw-alias-label-dimmed,#9aa0a6);opacity:.7}
3142
3201
  .dsm-workbuddy-pool-prov-age{color:var(--dsw-alias-label-tertiary,#999);white-space:nowrap}
3202
+ /* WHICH model a verdict is about (issue #61). Deliberately quiet: it is scope
3203
+ information on the provenance line, not a status of its own, so an ok row and
3204
+ a rate-limited row must not look different because of it. */
3205
+ .dsm-workbuddy-pool-prov-model{color:var(--dsw-alias-label-tertiary,#999);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:190px}
3143
3206
  .dsm-workbuddy-pool-members{display:flex;flex-direction:column;gap:10px}
3144
3207
  .dsm-workbuddy-pool-members-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
3145
3208
  .dsm-workbuddy-pool-members-title{margin:0 0 3px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
@@ -3193,6 +3256,9 @@ window.__ModuleLoader__.load({
3193
3256
  /* Per-row actions (test / remove), right side of each pool row. */
3194
3257
  .dsm-workbuddy-pool-row-actions{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex:none}
3195
3258
  .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{padding:2px 10px;font-size:12px}
3259
+ /* This account's own test model (issue #61). Narrower than the section-level
3260
+ picker so a long model name cannot push the two buttons out of the row. */
3261
+ .dsm-workbuddy-pool-row-model{max-width:150px;padding:2px 6px;font-size:12px}
3196
3262
  @media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
3197
3263
  /* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
3198
3264
  .dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
@@ -5571,6 +5637,11 @@ window.__ModuleLoader__.load({
5571
5637
  "row.poolProbeSourceTest": "tested",
5572
5638
  "row.poolProbeSourceLive": "hit by a live request",
5573
5639
  "row.poolProbeSourceUnknown": "source unknown",
5640
+ "row.poolProbeModel": "model {model}",
5641
+ "row.poolProbeModelUnknown": "model not recorded",
5642
+ "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.",
5643
+ "row.poolRowModelAria": "Test model for {accountName}",
5644
+ "row.poolRowModelInherit": "region target",
5574
5645
  "row.poolSettingsTitle": "Account pool settings",
5575
5646
  "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.",
5576
5647
  "row.poolEnabled": "Enable the account pool",
@@ -5837,6 +5908,11 @@ window.__ModuleLoader__.load({
5837
5908
  "row.poolProbeSourceTest": "测得",
5838
5909
  "row.poolProbeSourceLive": "聊天撞上",
5839
5910
  "row.poolProbeSourceUnknown": "来源未知",
5911
+ "row.poolProbeModel": "模型 {model}",
5912
+ "row.poolProbeModelUnknown": "未记录模型",
5913
+ "row.poolRowModelHint": "这个账号测试用哪个模型。留「区域目标」就跟随账号池的目标模型;当某个号必须用你实际在聊的模型来测时,在这里单独指定。",
5914
+ "row.poolRowModelAria": "{accountName} 的测试模型",
5915
+ "row.poolRowModelInherit": "区域目标",
5840
5916
  "row.poolSettingsTitle": "账号池设置",
5841
5917
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
5842
5918
  "row.poolEnabled": "启用账号池",
@@ -1220,6 +1220,24 @@ function ensureSystemHead(obj) {
1220
1220
  * `tool_choice` (the upstream's field is a string; object forms return 400),
1221
1221
  * and guarantee a leading system message.
1222
1222
  */
1223
+ /**
1224
+ * The model id a prepared body asks for, when it carries one (issue #61).
1225
+ *
1226
+ * Lives beside {@link prepareChatBody} because it reads the same shape: the
1227
+ * serialized OpenAI body, after this plugin's rewrites. Never throws — a
1228
+ * malformed body is the upstream's to reject, not a reason to lose the failure
1229
+ * measurement that this id is needed for.
1230
+ */
1231
+ function modelIdOfBody(bodyJson) {
1232
+ try {
1233
+ const parsed = JSON.parse(bodyJson);
1234
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1235
+ const model = parsed["model"];
1236
+ return typeof model === "string" && model !== "" ? model : void 0;
1237
+ } catch {
1238
+ return;
1239
+ }
1240
+ }
1223
1241
  function prepareChatBody(source) {
1224
1242
  let body;
1225
1243
  try {
@@ -3807,7 +3825,7 @@ function generationOf(version) {
3807
3825
  //#endregion
3808
3826
  //#region src/version.ts
3809
3827
  /** The npm package version this build was produced from. */
3810
- const WORKBUDDY_CONNECT_VERSION = "4.4.5";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.4.6";
3811
3829
  //#endregion
3812
3830
  //#region src/host-heartbeat.ts
3813
3831
  /**
@@ -3965,4 +3983,4 @@ async function clearHostHeartbeat() {
3965
3983
  await rm(workbuddyHostHeartbeatPath(), { force: true });
3966
3984
  }
3967
3985
  //#endregion
3968
- export { isWorkbuddyBundle as $, workbuddyAccountId as A, parseCreditMultiplier as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, WORKBUDDY_APP_EXECUTABLE_ENV as G, parseUpstreamModel as H, classifyUpstreamError as I, deriveAtRestKeyId as J, clearAtRestKeyCache as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, isEncryptedFieldWrapper as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, prepareChatBody as U, parseReasoning as V, regionOf as W, findWorkbuddyAppExecutable as X, fetchAtRestKeyPayload as Y, findWorkbuddyAppExecutableWithSource as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, hostPeersInfo as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, writeFileAtomic as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, macosBundleExecutable as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, workbuddyAppExecutableCandidates as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, openEncryptedField as nt, workbuddyHostHeartbeatPath as o, resolveDshHome as ot, applyContextBudgets as p, deriveAtRestKey as q, isHeartbeatProcessAlive as r, readAtRestKey as rt, writeHostHeartbeat as s, withFileLock as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosNestedAppBundles as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
3986
+ export { isEncryptedFieldWrapper as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, deriveAtRestKey as J, WORKBUDDY_APP_EXECUTABLE_ENV as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, findWorkbuddyAppExecutableWithSource as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, fetchAtRestKeyPayload as X, deriveAtRestKeyId as Y, findWorkbuddyAppExecutable as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, workbuddyAppExecutableCandidates as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, withFileLock as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, readAtRestKey as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, writeFileAtomic as lt, deriveCatalog as m, clearHostHeartbeat as n, macosNestedAppBundles as nt, workbuddyHostHeartbeatPath as o, hostPeersInfo as ot, applyContextBudgets as p, clearAtRestKeyCache as q, isHeartbeatProcessAlive as r, openEncryptedField as rt, writeHostHeartbeat as s, resolveDshHome as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosBundleExecutable as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
package/lib/index.d.ts CHANGED
@@ -798,6 +798,15 @@ declare function knownRegionOf(domain: string): WorkBuddyRegion | undefined;
798
798
  * `tool_choice` (the upstream's field is a string; object forms return 400),
799
799
  * and guarantee a leading system message.
800
800
  */
801
+ /**
802
+ * The model id a prepared body asks for, when it carries one (issue #61).
803
+ *
804
+ * Lives beside {@link prepareChatBody} because it reads the same shape: the
805
+ * serialized OpenAI body, after this plugin's rewrites. Never throws — a
806
+ * malformed body is the upstream's to reject, not a reason to lose the failure
807
+ * measurement that this id is needed for.
808
+ */
809
+ declare function modelIdOfBody(bodyJson: string): string | undefined;
801
810
  declare function prepareChatBody(source: string): string;
802
811
  /** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
803
812
  declare const CREDENTIAL_REJECTED_CODE = "WORKBUDDY_CREDENTIAL_REJECTED";
@@ -1107,7 +1116,29 @@ interface WorkBuddyShimOptions {
1107
1116
  status: number;
1108
1117
  kind: UpstreamErrorKind;
1109
1118
  message: string;
1119
+ /**
1120
+ * The model that request asked for (issue #61).
1121
+ *
1122
+ * Carried because the failure is remembered against the ACCOUNT, and `6004`
1123
+ * is an account × model condition: without this the record cannot say which
1124
+ * model the measurement belongs to, so the card has to present a
1125
+ * single-model result as account-level availability.
1126
+ */
1127
+ modelId?: string;
1110
1128
  }) => void;
1129
+ /**
1130
+ * Called with the FINAL serialized body of every chat request, just before it
1131
+ * goes upstream (issue #59).
1132
+ *
1133
+ * It exists so the plugin can record what was actually sent without the shim
1134
+ * having to know where that record goes: the region belongs to whoever wires
1135
+ * this up, and the report is a plugin concern. The body handed over is the
1136
+ * post-`prepareChatBody` one, because THAT is what the upstream counts.
1137
+ *
1138
+ * Fired without awaiting: a request must never wait on a diagnostic. The
1139
+ * callee must treat the string as read-only.
1140
+ */
1141
+ onRequestBody?: (bodyJson: string) => void;
1111
1142
  }
1112
1143
  /**
1113
1144
  * Start the loopback endpoint. Requests must carry the shim's shared secret;
@@ -1944,6 +1975,31 @@ interface WorkBuddyPoolProbe {
1944
1975
  * old probe files stay readable rather than being discarded.
1945
1976
  */
1946
1977
  message?: string;
1978
+ /**
1979
+ * WHICH MODEL this measurement is about (issue #61).
1980
+ *
1981
+ * The pool's "test all accounts" action probes ONE model — the region's
1982
+ * `targetModelId` — while `6004` is an ACCOUNT × MODEL condition: the reporter
1983
+ * measured two accounts where `hy4-preview-f` answered and `step-5-preview`
1984
+ * returned `429 / 6004` at the same moment. A row rendered as plain "可用"
1985
+ * therefore claims more than the measurement says, and the pool's own routing
1986
+ * reads that `ok` as an account-level fact.
1987
+ *
1988
+ * Storing the model id is what makes the claim checkable: the card can say
1989
+ * WHICH model an `ok` came from, and a reader can see that a different model
1990
+ * was never tested at all. `probeModel()` has always returned this field; the
1991
+ * write simply dropped it.
1992
+ *
1993
+ * Deliberately NOT inherited by {@link mergePoolProbe} when an update omits it
1994
+ * — the opposite of `accountName` and `retryAtMs`. Those two describe the
1995
+ * ACCOUNT (a name that a later writer could not see is still true, and a
1996
+ * future refresh time is still in the future), whereas the model describes
1997
+ * THIS measurement. Carrying an older model id forward would relabel a fresh
1998
+ * measurement with a model it was not taken on, which is exactly the confusion
1999
+ * this field exists to end. ABSENT therefore means "unknown scope", never a
2000
+ * guess.
2001
+ */
2002
+ modelId?: string;
1947
2003
  /**
1948
2004
  * The account's display name AT THE MOMENT IT WAS MEASURED (issue #60).
1949
2005
  *
@@ -2483,7 +2539,13 @@ interface WorkBuddyWebPoolAccount {
2483
2539
  expiringSoon?: number;
2484
2540
  /** When the nearest package expires, in ms. */
2485
2541
  nearestExpiryMs?: number;
2486
- /** The last test of the target model, absent when never tested. */
2542
+ /**
2543
+ * The last test of this account's target model, absent when never tested.
2544
+ *
2545
+ * The model is recorded INSIDE the probe (issue #61) rather than assumed from
2546
+ * the row's current setting: the setting can be changed after the test, and a
2547
+ * result must keep naming what it actually measured.
2548
+ */
2487
2549
  probe?: {
2488
2550
  outcome: WorkBuddyWebProbeOutcome;
2489
2551
  atMs: number;
@@ -2495,6 +2557,16 @@ interface WorkBuddyWebPoolAccount {
2495
2557
  * guess which of the two it was.
2496
2558
  */
2497
2559
  source?: 'test-batch' | 'live-request';
2560
+ /**
2561
+ * WHICH model this measurement is about (issue #61).
2562
+ *
2563
+ * `6004` is an account × model condition, so an `ok` without a model reads as
2564
+ * account-level availability — which is exactly the false positive the
2565
+ * reporter hit: the card said 可用 for two accounts whose actual model
2566
+ * returned `429 / 6004`. ABSENT for records written before the field existed;
2567
+ * the card must then say the scope is unknown rather than assume it.
2568
+ */
2569
+ modelId?: string;
2498
2570
  /** Only when the upstream named a time; never invented locally. */
2499
2571
  retryAtMs?: number;
2500
2572
  /**
@@ -2527,6 +2599,17 @@ interface WorkBuddyWebPoolAccount {
2527
2599
  * result the user just watched succeed.
2528
2600
  */
2529
2601
  checkedInToday?: boolean;
2602
+ /**
2603
+ * The model a TEST of this row would use (issue #61).
2604
+ *
2605
+ * Per row, because a test model can be chosen per account: the card shows it
2606
+ * beside the row it applies to rather than implying one model for the table.
2607
+ */
2608
+ testModelId?: string;
2609
+ /** Where this row's test target came from (see {@link WorkBuddyWebPool.targetModelSource}). */
2610
+ testModelSource?: 'preferred' | 'free' | 'none' | 'stale';
2611
+ /** This row's saved override that the region no longer offers. */
2612
+ staleTestModelId?: string;
2530
2613
  }
2531
2614
  /**
2532
2615
  * The pool's state for one region.
@@ -2556,6 +2639,14 @@ interface WorkBuddyWebPool {
2556
2639
  * would run against a model the upstream does not offer.
2557
2640
  */
2558
2641
  staleTargetModelId?: string;
2642
+ /**
2643
+ * The SAVED per-account test models, keyed by account id (issue #61).
2644
+ *
2645
+ * Sent verbatim rather than as the effective set: the card edits this map, and
2646
+ * an id whose model has left the catalog must survive a save so the user's
2647
+ * record of what they chose is not silently destroyed.
2648
+ */
2649
+ targetModelIdByAccount?: Readonly<Record<string, string>>;
2559
2650
  /**
2560
2651
  * Where the target came from. `'stale'` means the SAVED id is no longer in
2561
2652
  * the catalog — a distinct state from `'none'` (no free model exists),
@@ -2840,6 +2931,8 @@ interface WorkBuddyPoolDeps {
2840
2931
  preferences(region: WorkBuddyRegion): {
2841
2932
  enabled: boolean;
2842
2933
  targetModelId: string;
2934
+ /** Per-account override of `targetModelId` (issue #61). */
2935
+ targetModelIdByAccount?: Readonly<Record<string, string>>;
2843
2936
  /**
2844
2937
  * Tokens each test probe sends as saved; `0` when the user never chose.
2845
2938
  *
@@ -2935,7 +3028,18 @@ interface WorkBuddyPoolDeps {
2935
3028
  * the route handles both, because a missing reporter must degrade to the
2936
3029
  * previous behaviour rather than to no rows at all.
2937
3030
  */
2938
- test?(region: WorkBuddyRegion, modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
3031
+ test?(region: WorkBuddyRegion,
3032
+ /**
3033
+ * One model for the whole batch, or a per-account resolver (issue #61).
3034
+ *
3035
+ * The resolver form is what lets a batch honour a per-account test model; a
3036
+ * resolver that names no model yields an `unavailable` row for that account
3037
+ * rather than silently testing the region default.
3038
+ */
3039
+ modelId: string | ((accountId: string) => {
3040
+ modelId?: string;
3041
+ staleModelId?: string;
3042
+ }), onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
2939
3043
  /** The region's live catalog, used to resolve the free target model. */
2940
3044
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
2941
3045
  }
@@ -3045,6 +3149,18 @@ interface WorkBuddyPoolPreferences {
3045
3149
  enabled?: boolean;
3046
3150
  /** Model id to test; `''` means auto-pick a free model from this region. */
3047
3151
  targetModelId?: string;
3152
+ /**
3153
+ * Per-account override of {@link targetModelId}, keyed by account id (#61).
3154
+ *
3155
+ * `6004` is an ACCOUNT × MODEL condition, so "can this account serve" has no
3156
+ * single answer: the reporter measured two accounts where one model answered
3157
+ * and another returned `429 / 6004` in the same second. The region-wide target
3158
+ * stays the default — this map only says "for THIS account, test THAT model",
3159
+ * which is what makes the pool's verdict match the model the user is actually
3160
+ * chatting with. An id that is no longer in the catalog is reported as stale
3161
+ * rather than replaced, so no credits are spent on a model nobody chose.
3162
+ */
3163
+ targetModelIdByAccount?: Record<string, string>;
3048
3164
  /**
3049
3165
  * How much input a probe sends, in tokens — the pool's biggest single knob.
3050
3166
  *
@@ -3216,4 +3332,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
3216
3332
  */
3217
3333
  declare function apply(ctx: Context, config: Config): void;
3218
3334
  //#endregion
3219
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, WorkBuddyEncryptedCredentialError, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyNativeModality, WorkBuddyPoolPreferences, type WorkBuddyProbeClient, type WorkBuddyProbeOutcome, type WorkBuddyProbeResult, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
3335
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, WorkBuddyEncryptedCredentialError, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyNativeModality, WorkBuddyPoolPreferences, type WorkBuddyProbeClient, type WorkBuddyProbeOutcome, type WorkBuddyProbeResult, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, 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-Bj7JvSYJ.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-B2jrUDhg.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { readFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { join } from "node:path";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
@@ -1776,6 +1776,7 @@ function createWorkBuddyShim(options) {
1776
1776
  const failoverAccount = options.failoverAccount;
1777
1777
  const prepareAccount = options.prepareAccount;
1778
1778
  const onAccountFailure = options.onAccountFailure;
1779
+ const onRequestBody = options.onRequestBody;
1779
1780
  const SHARED_SECRET = randomBytes(32).toString("base64url");
1780
1781
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
1781
1782
  function bearerOk(req) {
@@ -1886,6 +1887,12 @@ function createWorkBuddyShim(options) {
1886
1887
  }
1887
1888
  const raw = (await readBody(req)).toString("utf8");
1888
1889
  const prepared = prepareChatBody(raw);
1890
+ const requestModelId = modelIdOfBody(prepared);
1891
+ if (onRequestBody !== void 0) try {
1892
+ onRequestBody(prepared);
1893
+ } catch (error) {
1894
+ logger?.warn("dsh-connect-workbuddy: body report failed", error);
1895
+ }
1889
1896
  const controller = new AbortController();
1890
1897
  res.on("close", () => controller.abort());
1891
1898
  let result = await client.chatStream(credential, prepared, controller.signal);
@@ -1929,7 +1936,8 @@ function createWorkBuddyShim(options) {
1929
1936
  onAccountFailure(entry.accountId, {
1930
1937
  status: entry.failure.status,
1931
1938
  kind: entry.failure.kind,
1932
- message: entry.failure.message
1939
+ message: entry.failure.message,
1940
+ ...requestModelId === void 0 ? {} : { modelId: requestModelId }
1933
1941
  });
1934
1942
  } catch (error) {
1935
1943
  logger?.warn("dsh-connect-workbuddy: recording an account failure failed", error);
@@ -2042,6 +2050,246 @@ function createWorkBuddyShim(options) {
2042
2050
  };
2043
2051
  }
2044
2052
  //#endregion
2053
+ //#region src/body-report.ts
2054
+ /**
2055
+ * Body-composition report: WHAT was sent upstream, in counts (issue #59).
2056
+ *
2057
+ * WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
2058
+ * ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
2059
+ * which turns a region switch into an instant `code 11115 prompt is too long`.
2060
+ * Two worlds explain that, and they have opposite owners:
2061
+ *
2062
+ * - the gateways count identical bytes differently (upstream's business); or
2063
+ * - the two requests did not carry the same messages (ours to fix).
2064
+ *
2065
+ * The second world was left standing by two independent measurements. The
2066
+ * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
2067
+ * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
2068
+ * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
2069
+ * identically. And a code trace showed historical `reasoning` never reaches the
2070
+ * wire at all: pi-ai only forwards `thinking` blocks as text when
2071
+ * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
2072
+ * `reasoning_details` route needs a signature the stream parser never writes.
2073
+ * With the counting difference excluded and reasoning excluded, the remaining
2074
+ * candidate is that the two bodies differ in the first place.
2075
+ *
2076
+ * This module exists to settle exactly that, and it can do it WITHOUT either
2077
+ * region having credit: the body is assembled locally, before the upstream ever
2078
+ * gets a chance to answer `14018 Credits exhausted`. Capture one composition per
2079
+ * region in the same session and diff them.
2080
+ *
2081
+ * It records COUNTS ONLY — never a byte of message text, no account name, no
2082
+ * token. That is a hard rule, not a default: the point is to compare shape
2083
+ * (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
2084
+ * and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
2085
+ * asserts the report cannot leak a fixture's text.
2086
+ */
2087
+ /**
2088
+ * Environment variable that enables the report.
2089
+ *
2090
+ * Unset or blank means OFF — a diagnostic that writes files must be opt-in.
2091
+ * `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
2092
+ * directory path, so a user can point it at an existing folder (and, on
2093
+ * Windows, one with spaces).
2094
+ */
2095
+ const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
2096
+ /**
2097
+ * Character ranges that tokenize (and therefore bill) like CJK.
2098
+ *
2099
+ * Deliberately generous: the caller's question is "how much of this is the ~3×
2100
+ * kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
2101
+ * class that is borderline costs far less than mis-attributing a whole bucket.
2102
+ * Ranges follow Unicode blocks, plus the compatibility forms and the two
2103
+ * extension planes where rare Han characters live.
2104
+ */
2105
+ const CJK_RANGES = [
2106
+ [11904, 12031],
2107
+ [12288, 12351],
2108
+ [12352, 12543],
2109
+ [13312, 19903],
2110
+ [19968, 40959],
2111
+ [63744, 64255],
2112
+ [65072, 65103],
2113
+ [65280, 65519],
2114
+ [131072, 195103]
2115
+ ];
2116
+ /** Whether one code point bills like CJK. */
2117
+ function isCjkCodePoint(code) {
2118
+ return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
2119
+ }
2120
+ /** Count characters (code points) and how many of them are CJK. */
2121
+ function countChars(text) {
2122
+ let chars = 0;
2123
+ let cjkChars = 0;
2124
+ for (const character of text) {
2125
+ chars += 1;
2126
+ const code = character.codePointAt(0);
2127
+ if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
2128
+ }
2129
+ return {
2130
+ chars,
2131
+ cjkChars
2132
+ };
2133
+ }
2134
+ /** Build one block report from already-extracted text. */
2135
+ function block(type, text) {
2136
+ return {
2137
+ type,
2138
+ ...countChars(text)
2139
+ };
2140
+ }
2141
+ /** Whether a value is a plain object (not null, not an array). */
2142
+ function isRecord(value) {
2143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2144
+ }
2145
+ /**
2146
+ * Describe one message's blocks.
2147
+ *
2148
+ * The role decides what a content string IS, and that is not cosmetic: a
2149
+ * `tool` message's payload is a tool result, so counting it as `text` would hide
2150
+ * the very bucket a long agentic session is mostly made of.
2151
+ */
2152
+ function describeMessage(message) {
2153
+ const role = typeof message["role"] === "string" ? message["role"] : "unknown";
2154
+ const blocks = [];
2155
+ const content = message["content"];
2156
+ /**
2157
+ * The block type a plain string carries, by the ROLE carrying it.
2158
+ *
2159
+ * A `tool` message's string is a tool RESULT and a `system` one is the system
2160
+ * prompt; calling both `text` would fold the biggest bucket of a long agentic
2161
+ * session into the smallest one, which is the opposite of what a composition
2162
+ * report is for.
2163
+ */
2164
+ const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
2165
+ if (typeof content === "string") {
2166
+ if (content !== "") blocks.push(block(stringType, content));
2167
+ } else if (Array.isArray(content)) for (const part of content) {
2168
+ if (!isRecord(part)) continue;
2169
+ const type = typeof part["type"] === "string" ? part["type"] : "unknown";
2170
+ if (type === "text") {
2171
+ const text = part["text"];
2172
+ if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
2173
+ continue;
2174
+ }
2175
+ blocks.push({
2176
+ type: type === "image_url" ? "image" : type,
2177
+ chars: 0,
2178
+ cjkChars: 0
2179
+ });
2180
+ }
2181
+ const toolCalls = message["tool_calls"];
2182
+ if (Array.isArray(toolCalls)) for (const call of toolCalls) {
2183
+ if (!isRecord(call)) continue;
2184
+ const fn = call["function"];
2185
+ const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
2186
+ const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
2187
+ blocks.push(block("tool-call", `${name}${args}`));
2188
+ }
2189
+ for (const field of [
2190
+ "reasoning_content",
2191
+ "reasoning",
2192
+ "reasoning_text"
2193
+ ]) {
2194
+ const value = message[field];
2195
+ if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
2196
+ }
2197
+ return {
2198
+ role,
2199
+ chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
2200
+ cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
2201
+ blocks
2202
+ };
2203
+ }
2204
+ /**
2205
+ * Describe one serialized request body.
2206
+ *
2207
+ * Returns `undefined` when the body is not a JSON object — a diagnostic must
2208
+ * never be the reason a chat request fails, so every caller treats `undefined`
2209
+ * as "nothing to report" rather than an error.
2210
+ */
2211
+ function describeBody(bodyJson, region, nowMs) {
2212
+ let parsed;
2213
+ try {
2214
+ parsed = JSON.parse(bodyJson);
2215
+ } catch {
2216
+ return;
2217
+ }
2218
+ if (!isRecord(parsed)) return void 0;
2219
+ const rawMessages = parsed["messages"];
2220
+ const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
2221
+ const rawTools = parsed["tools"];
2222
+ const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
2223
+ type: "tools",
2224
+ chars: 0,
2225
+ cjkChars: 0
2226
+ };
2227
+ const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
2228
+ const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
2229
+ return {
2230
+ at: new Date(nowMs).toISOString(),
2231
+ region,
2232
+ ...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
2233
+ bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
2234
+ messageCount: messages.length,
2235
+ chars,
2236
+ cjkChars,
2237
+ tools,
2238
+ messages
2239
+ };
2240
+ }
2241
+ /**
2242
+ * Resolve the directory the report writes to, or `undefined` when disabled.
2243
+ *
2244
+ * Exported for the same reason the other env-var resolvers in this plugin are:
2245
+ * the behaviour is worth a unit test, and the test must not have to set a real
2246
+ * process-wide variable to get one.
2247
+ */
2248
+ function bodyReportDirectory(env = process.env) {
2249
+ const configured = env[WORKBUDDY_BODY_REPORT_ENV];
2250
+ if (configured === void 0) return void 0;
2251
+ const trimmed = configured.trim();
2252
+ if (trimmed === "") return void 0;
2253
+ if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
2254
+ return trimmed;
2255
+ }
2256
+ /**
2257
+ * Append one composition to `<dir>/<region>.jsonl`.
2258
+ *
2259
+ * One file per region is the whole point: the reporter captures a session, then
2260
+ * diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
2261
+ * one file per request because a single turn makes many upstream calls (one per
2262
+ * tool step) and the LAST one is the interesting one.
2263
+ *
2264
+ * Read-modify-write is avoided on purpose — a long session would rewrite a
2265
+ * growing file on every step. The lock keeps two concurrent requests from
2266
+ * interleaving a line.
2267
+ */
2268
+ async function appendBodyReport(directory, report) {
2269
+ const file = join(directory, `${report.region}.jsonl`);
2270
+ await mkdir(directory, { recursive: true });
2271
+ const line = `${JSON.stringify(report)}\n`;
2272
+ await withFileLock(file, () => appendFile(file, line, "utf8"));
2273
+ }
2274
+ /**
2275
+ * Record one outgoing body, if the report is enabled.
2276
+ *
2277
+ * NEVER THROWS and never awaits long: this is called on the request path, and a
2278
+ * chat request must not fail (or stall) because a diagnostic could not write a
2279
+ * file. A failure is reported through `onError` so the caller can log it.
2280
+ */
2281
+ async function recordBodyReport(region, bodyJson, options = {}) {
2282
+ const directory = bodyReportDirectory(options.env);
2283
+ if (directory === void 0) return;
2284
+ try {
2285
+ const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
2286
+ if (report === void 0) return;
2287
+ await appendBodyReport(directory, report);
2288
+ } catch (error) {
2289
+ options.onError?.(error);
2290
+ }
2291
+ }
2292
+ //#endregion
2045
2293
  //#region src/probe.ts
2046
2294
  /**
2047
2295
  * How long to bench a MODEL that answered 14003 with no stated time.
@@ -2504,7 +2752,22 @@ async function testAllAccounts(targets, modelId, deps, onRow) {
2504
2752
  const rows = [];
2505
2753
  for (const [index, target] of targets.entries()) {
2506
2754
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2507
- const row = await testOne(target, modelId, deps);
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);
2508
2771
  rows.push(row);
2509
2772
  onRow?.(row);
2510
2773
  }
@@ -2567,6 +2830,7 @@ function probeUpdatesOf(rows) {
2567
2830
  outcome: row.result.outcome,
2568
2831
  atMs,
2569
2832
  source: "test-batch",
2833
+ modelId: row.result.modelId,
2570
2834
  ...row.accountName === "" ? {} : { accountName: row.accountName },
2571
2835
  ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
2572
2836
  ...row.result.message === void 0 ? {} : { message: row.result.message }
@@ -2810,6 +3074,30 @@ function pickFreeModel(catalog) {
2810
3074
  * That is the safe direction: no credits are spent while the roster is unknown,
2811
3075
  * and the card offers to switch back to automatic.
2812
3076
  */
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
+ }
2813
3101
  function resolveTargetModel(catalog, preferredId) {
2814
3102
  if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
2815
3103
  modelId: preferredId,
@@ -4014,7 +4302,8 @@ async function workBuddyWebPool(deps, region) {
4014
4302
  const preferences = pool.preferences(region);
4015
4303
  const members = await pool.members(region);
4016
4304
  const ranked = rankPool(members, Date.now());
4017
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
4305
+ const catalog = pool.catalog?.(region) ?? [];
4306
+ const target = resolveTargetModel(catalog, preferences.targetModelId);
4018
4307
  const currentAccountId = await pool.currentAccountId?.(region);
4019
4308
  const byId = new Map(members.map((member) => [member.account.id, member]));
4020
4309
  const rankedIds = new Set(ranked.map((row) => row.account.id));
@@ -4053,8 +4342,17 @@ async function workBuddyWebPool(deps, region) {
4053
4342
  atMs: measured.probe.atMs,
4054
4343
  ...measured.probe.source === void 0 ? {} : { source: measured.probe.source },
4055
4344
  ...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs },
4056
- ...measured.probe.message === void 0 ? {} : { message: measured.probe.message }
4345
+ ...measured.probe.message === void 0 ? {} : { message: measured.probe.message },
4346
+ ...measured.probe.modelId === void 0 ? {} : { modelId: measured.probe.modelId }
4057
4347
  } },
4348
+ ...(() => {
4349
+ const perRow = resolveTargetModel(catalog, targetModelPreference(preferences, account.id));
4350
+ return {
4351
+ ...perRow.modelId === void 0 ? {} : { testModelId: perRow.modelId },
4352
+ testModelSource: perRow.source,
4353
+ ...perRow.staleModelId === void 0 ? {} : { staleTestModelId: perRow.staleModelId }
4354
+ };
4355
+ })(),
4058
4356
  ...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
4059
4357
  current: account.id === currentAccountId,
4060
4358
  member,
@@ -4067,6 +4365,7 @@ async function workBuddyWebPool(deps, region) {
4067
4365
  ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
4068
4366
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
4069
4367
  targetModelSource: target.source,
4368
+ targetModelIdByAccount: preferences.targetModelIdByAccount ?? {},
4070
4369
  memberAccountIds: preferences.memberAccountIds,
4071
4370
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4072
4371
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
@@ -4310,7 +4609,20 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4310
4609
  reason: "pool-unavailable",
4311
4610
  error: "pool test unavailable"
4312
4611
  });
4313
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], pool.preferences(region).targetModelId);
4612
+ const preferences = pool.preferences(region);
4613
+ const catalog = pool.catalog?.(region) ?? [];
4614
+ /**
4615
+ * The model ONE member is tested on (issue #61).
4616
+ *
4617
+ * Per account, because a test model is a per-account choice: `6004` is
4618
+ * an account × model condition, so a batch that tested every member on
4619
+ * one model would report "可用" for an account whose own model is the
4620
+ * one that is limited. Resolution still runs through
4621
+ * `resolveTargetModel`, so a stale override is reported, never
4622
+ * silently swapped.
4623
+ */
4624
+ const targetFor = (accountId) => resolveTargetModel(catalog, targetModelPreference(preferences, accountId));
4625
+ const target = resolveTargetModel(catalog, targetModelPreference(preferences, void 0));
4314
4626
  if (target.modelId === void 0) return json(res, 409, {
4315
4627
  action: "test",
4316
4628
  modelId: void 0,
@@ -4332,7 +4644,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4332
4644
  modelId: target.modelId
4333
4645
  });
4334
4646
  try {
4335
- await pool.test(region, target.modelId, (row) => {
4647
+ await pool.test(region, targetFor, (row) => {
4336
4648
  writeLine({ row });
4337
4649
  });
4338
4650
  writeLine({ done: true });
@@ -4670,6 +4982,7 @@ const modelConfig = z.object({
4670
4982
  const poolConfig = z.object({
4671
4983
  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."),
4672
4984
  targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
4985
+ 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."),
4673
4986
  /**
4674
4987
  * Sizing of the test probe, as a token count the user picks from a fixed set.
4675
4988
  *
@@ -4927,6 +5240,9 @@ function apply(ctx, config) {
4927
5240
  * traffic once the provider is registered, which happens after this whole
4928
5241
  * setup completes.
4929
5242
  */
5243
+ onRequestBody: (body) => {
5244
+ recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) });
5245
+ },
4930
5246
  prepareAccount: () => applyPoolSelection(region),
4931
5247
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
4932
5248
  onAccountFailure: (accountId, failure) => {
@@ -5260,7 +5576,7 @@ function apply(ctx, config) {
5260
5576
  * rather than pretending a test ran.
5261
5577
  */
5262
5578
  testOne: async (region, accountId) => {
5263
- const target = regionTargetModel(region);
5579
+ const target = regionTargetModel(region, accountId);
5264
5580
  if (target === void 0) return void 0;
5265
5581
  const store = stacks[region].store;
5266
5582
  const credential = await store.credentialFor(accountId).catch(() => void 0);
@@ -5535,6 +5851,7 @@ function apply(ctx, config) {
5535
5851
  outcome,
5536
5852
  atMs: Date.now(),
5537
5853
  source: "live-request",
5854
+ ...failure.modelId === void 0 ? {} : { modelId: failure.modelId },
5538
5855
  ...retryAtMs === void 0 ? {} : { retryAtMs },
5539
5856
  ...message === "" ? {} : { message }
5540
5857
  } });
@@ -5625,11 +5942,12 @@ function apply(ctx, config) {
5625
5942
  /**
5626
5943
  * The model a per-row test would use right now: the SAME resolution the
5627
5944
  * batch route performs (`resolveTargetModel` over the region's displayed
5628
- * catalog and the saved preference), so a row test and a batch test never
5629
- * measure different models. Undefined mirrors the route's 409 states.
5945
+ * catalog and the saved preference — including a per-account override,
5946
+ * issue #61), so a row test and a batch test never measure different models.
5947
+ * Undefined mirrors the route's 409 states.
5630
5948
  */
5631
- const regionTargetModel = (region) => {
5632
- return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5949
+ const regionTargetModel = (region, accountId) => {
5950
+ return resolveTargetModel(displayModels(current(), region), targetModelPreference(poolPreferencesOf(current(), region), accountId)).modelId;
5633
5951
  };
5634
5952
  /** One region's pool preferences, with the schema's defaults applied. */
5635
5953
  const poolPreferencesOf = (config, region) => {
@@ -5638,6 +5956,7 @@ function apply(ctx, config) {
5638
5956
  return {
5639
5957
  enabled: pool?.enabled === true,
5640
5958
  targetModelId: pool?.targetModelId ?? "",
5959
+ targetModelIdByAccount: { ...pool?.targetModelIdByAccount },
5641
5960
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
5642
5961
  memberAccountIds: pool?.memberAccountIds ?? []
5643
5962
  };
@@ -5812,4 +6131,4 @@ function apply(ctx, config) {
5812
6131
  });
5813
6132
  }
5814
6133
  //#endregion
5815
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
6134
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
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.4.5",
5
+ "version": "4.4.6",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",