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 +94 -0
- package/README.md +1 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +208 -7
- package/lib/{host-heartbeat-DtMzOwZr.js → host-heartbeat-r1M_OXJI.js} +70 -8
- package/lib/index.d.ts +54 -1
- package/lib/index.js +425 -15
- package/package.json +1 -1
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
|
|
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,
|
|
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
|
-
/*
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
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.
|
|
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} 个请求`}
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
596
|
-
streamSimple: (model, context, options) => api.streamSimple(model, transcript(
|
|
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(
|
|
601
|
-
streamSimple: (model, context, options) => api.streamSimple(model, transcript(
|
|
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
|
-
|
|
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
|
|
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.
|
|
5
|
+
"version": "5.2.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|