dsh-connect-workbuddy 5.0.1 → 5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,99 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.2.2 (2026-10-22)
4
+
5
+ ### Fixes
6
+
7
+ - **排队不再承诺、也不再触发自动重试(按维护者要求)** —— 两半都得改,因为它们是同一件事。
8
+ - **文案**:原来结尾是「约 N 秒后自动重试」。**没有任何东西在重试**:插件没有排队轮询循环(`src/queue.ts` 的 "WHAT THIS MODULE DOES NOT DO YET" 写明了这一点),也不会重发。现在改成「请稍后重新发送这条消息,或换一个模型」——位次照常报,但把决定权交回给用户。
9
+ - **请求本身**:那句谎言还有成本。`@deepseek-ai/dsh-llm` 把 HTTP 429 映射成 `RATE_LIMIT`,而 `RATE_LIMIT` 在它的 `DEFAULT_RETRYABLE_CODES` 里 —— 所以宿主会跑 **5 次** 原地重试(0.5s→8s 退避,实测合计约 15 秒)。一次排队在几千位之后,15 秒不可能排到,这 5 次全是**真实请求**,每次都只为再听一遍同一个位次。现在该响应带上 `x-should-retry: false`(两个客户端都**先**看这个头,再看状态码)与 `retry-after: 0`。
10
+ - **不转发上游的 `retry_after`**:7 秒也清不掉 9021 位的队,转发它只会让客户端先等一会再收到同一个答复。
11
+ - **`retryAfterSec` 字段一并删除**:它只用来喂那句倒计时,现在没人读。留着解析但不用的数据,等于给这个承诺留了条回头的路。
12
+ - **其他 429 仍然可以重试**:`model_busy`(14003)是模型级限流,确实约一分钟就恢复,宿主重试正是对的处置。只把排队这一类标成终局,不是一刀切。
13
+
14
+ ### Tests
15
+
16
+ - `tests/queue-no-retry.spec.ts`(6 条,新增):`x-should-retry: false`、`retry-after: 0`、不转发上游 `retry_after`、位次与队列长度仍在、文案里没有「自动重试」、**其他 429 不受影响**(最后这条防止把可恢复的限流也变成即时失败)。
17
+ - `tests/queue.spec.ts`:`retryAfterSec` 相关断言改写为「不携带倒计时」。
18
+ - 全量:**111 文件 / 1807 条全绿**;两个 tsconfig 均 exit 0;build 成功。
19
+
20
+ ## 5.2.1 (2026-10-22)
21
+
22
+ ### Fixes
23
+
24
+ - **每一次成功请求前都要先浪费一次被拒请求(自查发现)** —— 重试阶梯是「先发原文→被 11128 拒→再发扰动版」。第一次这样是对的,但**之后每一次都重复这个失败**,而一个会话里提示词的形状并不变。实测每次多付 **209ms + 一次被拒请求**。
25
+ - 修法:扰动版成功过一次之后,记住它,**下一次直接从扰动版开始**。实测:第 1 次请求用 2 次上游调用,之后每次 1 次。
26
+ - **只记在内存里,不落盘**:上游的指纹名单是可以**撤销**的,不只是增加。落盘的标志会活过它成立的条件,继续扰动一个已经不再拒绝的网关。
27
+ - **而且会自我纠正**:扰动版一旦也被拒,立刻清掉标志、退回原文重新探测 —— 所以规则被撤销、或同一网关后面换了别的模型,都不会让插件卡在一个不再有用的形状上。
28
+ - 只在**成功**时记忆。一次也被拒的扰动没有教会我们任何关于网关想要什么的信息。
29
+
30
+ ### 关于用户反馈的那份「改提示词」方案
31
+
32
+ 有用户报告用 `dsm-prompt-easymanager` 取消勾选 `harness:identity` 并把它改成 `DSH` 就能解决。**我实测了,结论是部分有效、但不是根本解法**:
33
+
34
+ | 做法 | 实测 |
35
+ |---|---|
36
+ | 只取消勾选 `harness:identity` | **400 / 11128**(不够) |
37
+ | 只把 `identity` 改成 `DSH`,另两段原文 | **400 / 11128**(不够) |
38
+ | 只改 `source` + `web`,`identity` 原文 | **400 / 11128**(不够) |
39
+ | **三段全改成 `DSH`** | **200** ✅ |
40
+ | 插件当前(零宽扰动) | **200** ✅ |
41
+
42
+ 也就是说**必须三段全部改**才有效,只改一段不够 —— 这份方案之所以在那个用户的机器上「可用」,是因为他改了三段。而插件的扰动方案不需要用户改任何东西。
43
+
44
+ 顺带定位到了一处更精确的事实:门 B 的触发条件不是「含 `DeepSeek Harness`」,而是**特定句子组合**。单独 `source` 段 200、单独 `web` 首句 200、单独 `web` 次句 200,但**两段一起**就 400;而 `web` 次句末尾的**句号**决定成败(改成感叹号则 200)。所以这是一条**内容指纹**规则,不是长度规则 —— 也再次确认 #105 里那个「需要 15,196 字符」的结论不成立。
45
+
46
+ ## 5.2.0 (2026-10-22)
47
+
48
+ > 新功能:实测输出速度(issue #92)。默认不跑,点了才花积分。
49
+
50
+ ### Features
51
+
52
+ - **一键测速 / 单模型测速:输出速度(issue #92)** —— 倍率不再是选模型的唯一依据了。DSH 会把模型当**子代理并行派发**,每个子任务还有多轮往返,所以**输出速度会乘进整个任务的实际耗时**:一个便宜但慢的模型,总耗时可能超过贵但快的。issue 报告者实测同倍率(x0.08)的四个模型,中位输出速度差 **3.8 倍**。
53
+ - **已有探针为什么给不出这个数**:现有可用性探测发 25,000 输入 token、输出上限 **1 个**(`PROBE_MAX_TOKENS = 1`),它的 `elapsedMs` 是**首字延迟**,没有生成跨度可除。测速需要相反的请求形状:极小输入 + 长输出。所以这是**独立的路由**,不是给老路由加字段。
54
+ - **成本比可用性探测低一个数量级**:这个网关按**输入**计价。可用性探测实测 `credit 0.72`;测速实测 **0.01–0.15**(取决于模型倍率)。每次 3 轮,也就是单模型约 0.03–0.45。
55
+ - **3 次取中位数,不是均值**:实测 `glm-5.3-flash` 五次是 110 / 105 / **177** / 103 / 109 tok/s —— 均值 121 被离群值拉高,而四次都在 103–110。中位数 109 才是它真实的位置。所以轮数取**奇数**(偶数中位数要平均两个中间值,等于把离群值的影响又请回来)。
56
+ - **失败的轮次丢弃,不记 0**:一个模型两次跑到 400 tok/s,不会因为第三次超时就变慢;差距体现在 `samples`("3 次"变"2 次")而不是速率上。
57
+ - **测不出就报测不出,绝不编**:分块太少(整个答案一次到达)→ 报「无法测速:分块太少」;上游没报 token 数 → 报「上游没报 token 数」。绝不拿 `elapsedMs` 倒推一个速率。
58
+ - **结果带时间戳**:速度随负载变,一个过期的数字比没有更糟。行里显示 `· 10-22 02:31 测`。
59
+ - **不影响可用性判定**:独立状态、独立路由、行里独立一行显示 —— 一个「快但不可用」的模型不会被渲染成一个不存在的合成状态。
60
+
61
+ ### Fixes
62
+
63
+ - **测速的首字延迟一开始是错的(自查发现,未发布)** —— 第一版在 `probeChat` resolve 之后才取时钟,也就是「响应头到达」之后才计时,于是报的是「响应头→首块」而不是「发请求→首块」。实测三个模型里两个报 `timeToFirstTokenMs: 0`,正是这个错误的签名:读时钟时首块已经在缓冲区里了。
64
+ - **速率不受影响**(它是首块→末块),这恰恰是为什么容易漏掉:主数字一直是对的,副数字是垃圾。
65
+ - 已修,并补了一条测试:模拟服务端 120ms 才开始应答,断言 TTFT ≥ 100ms。时钟一旦被挪回响应头之后,这条会立刻红。
66
+
67
+ ### Tests
68
+
69
+ - `tests/tps.spec.ts`(21 条,新增):请求形状(输出上限远大于 1、输入极小、要求低推理档)、流式读取真的在测间隔(已知到达节奏下断言跨度)、只计 content 不计 reasoning、畸形/分片帧不抛、单块流报「无法测速」、中位数抗离群、丢失败轮不记 0、区分「无 token 数」与「分块太少」、积分求和去浮点噪声、真实循环驱动、单轮失败仍继续、全失败不抛、帧语法单测、以及**TTFT 时钟位置**的回归测试。
70
+ - `tests/web-status.spec.ts`:路由清单断言补上测速路由。
71
+ - 全量:**109 文件 / 1794 条全绿**;两个 tsconfig 均 exit 0;build 成功。
72
+
73
+ ## 5.1.0 (2026-10-22)
74
+
75
+ > 版本号是 **minor**:修了两个行为缺陷(不是接口变更),但其中第一个会改变每一轮请求实际发出去的内容,所以不用 patch。
76
+
77
+ ### Fixes
78
+
79
+ - **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 里全是工作笔记」的形状。
80
+ - 国内区之所以正常,只是因为它**恰好**路由名 == 章名(`workbuddy` === `workbuddy`)。
81
+ - 修法:新增 `alignAssistantIdentities()`,在 transcript 交给 pi-ai 之前把历史 assistant 消息的 `provider` 改写成 identity。**只改 `provider`,不动 `api`**——`dsh-foreign` 消息的 api 是插件从不会发的形状,声称 `openai-completions` 等于断言一个它从未按之构建的线上格式;而 `isSameModel` 仍然要求 model id 匹配,所以真正的换模型照样降级(有测试钉住)。
82
+ - **测试先改红再改绿**:`tests/issue59-cross-region-thinking.spec.ts` 里那条「双向」用例原本是 `for (...) { void recorded; void replayed; ... }`——两个循环变量被直接丢弃,两次迭代断言的是同一件事,从未测过跨区域。现在按**真实形状**喂(`source.provider` = 路由名 + 请求侧 = identity),修复前必红。
83
+ - 新增 5 条边界用例:user / toolResult 不动(原对象返回,请求路径上不分配)、已对齐时不分配、只改 provider、不同 model id 仍降级、畸形条目不抛。
84
+
85
+ - **`11115` 让宿主认不出「上下文溢出」,丢掉了自愈能力(issue #101)** —— 上游这个码有两种报文:形态 A 带 `prompt is too long: N tokens > M maximum`,形态 B 只有一句中文「本次输入加上历史对话超过了模型上限」。而宿主是靠**英文正则**(25 条 pattern,第一条就是 `prompt (?:is )?too long`)判断要不要跑压缩救援的——中文一句都不命中。于是形态 B 既不压缩也不重试,直接把 400 甩给用户,连它自己那句「请精简内容」的建议宿主都无法执行。
86
+ - 修法:错误文案前面加一句 `prompt is too long (upstream code 11115)`,上游原文紧跟在后面。机器拿到信号,人仍然看到网关自己的建议。
87
+ - **不编造 token 数字**:形态 B 根本没给数字,写一个「N tokens > M」正是这个仓库一直在避免的那种「自信的错误诊断」。
88
+ - `isContextTooLongBody()` 用带边界的正则,`111150` / `1111` 不会被误判成溢出——误判会让宿主跑一轮毫无用处的压缩。
89
+ - 分类不变:仍是 `client`,仍然不换号(溢出是请求的问题,换账号只会每个账号烧一次)。
90
+
91
+ ### Tests
92
+
93
+ - `tests/issue101-context-overflow.spec.ts`(8 条,新增):两种形态都识别、相邻码不误伤、marker 命中宿主 pattern 且不被误认为限流、不编造数字、分类不变;外加一条**端到端**用例真的把一次 11115 请求驱过 shim,读宿主会看到的那句文案,确认 marker 在、网关原文在、requestId 在。
94
+ - `tests/issue59-cross-region-thinking.spec.ts`(12 条,改写 1 条 + 新增 5 条)。
95
+ - 全量 `npx vitest run` = **108 文件 / 1773 测试全绿**;`tsc` 两个 project 均 exit 0;`npm run build` 成功。
96
+
3
97
  ## 5.0.1 (2026-10-22)
4
98
 
5
99
  > 版本号是 **patch**:改卡面上的一个字符串(标题)、去掉模型列表的一条 CSS 规则与两行路由项,以及几处描述它们的文档。没有接口或配置结构的变更——但如果你的配置里存着 `auto` / `default-model`,升级后它不再出现在列表里。
package/README.md CHANGED
@@ -43,7 +43,7 @@
43
43
  - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
44
44
  - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
45
45
  - **模型行上的繁忙程度** —— 上游另有一条只读探针(`POST /v2/chat/busy`,一次最多 50 个 id,免费、不动账号状态)回答「这个模型此刻多忙」,插件在每次刷新目录时顺带问一次,把等级拼在倍率后面:`Hy4 preview · x0.29 · 极度拥堵`。**只画 `busy` / `congested` / `unavailable`**(轻微拥堵 / 极度拥堵 / 不可用):`idle` 有词但不画——实测 20 个模型里 7 个空闲,每行都挂就成了装饰而不是警告;`normal` 与探针没回答的 `unknown` 一律不画,**「没被告知」不会被当成「一切正常」**。**等级不落盘**:目录是能活过重启的事实,负载只是那一分钟的事实,所以等级只盖在运行时目录上、随进程消失,探针失败只记一条 warn,绝不挡住目录刷新。实测这条探针在 CLI 与 App 两种身份下答案逐字节相同,50 个 id 的上限也真实存在(62 个 → HTTP 400),19 个模型 186 ms 返回。
46
- - **排队时说清楚你排在第几位** —— 上游用三个业务码表达排队(`6020` 等待、`6021` 队列满、`6022` 已达上限)。撞上它们时,插件不再给一句上游报错,而是「模型排队中:你排在第 8777 位,队列共 9021 个请求;约 7 秒后自动重试」。**排队不会触发换号**:队列跟随的是模型与平台级的队伍,换个账号拿到的还是同一个位次,所以这里既不走账号池也不烧额外请求(上游 `/v2/chat/queue/status` 回的 `message` 是 i18n key 而不是人话,插件因此从不转述它,只用自己的措辞)。**「排到之后自动重发」这一半没有实现**——它需要排队答案在 OpenAI 兼容通道上的真实信封,没测到就不写。
46
+ - **排队时说清楚你排在第几位** —— 上游用三个业务码表达排队(`6020` 等待、`6021` 队列满、`6022` 已达上限)。撞上它们时,插件不再给一句上游报错,而是「模型排队中:你排在第 8777 位,队列共 9021 个请求。请稍后重新发送这条消息,或换一个模型」。**排队不会触发换号**:队列跟随的是模型与平台级的队伍,换个账号拿到的还是同一个位次,所以这里既不走账号池也不额外烧请求(上游 `/v2/chat/queue/status` 回的 `message` 是 i18n key 而不是人话,插件因此从不转述它,只用自己的措辞)。**排队不会自动重发,也不会被自动重试**——插件没有排队轮询循环(它需要排队答案在 OpenAI 兼容通道上的真实信封,没测到就不写),并且明确告诉宿主不要重试(`x-should-retry: false`):一次排队在几千位之后,重试十几次也轮不到,只会把真实请求白白花掉。位次报出来,由你决定是重发还是换模型。
47
47
  - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
48
48
 
49
49
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件按模型的**原生窗口**向 DSH 宣称(3.5.0 起不再默认压到 200K),所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。想让 DSH 更早压缩,就在模型行上手动设一个「DSH 上下文预算」上限(可选 200K / 500K / 模型原生窗口)。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, I as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, ht as WORKBUDDY_APP_EXECUTABLE_ENV, k as legacyWorkbuddyOwnAuthPath, kt as hostPeersInfo, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, w as defaultDesktopAuthDirs, xt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-DtMzOwZr.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-r1M_OXJI.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;
@@ -4294,10 +4305,12 @@ window.__ModuleLoader__.load({
4294
4305
  meta line, because a cooldown sentence ("rate limited - the upstream gave no
4295
4306
  time") does not fit next to the context/output chips, and truncating it would
4296
4307
  hide exactly the part the user needs. */
4297
- /* Three buttons now live here (refresh + the two sweeps), and the sweep labels are
4298
- long in Chinese (一键测试所有模型). flex:none alone let the group run past the
4299
- header on a narrow card, so the group may WRAP and each label refuses to break
4300
- 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. */
4301
4314
  .dsm-workbuddy-models-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
4302
4315
  .dsm-workbuddy-models-head-actions .dsm-btn{white-space:nowrap}
4303
4316
  /* The sweep buttons keep the outline weight of the refresh button beside them, so
@@ -5215,6 +5228,37 @@ window.__ModuleLoader__.load({
5215
5228
  const remaining = remainingText(t, untilMs, nowMs);
5216
5229
  return remaining === "" ? "" : ` · ${remaining}`;
5217
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;
5218
5262
  function probeResultView(result, t, nowMs = Date.now()) {
5219
5263
  switch (result.outcome) {
5220
5264
  case "ok": {
@@ -5347,6 +5391,26 @@ window.__ModuleLoader__.load({
5347
5391
  const probesRef = (0, react.useRef)({});
5348
5392
  /** Progress of an in-flight sweep, or undefined when none is running. */
5349
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)({});
5350
5414
  /** The last sweep's tally, cleared when the next one starts. */
5351
5415
  const [sweepSummary, setSweepSummary] = (0, react.useState)(void 0);
5352
5416
  const mounted = (0, react.useRef)(true);
@@ -6008,6 +6072,83 @@ window.__ModuleLoader__.load({
6008
6072
  sweepModels(visibleModels.filter((model) => activeEnabledIds.has(model.id)).map((model) => model.id));
6009
6073
  };
6010
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
+ /**
6011
6152
  * Sweep EVERY model in the region's live catalog, enabled or not.
6012
6153
  *
6013
6154
  * Deliberately the whole VISIBLE catalog and nothing else: those are the ids
@@ -6469,6 +6610,17 @@ window.__ModuleLoader__.load({
6469
6610
  done: sweeping.done,
6470
6611
  total: sweeping.total
6471
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
+ })
6472
6624
  })
6473
6625
  ]
6474
6626
  })]
@@ -6579,9 +6731,9 @@ window.__ModuleLoader__.load({
6579
6731
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })
6580
6732
  ]
6581
6733
  }),
6582
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6583
6735
  className: "dsm-workbuddy-model-actions-buttons",
6584
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6736
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6585
6737
  type: "button",
6586
6738
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
6587
6739
  disabled: probing[model.id] === true || status.status !== "signed-in",
@@ -6590,7 +6742,16 @@ window.__ModuleLoader__.load({
6590
6742
  probeModels([model.id]);
6591
6743
  },
6592
6744
  children: probing[model.id] === true ? t("row.probing") : t("row.probe")
6593
- })
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
+ })]
6594
6755
  })
6595
6756
  ]
6596
6757
  }),
@@ -6616,6 +6777,16 @@ window.__ModuleLoader__.load({
6616
6777
  ...result.message === void 0 ? {} : { title: result.message },
6617
6778
  children: [view.text, reason === void 0 ? null : " · " + reason]
6618
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
+ });
6619
6790
  })()
6620
6791
  ]
6621
6792
  }, model.id))
@@ -7885,6 +8056,21 @@ window.__ModuleLoader__.load({
7885
8056
  "row.modelsTesting": "Testing {done}/{total}…",
7886
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.",
7887
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}",
7888
8074
  "row.modelsTestDone": "Done: {available} of {total} available",
7889
8075
  "row.modelsTestAuth": "Stopped: the credential was rejected, so every remaining model would answer the same way. Sign in again, then re-run.",
7890
8076
  "row.probe": "Test",
@@ -8229,6 +8415,21 @@ window.__ModuleLoader__.load({
8229
8415
  "row.modelsTesting": "测试中 {done}/{total}…",
8230
8416
  "row.modelsTestEnabledHint": "逐个测试你**已启用**的模型,一个模型一次请求 —— 每次探测都发真实体积的请求,真花积分(默认 25000 token 体积下约 0.01/个)。每行的结果一出来就显示。凭据被拒时提前停止。",
8231
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} 测",
8232
8433
  "row.modelsTestDone": "完成:{total} 个里 {available} 个可用",
8233
8434
  "row.modelsTestAuth": "已停止:凭据被拒,剩下的模型都会得到同一个答案。请重新登录后再跑一次。",
8234
8435
  "row.probe": "测试",
@@ -1045,8 +1045,6 @@ const WORKBUDDY_QUEUE_CODES = [
1045
1045
  6021,
1046
1046
  6022
1047
1047
  ];
1048
- /** The app's default when the upstream sends no `retry_after` (`?? 5`). */
1049
- const WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC = 5;
1050
1048
  /** True when a business code is one of the app's three queue codes. */
1051
1049
  function isQueueCode(value) {
1052
1050
  return typeof value === "number" && WORKBUDDY_QUEUE_CODES.includes(value);
@@ -1058,12 +1056,10 @@ function reportFrom(code, data) {
1058
1056
  const numberField = (key) => typeof body[key] === "number" && Number.isFinite(body[key]) ? body[key] : void 0;
1059
1057
  const position = numberField("queue_position");
1060
1058
  const size = numberField("queue_size");
1061
- const retryAfter = numberField("retry_after");
1062
1059
  return {
1063
1060
  code,
1064
1061
  ...position === void 0 ? {} : { position },
1065
- ...size === void 0 ? {} : { size },
1066
- retryAfterSec: retryAfter !== void 0 && retryAfter > 0 ? retryAfter : WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC
1062
+ ...size === void 0 ? {} : { size }
1067
1063
  };
1068
1064
  }
1069
1065
  /**
@@ -1121,9 +1117,22 @@ function readEnvelope$1(value) {
1121
1117
  *
1122
1118
  * `waiting` names both numbers because they answer different questions: the
1123
1119
  * POSITION is how long the wait is, the SIZE is whether it is moving at all.
1120
+ *
1121
+ * IT NO LONGER PROMISES "自动重试" (removed 2026-10-22, at the maintainer's
1122
+ * request). The sentence used to end with 「约 N 秒后自动重试」, and nothing
1123
+ * retried: this plugin does not poll `/v2/chat/queue/status` and does not resend
1124
+ * (see "WHAT THIS MODULE DOES NOT DO YET" above). The only retry that ever
1125
+ * happened was the HOST's own bounded in-place retry on a 429 — two attempts,
1126
+ * 0.5s then 1s, about 1.5 seconds — which is not "N seconds" and gives up long
1127
+ * before a real queue of thousands moves.
1128
+ *
1129
+ * So the sentence now states the WAIT and leaves the next move to the reader.
1130
+ * Reporting the position stays honest and useful — knowing you are 8,777th is
1131
+ * the answer to "wait or switch" — but claiming a recovery the plugin does not
1132
+ * perform turned a diagnostic into a promise it could not keep.
1124
1133
  */
1125
1134
  function queueNoticeText(report) {
1126
- if (report.code === 6020) return `模型排队中:你排在第 ${report.position === void 0 ? "未知" : `${report.position}`} 位${report.size === void 0 ? "" : `,队列共 ${report.size} 个请求`};约 ${report.retryAfterSec} 秒后自动重试`;
1135
+ if (report.code === 6020) return `模型排队中:你排在第 ${report.position === void 0 ? "未知" : `${report.position}`} 位${report.size === void 0 ? "" : `,队列共 ${report.size} 个请求`}。请稍后重新发送这条消息,或换一个模型`;
1127
1136
  if (report.code === 6021) return `模型队列已满${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},现在无法排队;请稍后重试或换一个模型`;
1128
1137
  return `你的排队任务已达上限${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},请等待已排队请求完成后再试`;
1129
1138
  }
@@ -1997,6 +2006,59 @@ const MODEL_BUSY_CODE = "\"code\":14003";
1997
2006
  */
1998
2007
  const CHANNEL_REJECT_CODE = "\"code\":11128";
1999
2008
  /**
2009
+ * Business code for "the request exceeded the model's context window" (issue #101).
2010
+ *
2011
+ * The gateway answers `11115` in TWO shapes, and only one of them is
2012
+ * self-describing:
2013
+ *
2014
+ * A. `prompt is too long: 100001 tokens > 100000 maximum` — carries numbers;
2015
+ * B. the localized sentence only (「本次输入加上历史对话超过了模型上限…」), no numbers.
2016
+ *
2017
+ * Shape B is the one that hurt. The HOST decides whether to run its
2018
+ * context-overflow rescue by matching the error text against a list of ENGLISH
2019
+ * patterns (`prompt (?:is )?too long`, `exceeds the context window`,
2020
+ * `context[_ ]length[_ ]exceeded`, …25 of them), and a Chinese sentence matches
2021
+ * none of them. So on shape B the host neither compacts nor retries: the user
2022
+ * gets a bare 400 whose own advice ("请精简内容") the host cannot act on.
2023
+ *
2024
+ * {@link contextOverflowMarker} is the fix: the plugin adds an English clause
2025
+ * the host recognises, and keeps the upstream's own sentence beside it.
2026
+ */
2027
+ /**
2028
+ * The business code, in either of the two spellings the plugin ever holds.
2029
+ *
2030
+ * Two forms are in circulation and BOTH reach this predicate:
2031
+ * - the raw upstream body: `{"code":11115,...}`;
2032
+ * - the reply this plugin already composed, which renders it as
2033
+ * `(code 11115,requestId …)` — the form the retry path re-reads.
2034
+ *
2035
+ * The trailing `(?!\d)` matters: `111150` and `1111` both contain the digits
2036
+ * after a naive substring test, and misreading a DIFFERENT refusal as an
2037
+ * overflow would send the host into a compaction pass that cannot help.
2038
+ */
2039
+ const CONTEXT_TOO_LONG_CODE = /"code"\s*:\s*11115(?!\d)|code\s+11115(?!\d)/u;
2040
+ /**
2041
+ * Whether this body is the context-length refusal, in EITHER of its shapes.
2042
+ *
2043
+ * Shape B carries no number and no English phrase, so the business code is the
2044
+ * only reliable handle — the same reasoning as {@link isChannelRejectBody}'s
2045
+ * code-plus-wording pair, except that here the wording is what VARIES.
2046
+ */
2047
+ function isContextTooLongBody(body) {
2048
+ return CONTEXT_TOO_LONG_CODE.test(body);
2049
+ }
2050
+ /**
2051
+ * An English clause the HOST's overflow detector recognises, for issue #101.
2052
+ *
2053
+ * `prompt is too long` is the first entry of the host's pattern list and the
2054
+ * phrasing Anthropic-compatible gateways already use, so it is the cheapest way
2055
+ * to say "this is an overflow" to a detector that only reads English. The
2056
+ * numbers are omitted deliberately: the upstream never gave them in shape B, and
2057
+ * inventing them would be the exact confident-wrong-diagnosis this plugin spends
2058
+ * its comments avoiding.
2059
+ */
2060
+ const CONTEXT_OVERFLOW_MARKER = "prompt is too long (upstream code 11115)";
2061
+ /**
2000
2062
  * Wording that identifies the CHANNEL half of `11128` (the other halves being
2001
2063
  * `conversation id missing` and `first message is not system prompt`).
2002
2064
  *
@@ -5799,7 +5861,7 @@ function generationOf(version) {
5799
5861
  //#endregion
5800
5862
  //#region src/version.ts
5801
5863
  /** The npm package version this build was produced from. */
5802
- const WORKBUDDY_CONNECT_VERSION = "5.0.1";
5864
+ const WORKBUDDY_CONNECT_VERSION = "5.2.2";
5803
5865
  //#endregion
5804
5866
  //#region src/host-heartbeat.ts
5805
5867
  /**
@@ -5957,4 +6019,4 @@ async function clearHostHeartbeat() {
5957
6019
  await rm(workbuddyHostHeartbeatPath(), { force: true });
5958
6020
  }
5959
6021
  //#endregion
5960
- export { busyDisplaySuffix as $, parseWorkBuddyAuth as A, resolveDshHome as At, isConversationIdGateBody as B, defaultDesktopAuthCandidates as C, isWorkbuddyBundle as Ct, isEncryptedCredentialError as D, readAtRestKey as Dt, hasEncryptedCredentialFields as E, openEncryptedField as Et, WorkBuddyCredentialRejectedError as F, openingEvidenceOfBody as G, isFirstMessageGateBody as H, WorkBuddyUpstreamClient as I, parseUpstreamModel as J, parseCreditMultiplier as K, classifyUpstreamError as L, workbuddyOwnAuthPath as M, writeFileAtomic as Mt, CREDENTIAL_REJECTED_CODE as N, isVaultAccountId as O, workbuddyAppExecutableCandidates as Ot, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as P, attachBusyLevels as Q, declaredTools as R, authFileName as S, isEncryptedFieldWrapper as St, defaultDesktopAuthPath as T, macosNestedAppBundles as Tt, knownRegionOf as U, isCredentialRejectedError as V, modelIdOfBody as W, prepareChatBody as X, perturbChannelFingerprints as Y, regionOf as Z, ENCRYPTED_CREDENTIAL_CODE as _, deriveAtRestKey as _t, readHostHeartbeat as a, recordRefusal as at, WorkBuddyCredentialStore as b, findWorkbuddyAppExecutable as bt, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyImageError as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, queueNoticeText as dt, REFUSES_OFF_MODEL_IDS as et, WorkBuddyCatalog as f, NATIVE_MODALITY_BY_MODEL_ID as ft, withoutRouters as g, clearAtRestKeyCache as gt, fallbackModelsFor as h, WORKBUDDY_APP_EXECUTABLE_ENV as ht, processStartTimeMs as i, offRefused as it, workbuddyAccountId as j, withFileLock as jt, legacyWorkbuddyOwnAuthPath as k, hostPeersInfo as kt, piAiRuntimeInfo as l, pickImageModel as lt, deriveCatalog as m, nativeModalityOf as mt, clearHostHeartbeat as n, lastRefusalFor as nt, workbuddyHostHeartbeatPath as o, withEffectiveOff as ot, applyContextBudgets as p, imageDefaultFor as pt, parseReasoning as q, isHeartbeatProcessAlive as r, offDefaultFor as rt, writeHostHeartbeat as s, DEFAULT_IMAGE_SIZE as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, defaultOffModelIds as tt, FALLBACK_WORKBUDDY_MODELS as u, parseQueueAnswer as ut, WORKBUDDY_AUTH_FILENAME as v, deriveAtRestKeyId as vt, defaultDesktopAuthDirs as w, macosBundleExecutable as wt, WorkBuddyEncryptedCredentialError as x, findWorkbuddyAppExecutableWithSource as xt, WORKBUDDY_AUTH_FILE_ENV as y, fetchAtRestKeyPayload as yt, forceNeutralHead as z };
6022
+ export { regionOf as $, parseWorkBuddyAuth as A, workbuddyAppExecutableCandidates as At, forceNeutralHead as B, defaultDesktopAuthCandidates as C, findWorkbuddyAppExecutableWithSource as Ct, isEncryptedCredentialError as D, macosNestedAppBundles as Dt, hasEncryptedCredentialFields as E, macosBundleExecutable as Et, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as F, knownRegionOf as G, isConversationIdGateBody as H, WorkBuddyCredentialRejectedError as I, parseCreditMultiplier as J, modelIdOfBody as K, WorkBuddyUpstreamClient as L, workbuddyOwnAuthPath as M, resolveDshHome as Mt, CONTEXT_OVERFLOW_MARKER as N, withFileLock as Nt, isVaultAccountId as O, openEncryptedField as Ot, CREDENTIAL_REJECTED_CODE as P, writeFileAtomic as Pt, prepareChatBody as Q, classifyUpstreamError as R, authFileName as S, findWorkbuddyAppExecutable as St, defaultDesktopAuthPath as T, isWorkbuddyBundle as Tt, isCredentialRejectedError as U, isContextTooLongBody as V, isFirstMessageGateBody as W, parseUpstreamModel as X, parseReasoning as Y, perturbChannelFingerprints as Z, ENCRYPTED_CREDENTIAL_CODE as _, WORKBUDDY_APP_EXECUTABLE_ENV as _t, readHostHeartbeat as a, offDefaultFor as at, WorkBuddyCredentialStore as b, deriveAtRestKeyId as bt, WORKBUDDY_CONNECT_VERSION as c, withEffectiveOff as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, pickImageModel as dt, attachBusyLevels as et, WorkBuddyCatalog as f, parseQueueAnswer as ft, withoutRouters as g, nativeModalityOf as gt, fallbackModelsFor as h, imageDefaultFor as ht, processStartTimeMs as i, lastRefusalFor as it, workbuddyAccountId as j, hostPeersInfo as jt, legacyWorkbuddyOwnAuthPath as k, readAtRestKey as kt, piAiRuntimeInfo as l, DEFAULT_IMAGE_SIZE as lt, deriveCatalog as m, NATIVE_MODALITY_BY_MODEL_ID as mt, clearHostHeartbeat as n, REFUSES_OFF_MODEL_IDS as nt, workbuddyHostHeartbeatPath as o, offRefused as ot, applyContextBudgets as p, queueNoticeText as pt, openingEvidenceOfBody as q, isHeartbeatProcessAlive as r, defaultOffModelIds as rt, writeHostHeartbeat as s, recordRefusal as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, busyDisplaySuffix as tt, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyImageError as ut, WORKBUDDY_AUTH_FILENAME as v, clearAtRestKeyCache as vt, defaultDesktopAuthDirs as w, isEncryptedFieldWrapper as wt, WorkBuddyEncryptedCredentialError as x, fetchAtRestKeyPayload as xt, WORKBUDDY_AUTH_FILE_ENV as y, deriveAtRestKey as yt, declaredTools as z };
package/lib/index.d.ts CHANGED
@@ -2770,6 +2770,17 @@ declare const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
2770
2770
  * browser half.
2771
2771
  */
2772
2772
  declare const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
2773
+ /**
2774
+ * Plugin-owned OUTPUT-SPEED measurement endpoint (issue #92).
2775
+ *
2776
+ * Separate from {@link WORKBUDDY_PROBE_PATH} because the two ask opposite
2777
+ * questions and cost different amounts: the availability probe sends 25,000
2778
+ * input tokens and caps output at one (measured `credit 0.72`), while this one
2779
+ * sends ~35 input tokens and caps output at 256 (measured `credit 0.01`–`0.15`).
2780
+ * Sharing the route would mean sharing its one-model-per-request cap and its
2781
+ * cost wording, and the two would then be wrong for each other.
2782
+ */
2783
+ declare const WORKBUDDY_TPS_PATH = "/plugins/dsh-connect-workbuddy/tps";
2773
2784
  /**
2774
2785
  * One entry of the transfer PREVIEW: display fields only, never token
2775
2786
  * material. `hasToken` is the import decision input — the import step skips
@@ -3265,6 +3276,33 @@ interface WorkBuddyWebProbeResult {
3265
3276
  /** Which of those four sources supplied {@link retryAtMs}. */
3266
3277
  retrySource?: 'retry-after' | 'upstream-message' | 'quota-refresh' | 'upstream-tips';
3267
3278
  }
3279
+ /**
3280
+ * One model's output-speed result, as the card renders it (issue #92).
3281
+ *
3282
+ * Deliberately its own type rather than extra fields on
3283
+ * {@link WorkBuddyWebProbeResult}: the two runs answer different questions, and
3284
+ * a shared type would let a card render an availability verdict with a speed on
3285
+ * it, or vice versa, with nothing to stop it.
3286
+ */
3287
+ interface WorkBuddyWebTpsResult {
3288
+ modelId: string;
3289
+ /** `ok` when at least one run answered; the speed fields say what was learned. */
3290
+ outcome: 'ok' | 'unavailable' | 'credential-rejected' | 'failed';
3291
+ /** Median output tokens/second over the usable runs. */
3292
+ tokensPerSecond?: number;
3293
+ /** Median time to first content chunk, in ms. */
3294
+ timeToFirstTokenMs?: number;
3295
+ /** Output tokens the upstream counted for the fastest usable run. */
3296
+ outputTokens?: number;
3297
+ /** How many runs contributed; below the planned count when runs were lost. */
3298
+ samples: number;
3299
+ /** Why {@link tokensPerSecond} is absent, when it is. */
3300
+ reason?: 'few-chunks' | 'no-usage' | 'all-failed';
3301
+ /** Total credit the runs reported, rounded to 2dp. */
3302
+ credit?: number;
3303
+ /** Upstream's own words when the run failed outright. */
3304
+ message?: string;
3305
+ }
3268
3306
  /** Why one account is currently out of the pool. */
3269
3307
  type WorkBuddyWebPoolExclusion = 'unusable' | 'rate-limited' |
3270
3308
  /**
@@ -3605,6 +3643,21 @@ interface WorkBuddyStatusRouteOptions {
3605
3643
  probeModels?(region: WorkBuddyRegion, modelIds: readonly string[], options?: {
3606
3644
  signal?: AbortSignal;
3607
3645
  }): Promise<readonly WorkBuddyWebProbeResult[]>;
3646
+ /**
3647
+ * Measure OUTPUT SPEED for each named model of a region (issue #92).
3648
+ *
3649
+ * Separate from {@link probeModels} because it answers the opposite question
3650
+ * with the opposite request shape: tiny input, long output. The availability
3651
+ * probe's `max_tokens: 1` means its `elapsedMs` is time-to-first-token and
3652
+ * carries no rate information at all.
3653
+ *
3654
+ * Costs a fraction of a probe per model (measured `credit 0.01`–`0.15` against
3655
+ * `0.72`), because this gateway prices by INPUT. It still spends real credit,
3656
+ * so it stays one model per click like every other spending route.
3657
+ */
3658
+ measureTps?(region: WorkBuddyRegion, modelIds: readonly string[], options?: {
3659
+ signal?: AbortSignal;
3660
+ }): Promise<readonly WorkBuddyWebTpsResult[]>;
3608
3661
  /**
3609
3662
  * The region's next monthly quota refresh, when the upstream declares one.
3610
3663
  *
@@ -4263,4 +4316,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
4263
4316
  */
4264
4317
  declare function apply(ctx: Context, config: Config): void;
4265
4318
  //#endregion
4266
- 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 };
4319
+ 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_TPS_PATH, 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,4 +1,4 @@
1
- import { $ as busyDisplaySuffix, A as parseWorkBuddyAuth, At as resolveDshHome, B as isConversationIdGateBody, C as defaultDesktopAuthCandidates, Ct as isWorkbuddyBundle, D as isEncryptedCredentialError, Dt as readAtRestKey, E as hasEncryptedCredentialFields, Et as openEncryptedField, F as WorkBuddyCredentialRejectedError, G as openingEvidenceOfBody, H as isFirstMessageGateBody, I as WorkBuddyUpstreamClient, J as parseUpstreamModel, K as parseCreditMultiplier, L as classifyUpstreamError, M as workbuddyOwnAuthPath, Mt as writeFileAtomic, N as CREDENTIAL_REJECTED_CODE, O as isVaultAccountId, Ot as workbuddyAppExecutableCandidates, P as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Q as attachBusyLevels, R as declaredTools, S as authFileName, St as isEncryptedFieldWrapper, T as defaultDesktopAuthPath, Tt as macosNestedAppBundles, U as knownRegionOf, V as isCredentialRejectedError, W as modelIdOfBody, X as prepareChatBody, Y as perturbChannelFingerprints, Z as regionOf, _ as ENCRYPTED_CREDENTIAL_CODE, _t as deriveAtRestKey, a as readHostHeartbeat, at as recordRefusal, b as WorkBuddyCredentialStore, bt as findWorkbuddyAppExecutable, c as WORKBUDDY_CONNECT_VERSION, ct as WorkBuddyImageError, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as queueNoticeText, et as REFUSES_OFF_MODEL_IDS, f as WorkBuddyCatalog, ft as NATIVE_MODALITY_BY_MODEL_ID, g as withoutRouters, gt as clearAtRestKeyCache, h as fallbackModelsFor, ht as WORKBUDDY_APP_EXECUTABLE_ENV, i as processStartTimeMs, it as offRefused, j as workbuddyAccountId, jt as withFileLock, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, lt as pickImageModel, m as deriveCatalog, mt as nativeModalityOf, n as clearHostHeartbeat, nt as lastRefusalFor, o as workbuddyHostHeartbeatPath, ot as withEffectiveOff, p as applyContextBudgets, pt as imageDefaultFor, q as parseReasoning, r as isHeartbeatProcessAlive, rt as offDefaultFor, s as writeHostHeartbeat, st as DEFAULT_IMAGE_SIZE, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as defaultOffModelIds, u as FALLBACK_WORKBUDDY_MODELS, ut as parseQueueAnswer, v as WORKBUDDY_AUTH_FILENAME, vt as deriveAtRestKeyId, w as defaultDesktopAuthDirs, wt as macosBundleExecutable, x as WorkBuddyEncryptedCredentialError, y as WORKBUDDY_AUTH_FILE_ENV, yt as fetchAtRestKeyPayload, z as forceNeutralHead } from "./host-heartbeat-DtMzOwZr.js";
1
+ import { $ as regionOf, A as parseWorkBuddyAuth, At as workbuddyAppExecutableCandidates, B as forceNeutralHead, C as defaultDesktopAuthCandidates, D as isEncryptedCredentialError, Dt as macosNestedAppBundles, E as hasEncryptedCredentialFields, Et as macosBundleExecutable, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, G as knownRegionOf, H as isConversationIdGateBody, I as WorkBuddyCredentialRejectedError, J as parseCreditMultiplier, K as modelIdOfBody, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as resolveDshHome, N as CONTEXT_OVERFLOW_MARKER, Nt as withFileLock, O as isVaultAccountId, Ot as openEncryptedField, P as CREDENTIAL_REJECTED_CODE, Pt as writeFileAtomic, Q as prepareChatBody, R as classifyUpstreamError, S as authFileName, St as findWorkbuddyAppExecutable, T as defaultDesktopAuthPath, Tt as isWorkbuddyBundle, U as isCredentialRejectedError, V as isContextTooLongBody, W as isFirstMessageGateBody, X as parseUpstreamModel, Y as parseReasoning, Z as perturbChannelFingerprints, _ as ENCRYPTED_CREDENTIAL_CODE, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, at as offDefaultFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKeyId, c as WORKBUDDY_CONNECT_VERSION, ct as withEffectiveOff, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as pickImageModel, et as attachBusyLevels, f as WorkBuddyCatalog, ft as parseQueueAnswer, g as withoutRouters, gt as nativeModalityOf, h as fallbackModelsFor, ht as imageDefaultFor, i as processStartTimeMs, it as lastRefusalFor, j as workbuddyAccountId, k as legacyWorkbuddyOwnAuthPath, kt as readAtRestKey, l as piAiRuntimeInfo, lt as DEFAULT_IMAGE_SIZE, m as deriveCatalog, mt as NATIVE_MODALITY_BY_MODEL_ID, n as clearHostHeartbeat, nt as REFUSES_OFF_MODEL_IDS, o as workbuddyHostHeartbeatPath, ot as offRefused, p as applyContextBudgets, pt as queueNoticeText, q as openingEvidenceOfBody, r as isHeartbeatProcessAlive, rt as defaultOffModelIds, s as writeHostHeartbeat, st as recordRefusal, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as busyDisplaySuffix, u as FALLBACK_WORKBUDDY_MODELS, ut as WorkBuddyImageError, v as WORKBUDDY_AUTH_FILENAME, vt as clearAtRestKeyCache, w as defaultDesktopAuthDirs, wt as isEncryptedFieldWrapper, x as WorkBuddyEncryptedCredentialError, xt as fetchAtRestKeyPayload, y as WORKBUDDY_AUTH_FILE_ENV, yt as deriveAtRestKey, z as declaredTools } from "./host-heartbeat-r1M_OXJI.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -589,16 +589,80 @@ function foldModernContext(context) {
589
589
  function transcript(context) {
590
590
  return context;
591
591
  }
592
- function withLegacyContext(api, generation) {
592
+ /**
593
+ * Rewrite every historical assistant message to the identity this adapter stamps.
594
+ *
595
+ * ISSUE #102. pi-ai replays a thinking block as `thinking` only when the message
596
+ * that produced it names the same `provider` / `api` / `model` as the request in
597
+ * flight (`utils/transform-messages.js`, the `isSameModel` branch); everything
598
+ * else is flattened to `{type:'text', text: thinking}`.
599
+ *
600
+ * The durable transcript the HOST hands back does not carry the identity. The
601
+ * agent loop writes `source.provider = request.provider`, and `request.provider`
602
+ * is the ROUTE id (`workbuddy` / `workbuddy-global`), while this adapter stamps
603
+ * `provider: WORKBUDDY_MODEL_IDENTITY` on the model. So:
604
+ *
605
+ * - CN sessions survive by COINCIDENCE: its route id happens to equal the
606
+ * identity (`workbuddy` === `workbuddy`).
607
+ * - GLOBAL sessions never match (`workbuddy-global` !== `workbuddy`), so every
608
+ * historical thinking block is downgraded on EVERY turn — not only when the
609
+ * user switches regions, which is what #59's write-up assumed.
610
+ *
611
+ * The model then sees a context in which all of its own past reasoning was
612
+ * plain assistant text, and writes its new reasoning into `text` to match. That
613
+ * is the reported symptom: reasoning collapses to a character or two while the
614
+ * answer channel fills with working notes.
615
+ *
616
+ * WHY REWRITING IS SAFE. Every message here was produced by THIS plugin, in THIS
617
+ * region — the transcript never contains another provider's assistant turns
618
+ * (`dsh-llm` splits history by provider before the API sees it). The identity is
619
+ * already the field this adapter stamps on the models it serves, and it resolves
620
+ * as a registry key in both regions (issue #75). Nothing about routing, billing
621
+ * or the destination changes: `baseUrl` comes from the model, not from the
622
+ * message.
623
+ *
624
+ * Only `provider` is rewritten. `api` is left alone on purpose: a legacy
625
+ * (`dsh-foreign`) message carries an api this plugin never sends, and claiming
626
+ * `openai-completions` for it would assert a wire shape that message was never
627
+ * built for. The `provider` mismatch is what decides the downgrade, and fixing
628
+ * it is enough — `isSameModel` still requires the model id and api to match, so
629
+ * a genuinely different model keeps degrading (covered by a test).
630
+ *
631
+ * Returns the same object when nothing changed, so the common case (an
632
+ * already-normalized transcript) allocates nothing.
633
+ */
634
+ function alignAssistantIdentities(context, identity = WORKBUDDY_MODEL_IDENTITY) {
635
+ const messages = context.messages;
636
+ if (!Array.isArray(messages) || messages.length === 0) return context;
637
+ let changed = false;
638
+ const next = messages.map((message) => {
639
+ if (typeof message !== "object" || message === null) return message;
640
+ const record = message;
641
+ if (record["role"] !== "assistant") return message;
642
+ if (record["provider"] === identity) return message;
643
+ changed = true;
644
+ return {
645
+ ...record,
646
+ provider: identity
647
+ };
648
+ });
649
+ return changed ? {
650
+ ...context,
651
+ messages: next
652
+ } : context;
653
+ }
654
+ function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY) {
655
+ /** One place so both branches and both entry points agree on the order. */
656
+ const prepare = (context) => alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity);
593
657
  if (generation === "modern") return {
594
658
  ...api,
595
- stream: (model, context, options) => api.stream(model, transcript(foldModernContext(context)), options),
596
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(foldModernContext(context)), options)
659
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
660
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
597
661
  };
598
662
  return {
599
663
  ...api,
600
- stream: (model, context, options) => api.stream(model, transcript(adaptLegacyPiAiContext(context)), options),
601
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(adaptLegacyPiAiContext(context)), options)
664
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
665
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
602
666
  };
603
667
  }
604
668
  /**
@@ -776,7 +840,7 @@ function createWorkBuddyAdapter(options) {
776
840
  }
777
841
  } },
778
842
  models: buildModels(),
779
- api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation), options.resolveContextRescue)
843
+ api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation, modelIdentity), options.resolveContextRescue)
780
844
  }),
781
845
  getModels: () => buildModels()
782
846
  };
@@ -2370,11 +2434,12 @@ const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
2370
2434
  function hostErrorCode(kind) {
2371
2435
  return KIND_HOST_CODE[kind] ?? kind;
2372
2436
  }
2373
- function writeJson(res, status, body) {
2437
+ function writeJson(res, status, body, headers) {
2374
2438
  const payload = JSON.stringify(body);
2375
2439
  res.writeHead(status, {
2376
2440
  "Content-Type": "application/json",
2377
- "Content-Length": Buffer.byteLength(payload)
2441
+ "Content-Length": Buffer.byteLength(payload),
2442
+ ...headers
2378
2443
  });
2379
2444
  res.end(payload);
2380
2445
  }
@@ -2386,13 +2451,38 @@ function writeJson(res, status, body) {
2386
2451
  * must not be disguised as one. The upstream failure path passes an explicit
2387
2452
  * `code` so the host classifies it correctly — see {@link KIND_HOST_CODE}.
2388
2453
  */
2389
- function writeOpenAIError(res, status, kind, message, code) {
2454
+ function writeOpenAIError(res, status, kind, message, code, headers) {
2390
2455
  writeJson(res, status, { error: {
2391
2456
  message,
2392
2457
  type: kind,
2393
2458
  code: code ?? kind
2394
- } });
2459
+ } }, headers);
2395
2460
  }
