dsh-connect-workbuddy 5.0.0 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,116 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.2.1 (2026-10-22)
4
+
5
+ ### Fixes
6
+
7
+ - **每一次成功请求前都要先浪费一次被拒请求(自查发现)** —— 重试阶梯是「先发原文→被 11128 拒→再发扰动版」。第一次这样是对的,但**之后每一次都重复这个失败**,而一个会话里提示词的形状并不变。实测每次多付 **209ms + 一次被拒请求**。
8
+ - 修法:扰动版成功过一次之后,记住它,**下一次直接从扰动版开始**。实测:第 1 次请求用 2 次上游调用,之后每次 1 次。
9
+ - **只记在内存里,不落盘**:上游的指纹名单是可以**撤销**的,不只是增加。落盘的标志会活过它成立的条件,继续扰动一个已经不再拒绝的网关。
10
+ - **而且会自我纠正**:扰动版一旦也被拒,立刻清掉标志、退回原文重新探测 —— 所以规则被撤销、或同一网关后面换了别的模型,都不会让插件卡在一个不再有用的形状上。
11
+ - 只在**成功**时记忆。一次也被拒的扰动没有教会我们任何关于网关想要什么的信息。
12
+
13
+ ### 关于用户反馈的那份「改提示词」方案
14
+
15
+ 有用户报告用 `dsm-prompt-easymanager` 取消勾选 `harness:identity` 并把它改成 `DSH` 就能解决。**我实测了,结论是部分有效、但不是根本解法**:
16
+
17
+ | 做法 | 实测 |
18
+ |---|---|
19
+ | 只取消勾选 `harness:identity` | **400 / 11128**(不够) |
20
+ | 只把 `identity` 改成 `DSH`,另两段原文 | **400 / 11128**(不够) |
21
+ | 只改 `source` + `web`,`identity` 原文 | **400 / 11128**(不够) |
22
+ | **三段全改成 `DSH`** | **200** ✅ |
23
+ | 插件当前(零宽扰动) | **200** ✅ |
24
+
25
+ 也就是说**必须三段全部改**才有效,只改一段不够 —— 这份方案之所以在那个用户的机器上「可用」,是因为他改了三段。而插件的扰动方案不需要用户改任何东西。
26
+
27
+ 顺带定位到了一处更精确的事实:门 B 的触发条件不是「含 `DeepSeek Harness`」,而是**特定句子组合**。单独 `source` 段 200、单独 `web` 首句 200、单独 `web` 次句 200,但**两段一起**就 400;而 `web` 次句末尾的**句号**决定成败(改成感叹号则 200)。所以这是一条**内容指纹**规则,不是长度规则 —— 也再次确认 #105 里那个「需要 15,196 字符」的结论不成立。
28
+
29
+ ## 5.2.0 (2026-10-22)
30
+
31
+ > 新功能:实测输出速度(issue #92)。默认不跑,点了才花积分。
32
+
33
+ ### Features
34
+
35
+ - **一键测速 / 单模型测速:输出速度(issue #92)** —— 倍率不再是选模型的唯一依据了。DSH 会把模型当**子代理并行派发**,每个子任务还有多轮往返,所以**输出速度会乘进整个任务的实际耗时**:一个便宜但慢的模型,总耗时可能超过贵但快的。issue 报告者实测同倍率(x0.08)的四个模型,中位输出速度差 **3.8 倍**。
36
+ - **已有探针为什么给不出这个数**:现有可用性探测发 25,000 输入 token、输出上限 **1 个**(`PROBE_MAX_TOKENS = 1`),它的 `elapsedMs` 是**首字延迟**,没有生成跨度可除。测速需要相反的请求形状:极小输入 + 长输出。所以这是**独立的路由**,不是给老路由加字段。
37
+ - **成本比可用性探测低一个数量级**:这个网关按**输入**计价。可用性探测实测 `credit 0.72`;测速实测 **0.01–0.15**(取决于模型倍率)。每次 3 轮,也就是单模型约 0.03–0.45。
38
+ - **3 次取中位数,不是均值**:实测 `glm-5.3-flash` 五次是 110 / 105 / **177** / 103 / 109 tok/s —— 均值 121 被离群值拉高,而四次都在 103–110。中位数 109 才是它真实的位置。所以轮数取**奇数**(偶数中位数要平均两个中间值,等于把离群值的影响又请回来)。
39
+ - **失败的轮次丢弃,不记 0**:一个模型两次跑到 400 tok/s,不会因为第三次超时就变慢;差距体现在 `samples`("3 次"变"2 次")而不是速率上。
40
+ - **测不出就报测不出,绝不编**:分块太少(整个答案一次到达)→ 报「无法测速:分块太少」;上游没报 token 数 → 报「上游没报 token 数」。绝不拿 `elapsedMs` 倒推一个速率。
41
+ - **结果带时间戳**:速度随负载变,一个过期的数字比没有更糟。行里显示 `· 10-22 02:31 测`。
42
+ - **不影响可用性判定**:独立状态、独立路由、行里独立一行显示 —— 一个「快但不可用」的模型不会被渲染成一个不存在的合成状态。
43
+
44
+ ### Fixes
45
+
46
+ - **测速的首字延迟一开始是错的(自查发现,未发布)** —— 第一版在 `probeChat` resolve 之后才取时钟,也就是「响应头到达」之后才计时,于是报的是「响应头→首块」而不是「发请求→首块」。实测三个模型里两个报 `timeToFirstTokenMs: 0`,正是这个错误的签名:读时钟时首块已经在缓冲区里了。
47
+ - **速率不受影响**(它是首块→末块),这恰恰是为什么容易漏掉:主数字一直是对的,副数字是垃圾。
48
+ - 已修,并补了一条测试:模拟服务端 120ms 才开始应答,断言 TTFT ≥ 100ms。时钟一旦被挪回响应头之后,这条会立刻红。
49
+
50
+ ### Tests
51
+
52
+ - `tests/tps.spec.ts`(21 条,新增):请求形状(输出上限远大于 1、输入极小、要求低推理档)、流式读取真的在测间隔(已知到达节奏下断言跨度)、只计 content 不计 reasoning、畸形/分片帧不抛、单块流报「无法测速」、中位数抗离群、丢失败轮不记 0、区分「无 token 数」与「分块太少」、积分求和去浮点噪声、真实循环驱动、单轮失败仍继续、全失败不抛、帧语法单测、以及**TTFT 时钟位置**的回归测试。
53
+ - `tests/web-status.spec.ts`:路由清单断言补上测速路由。
54
+ - 全量:**109 文件 / 1794 条全绿**;两个 tsconfig 均 exit 0;build 成功。
55
+
56
+ ## 5.1.0 (2026-10-22)
57
+
58
+ > 版本号是 **minor**:修了两个行为缺陷(不是接口变更),但其中第一个会改变每一轮请求实际发出去的内容,所以不用 patch。
59
+
60
+ ### Fixes
61
+
62
+ - **global 区的历史思考不再被降级成正文(issue #102)** —— 这是本仓库最严重的一个 bug,而且它被一条假绿的测试盖了四个版本。pi-ai 只在「记这条消息的 provider / api / model 三者都等于当前请求」时才把历史 thinking 重放成 thinking,否则整块摊平成 `text`。DSH 的 agent loop 写消息时用的是**路由名**(`source.provider = request.provider` → `workbuddy-global`),而插件给模型盖的章是**共享 identity**(`workbuddy`),两者永远不等。后果:global 会话**每一轮**都把模型自己的历史推理翻成正文,模型看到「我以前的思考全是正文」,于是把新推理也写进正文——这就是报告里「reasoning 只有 1 个字、text 里全是工作笔记」的形状。
63
+ - 国内区之所以正常,只是因为它**恰好**路由名 == 章名(`workbuddy` === `workbuddy`)。
64
+ - 修法:新增 `alignAssistantIdentities()`,在 transcript 交给 pi-ai 之前把历史 assistant 消息的 `provider` 改写成 identity。**只改 `provider`,不动 `api`**——`dsh-foreign` 消息的 api 是插件从不会发的形状,声称 `openai-completions` 等于断言一个它从未按之构建的线上格式;而 `isSameModel` 仍然要求 model id 匹配,所以真正的换模型照样降级(有测试钉住)。
65
+ - **测试先改红再改绿**:`tests/issue59-cross-region-thinking.spec.ts` 里那条「双向」用例原本是 `for (...) { void recorded; void replayed; ... }`——两个循环变量被直接丢弃,两次迭代断言的是同一件事,从未测过跨区域。现在按**真实形状**喂(`source.provider` = 路由名 + 请求侧 = identity),修复前必红。
66
+ - 新增 5 条边界用例:user / toolResult 不动(原对象返回,请求路径上不分配)、已对齐时不分配、只改 provider、不同 model id 仍降级、畸形条目不抛。
67
+
68
+ - **`11115` 让宿主认不出「上下文溢出」,丢掉了自愈能力(issue #101)** —— 上游这个码有两种报文:形态 A 带 `prompt is too long: N tokens > M maximum`,形态 B 只有一句中文「本次输入加上历史对话超过了模型上限」。而宿主是靠**英文正则**(25 条 pattern,第一条就是 `prompt (?:is )?too long`)判断要不要跑压缩救援的——中文一句都不命中。于是形态 B 既不压缩也不重试,直接把 400 甩给用户,连它自己那句「请精简内容」的建议宿主都无法执行。
69
+ - 修法:错误文案前面加一句 `prompt is too long (upstream code 11115)`,上游原文紧跟在后面。机器拿到信号,人仍然看到网关自己的建议。
70
+ - **不编造 token 数字**:形态 B 根本没给数字,写一个「N tokens > M」正是这个仓库一直在避免的那种「自信的错误诊断」。
71
+ - `isContextTooLongBody()` 用带边界的正则,`111150` / `1111` 不会被误判成溢出——误判会让宿主跑一轮毫无用处的压缩。
72
+ - 分类不变:仍是 `client`,仍然不换号(溢出是请求的问题,换账号只会每个账号烧一次)。
73
+
74
+ ### Tests
75
+
76
+ - `tests/issue101-context-overflow.spec.ts`(8 条,新增):两种形态都识别、相邻码不误伤、marker 命中宿主 pattern 且不被误认为限流、不编造数字、分类不变;外加一条**端到端**用例真的把一次 11115 请求驱过 shim,读宿主会看到的那句文案,确认 marker 在、网关原文在、requestId 在。
77
+ - `tests/issue59-cross-region-thinking.spec.ts`(12 条,改写 1 条 + 新增 5 条)。
78
+ - 全量 `npx vitest run` = **108 文件 / 1773 测试全绿**;`tsc` 两个 project 均 exit 0;`npm run build` 成功。
79
+
80
+ ## 5.0.1 (2026-10-22)
81
+
82
+ > 版本号是 **patch**:改卡面上的一个字符串(标题)、去掉模型列表的一条 CSS 规则与两行路由项,以及几处描述它们的文档。没有接口或配置结构的变更——但如果你的配置里存着 `auto` / `default-model`,升级后它不再出现在列表里。
83
+
84
+ ### Fixes
85
+
86
+ - **国际区的 `Auto` 行也从模型列表里去掉了** —— 它和国内区的 `auto` 是**同一个东西的两个 id**:都是该区的默认路由(`isDefault: true`、都叫 `Auto`),也都是各自名册里**唯一不声明积分倍率**的那一行(实测 2026-10-22:国内 20 个里 19 个标了价,国际 28 个里 27 个)。国内区的 `auto` 早就被过滤了,国际区的 `default-model` 一直漏着——一个插件无法标价的行,就是它无法向你交代成本的行。
87
+ - **判据不是名字**:`fast-model` / `balanced-model` / `primary-model` 也是别名,但它们都声明了真实倍率(x0.34 / x0.59 / x3.31),所以保留。丢掉它们会平白拿走三个用户真正会选的模型。
88
+ - **顺带修掉一个会把它带回来的漏洞**:`keepIds` 分支(让用户正在用的模型不被刷新撤掉,issue #32)原本不过滤路由,于是**旧配置里 `enabledModelIds` 还留着 `auto` 的人,每次刷新都会把这行重新加回来**——而且取消勾选也去不掉。现在两条路径都过滤。
89
+ - **在读取点过滤,而不是迁移已保存的配置**:`lastCatalog` 是配置里带着的历史快照,卡片读的就是它。只改刷新逻辑的话,升级后这一行会继续显示到下次刷新为止,看起来就像「没修好」。现在 `withoutRouters()` 在 `displayModels` 与 `configuredModels` 两处读取时过滤,存进去的值不动——升级后立刻消失,而不是等一次刷新。
90
+
91
+ - **模型页不再有内部滚动条,所有模型一次全部呈现** —— 设置卡片「模型」栏的列表此前带 `max-height: 520px` + `overflow-y: auto`,20 多个模型只显示七行左右,其余藏在一条需要先发现、再拖动的滚动条后面;而面板本身已经在滚动,于是是两条滚动条叠在一起。
92
+ - **`overflow: hidden` 保留**,而且是必要的:圆角由这个带边框的盒子绘制,不裁剪的话首行与末行会把直角画到圆角上。只是现在盒子的高度就等于内容高度,不会再裁掉任何东西。
93
+ - **5.0.0 的描述写错了**,本版一并更正:当时记的是「模型表自己内滚」,与最终代码不符。
94
+
95
+ - **插件卡片的标题改回包名 `dsh-connect-workbuddy`** —— 5.0.0 里补 `locale/{en,zh}.json` 时,`meta.title` 写成了「WorkBuddy 模型接入」/「WorkBuddy Model Access」,于是卡片标题从裸包名变成了那个名字。这个插件的身份就是包名——仓库、npm 页面、issue 里都是这个叫法——所以改回去。
96
+ - **显式写出包名,而不是删掉 `meta.title` 留空**。两种做法渲染出的字完全一样(宿主 `localizedText` 的 fallback 就是 `manifest.name`),但留空之后文件里没有任何地方记录「标题应当等于包名」这件事,下一次「给它起个好听点的名字」看起来就会像一次免费改进,而不是一次回退。`tests/locale-meta.spec.ts` 现在钉住它。
97
+ - **简介不受影响**:仍由 `locale/zh.json` / `locale/en.json` 各自的 `meta.description` 提供,中英分离如初。
98
+
99
+ ### Docs
100
+
101
+ - `docs/DESIGN.md` 更正一处过时说法(原文写「本仓库的 `package.json.description` 是英文」,那是 PR 原本的写法,合并时按「保持中文」处理了);并补上第 4 条机制事实:`meta.title` 就写成包名,以及为什么写出来而不是留空。
102
+ - `CHANGELOG` 的 5.0.0 条目同步改成包名口径。
103
+ - 顺带说明 `package.json` 里那个 `DSH Connect WorkBuddy`(`displayName`)**不参与任何渲染**——宿主标题的回退是 `manifest.name`,不是它。保留只是历史遗留。
104
+
105
+ ### Tests
106
+
107
+ - `tests/locale-meta.spec.ts` 那条从「标题**不**等于包名」改为「两种语言的标题**都**等于包名」,并把理由写进用例:这是一个决定,不是一个默认值。
108
+ - `tests/client-host-contract.spec.ts` 新增 1 条守卫:`.dsm-workbuddy-model-list` 的规则里**不得**出现 `max-height` 或 `overflow-y`,同时**必须**保留 `overflow:hidden`(只断言「没有上限」会让人以后把裁剪也顺手删掉,而那是圆角所依赖的)。
109
+ - `tests/catalog.spec.ts` 新增 4 条 `withoutRouters` 用例:过滤旧快照里的路由、CN 的 `auto` 与国��的 `default-model` 都被认出、`fast`/`balanced`/`primary` 不受牵连、不修改入参;并把「两个区的 fallback 名册都不含各自的路由」从只查 `auto` 扩到两个 id。
110
+ - `tests/upstream.spec.ts` 新增 4 条 `selectCliModels` 用例:两个区各自的 `auto` 都被丢掉、**旧配置里仍写着 `auto` 时不会借 `keepIds` 复活**、真正的「已下架但你在用」的模型仍然被保住(issue #32 那一半不能被这次过滤破坏)。
111
+ - `tests/native-modality.spec.ts` 的路由用例扩成两个 id,并明确 `fast`/`balanced`/`primary`/`deep` 不算路由。
112
+ - 全量 `npx vitest run` = **107 文件 / 1760 测试全绿**;`tsc` 两个 project 均 exit 0;`npm run build` 成功。
113
+
3
114
  ## 5.0.0 (2026-10-21)
4
115
 
5
116
  > 版本号是 **major**:接口与配置项没有破坏性变更(顶层配置字段一个不少,也没有导出被删),但这一版把「花不花钱」的决定权**第一次交给了模型**。在此之前,本插件每一次花钱都跟一个人工动作绑在一起:一条聊天请求是你发的,模型行的「测试」按钮是你点的。5.0.0 新增的 `workbuddy_draw_image` 是模型自己决定调的——约 5.71 积分一张,而宿主没有「调用前先确认」的机制(`needsApproval` / `toolApproval` 在 `app.asar` 里都不存在)。
@@ -18,7 +129,7 @@
18
129
  - **设置卡片按「账号 / 模型 / 诊断」分栏** —— 这张卡片原来是一条 3538 像素的长页,把两类提交语义完全不同的事情混排在一根竖线上:账号改动点一下立刻生效,目录改动是草稿、必须按「保存」,而用户在第 1 屏勾完模型后要滚三屏才能找到那个按钮。
19
130
  - **三栏**:**账号**(地区说明、积分开关、凭据类错误、账号池的切换/签到/导入导出/日志)、**模型**(模型表、保存/放弃)、**诊断**(只读事实表:区域、上游域名、当前服务的账号名、登录有效期、目录模型数、已启用数、上游最近一次错误的原文)。地区的「国内版 / 国际版」两个 tab 原样保留在最上面,它是这三栏共同的作用域。
20
131
  - **切栏不重新拉、不丢草稿**:非活动栏用 `hidden` 留在页面里而不是卸载,所以改了几个勾、切去看一眼余额、再切回来的改动还在,账号池的日志也不重建。`hidden` 同时把非活动栏移出无障碍树,`role="tabpanel"` 的语义成立(用 CSS `display:none` 反而会被后续样式覆盖)。
21
- - **模型表自己内滚**(`max-height: 520px`),栏高不再由模型条数决定。
132
+ - 模型表**全部展开,不设内部滚动**。(5.0.0 原本写成 `max-height: 520px` 内滚,5.0.2 改掉:见该版说明。)
22
133
  - **签出态不画分栏**:没有登录时这张卡片就是一屏说明,套三个空 tab 只会把唯一的说明藏起来。
23
134
  - **诊断栏没有任何控件**,它存在的唯一理由是能被整段贴进问题反馈:账号显示**名字**而不是 id(测试里专门断言不出现 id),上游错误是原文引用、不翻译不概括。
24
135
 
@@ -32,7 +143,7 @@
32
143
  - **`locale/en.json` 是锚点**:读取端遍历的是「英文资源所在目录」,没有它 `zh.json` 永远不会被读——而且不报错。
33
144
  - **`exports` 缺 `"./locale/*.json"` 通配 = 静默失败**:解析抛 `ERR_PACKAGE_PATH_NOT_EXPORTED`,读取端把它连同其它失败一起吞掉。
34
145
  - **`files` 必须带裸目录 `locale`**,否则发布出去的 tarball 里没有它自己那份文案。
35
- - 现在中文界面显示「WorkBuddy 模型接入」+中文简介,英文界面显示「WorkBuddy Model Access」+英文简介。`package.json` 的 `description` **保持中文**:它同时是 npm 包页面的简介、GitHub 的仓库简介,和「万一 locale 读不到」的最后兜底——英文文案住在 `locale/en.json` 里,那才是能覆盖兜底的那一份。
146
+ - 现在中文界面读 `locale/zh.json`、英文界面读 `locale/en.json`,两边各有自己的简介。**标题就是包名 `dsh-connect-workbuddy`**(两处 `meta.title` 都这么写):这个插件的身份就是那个包名,仓库、npm 页面与 issue 里都是这个叫法;写出来而不是留空,是因为留空也会经 `localizedText` 的 `fallback` 渲染出同样的字,但文件里就没有任何地方记录「这是决定」。`package.json` 的 `description` **保持中文**:它同时是 npm 包页面的简介、GitHub 的仓库简介,和「万一 locale 读不到」的最后兜底——英文文案住在 `locale/en.json` 里,那才是能覆盖兜底的那一份。
36
147
 
37
148
  - **新增生图工具 `workbuddy_draw_image`(国内区,默认关闭,需显式授权)** —— 把 WorkBuddy 官方 CLI 的文生图能力暴露成一个 DSH agent 工具,模型可以按用户的描述直接画一张图。
38
149
  - **为什么它是 opt-in,而其他功能不是**:这是本插件唯一「由模型决定花钱」的能力。聊天请求是你发的,「测试」按钮是你点的,而工具调用是模型自己发起的——约 5.71 积分一张,宿主又没有逐次确认的机制。所以开关归你:`regions.cn.imageToolEnabled`,默认 `false`,关着时工具不注册。这与 3.0.0 给账号池的处理是同一个道理(能改变账单落点的功能必须显式打开)。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, Ot as hostPeersInfo, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, bt as findWorkbuddyAppExecutableWithSource, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, mt as WORKBUDDY_APP_EXECUTABLE_ENV, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-D313ENPV.js";
2
+ import { C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutableWithSource, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, jt as hostPeersInfo, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, w as defaultDesktopAuthDirs, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-Icz67WtG.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
@@ -62,6 +62,17 @@ window.__ModuleLoader__.load({
62
62
  */
63
63
  const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
64
64
  /**
65
+ * Plugin-owned OUTPUT-SPEED measurement endpoint (issue #92).
66
+ *
67
+ * Separate from {@link WORKBUDDY_PROBE_PATH} because the two ask opposite
68
+ * questions and cost different amounts: the availability probe sends 25,000
69
+ * input tokens and caps output at one (measured `credit 0.72`), while this one
70
+ * sends ~35 input tokens and caps output at 256 (measured `credit 0.01`–`0.15`).
71
+ * Sharing the route would mean sharing its one-model-per-request cap and its
72
+ * cost wording, and the two would then be wrong for each other.
73
+ */
74
+ const WORKBUDDY_TPS_PATH = "/plugins/dsh-connect-workbuddy/tps";
75
+ /**
65
76
  * Plugin-owned account-pool endpoint: one batch action per call.
66
77
  *
67
78
  * `?action=checkin` claims the daily reward for every account of a region;
@@ -1556,17 +1567,30 @@ window.__ModuleLoader__.load({
1556
1567
  /**
1557
1568
  * Ids the upstream lists in the model roster that are ROUTERS, not models.
1558
1569
  *
1559
- * `auto` (CN) is the one observed today: the gateway resolves it to whichever
1560
- * model it considers the best fit (measured: CN `auto` → `hy4-preview`), so its
1561
- * price, context window and behaviour are decided at request time by something
1562
- * else. Offering it as a row advertises a model whose terms the plugin cannot
1563
- * state, and it duplicates a real row already in the same roster.
1570
+ * Two of them are known, one per region, and they are the SAME concept under
1571
+ * two ids:
1572
+ *
1573
+ * - `auto` (CN gateway) — the gateway resolves it to whichever model it
1574
+ * considers the best fit (measured: CN `auto` → `hy4-preview`), and its own
1575
+ * description says so outright: 「平衡效果与速度。自动为每个任务匹配最优模型,
1576
+ * 积分倍率随之浮动」 ("picks the best model per task; the credit multiplier
1577
+ * floats accordingly").
1578
+ * - `default-model` (international gateway) — identical role (name "Auto",
1579
+ * `isDefault: true`, the region's default pick), also the ONLY entry in that
1580
+ * roster that declares no `credits` at all.
1581
+ *
1582
+ * The evidence for treating both as routers is the shared, self-describing
1583
+ * shape, not the name: measured 2026-10-22 against both live gateways, these
1584
+ * two entries are the ONLY ones whose `credits` field is absent/empty (CN 19
1585
+ * of 20 priced, global 27 of 28), and the ones carrying `isDefault: true`.
1586
+ * A row the plugin cannot price is a row whose cost it cannot state, and the
1587
+ * router duplicates a real row already in the same roster.
1564
1588
  *
1565
1589
  * Kept as an explicit table rather than a heuristic: a router is an upstream
1566
1590
  * concept with no declared flag, and guessing from the name would eventually
1567
1591
  * hide a real model. Add an id here only after the same kind of evidence.
1568
1592
  */
1569
- const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto"]);
1593
+ const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto", "default-model"]);
1570
1594
  /** Whether this id is an upstream router rather than a model. */
1571
1595
  function isRouterModelId(modelId) {
1572
1596
  return ROUTER_MODEL_IDS.has(modelId);
@@ -4231,14 +4255,22 @@ window.__ModuleLoader__.load({
4231
4255
  an error banner, because the fact it states is normal product behaviour
4232
4256
  rather than a failure. Weight is the only signal it needs. */
4233
4257
  .dsm-workbuddy-free-quota-warn{font-weight:600}
4234
- /* The rows scroll INSIDE the box once the list outgrows it: the section owns a
4235
- fixed share of the card instead of setting its height from the roster's
4236
- length. Without this the list was the card's height all by itself — 20-odd
4237
- models, each carrying a budget field, a reasoning picker and an image toggle.
4238
- The rounded corners survive because the scroll container is the bordered box
4239
- itself, so the first and last row are clipped by the same radius the border
4240
- already draws. */
4241
- .dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden;max-height:520px;overflow-y:auto}
4258
+ /* Every model is laid out in full — NO inner scroll box.
4259
+ *
4260
+ * The previous rule capped this at max-height 520px with overflow-y auto, so a
4261
+ * 20-model roster showed about seven rows and hid the rest behind a scrollbar
4262
+ * the user had to discover and then drag inside a panel that already scrolls.
4263
+ * A model picker whose whole job is "let me see and tick models" should not
4264
+ * have two scrollbars stacked on top of each other.
4265
+ *
4266
+ * overflow:hidden stays, and it is load-bearing rather than vestigial: the
4267
+ * rounded corners are drawn by this bordered box, so without clipping the first
4268
+ * and last row would paint square corners over it. Nothing is CLIPPED by it any
4269
+ * more, because the box is now exactly as tall as its contents.
4270
+ *
4271
+ * NOTE: no backticks in CSS comments inside this template literal — one would
4272
+ * terminate the string early and silently drop every rule after it. */
4273
+ .dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden}
4242
4274
  .dsm-workbuddy-model{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#232529);transition:opacity .16s}
4243
4275
  .dsm-workbuddy-model-disabled{opacity:.55}
4244
4276
  .dsm-workbuddy-model+.dsm-workbuddy-model{border-top:1px solid var(--dsw-alias-border-l2,#36373b)}
@@ -4273,10 +4305,12 @@ window.__ModuleLoader__.load({
4273
4305
  meta line, because a cooldown sentence ("rate limited - the upstream gave no
4274
4306
  time") does not fit next to the context/output chips, and truncating it would
4275
4307
  hide exactly the part the user needs. */
4276
- /* Three buttons now live here (refresh + the two sweeps), and the sweep labels are
4277
- long in Chinese (一键测试所有模型). flex:none alone let the group run past the
4278
- header on a narrow card, so the group may WRAP and each label refuses to break
4279
- mid-word — the same failure the pool row's action buttons had. */
4308
+ /* FOUR buttons now live here (refresh + two availability sweeps + the speed
4309
+ sweep added for issue #92), and the labels are long in Chinese
4310
+ (一键测速启用模型). flex:none alone let the group run past the header on a
4311
+ narrow card, so the group may WRAP and each label refuses to break mid-word —
4312
+ the same failure the pool row's action buttons had. The wrap is what makes a
4313
+ fourth button safe; without it the header would need a scroll. */
4280
4314
  .dsm-workbuddy-models-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
4281
4315
  .dsm-workbuddy-models-head-actions .dsm-btn{white-space:nowrap}
4282
4316
  /* The sweep buttons keep the outline weight of the refresh button beside them, so
@@ -5194,6 +5228,37 @@ window.__ModuleLoader__.load({
5194
5228
  const remaining = remainingText(t, untilMs, nowMs);
5195
5229
  return remaining === "" ? "" : ` · ${remaining}`;
5196
5230
  }
5231
+ /**
5232
+ * Render one measured speed, or why there is none (issue #92).
5233
+ *
5234
+ * The timestamp is PART of the value, not decoration: speed changes with load,
5235
+ * and a number without its moment is indistinguishable from a fresh one. When the
5236
+ * measurement could not be timed the row says so and names the reason — never a
5237
+ * zero, and never a guess extrapolated from `elapsedMs`, which for some runs is
5238
+ * time-to-first-token and for others a whole-request duration.
5239
+ */
5240
+ function tpsResultView(result, t, measuredAtMs) {
5241
+ const stamp = measuredAtMs === void 0 ? "" : ` · ${t("row.tpsMeasuredAt", { at: formatDate(measuredAtMs) })}`;
5242
+ if (result.tokensPerSecond === void 0) {
5243
+ const reason = result.reason === "few-chunks" ? "row.tpsNoSample" : result.reason === "no-usage" ? "row.tpsNoUsage" : "row.tpsAllFailed";
5244
+ return {
5245
+ text: result.samples > 0 && result.reason !== void 0 ? `${t(reason)}${stamp}` : `${t("row.tpsAllFailed")}${stamp}`,
5246
+ tone: "bad"
5247
+ };
5248
+ }
5249
+ return {
5250
+ text: `${result.samples < TPS_EXPECTED_RUNS ? t("row.tpsPartial", {
5251
+ tps: String(result.tokensPerSecond),
5252
+ samples: String(result.samples)
5253
+ }) : result.timeToFirstTokenMs === void 0 ? t("row.tpsValue", { tps: String(result.tokensPerSecond) }) : t("row.tpsValueTtft", {
5254
+ tps: String(result.tokensPerSecond),
5255
+ ttft: (result.timeToFirstTokenMs / 1e3).toFixed(1)
5256
+ })}${result.credit === void 0 ? "" : ` · ${t("row.tpsCredit", { credit: String(result.credit) })}`}${stamp}`,
5257
+ tone: result.samples < TPS_EXPECTED_RUNS ? "warn" : "ok"
5258
+ };
5259
+ }
5260
+ /** Runs a complete measurement takes; below this the row says "n runs". */
5261
+ const TPS_EXPECTED_RUNS = 3;
5197
5262
  function probeResultView(result, t, nowMs = Date.now()) {
5198
5263
  switch (result.outcome) {
5199
5264
  case "ok": {
@@ -5326,6 +5391,26 @@ window.__ModuleLoader__.load({
5326
5391
  const probesRef = (0, react.useRef)({});
5327
5392
  /** Progress of an in-flight sweep, or undefined when none is running. */
5328
5393
  const [sweeping, setSweeping] = (0, react.useState)(void 0);
5394
+ /**
5395
+ * Measured output speed per model (issue #92), keyed by model id.
5396
+ *
5397
+ * Kept in its own map, NOT merged into `probes`: a speed run says nothing about
5398
+ * availability and an availability run says nothing about speed, so one map
5399
+ * holding both would let a fresh speed reading overwrite a stale availability
5400
+ * verdict (or the reverse) with no field to tell them apart.
5401
+ *
5402
+ * Deliberately survives a region switch's re-render but is NOT persisted: a
5403
+ * speed number is only meaningful with the moment it was taken, and a stored
5404
+ * one would be indistinguishable from a fresh measurement. The row prints the
5405
+ * time beside it for the same reason.
5406
+ */
5407
+ const [tpsResults, setTpsResults] = (0, react.useState)({});
5408
+ /** Which speed runs are in flight, so the row and the sweep button can spin. */
5409
+ const [tpsProbing, setTpsProbing] = (0, react.useState)({});
5410
+ /** Progress of a speed sweep, so the button can show "n/m" instead of hanging. */
5411
+ const [tpsSweeping, setTpsSweeping] = (0, react.useState)(void 0);
5412
+ /** When each model's speed was measured, in ms; printed beside the number. */
5413
+ const [tpsMeasuredAt, setTpsMeasuredAt] = (0, react.useState)({});
5329
5414
  /** The last sweep's tally, cleared when the next one starts. */
5330
5415
  const [sweepSummary, setSweepSummary] = (0, react.useState)(void 0);
5331
5416
  const mounted = (0, react.useRef)(true);
@@ -5987,6 +6072,83 @@ window.__ModuleLoader__.load({
5987
6072
  sweepModels(visibleModels.filter((model) => activeEnabledIds.has(model.id)).map((model) => model.id));
5988
6073
  };
5989
6074
  /**
6075
+ * Measure OUTPUT SPEED for one or more models (issue #92).
6076
+ *
6077
+ * One model per request, like every other spending route: the upstream route
6078
+ * refuses more than one id, and that cap is what keeps a stale bundle from
6079
+ * turning a click into an unaccounted spend over a whole roster. The loop for a
6080
+ * sweep therefore lives here, not on the Host.
6081
+ *
6082
+ * A failed model does NOT abort the sweep. Unlike an availability sweep — where
6083
+ * a rejected credential answers identically for every remaining model and
6084
+ * continuing only spends credit to hear the same answer — a speed failure is
6085
+ * specific to that model, so stopping would hide the one thing the sweep was
6086
+ * asked to find out.
6087
+ */
6088
+ const measureTpsFor = async (modelIds) => {
6089
+ if (modelIds.length === 0) return;
6090
+ setTpsProbing((previous) => {
6091
+ const next = { ...previous };
6092
+ for (const id of modelIds) next[id] = true;
6093
+ return next;
6094
+ });
6095
+ try {
6096
+ for (const [index, modelId] of modelIds.entries()) {
6097
+ if (!mounted.current) return;
6098
+ try {
6099
+ const response = await fetch(withWorkBuddyRegion(WORKBUDDY_TPS_PATH, activeRegion), {
6100
+ method: "POST",
6101
+ headers: {
6102
+ "content-type": "application/json",
6103
+ accept: "application/json"
6104
+ },
6105
+ credentials: "same-origin",
6106
+ body: JSON.stringify({ modelIds: [modelId] })
6107
+ });
6108
+ const body = await response.json().catch(() => void 0);
6109
+ if (!response.ok || !Array.isArray(body?.results)) throw new Error(body?.error ?? `HTTP ${String(response.status)}`);
6110
+ if (!mounted.current) return;
6111
+ const landed = body.results[0];
6112
+ if (landed !== void 0) {
6113
+ setTpsResults((previous) => ({
6114
+ ...previous,
6115
+ [landed.modelId]: landed
6116
+ }));
6117
+ setTpsMeasuredAt((previous) => ({
6118
+ ...previous,
6119
+ [landed.modelId]: Date.now()
6120
+ }));
6121
+ }
6122
+ } catch (error) {
6123
+ if (!mounted.current) return;
6124
+ setProbeError(error instanceof Error ? error.message : t("row.requestFailed"));
6125
+ } finally {
6126
+ if (mounted.current) setTpsProbing((previous) => {
6127
+ const next = { ...previous };
6128
+ delete next[modelId];
6129
+ return next;
6130
+ });
6131
+ }
6132
+ if (mounted.current) setTpsSweeping({
6133
+ done: index + 1,
6134
+ total: modelIds.length
6135
+ });
6136
+ }
6137
+ } finally {
6138
+ if (mounted.current) setTpsSweeping(void 0);
6139
+ }
6140
+ };
6141
+ /**
6142
+ * Sweep speed for the models this region has ENABLED.
6143
+ *
6144
+ * Enabled-only rather than the whole catalog: a speed run is three real
6145
+ * requests, so covering a 20-model roster would cost 60 requests to learn
6146
+ * something about 18 models the user does not use.
6147
+ */
6148
+ const sweepTpsEnabledModels = () => {
6149
+ measureTpsFor(visibleModels.filter((model) => activeEnabledIds.has(model.id)).map((model) => model.id));
6150
+ };
6151
+ /**
5990
6152
  * Sweep EVERY model in the region's live catalog, enabled or not.
5991
6153
  *
5992
6154
  * Deliberately the whole VISIBLE catalog and nothing else: those are the ids
@@ -6448,6 +6610,17 @@ window.__ModuleLoader__.load({
6448
6610
  done: sweeping.done,
6449
6611
  total: sweeping.total
6450
6612
  })
6613
+ }),
6614
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6615
+ type: "button",
6616
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-models-sweep",
6617
+ disabled: busy || tpsSweeping !== void 0 || tpsProbing !== void 0 || activeEnabledIds.size === 0,
6618
+ title: t("row.tpsSweepEnabledHint"),
6619
+ onClick: sweepTpsEnabledModels,
6620
+ children: tpsSweeping === void 0 ? t("row.tpsSweepEnabled") : t("row.tpsTesting", {
6621
+ done: tpsSweeping.done,
6622
+ total: tpsSweeping.total
6623
+ })
6451
6624
  })
6452
6625
  ]
6453
6626
  })]
@@ -6558,9 +6731,9 @@ window.__ModuleLoader__.load({
6558
6731
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })
6559
6732
  ]
6560
6733
  }),
6561
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6562
6735
  className: "dsm-workbuddy-model-actions-buttons",
6563
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6736
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6564
6737
  type: "button",
6565
6738
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
6566
6739
  disabled: probing[model.id] === true || status.status !== "signed-in",
@@ -6569,7 +6742,16 @@ window.__ModuleLoader__.load({
6569
6742
  probeModels([model.id]);
6570
6743
  },
6571
6744
  children: probing[model.id] === true ? t("row.probing") : t("row.probe")
6572
- })
6745
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6746
+ type: "button",
6747
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
6748
+ disabled: tpsProbing[model.id] === true || status.status !== "signed-in",
6749
+ title: t("row.tpsHint"),
6750
+ onClick: () => {
6751
+ measureTpsFor([model.id]);
6752
+ },
6753
+ children: tpsProbing[model.id] === true ? t("row.tpsMeasuring") : t("row.tpsMeasure")
6754
+ })]
6573
6755
  })
6574
6756
  ]
6575
6757
  }),
@@ -6595,6 +6777,16 @@ window.__ModuleLoader__.load({
6595
6777
  ...result.message === void 0 ? {} : { title: result.message },
6596
6778
  children: [view.text, reason === void 0 ? null : " · " + reason]
6597
6779
  });
6780
+ })(),
6781
+ (() => {
6782
+ const measured = tpsResults[model.id];
6783
+ if (measured === void 0) return null;
6784
+ const view = tpsResultView(measured, t, tpsMeasuredAt[model.id]);
6785
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6786
+ className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
6787
+ role: "status",
6788
+ children: view.text
6789
+ });
6598
6790
  })()
6599
6791
  ]
6600
6792
  }, model.id))
@@ -7864,6 +8056,21 @@ window.__ModuleLoader__.load({
7864
8056
  "row.modelsTesting": "Testing {done}/{total}…",
7865
8057
  "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.",
7866
8058
  "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.",
8059
+ "row.tpsSweepEnabled": "Measure speed (enabled)",
8060
+ "row.tpsSweepAll": "Measure speed (all)",
8061
+ "row.tpsSweepEnabledHint": "Measures OUTPUT SPEED for every model you have ENABLED: three runs each, taking the median. Unlike the availability probes above, this sends a tiny prompt and asks for 256 output tokens back, so it costs far less per model (measured 0.01–0.15 credit against 0.72), because this gateway prices by input. Speed matters because DSH dispatches models as parallel sub-agents, so a slow model costs wall-clock time across the whole run.",
8062
+ "row.tpsTesting": "Measuring {done}/{total}…",
8063
+ "row.tpsMeasure": "Speed",
8064
+ "row.tpsMeasuring": "Measuring…",
8065
+ "row.tpsHint": "Measure this model's output speed: three short requests, median taken. Uses about 256 output tokens per run and costs far less than an availability probe (measured 0.01–0.15 credit). The result is shown with the time it was taken, because speed changes with load and an old number is worse than none.",
8066
+ "row.tpsValue": "{tps} tok/s",
8067
+ "row.tpsValueTtft": "{tps} tok/s · first token {ttft}s",
8068
+ "row.tpsPartial": "{tps} tok/s ({samples} runs)",
8069
+ "row.tpsNoSample": "Could not time this model (too few stream chunks)",
8070
+ "row.tpsNoUsage": "Could not time this model (upstream reported no token count)",
8071
+ "row.tpsAllFailed": "Speed measurement failed",
8072
+ "row.tpsCredit": "cost {credit} credit",
8073
+ "row.tpsMeasuredAt": "measured {at}",
7867
8074
  "row.modelsTestDone": "Done: {available} of {total} available",
7868
8075
  "row.modelsTestAuth": "Stopped: the credential was rejected, so every remaining model would answer the same way. Sign in again, then re-run.",
7869
8076
  "row.probe": "Test",
@@ -8208,6 +8415,21 @@ window.__ModuleLoader__.load({
8208
8415
  "row.modelsTesting": "测试中 {done}/{total}…",
8209
8416
  "row.modelsTestEnabledHint": "逐个测试你**已启用**的模型,一个模型一次请求 —— 每次探测都发真实体积的请求,真花积分(默认 25000 token 体积下约 0.01/个)。每行的结果一出来就显示。凭据被拒时提前停止。",
8210
8417
  "row.modelsTestAllHint": "测试本区域目录里的**每一个**模型,含**未启用**的 —— 所以比「仅测试启用模型」明显更贵。单次探测成本相同,凭据被拒时同样提前停止。",
8418
+ "row.tpsSweepEnabled": "一键测速启用模型",
8419
+ "row.tpsSweepAll": "一键测速所有模型",
8420
+ "row.tpsSweepEnabledHint": "测**已启用**模型的**输出速度**:每个模型跑 3 次取中位数。和上面的可用性探测不同,它只发一个极小的提示、要求返回 256 个输出 token —— 这个网关按**输入**计价,所以单模型成本低得多(实测 0.01–0.15 积分,可用性探测是 0.72)。速度之所以重要:DSH 会把模型当子代理并行派发,慢模型拖的是整个任务的实际耗时。",
8421
+ "row.tpsTesting": "测速中 {done}/{total}…",
8422
+ "row.tpsMeasure": "测速",
8423
+ "row.tpsMeasuring": "测速中…",
8424
+ "row.tpsHint": "测量该模型的输出速度:3 次短请求取中位数。每次约 256 个输出 token,成本远低于可用性探测(实测 0.01–0.15 积分)。结果旁边会标出**测量时间** —— 速度随负载变化,一个过期的数字比没有更糟。",
8425
+ "row.tpsValue": "{tps} tok/s",
8426
+ "row.tpsValueTtft": "{tps} tok/s · 首字 {ttft}s",
8427
+ "row.tpsPartial": "{tps} tok/s({samples} 次)",
8428
+ "row.tpsNoSample": "无法测速:流里的分块太少,算不出速率",
8429
+ "row.tpsNoUsage": "无法测速:上游没报 token 数",
8430
+ "row.tpsAllFailed": "测速失败",
8431
+ "row.tpsCredit": "花费 {credit} 积分",
8432
+ "row.tpsMeasuredAt": "{at} 测",
8211
8433
  "row.modelsTestDone": "完成:{total} 个里 {available} 个可用",
8212
8434
  "row.modelsTestAuth": "已停止:凭据被拒,剩下的模型都会得到同一个答案。请重新登录后再跑一次。",
8213
8435
  "row.probe": "测试",