2461
+ /**
2462
+ * Tell every retrying client NOT to retry this failure (2026-10-22, at the
2463
+ * maintainer's request: the queue's automatic retry is removed).
2464
+ *
2465
+ * `x-should-retry` is a non-standard header that both clients in this stack
2466
+ * check BEFORE their status-based rules — the pinned OpenAI SDK's `shouldRetry`
2467
+ * and pi-ai's `isRetryableProviderError` each return false on "false".
2468
+ *
2469
+ * WHY THE QUEUE MUST NOT BE RETRIED. Without this header a 429 is retried by
2470
+ * construction: `@deepseek-ai/dsh-llm` maps status 429 to the `RATE_LIMIT` code
2471
+ * and `RATE_LIMIT` is in `DEFAULT_RETRYABLE_CODES`, so the HOST runs five
2472
+ * in-place attempts with 0.5s→8s backoff. Measured, that budget totals roughly
2473
+ * 15 seconds — against a queue the upstream reports in thousands. Every one of
2474
+ * those attempts is a real request spent to be told the same position again, and
2475
+ * none of them can clear the queue.
2476
+ *
2477
+ * So the queue is now reported ONCE, with the position intact, and the reader
2478
+ * decides whether to resend or switch models. That is exactly what
2479
+ * {@link queueNoticeText} tells them to do — the two changes are one decision: a
2480
+ * diagnostic that declares itself final must actually be final.
2481
+ */
2482
+ const NO_RETRY_HEADERS = Object.freeze({
2483
+ "x-should-retry": "false",
2484
+ "retry-after": "0"
2485
+ });
2396
2486
  /** Read a request body with a size cap; over-limit bodies fail the request. */
2397
2487
  function readBody(req) {
2398
2488
  return new Promise((resolve, reject) => {
@@ -3193,6 +3283,34 @@ function createWorkBuddyShim(options) {
3193
3283
  const onRequestBody = options.onRequestBody;
3194
3284
  const onUpstreamUsage = options.onUpstreamUsage;
3195
3285
  const SHARED_SECRET = randomBytes(32).toString("base64url");
3286
+ /**
3287
+ * Whether this process's prompts have been PROVEN to need the invisible
3288
+ * perturbation before the gateway will accept them.
3289
+ *
3290
+ * WHY THIS EXISTS (measured 2026-10-22). The retry ladder below is
3291
+ * discover-by-failing: a request goes out unmodified, is refused with `11128`,
3292
+ * and the ladder re-sends a perturbed copy. That is correct the first time and
3293
+ * wasteful every time after — the prompt SHAPE that carries a fingerprinted
3294
+ * substring does not change between turns of a session, so the plugin was
3295
+ * paying one refused request (~209ms measured) on EVERY request, forever.
3296
+ *
3297
+ * So once the perturbation is what made a request succeed, the next request
3298
+ * starts from the perturbed form and skips the doomed first attempt. The
3299
+ * saving is one round trip plus one refused request per turn.
3300
+ *
3301
+ * WHY A PROCESS-LOCAL FLAG AND NOT A CONFIG. The gateway's fingerprint list is
3302
+ * a snapshot the upstream can change at any time, in either direction: a rule
3303
+ * can be withdrawn as easily as added. A persisted flag would therefore
3304
+ * outlive the condition that justified it and could keep perturbing prompts a
3305
+ * gateway no longer objects to. In memory it is forgotten at restart, which is
3306
+ * the natural expiry for "what this gateway refused recently".
3307
+ *
3308
+ * AND IT SELF-CORRECTS. If a perturbed body is EVER refused with the channel
3309
+ * gate, the flag is cleared and the request falls back to the plain ladder —
3310
+ * so a withdrawn rule, or a different model behind the same gateway, cannot
3311
+ * leave the plugin stuck sending a shape that no longer helps.
3312
+ */
3313
+ let channelNeedsPerturbation = false;
3196
3314
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
3197
3315
  function bearerOk(req) {
3198
3316
  const header = req.headers.authorization;
@@ -3326,7 +3444,25 @@ function createWorkBuddyShim(options) {
3326
3444
  }
3327
3445
  const controller = new AbortController();
3328
3446
  res.on("close", () => controller.abort());
3329
- let result = await client.chatStream(credential, prepared, controller.signal);
3447
+ /**
3448
+ * The body to send FIRST, which is the perturbed form once this process has
3449
+ * proven the gateway demands it.
3450
+ *
3451
+ * `perturbChannelFingerprints` is consulted only when the flag is set, so a
3452
+ * fresh process still pays the plain attempt — that is what discovers
3453
+ * whether the gate is on at all (see `channelNeedsPerturbation`).
3454
+ *
3455
+ * The perturbed form is a purely additive transform: it inserts zero-width
3456
+ * spaces and changes no word the model reads, so starting from it is not a
3457
+ * behaviour change, only a saved round trip.
3458
+ */
3459
+ const preferred = channelNeedsPerturbation ? perturbChannelFingerprints(prepared) ?? prepared : prepared;
3460
+ let result = await client.chatStream(credential, preferred, controller.signal);
3461
+ if (channelNeedsPerturbation && !result.ok && result.kind === "channel_reject") {
3462
+ channelNeedsPerturbation = false;
3463
+ logger?.warn("dsh-connect-workbuddy: a perturbed body was refused too; clearing the learned preference and retrying from the plain form");
3464
+ result = await client.chatStream(credential, prepared, controller.signal);
3465
+ }
3330
3466
  /**
3331
3467
  * The retry ladder that does not depend on knowing the fingerprint
3332
3468
  * (issue #98, both halves).
@@ -3374,6 +3510,7 @@ function createWorkBuddyShim(options) {
3374
3510
  logger?.warn("dsh-connect-workbuddy: the channel gate refused the body again; retrying once with an invisible perturbation of the scanned text");
3375
3511
  result = await client.chatStream(credential, rescued, controller.signal);
3376
3512
  retriedWithPerturbation = true;
3513
+ if (result.ok) channelNeedsPerturbation = true;
3377
3514
  }
3378
3515
  }
3379
3516
  }
@@ -3459,7 +3596,7 @@ function createWorkBuddyShim(options) {
3459
3596
  if (refusedReport !== void 0) reportRefusedTier(refusedReport);
3460
3597
  const queue = result.kind === "model_queue" ? parseQueueAnswer(result.message) : void 0;
3461
3598
  if (queue !== void 0) {
3462
- writeOpenAIError(res, 429, "model_queue", `${queueNoticeText(queue)}(上游 code ${queue.code})${note}`);
3599
+ writeOpenAIError(res, 429, "model_queue", `${queueNoticeText(queue)}(上游 code ${queue.code})${note}`, void 0, NO_RETRY_HEADERS);
3463
3600
  return;
3464
3601
  }
3465
3602
  if (result.kind === "policy_reject") {
@@ -3506,7 +3643,8 @@ function createWorkBuddyShim(options) {
3506
3643
  const refusedTier = reasoningTierRefusal(result);
3507
3644
  const receipt = refusedTier ?? detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3508
3645
  const advice = refusedTier !== void 0 ? "这不是账号或网络问题:该模型实测拒绝「off」推理档位,请到模型卡片里取消该行的「可关闭思考」勾选后重试" : result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3509
- const body = siblingGate !== void 0 ? siblingGate : advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3646
+ const overflow = isContextTooLongBody(result.message) ? `${CONTEXT_OVERFLOW_MARKER}。` : "";
3647
+ const body = siblingGate !== void 0 ? siblingGate : `${overflow}${advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`}`;
3510
3648
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
3511
3649
  return;
3512
3650
  }
@@ -4206,6 +4344,199 @@ async function probeModel(input) {
4206
4344
  };
4207
4345
  }
4208
4346
  /**
4347
+ * Smallest spacing between content chunks, in milliseconds, that still counts
4348
+ * as generation.
4349
+ *
4350
+ * Guards the divide-by-zero case where a fast model delivers every token in a
4351
+ * single burst: `chunks === 1` means the stream told us nothing about the RATE,
4352
+ * and the run reports no speed rather than an invented one. The same applies
4353
+ * when the first and last content chunk land in the same millisecond.
4354
+ */
4355
+ const MIN_GENERATION_MS = 1;
4356
+ /** Build the request body for one speed run. */
4357
+ function tpsProbeRequestBody(modelId) {
4358
+ return JSON.stringify({
4359
+ model: modelId,
4360
+ messages: [{
4361
+ role: "system",
4362
+ content: "You are a throughput benchmark. Output a long, uninterrupted sequence of short words separated by spaces. No preamble, no explanation, no code block."
4363
+ }, {
4364
+ role: "user",
4365
+ content: "List the numbers from 1 to 400 in words, separated by single spaces."
4366
+ }],
4367
+ max_tokens: 256,
4368
+ ...OFF_REASONING_FIELDS,
4369
+ stream: true
4370
+ });
4371
+ }
4372
+ /**
4373
+ * Ask for the lowest reasoning effort available.
4374
+ *
4375
+ * A reasoning model asked to count spends its `max_tokens` inside the reasoning
4376
+ * channel; the stream then carries thinking deltas and little or no content, and
4377
+ * timing it would measure the wrong thing. `none` is the widest supported
4378
+ * spelling; gateways that do not know it ignore the field (measured: the CN
4379
+ * gateway answers 200 with it).
4380
+ */
4381
+ const OFF_REASONING_FIELDS = { reasoning_effort: "none" };
4382
+ /**
4383
+ * Read one SSE stream INCREMENTALLY, timing each content deltas arrival.
4384
+ *
4385
+ * The incremental read is the measurement. `response.text()` buffers the whole
4386
+ * body and hands it over once, which erases the only signal a throughput number
4387
+ * is made of: the interval between the first and last content chunk. A version of
4388
+ * this file that parsed a buffered string reported the PARSING time, not the
4389
+ * generation time, and looked entirely plausible — which is why the reader takes
4390
+ * the response body and not a string.
4391
+ *
4392
+ * Only `choices[0].delta.content` is timed. A reasoning delta is not output:
4393
+ * counting it would report a reasoning model as fast because it produced a lot of
4394
+ * private thinking, and reasoning models are precisely the ones where the
4395
+ * distinction matters.
4396
+ *
4397
+ * `startedAt` is the caller's clock reading taken BEFORE the request went out.
4398
+ * Defaulting it to "now" would turn time-to-first-token into "time to start
4399
+ * reading" — a plausible-looking number wrong by the whole connect+TTFB.
4400
+ *
4401
+ * Never throws on a malformed frame: the upstream is allowed to send comments,
4402
+ * blank lines and `[DONE]`, and a stream that ends early still reports what it
4403
+ * timed.
4404
+ */
4405
+ async function readTimedStream(body, startedAt) {
4406
+ const reader = body.getReader();
4407
+ const decoder = new TextDecoder();
4408
+ let pending = "";
4409
+ let firstAt;
4410
+ let lastAt;
4411
+ let usage;
4412
+ try {
4413
+ while (true) {
4414
+ const { done, value } = await reader.read();
4415
+ if (done) break;
4416
+ const arrival = Date.now();
4417
+ pending += decoder.decode(value, { stream: true });
4418
+ const lines = pending.split("\n");
4419
+ pending = lines.pop() ?? "";
4420
+ for (const line of lines) {
4421
+ const reading = timingsOfLine(line, arrival);
4422
+ if (reading.usage !== void 0) usage = reading.usage;
4423
+ if (reading.content !== void 0) {
4424
+ if (firstAt === void 0) firstAt = arrival;
4425
+ lastAt = arrival;
4426
+ }
4427
+ }
4428
+ }
4429
+ } catch {} finally {
4430
+ reader.cancel().catch(() => {});
4431
+ }
4432
+ const outputTokens = typeof usage?.["completion_tokens"] === "number" ? usage["completion_tokens"] : void 0;
4433
+ const credit = typeof usage?.["credit"] === "number" ? usage["credit"] : void 0;
4434
+ return {
4435
+ ...outputTokens === void 0 ? {} : { outputTokens },
4436
+ ...firstAt === void 0 ? {} : { firstContentMs: firstAt - startedAt },
4437
+ ...firstAt === void 0 || lastAt === void 0 ? {} : { generationMs: lastAt - firstAt },
4438
+ ...credit === void 0 ? {} : { credit }
4439
+ };
4440
+ }
4441
+ /**
4442
+ * What one SSE line says: whether it carried content, and any usage frame.
4443
+ *
4444
+ * Split out so the frame grammar is testable without a stream. The `arrival`
4445
+ * timestamp is threaded in by the reader, because a parser that read the clock
4446
+ * itself would be timing the parse, not the arrival.
4447
+ */
4448
+ function timingsOfLine(line, _arrival) {
4449
+ if (!line.startsWith("data:")) return {};
4450
+ const payload = line.slice(5).trim();
4451
+ if (payload === "" || payload === "[DONE]") return {};
4452
+ let frame;
4453
+ try {
4454
+ frame = JSON.parse(payload);
4455
+ } catch {
4456
+ return {};
4457
+ }
4458
+ if (typeof frame !== "object" || frame === null || Array.isArray(frame)) return {};
4459
+ const record = frame;
4460
+ const usageField = record["usage"];
4461
+ const usage = typeof usageField === "object" && usageField !== null && !Array.isArray(usageField) ? usageField : void 0;
4462
+ const choices = record["choices"];
4463
+ const first = Array.isArray(choices) ? choices[0] : void 0;
4464
+ const delta = typeof first === "object" && first !== null ? first["delta"] : void 0;
4465
+ const content = typeof delta === "object" && delta !== null ? delta["content"] : void 0;
4466
+ return {
4467
+ ...typeof content === "string" && content !== "" ? { content } : {},
4468
+ ...usage === void 0 ? {} : { usage }
4469
+ };
4470
+ }
4471
+ /** The middle value; undefined for an empty list. */
4472
+ function median(values) {
4473
+ if (values.length === 0) return void 0;
4474
+ const sorted = [...values].sort((a, b) => a - b);
4475
+ const middle = Math.floor(sorted.length / 2);
4476
+ return sorted.length % 2 === 1 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
4477
+ }
4478
+ /**
4479
+ * Reduce several run readings to one sample.
4480
+ *
4481
+ * The median per quantity is taken SEPARATELY rather than picking one "winner"
4482
+ * run: a run whose stream was truncated may still have timed its first chunk
4483
+ * honestly, and throwing that away would lose information for no reason. A
4484
+ * quantity with no usable reading is omitted, never filled in.
4485
+ */
4486
+ function summarizeTps(modelId, runs, attempted) {
4487
+ const complete = runs.length === attempted && attempted > 0;
4488
+ const speeds = [];
4489
+ const firsts = [];
4490
+ let outputTokens;
4491
+ let credit;
4492
+ for (const run of runs) {
4493
+ if (run.firstContentMs !== void 0) firsts.push(run.firstContentMs);
4494
+ if (run.generationMs !== void 0 && run.generationMs >= MIN_GENERATION_MS && run.outputTokens !== void 0 && run.outputTokens > 0) speeds.push(run.outputTokens / (run.generationMs / 1e3));
4495
+ if (run.outputTokens !== void 0) outputTokens = run.outputTokens;
4496
+ if (run.credit !== void 0) credit = (credit ?? 0) + run.credit;
4497
+ }
4498
+ const tokensPerSecond = median(speeds.map((speed) => Math.round(speed)));
4499
+ const reason = tokensPerSecond !== void 0 ? void 0 : runs.length === 0 ? "all-failed" : outputTokens === void 0 ? "no-usage" : "few-chunks";
4500
+ return {
4501
+ modelId,
4502
+ ...tokensPerSecond === void 0 ? {} : { tokensPerSecond },
4503
+ ...outputTokens === void 0 ? {} : { outputTokens },
4504
+ ...median(firsts) === void 0 ? {} : { timeToFirstTokenMs: median(firsts) },
4505
+ samples: runs.length,
4506
+ ...credit === void 0 ? {} : { credit: Math.round(credit * 100) / 100 },
4507
+ complete,
4508
+ ...reason === void 0 ? {} : { reason }
4509
+ };
4510
+ }
4511
+ /**
4512
+ * Measure one model's output speed.
4513
+ *
4514
+ * Runs {@link TPS_PROBE_RUNS} times and takes per-quantity medians (see
4515
+ * {@link summarizeTps} for why a median and not a mean). A failed run is dropped
4516
+ * rather than counted as zero — a model that answered twice at 400 tok/s did not
4517
+ * get slower because the third request timed out — but the sample count records
4518
+ * the shortfall so the card can say "n/3".
4519
+ *
4520
+ * Never throws: this is a diagnostic the user asked for, and a dead model must
4521
+ * produce a result row, not an exception.
4522
+ */
4523
+ async function measureTps(input) {
4524
+ const runs = input.runs ?? 3;
4525
+ const readings = [];
4526
+ for (let index = 0; index < runs; index++) {
4527
+ if (input.signal?.aborted === true) break;
4528
+ const startedAt = Date.now();
4529
+ try {
4530
+ const answer = await input.client.probeChat(input.credential, tpsProbeRequestBody(input.modelId), input.signal);
4531
+ if (!answer.ok || answer.response === void 0) continue;
4532
+ readings.push(await readTimedStream(answer.response.body, startedAt));
4533
+ } catch {
4534
+ continue;
4535
+ }
4536
+ }
4537
+ return summarizeTps(input.modelId, readings, runs);
4538
+ }
4539
+ /**
4209
4540
  * Check in every account, one at a time.
4210
4541
  *
4211
4542
  * Idempotent by design (see the module note): an account already checked in
@@ -5372,6 +5703,17 @@ const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
5372
5703
  */
5373
5704
  const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
5374
5705
  /**
5706
+ * Plugin-owned OUTPUT-SPEED measurement endpoint (issue #92).
5707
+ *
5708
+ * Separate from {@link WORKBUDDY_PROBE_PATH} because the two ask opposite
5709
+ * questions and cost different amounts: the availability probe sends 25,000
5710
+ * input tokens and caps output at one (measured `credit 0.72`), while this one
5711
+ * sends ~35 input tokens and caps output at 256 (measured `credit 0.01`–`0.15`).
5712
+ * Sharing the route would mean sharing its one-model-per-request cap and its
5713
+ * cost wording, and the two would then be wrong for each other.
5714
+ */
5715
+ const WORKBUDDY_TPS_PATH = "/plugins/dsh-connect-workbuddy/tps";
5716
+ /**
5375
5717
  * Plugin-owned account-pool endpoint: one batch action per call.
5376
5718
  *
5377
5719
  * `?action=checkin` claims the daily reward for every account of a region;
@@ -6227,6 +6569,28 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
6227
6569
  }
6228
6570
  }
6229
6571
  });
6572
+ const disposeTps = ctx.webServer.register({
6573
+ kind: "exact",
6574
+ path: WORKBUDDY_TPS_PATH,
6575
+ handler: async (req, res) => {
6576
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6577
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6578
+ if (deps.measureTps === void 0) return json(res, 503, { error: "tps measurement unavailable" });
6579
+ const region = requestRegion(req, res);
6580
+ if (region === void 0) return;
6581
+ try {
6582
+ const body = await readJsonBody(req);
6583
+ const modelIds = Array.isArray(body.modelIds) ? body.modelIds.filter((id) => typeof id === "string" && id !== "") : [];
6584
+ if (modelIds.length === 0) return json(res, 400, { error: "modelIds must be a non-empty array of strings" });
6585
+ if (modelIds.length > 1) return json(res, 400, { error: "a tps measurement accepts exactly one model" });
6586
+ const results = [];
6587
+ for (const modelId of modelIds) results.push(...await deps.measureTps(region, [modelId]));
6588
+ json(res, 200, { results });
6589
+ } catch (error) {
6590
+ json(res, 500, { error: safeMessage(error) });
6591
+ }
6592
+ }
6593
+ });
6230
6594
  const disposePool = ctx.webServer.register({
6231
6595
  kind: "exact",
6232
6596
  path: WORKBUDDY_POOL_PATH,
@@ -6502,6 +6866,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
6502
6866
  disposeUsage();
6503
6867
  disposeAccountCredits();
6504
6868
  disposeProbe();
6869
+ disposeTps();
6505
6870
  disposePool();
6506
6871
  disposeOAuth();
6507
6872
  disposeTransfer();
@@ -7152,6 +7517,51 @@ function apply(ctx, config) {
7152
7517
  }));
7153
7518
  return results;
7154
7519
  },
7520
+ /**
7521
+ * Output-speed measurement (issue #92), region-scoped like every other
7522
+ * spending path.
7523
+ *
7524
+ * Deliberately NOT routed through the probe's failover machinery: a speed
7525
+ * number describes the MODEL, and having the pool silently serve the run
7526
+ * from a different account would attach one account's rate limit (6004) to
7527
+ * another's measurement. The selected credential runs all three samples, so
7528
+ * "it failed" means this account could not measure it, which is the honest
7529
+ * answer.
7530
+ */
7531
+ async measureTps(region, modelIds, options) {
7532
+ let credential;
7533
+ try {
7534
+ credential = await stacks[region].store.resolve();
7535
+ } catch (error) {
7536
+ const message = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300);
7537
+ return modelIds.map((modelId) => ({
7538
+ modelId,
7539
+ outcome: "credential-rejected",
7540
+ samples: 0,
7541
+ message
7542
+ }));
7543
+ }
7544
+ const results = [];
7545
+ for (const modelId of modelIds) {
7546
+ const sample = await measureTps({
7547
+ client,
7548
+ credential,
7549
+ modelId,
7550
+ ...options?.signal === void 0 ? {} : { signal: options.signal }
7551
+ });
7552
+ results.push({
7553
+ modelId: sample.modelId,
7554
+ outcome: sample.tokensPerSecond === void 0 ? "failed" : "ok",
7555
+ ...sample.tokensPerSecond === void 0 ? {} : { tokensPerSecond: sample.tokensPerSecond },
7556
+ ...sample.timeToFirstTokenMs === void 0 ? {} : { timeToFirstTokenMs: sample.timeToFirstTokenMs },
7557
+ ...sample.outputTokens === void 0 ? {} : { outputTokens: sample.outputTokens },
7558
+ samples: sample.samples,
7559
+ ...sample.reason === void 0 ? {} : { reason: sample.reason },
7560
+ ...sample.credit === void 0 ? {} : { credit: sample.credit }
7561
+ });
7562
+ }
7563
+ return results;
7564
+ },
7155
7565
  regionUsable(region, usable) {
7156
7566
  if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
7157
7567
  },
@@ -8017,4 +8427,4 @@ function apply(ctx, config) {
8017
8427
  });
8018
8428
  }
8019
8429
  //#endregion
8020
- 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 };
8430
+ 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_TPS_PATH, 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": "5.0.1",
5
+ "version": "5.2.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",