dsh-connect-workbuddy 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +7 -4
- package/lib/bin.js +1 -1
- package/lib/client.js +237 -41
- package/lib/{host-heartbeat-t93cLCoW.js → host-heartbeat-DSO-07lc.js} +34 -2
- package/lib/index.d.ts +45 -2
- package/lib/index.js +173 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.1 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **「要等几小时才恢复」的配额错误不再被宿主当成「等两秒就好」的瞬时抖动(issue #28)。**
|
|
8
|
+
- **现象**:上游 429 / code 6004 的原文是「您的使用量已超出频率限制,**将在 13:37:03 重置**」——要等几小时。本插件把它正确分类成 `soft_rate`(`src/upstream.ts`),但写回宿主时 `type` 与 `code` 都用了 `soft_rate` 这个内部名字。
|
|
9
|
+
- **根因**:宿主决定「这个失败要不要在原地重试」靠的是从 `type + code + message` 里匹配**几个英文措辞**(`@deepseek-ai/dsh-llm` 的 `isQuotaExceededError`)。`soft_rate` 不在那份名单里,上游原话又是中文,于是宿主只能按 `429` 的默认含义理解——进入 `RATE_LIMIT`,而 `RATE_LIMIT` **在**宿主的重试集合里(`DEFAULT_RETRYABLE_CODES`),`QUOTA` **不在**。宿主于是原地重试 5 次、指数退避,把预算烧光;而真正能救场的跨供应商故障转移(`dsh-subagent-default-model` 的 `failoverEnabled`)根本没被叫到——实测一次会话 1228 个 step、只切换过 1 次供应商、以 429 收场,`models` 里其它供应商全程闲置。
|
|
10
|
+
- **修法**:只翻译写回宿主的 `code`——`soft_rate` → `quota_exceeded`(宿主认得这个词,且它符合上游实际在说的事)。**HTTP 状态码仍是 429**(那才是上游返回的东西,改成 402 是谎报),上游原话仍原样留在 `message` 里给人看。`type` 保持插件自己的内部名字,便于读日志与报错。
|
|
11
|
+
- **只翻译这一处**:其余 kind 本来就落在重试集合之外(`hard_credit` 402 → 按状态就是 QUOTA,`session_dead` 401 → AUTH,502/400 → OTHER),所以**不做多余翻译**——没有人需要的翻译只是等着漂移的谎言。插件**自己的**错误(`unauthorized` / `not_found` / `host_not_allowed` / `internal`)也一律不翻译:把自身的 401/404 说成「配额用尽」,会让宿主去为一个换供应商也治不好的错误做故障转移。
|
|
12
|
+
- 测试直接调用**宿主自己的** `isQuotaExceededError`(`@deepseek-ai/dsh-llm` 导出)来钉住这件事,而不是在测试里抄一份正则——抄一份只能证明两份副本今天一致。三次变异验证:撤回翻译 / 把翻译扩大到所有 kind / 让本地错误也翻译,均会变红。
|
|
13
|
+
|
|
14
|
+
## 3.4.0 (2026-10-02)
|
|
15
|
+
|
|
16
|
+
### Fixes
|
|
17
|
+
|
|
18
|
+
- **账号池不再被一次瞬时故障永久打死:`unavailable` / `failed` 现在有 30 分钟冷却。**
|
|
19
|
+
- **原来的形状**:`exclusionOf` 只给 `rate-limited` / `out-of-credit` 判冷却到期,`unavailable` / `failed` 一律无条件出池且**不带恢复时间**。而自动复测定时器已在 3.2.0 删除,能刷新它的只剩手动「测试」——**一次 502 或一次网络抖动就能把一个健康账号永久踢出池子**。
|
|
20
|
+
- **症状**:池内每个成员都被这样剔除后,池子没有任何候选,`failoverAccountFor` 第一轮即返回 `undefined`,自动换号无处可去,上游原始 429/502 直接报给用户——界面上「账号池已启用」与「请求失败」同时成立,且报错没有 `(after trying N accounts)`(因为只试了 1 个账号)。
|
|
21
|
+
- **现在的形状**:`unavailable` / `failed` 与限流同形,走 `retryDue()`;上游没给时间就按 `POOL_UNKNOWN_COOLDOWN_MS`(30 分钟)兜底。冷却在**排序时求值**,所以不需要定时器——到点的成员在下一次请求自动变回候选。`credential-rejected` 与 `not-found` 保持永不冷却:一个要重新登录,一个是本区域不再提供的模型,等待都修不好。
|
|
22
|
+
- 反转了 `tests/account-pool.spec.ts` 里一条**明确钉住旧行为**的测试(其原注释即写明「这是留给未来主动决定的反转」),并在测试内记录反转理由。
|
|
23
|
+
|
|
24
|
+
- **自己中止的请求不再被记成账号故障。** 客户端断开时 `res.on('close')` 会 abort 上游调用,`fetch` 抛出的 `AbortError` 经 `chatStream` 统一成 `status: 0, kind: 'server'`,读起来与「网络真的断了」完全一样——于是**关一次面板就可能把正在服务的账号记成 `unavailable`**。换号循环本来就有 `controller.signal.aborted` 守卫,但那只管「要不要继续换号」,不管「要不要把这次失败记成测量」;`reportFailures()` 现在同样短路。
|
|
25
|
+
|
|
26
|
+
- **换号路径不再抓积分。** `failoverAccountFor` 用的是 `poolMembersOf`(逐成员 `fetchCredits`),而它自己上方的注释写着「不得再花请求去决定谁服务」——一次失败的对话因此先变成 N 个额外上游请求才发出重试。改用 `localPoolMembers`,与 `applyPoolSelection` 对齐;有新鲜快照时积分仍然参与排序。
|
|
27
|
+
|
|
28
|
+
- **探测请求补上超时(10 秒)。** `probeChat` 在没有 signal 时完全没有上限,而池的批量测试是**串行**且不传 signal——一个挂住的连接会阻塞整批,且界面上看不出是哪一个成员。现在给「等待响应」设 10 秒上限(远高于正常网关的首字节,远低于用户的耐心),并与调用方传入的 signal 用 `AbortSignal.any` 合并,单模型路由的取消行为不变。
|
|
29
|
+
- **上限只覆盖等待响应**:`fetch` 一 resolve 就 `clearTimeout`,绝不在读取 body 期间继续计时——否则一个健康但偏慢的模型会和死端点共用同一块表,这正是此处不能用裸 `AbortSignal.timeout` 的原因。
|
|
30
|
+
|
|
31
|
+
- **两句会误导人的文案改掉了。** `row.probeUnavailable` 去掉了「这是网络问题,**不是模型问题**」——`unavailable` 这个分类里同时装着网关 5xx、DNS 失败与请求超时,对后两者这个断言并不成立(5xx 恰恰是上游侧的问题)。新文案只陈述可核实的事实:「连不上上游——上游没有给出可用答复」,具体原因由旁边记录的 `message` 给出。
|
|
32
|
+
- `row.poolNoCandidateHint` 不再叫人「重新检测账号,或在桌面端重新登录」。池内全不可用时那个建议是错的:限流、积分耗尽与瞬时故障都会**自动回池**(上游给的时间,没给就 30 分钟兜底),只有「凭据被拒」才需要重新登录。原话会让用户去做一件没有任何用处的事。
|
|
33
|
+
- 顺带修掉 3 处本地化文案里的字面量 Markdown(`**真实体积**`、`**不会**`、`**手动动作**`)。React 不解析 Markdown,它们此前是原样把星号画在界面上的——与本轮改掉的两处属于同一类缺陷。
|
|
34
|
+
- **池表与模型行不再把上游报文原样贴一遍。** 限流账号的记录文本就是 `{"code":6004,"msg":"…将在 2026-10-02 06:53:43 重置…","requestId":"…"}`——与旁边刚显示的绝对时刻完全重复;加上新的相对说法后会变成同一时间印三次,后面还跟一个没人看的 requestId。规则现在只在 `src/client/probe-reason.ts` 里写一次、两处共用:
|
|
35
|
+
- **标签本身已说清的结果**(`rate-limited` / `out-of-credit`,它们都带恢复时刻)**不内联任何正文**;
|
|
36
|
+
- 其余结果内联**短**形式:JSON 信封里取 `msg` 那句人话,`transport error: ` 这个英文前缀也去掉(中文卡片上它只是噪声),整段封顶 72 字符——一行的病态正文不该把整张表挤出屏幕;
|
|
37
|
+
- 完整原文挂在 `title` 上,**缩短不等于丢掉**(含 requestId),悬停即可看到。
|
|
38
|
+
- 例外是有意的:`credential-rejected` / `not-found` 不在「已说清」之列——它们的标签只给结论(「重新登录」「没有这个模型」),上游原话才是让结论可核实的东西。
|
|
39
|
+
|
|
40
|
+
### Features
|
|
41
|
+
|
|
42
|
+
- **池表能说清「为什么出池」了。** `WorkBuddyPoolProbe` 新增 `message`:落盘前经 `redactUpstreamText` 脱敏(与探测路径共用同一实现),卡片在结果后追加 `· <上游原话>`。此前 DNS 失败、网关 502、被中止的请求在池表里渲染成**逐字相同**的一句话,用户拿到的是无法行动的提示。字段可选,旧记录照常读取而不被当成损坏。
|
|
43
|
+
|
|
44
|
+
- **冷却时间现在同时给出「还要等多久」。** 卡片一直只印上游给出的绝对时刻(`05:23:27`)——它回答「何时」,但要读者自己拿当前时间去减,这一步正是让冷却难以行动的地方。现在它旁边多一个相对说法:`05:23:27 之后可再用 · 约 1 小时后`。
|
|
45
|
+
- 粒度按距离切换:不足 1 分钟 → 「即将恢复」;不足 1 小时 → 「约 N 分钟后」;不足 2 天 → 「约 N 小时后」;再远 → 「约 N 天后」。**小时档从整点开始**(不是 90 分钟)——因为用户自己描述那个 69 分钟后的时刻时写的就是「约 1 小时后」,阈值放高反而会把触发这个改动的那个例子渲染成「约 69 分钟」。天档切在 **2 天**而不是 1 天,否则 36 小时会印成「约 1 天后」,少说 12 小时。
|
|
46
|
+
- **只对上游给过的时间生效**:`cooldownOf` 返回 `{}`(上游没说时间)时仍然只说「上游未给出何时恢复」,绝不编造倒计时——这是本功能一贯的红线,本次没有新增任何时间来源,只是把已有的那个换个说法。已经到点的时刻返回空串而不是「0 分钟后」。
|
|
47
|
+
- 模型行与池表两处都接上了;绝对时刻仍是主文案,相对说法是附加的便利。
|
|
48
|
+
|
|
49
|
+
### Docs
|
|
50
|
+
|
|
51
|
+
- README 的「账号为什么出池」表按新规则改写:区分「限流/欠费/瞬时故障会自动回池」与「凭据被拒/模型不存在只能手动重测」,并删掉已不存在的「定时器」表述。
|
|
52
|
+
- 清理死代码:`POOL_TICK_MS`、`POOL_MIN_INTERVAL_MINUTES`、`poolDueAt`、`duePoolRegions`、`PoolScheduleInput` / `PoolScheduleResult` 及其单元测试全部删除。它们自 3.2.0 起已无任何生产调用(构建时已被 tree-shake),却仍被测试覆盖——于是「测试全绿」对「账号会不会回来」这个问题什么也没说明。
|
|
53
|
+
|
|
3
54
|
## 3.3.0 (2026-10-01)
|
|
4
55
|
|
|
5
56
|
### Features
|
package/README.md
CHANGED
|
@@ -135,17 +135,20 @@ DSH PiAiAdapter(每个 provider 一套)
|
|
|
135
135
|
| 探测结果 | 对轮换的意义 |
|
|
136
136
|
| --- | --- |
|
|
137
137
|
| 可用 | 可入选 |
|
|
138
|
-
| 被限流 |
|
|
138
|
+
| 被限流 | **暂时**出池,按上游给出的重置时间自动回池(上游没说时间就按 30 分钟兜底,**不编造倒计时**) |
|
|
139
139
|
| 积分耗尽 | 出池,按月周期包的刷新时间回池 |
|
|
140
|
-
|
|
|
140
|
+
| 连不上上游 / 失败 | **暂时**出池,30 分钟后自动回池——这类结果说的往往不是账号,而是一次网络抖动或上游 5xx |
|
|
141
|
+
| 凭据被拒 / 模型不存在 | 出池,**只能靠手动「测试」回池**:一个需要重新登录,一个是本区域不再提供该模型,等待都修不好 |
|
|
141
142
|
|
|
142
|
-
|
|
143
|
+
关键一条:**「被限流」「连不上上游」都不等于不可用**。它们恰恰是最值得等的那两类状态——当成永久失效剔除,正好丢掉池子里本来最能用的账号,而且**当池内每个成员都被这样剔除时,池子就没有任何候选,自动换号会无处可去**,你只会看到上游原始报错。
|
|
144
|
+
|
|
145
|
+
**为什么只有限流/欠费/瞬时故障会自己回池**:这三类要么由上游写明了恢复时间,要么由插件按 30 分钟兜底;而「凭据被拒」「模型不存在」等待都无意义,只能由你手动重测或重新登录来刷新。插件**不再有自动复测定时器**(见下),所以后两类的唯一出路就是手动动作。
|
|
143
146
|
|
|
144
147
|
### 保存与草稿:为什么卡片上有两个「保存」
|
|
145
148
|
|
|
146
149
|
模型管理一个、账号池一个,这是**刻意保留**而非漏合并:两者的草稿域不同,合并后一次写入失败会连累另一边(模型目录写不进去,就连池偏好也存不了);一个共享的「放弃」还会在一次误点里同时丢掉两份草稿。两边都是**验证写入**——**写不成功不丢弃草稿**,否则用户的编辑就永久丢了。
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
池里还有一类状态**不走草稿**:探测结果、冷却时间、当前轮换到的账号。它们是**观测事实**,由插件自己高频写入。原因很实际——草稿是覆盖式的,若观测结果也进草稿,你手工点一次「保存」就可能把插件刚写进去的结果**回退成几分钟前的旧值**。
|
|
149
152
|
|
|
150
153
|
## 安装
|
|
151
154
|
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-
|
|
2
|
+
import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-DSO-07lc.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
|
@@ -525,6 +525,133 @@ window.__ModuleLoader__.load({
|
|
|
525
525
|
});
|
|
526
526
|
}
|
|
527
527
|
//#endregion
|
|
528
|
+
//#region src/client/ndjson.ts
|
|
529
|
+
/**
|
|
530
|
+
* Read a newline-delimited JSON body, one object at a time.
|
|
531
|
+
*
|
|
532
|
+
* NDJSON rather than SSE or a hand-rolled frame format because the pool's batch
|
|
533
|
+
* is a list of INDEPENDENT results: one line per result needs no state machine,
|
|
534
|
+
* survives a partially-delivered batch, and lets the reader act on account N
|
|
535
|
+
* while account N+1 is still being tested.
|
|
536
|
+
*
|
|
537
|
+
* A malformed line is SKIPPED, not fatal. The stream carries live progress, so
|
|
538
|
+
* discarding the rest of a batch because one frame did not parse would throw away
|
|
539
|
+
* rows the user already watched arrive — and a truncated final line is a normal
|
|
540
|
+
* way for a stream to end when something upstream misbehaves.
|
|
541
|
+
*
|
|
542
|
+
* @module dsh-connect-workbuddy/client/ndjson
|
|
543
|
+
*/
|
|
544
|
+
/** Hand each parsed object to `onLine`, in order, as it arrives. */
|
|
545
|
+
async function readNdjson(body, onLine) {
|
|
546
|
+
const reader = body?.getReader();
|
|
547
|
+
if (reader === void 0) return;
|
|
548
|
+
const decoder = new TextDecoder();
|
|
549
|
+
const emit = (line) => {
|
|
550
|
+
const trimmed = line.trim();
|
|
551
|
+
if (trimmed === "") return;
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(trimmed);
|
|
554
|
+
if (typeof parsed === "object" && parsed !== null) onLine(parsed);
|
|
555
|
+
} catch {}
|
|
556
|
+
};
|
|
557
|
+
let buffer = "";
|
|
558
|
+
try {
|
|
559
|
+
for (;;) {
|
|
560
|
+
const { done, value } = await reader.read();
|
|
561
|
+
if (done) break;
|
|
562
|
+
buffer += decoder.decode(value, { stream: true });
|
|
563
|
+
const lines = buffer.split("\n");
|
|
564
|
+
buffer = lines.pop() ?? "";
|
|
565
|
+
for (const line of lines) emit(line);
|
|
566
|
+
}
|
|
567
|
+
buffer += decoder.decode();
|
|
568
|
+
emit(buffer);
|
|
569
|
+
} finally {
|
|
570
|
+
reader.releaseLock();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/client/probe-reason.ts
|
|
575
|
+
/**
|
|
576
|
+
* Outcomes whose label + stated recovery time ARE the whole story.
|
|
577
|
+
*
|
|
578
|
+
* Both carry a reset instant, which the row prints right beside them; their
|
|
579
|
+
* bodies restate that instant and add a `code`/`requestId` pair nobody reads.
|
|
580
|
+
*
|
|
581
|
+
* `credential-rejected` and `not-found` are deliberately NOT here: their labels
|
|
582
|
+
* state a conclusion ("sign in again", "no such model"), and the upstream's own
|
|
583
|
+
* words are what make that conclusion checkable rather than asserted.
|
|
584
|
+
*/
|
|
585
|
+
const SELF_EXPLANATORY = /* @__PURE__ */ new Set(["rate-limited", "out-of-credit"]);
|
|
586
|
+
/**
|
|
587
|
+
* Longest inline reason kept.
|
|
588
|
+
*
|
|
589
|
+
* A cap rather than a wrapper: one pathological body must not push the rest of
|
|
590
|
+
* the table off screen. 72 characters holds a full transport error and a full
|
|
591
|
+
* upstream sentence, while stopping well short of a raw JSON document.
|
|
592
|
+
*/
|
|
593
|
+
const REASON_LIMIT = 72;
|
|
594
|
+
/** The inline reason for one measurement, or `undefined` when it adds nothing. */
|
|
595
|
+
function inlineProbeReason(outcome, message) {
|
|
596
|
+
if (message === void 0 || message === "") return void 0;
|
|
597
|
+
if (SELF_EXPLANATORY.has(outcome)) return void 0;
|
|
598
|
+
const text = (humanSentenceOf(message) ?? stripTransportPrefix(message)).trim();
|
|
599
|
+
return text === "" ? void 0 : truncate(text);
|
|
600
|
+
}
|
|
601
|
+
function truncate(text) {
|
|
602
|
+
return text.length <= REASON_LIMIT ? text : `${text.slice(0, 71)}…`;
|
|
603
|
+
}
|
|
604
|
+
/** `{"code":6004,"msg":"…","requestId":"…"}` → the `msg` sentence, if it has one. */
|
|
605
|
+
function humanSentenceOf(message) {
|
|
606
|
+
if (!message.startsWith("{")) return void 0;
|
|
607
|
+
let parsed;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(message);
|
|
610
|
+
} catch {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
614
|
+
const msg = parsed["msg"];
|
|
615
|
+
return typeof msg === "string" && msg !== "" ? msg : void 0;
|
|
616
|
+
}
|
|
617
|
+
/** `transport error: TimeoutError: …` → `TimeoutError: …`. */
|
|
618
|
+
function stripTransportPrefix(message) {
|
|
619
|
+
return message.replace(/^transport error:\s*/u, "");
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/client/remaining.ts
|
|
623
|
+
const MINUTE_MS = 6e4;
|
|
624
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
625
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
626
|
+
/**
|
|
627
|
+
* How far out hours stop being the useful unit.
|
|
628
|
+
*
|
|
629
|
+
* Below this, hours; at or above it, days. The cut is at TWO days rather than
|
|
630
|
+
* one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
|
|
631
|
+
* which would understate it by six hours.
|
|
632
|
+
*/
|
|
633
|
+
const DAYS_AT_MS = 2 * DAY_MS;
|
|
634
|
+
/**
|
|
635
|
+
* A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
|
|
636
|
+
*
|
|
637
|
+
* The empty string is the contract callers depend on: an elapsed cooldown means
|
|
638
|
+
* the account is already back, so appending "in 0 minutes" would assert the
|
|
639
|
+
* opposite of the state the card is showing. Callers append this only when it is
|
|
640
|
+
* non-empty.
|
|
641
|
+
*
|
|
642
|
+
* Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
|
|
643
|
+
* wants, and a precision that changes every minute would be noise rather than
|
|
644
|
+
* information. The absolute instant sits right beside it for anyone who needs it.
|
|
645
|
+
*/
|
|
646
|
+
function remainingText(t, untilMs, nowMs) {
|
|
647
|
+
const left = untilMs - nowMs;
|
|
648
|
+
if (left <= 0) return "";
|
|
649
|
+
if (left < MINUTE_MS) return t("row.remainingImminent");
|
|
650
|
+
if (left < HOUR_MS) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS) });
|
|
651
|
+
if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS) });
|
|
652
|
+
return t("row.remainingDays", { count: Math.round(left / DAY_MS) });
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
528
655
|
//#region src/account-pool.ts
|
|
529
656
|
/**
|
|
530
657
|
* The checked ids that still resolve to a local sign-in, in the caller's order.
|
|
@@ -1022,44 +1149,83 @@ window.__ModuleLoader__.load({
|
|
|
1022
1149
|
count: memberCount,
|
|
1023
1150
|
model: pool?.targetModelId ?? ""
|
|
1024
1151
|
}), "info");
|
|
1152
|
+
/**
|
|
1153
|
+
* One test row into the activity log.
|
|
1154
|
+
*
|
|
1155
|
+
* Shared by the STREAMED shape (a line per account) and the whole-batch
|
|
1156
|
+
* shape (an older Host's single document), so the two can never report the
|
|
1157
|
+
* same measurement differently — the failure mode this file's history is
|
|
1158
|
+
* mostly made of.
|
|
1159
|
+
*/
|
|
1160
|
+
const logTestRow = (row) => {
|
|
1161
|
+
const outcome = row?.result?.outcome;
|
|
1162
|
+
if (outcome === void 0) {
|
|
1163
|
+
appendLog(t("row.poolLogTestRowMalformed", { accountName: row?.accountName === "" || row?.accountName === void 0 ? t("row.accountUnnamed") : row.accountName }), "warn");
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
appendLog(t("row.poolLogTestRow", {
|
|
1167
|
+
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1168
|
+
outcome: outcomeText(t, outcome)
|
|
1169
|
+
}), outcomeOk(outcome) ? "ok" : "warn");
|
|
1170
|
+
};
|
|
1025
1171
|
try {
|
|
1026
1172
|
const response = await fetch(withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, action), {
|
|
1027
1173
|
method: "POST",
|
|
1028
1174
|
headers: { accept: "application/json" },
|
|
1029
1175
|
credentials: "same-origin"
|
|
1030
1176
|
});
|
|
1031
|
-
|
|
1032
|
-
if (
|
|
1033
|
-
status: response.status
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
credit: String(row.credit ?? 0)
|
|
1042
|
-
}), "ok");
|
|
1043
|
-
else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
|
|
1044
|
-
else appendLog(t("row.poolLogCheckinFailed", {
|
|
1045
|
-
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1046
|
-
message: row.message ?? t("row.requestFailed")
|
|
1047
|
-
}), "error");
|
|
1048
|
-
appendLog(t("row.poolLogCheckinDone"), "ok");
|
|
1049
|
-
} else {
|
|
1050
|
-
for (const row of body?.rows ?? []) {
|
|
1051
|
-
const outcome = row?.result?.outcome;
|
|
1052
|
-
if (outcome === void 0) {
|
|
1053
|
-
appendLog(t("row.poolLogTestRowMalformed", { accountName: row?.accountName === "" || row?.accountName === void 0 ? t("row.accountUnnamed") : row.accountName }), "warn");
|
|
1054
|
-
continue;
|
|
1177
|
+
let failed = false;
|
|
1178
|
+
if ((response.headers.get("content-type") ?? "").includes("ndjson")) {
|
|
1179
|
+
if (!response.ok) throw new Error(poolFailureText(t, { status: response.status }));
|
|
1180
|
+
let sawRow = false;
|
|
1181
|
+
await readNdjson(response.body, (line) => {
|
|
1182
|
+
const row = line["row"];
|
|
1183
|
+
if (row !== void 0) {
|
|
1184
|
+
sawRow = true;
|
|
1185
|
+
if (mounted.current) logTestRow(row);
|
|
1186
|
+
return;
|
|
1055
1187
|
}
|
|
1056
|
-
|
|
1188
|
+
const reason = line["reason"];
|
|
1189
|
+
if (typeof reason === "string") {
|
|
1190
|
+
failed = true;
|
|
1191
|
+
const message = poolFailureText(t, {
|
|
1192
|
+
status: 200,
|
|
1193
|
+
reason,
|
|
1194
|
+
error: typeof line["error"] === "string" ? line["error"] : void 0
|
|
1195
|
+
});
|
|
1196
|
+
if (mounted.current) {
|
|
1197
|
+
setActionError(message);
|
|
1198
|
+
appendLog(t("row.poolLogBatchFailed", { message }), "error");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
if (!mounted.current) return;
|
|
1203
|
+
if (!failed && sawRow) appendLog(t("row.poolLogTestDone"), "ok");
|
|
1204
|
+
} else {
|
|
1205
|
+
const body = await response.json().catch(() => void 0);
|
|
1206
|
+
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
1207
|
+
status: response.status,
|
|
1208
|
+
reason: body?.reason,
|
|
1209
|
+
error: body?.error
|
|
1210
|
+
}));
|
|
1211
|
+
if (!mounted.current) return;
|
|
1212
|
+
if (action === "checkin") {
|
|
1213
|
+
for (const row of body?.rows ?? []) if (row.status === "claimed") appendLog(t("row.poolLogCheckinClaimed", {
|
|
1214
|
+
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1215
|
+
credit: String(row.credit ?? 0)
|
|
1216
|
+
}), "ok");
|
|
1217
|
+
else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
|
|
1218
|
+
else appendLog(t("row.poolLogCheckinFailed", {
|
|
1057
1219
|
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1058
|
-
|
|
1059
|
-
}),
|
|
1220
|
+
message: row.message ?? t("row.requestFailed")
|
|
1221
|
+
}), "error");
|
|
1222
|
+
appendLog(t("row.poolLogCheckinDone"), "ok");
|
|
1223
|
+
} else {
|
|
1224
|
+
for (const row of body?.rows ?? []) logTestRow(row);
|
|
1225
|
+
appendLog(t("row.poolLogTestDone"), "ok");
|
|
1060
1226
|
}
|
|
1061
|
-
appendLog(t("row.poolLogTestDone"), "ok");
|
|
1062
1227
|
}
|
|
1228
|
+
if (failed) return;
|
|
1063
1229
|
onRefresh?.();
|
|
1064
1230
|
} catch (error) {
|
|
1065
1231
|
const message = error instanceof Error ? error.message : t("row.requestFailed");
|
|
@@ -1210,7 +1376,11 @@ window.__ModuleLoader__.load({
|
|
|
1210
1376
|
const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
|
|
1211
1377
|
const excluded = exclusionText(t, account);
|
|
1212
1378
|
const probe = account.probe;
|
|
1213
|
-
const probeLine =
|
|
1379
|
+
const probeLine = [
|
|
1380
|
+
probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome),
|
|
1381
|
+
probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now()),
|
|
1382
|
+
probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message)
|
|
1383
|
+
].filter((part) => part !== void 0 && part !== "").join(" · ");
|
|
1214
1384
|
return (0, react.createElement)("div", {
|
|
1215
1385
|
className: `dsm-workbuddy-pool-row${account.current ? " dsm-workbuddy-pool-row-current" : ""}`,
|
|
1216
1386
|
key: account.accountId
|
|
@@ -1223,7 +1393,10 @@ window.__ModuleLoader__.load({
|
|
|
1223
1393
|
})), (0, react.createElement)("span", {
|
|
1224
1394
|
className: "dsm-workbuddy-usage-dot",
|
|
1225
1395
|
style: { background: stateColor(account) }
|
|
1226
|
-
}), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
|
|
1396
|
+
}), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
|
|
1397
|
+
className: "dsm-workbuddy-pool-probe",
|
|
1398
|
+
...probe?.message === void 0 ? {} : { title: probe.message }
|
|
1399
|
+
}, probeLine), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
|
|
1227
1400
|
className: "dsm-workbuddy-pool-unknown",
|
|
1228
1401
|
title: t("row.poolNeverTested")
|
|
1229
1402
|
}, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null);
|
|
@@ -1866,7 +2039,20 @@ window.__ModuleLoader__.load({
|
|
|
1866
2039
|
* would look like an upstream answer while being a guess, and the user would
|
|
1867
2040
|
* wait for a moment that means nothing.
|
|
1868
2041
|
*/
|
|
1869
|
-
|
|
2042
|
+
/**
|
|
2043
|
+
* ` · 约 1 小时后` for a future instant, or `''` when there is none.
|
|
2044
|
+
*
|
|
2045
|
+
* Kept beside {@link probeResultView} rather than inlined three times so the
|
|
2046
|
+
* "append only when non-empty" rule cannot drift between the two limited
|
|
2047
|
+
* outcomes. The absolute instant stays the primary text: it is the upstream's
|
|
2048
|
+
* own answer, and the relative form is a convenience derived from it.
|
|
2049
|
+
*/
|
|
2050
|
+
function retrySuffix(t, untilMs, nowMs) {
|
|
2051
|
+
if (untilMs === void 0) return "";
|
|
2052
|
+
const remaining = remainingText(t, untilMs, nowMs);
|
|
2053
|
+
return remaining === "" ? "" : ` · ${remaining}`;
|
|
2054
|
+
}
|
|
2055
|
+
function probeResultView(result, t, nowMs = Date.now()) {
|
|
1870
2056
|
switch (result.outcome) {
|
|
1871
2057
|
case "ok": {
|
|
1872
2058
|
const elapsed = result.elapsedMs === void 0 ? void 0 : `${String(result.elapsedMs / 1e3)}s`;
|
|
@@ -1876,11 +2062,11 @@ window.__ModuleLoader__.load({
|
|
|
1876
2062
|
};
|
|
1877
2063
|
}
|
|
1878
2064
|
case "rate-limited": return {
|
|
1879
|
-
text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }),
|
|
2065
|
+
text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
|
|
1880
2066
|
tone: "warn"
|
|
1881
2067
|
};
|
|
1882
2068
|
case "out-of-credit": return {
|
|
1883
|
-
text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }),
|
|
2069
|
+
text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
|
|
1884
2070
|
tone: "bad"
|
|
1885
2071
|
};
|
|
1886
2072
|
case "credential-rejected": return {
|
|
@@ -2655,10 +2841,12 @@ window.__ModuleLoader__.load({
|
|
|
2655
2841
|
const result = probes[activeRegion]?.[model.id];
|
|
2656
2842
|
if (result === void 0) return null;
|
|
2657
2843
|
const view = probeResultView(result, t);
|
|
2844
|
+
const reason = inlineProbeReason(result.outcome, result.message);
|
|
2658
2845
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
2659
2846
|
className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
|
|
2660
2847
|
role: "status",
|
|
2661
|
-
|
|
2848
|
+
...result.message === void 0 ? {} : { title: result.message },
|
|
2849
|
+
children: [view.text, reason === void 0 ? null : " · " + reason]
|
|
2662
2850
|
});
|
|
2663
2851
|
})()
|
|
2664
2852
|
]
|
|
@@ -2837,14 +3025,14 @@ window.__ModuleLoader__.load({
|
|
|
2837
3025
|
"row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
|
|
2838
3026
|
"row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
|
|
2839
3027
|
"row.probeNotFound": "✗ The upstream does not know this model",
|
|
2840
|
-
"row.probeUnavailable": "✗ The upstream could not be reached —
|
|
3028
|
+
"row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
|
|
2841
3029
|
"row.probeFailed": "✗ Test failed",
|
|
2842
3030
|
"row.probeFailedStatus": "✗ Test failed (HTTP {status})",
|
|
2843
3031
|
"row.probeError": "Could not run the test: {message}",
|
|
2844
3032
|
"row.cheer": "Star on GitHub",
|
|
2845
3033
|
"row.poolTitle": "Account pool",
|
|
2846
3034
|
"row.poolNoCandidate": "No account in this pool can be used right now.",
|
|
2847
|
-
"row.poolNoCandidateHint": "
|
|
3035
|
+
"row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
|
|
2848
3036
|
"row.poolCheckinAll": "Check in all accounts",
|
|
2849
3037
|
"row.poolCheckingIn": "Checking in…",
|
|
2850
3038
|
"row.poolTestAll": "Test all accounts",
|
|
@@ -2880,6 +3068,10 @@ window.__ModuleLoader__.load({
|
|
|
2880
3068
|
"row.poolExcludedUnusable": "Unusable",
|
|
2881
3069
|
"row.poolRetryAt": "Back after {at}",
|
|
2882
3070
|
"row.poolRetryUnknown": "The upstream gave no time for when this frees up",
|
|
3071
|
+
"row.remainingImminent": "any moment now",
|
|
3072
|
+
"row.remainingMinutes": "in about {count} min",
|
|
3073
|
+
"row.remainingHours": "in about {count} h",
|
|
3074
|
+
"row.remainingDays": "in about {count} d",
|
|
2883
3075
|
"row.poolSettingsTitle": "Account pool settings",
|
|
2884
3076
|
"row.poolSettingsHint": "These are preferences: they take effect when you save (same as the model list). Test results are written automatically and are not affected by this button.",
|
|
2885
3077
|
"row.poolEnabled": "Enable the account pool",
|
|
@@ -3000,7 +3192,7 @@ window.__ModuleLoader__.load({
|
|
|
3000
3192
|
"row.modelCapabilityPending": "仅展示 WorkBuddy 接口明确公布的模型能力。",
|
|
3001
3193
|
"row.probe": "测试",
|
|
3002
3194
|
"row.probing": "测试中…",
|
|
3003
|
-
"row.probeHint": "
|
|
3195
|
+
"row.probeHint": "对该模型发一条真实体积的请求(约 25k 输入 token),报告它现在能不能用。上游的限流按请求体积触发——实测约 20k 能过、约 30k 被拒——所以小请求看着正常、长对话里却可能每次都被拒。这也是为什么每次测试都会消耗真实积分(约 0.7),且一次只测一个模型。",
|
|
3004
3196
|
"row.probeOk": "✓ 可用",
|
|
3005
3197
|
"row.probeOkMs": "✓ 可用({ms})",
|
|
3006
3198
|
"row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
|
|
@@ -3009,14 +3201,14 @@ window.__ModuleLoader__.load({
|
|
|
3009
3201
|
"row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
|
|
3010
3202
|
"row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
|
|
3011
3203
|
"row.probeNotFound": "✗ 上游不认识这个模型",
|
|
3012
|
-
"row.probeUnavailable": "✗
|
|
3204
|
+
"row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
|
|
3013
3205
|
"row.probeFailed": "✗ 测试失败",
|
|
3014
3206
|
"row.probeFailedStatus": "✗ 测试失败(HTTP {status})",
|
|
3015
3207
|
"row.probeError": "无法执行测试:{message}",
|
|
3016
3208
|
"row.cheer": "鼓励一下",
|
|
3017
3209
|
"row.poolTitle": "账号池",
|
|
3018
3210
|
"row.poolNoCandidate": "账号池里目前没有可用的账号。",
|
|
3019
|
-
"row.poolNoCandidateHint": "
|
|
3211
|
+
"row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
|
|
3020
3212
|
"row.poolCheckinAll": "一键签到所有账号",
|
|
3021
3213
|
"row.poolCheckingIn": "签到中…",
|
|
3022
3214
|
"row.poolTestAll": "一键测试所有账号",
|
|
@@ -3034,7 +3226,7 @@ window.__ModuleLoader__.load({
|
|
|
3034
3226
|
"row.poolTargetStaleHint": "本区域的模型目录已变化,不再包含这个模型,因此测试被停用——而不是拿一个上游可能不接受的模型去跑。请改选一个目标模型,或切回自动。",
|
|
3035
3227
|
"row.poolTargetStaleClear": "切回自动",
|
|
3036
3228
|
"row.poolTargetStaleOption": "{model}(已下架)",
|
|
3037
|
-
"row.poolTargetNoneHint": "
|
|
3229
|
+
"row.poolTargetNoneHint": "国内版的倍率要等模型目录刷新后才会出现。为了不让你在不知情的情况下花掉积分,插件不会自动改用收费模型——请先刷新模型目录,或自己指定一个目标模型。",
|
|
3038
3230
|
"row.poolColumnAccount": "账号",
|
|
3039
3231
|
"row.poolColumnCredits": "积分",
|
|
3040
3232
|
"row.poolColumnProbe": "目标模型",
|
|
@@ -3052,10 +3244,14 @@ window.__ModuleLoader__.load({
|
|
|
3052
3244
|
"row.poolExcludedUnusable": "不可用",
|
|
3053
3245
|
"row.poolRetryAt": "{at} 之后可再用",
|
|
3054
3246
|
"row.poolRetryUnknown": "上游未给出何时恢复",
|
|
3247
|
+
"row.remainingImminent": "即将恢复",
|
|
3248
|
+
"row.remainingMinutes": "约 {count} 分钟后",
|
|
3249
|
+
"row.remainingHours": "约 {count} 小时后",
|
|
3250
|
+
"row.remainingDays": "约 {count} 天后",
|
|
3055
3251
|
"row.poolSettingsTitle": "账号池设置",
|
|
3056
3252
|
"row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
|
|
3057
3253
|
"row.poolEnabled": "启用账号池",
|
|
3058
|
-
"row.poolEnabledHint": "
|
|
3254
|
+
"row.poolEnabledHint": "开启后由本区域的账号池自动决定每一次请求用谁(可用优先,其次积分高、快过期的积分包、凭据更新的),失败时自动换下一个可用账号。关闭后插件不替你选、也不替你换:你选的账号服务每一次请求,失败就如实报错。签到与测试是手动动作,两种状态下都可用 —— 这个开关只管「插件要不要自己决定」。",
|
|
3059
3255
|
"row.poolManualAccountLabel": "使用哪个账号",
|
|
3060
3256
|
"row.poolManualAccountHint": "账号池关闭时,由你在这里选定服务本区域的账号。开启账号池后改由排序自动决定。",
|
|
3061
3257
|
"row.poolManualAccountNone": "当前没有生效的账号",
|
|
@@ -416,6 +416,19 @@ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
|
416
416
|
const DESKTOP_UA = "WorkBuddy/5.5.2";
|
|
417
417
|
const JSON_TIMEOUT_MS = 3e4;
|
|
418
418
|
const ERROR_BODY_LIMIT = 4096;
|
|
419
|
+
/**
|
|
420
|
+
* Ceiling for one real-volume probe to get a RESPONSE, which is not a metadata
|
|
421
|
+
* call.
|
|
422
|
+
*
|
|
423
|
+
* Much larger than {@link JSON_TIMEOUT_MS} on purpose: a probe posts ~25k input
|
|
424
|
+
* tokens, so prompt processing legitimately takes a while before the first byte
|
|
425
|
+
* comes back. It exists only to stop a connection that will NEVER answer — the
|
|
426
|
+
* pool's batch is serial, so one hung member would otherwise block all the rest
|
|
427
|
+
* with nothing in the UI to say which one it was. Ten seconds is comfortably
|
|
428
|
+
* above a working gateway's first byte and far below a user's patience; the
|
|
429
|
+
* budget covers the response only, never the one-line body that follows.
|
|
430
|
+
*/
|
|
431
|
+
const PROBE_TIMEOUT_MS = 1e4;
|
|
419
432
|
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
420
433
|
const HARD_CREDIT_MARKERS = [
|
|
421
434
|
"insufficient credit",
|
|
@@ -946,9 +959,26 @@ var WorkBuddyUpstreamClient = class {
|
|
|
946
959
|
*
|
|
947
960
|
* The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
|
|
948
961
|
* holds the connection open.
|
|
962
|
+
*
|
|
963
|
+
* ALWAYS bounded in time. The pool's batch runner is serial and passes no
|
|
964
|
+
* signal, so without a ceiling here one stalled connection blocks every account
|
|
965
|
+
* behind it — the batch looks hung with no way to tell which member did it.
|
|
966
|
+
* A supplied signal is COMBINED with the timeout rather than replacing it, so
|
|
967
|
+
* the single-model route keeps its cancellation behaviour and still cannot
|
|
968
|
+
* hang forever.
|
|
969
|
+
*
|
|
970
|
+
* The ceiling covers ONLY the wait for a response — it is cleared the moment
|
|
971
|
+
* `fetch` resolves. Holding a timer over the body would put a healthy but slow
|
|
972
|
+
* model on the same clock as a dead endpoint, and the whole point of the
|
|
973
|
+
* period is to answer "did the upstream answer at all". A probe asks for a
|
|
974
|
+
* single token, so the body that follows is one SSE line.
|
|
949
975
|
*/
|
|
950
976
|
async probeChat(credential, bodyJson, signal) {
|
|
951
977
|
let response;
|
|
978
|
+
const ceiling = new AbortController();
|
|
979
|
+
const timer = setTimeout(() => {
|
|
980
|
+
ceiling.abort(/* @__PURE__ */ new Error(`no response within ${PROBE_TIMEOUT_MS}ms`));
|
|
981
|
+
}, PROBE_TIMEOUT_MS);
|
|
952
982
|
try {
|
|
953
983
|
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
954
984
|
method: "POST",
|
|
@@ -957,7 +987,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
957
987
|
"Authorization": `Bearer ${credential.accessToken}`
|
|
958
988
|
},
|
|
959
989
|
body: bodyJson,
|
|
960
|
-
|
|
990
|
+
signal: signal === void 0 ? ceiling.signal : AbortSignal.any([signal, ceiling.signal])
|
|
961
991
|
});
|
|
962
992
|
} catch (error) {
|
|
963
993
|
return {
|
|
@@ -966,6 +996,8 @@ var WorkBuddyUpstreamClient = class {
|
|
|
966
996
|
retryAfter: null,
|
|
967
997
|
body: `transport error: ${String(error)}`
|
|
968
998
|
};
|
|
999
|
+
} finally {
|
|
1000
|
+
clearTimeout(timer);
|
|
969
1001
|
}
|
|
970
1002
|
const retryAfter = response.headers.get("retry-after");
|
|
971
1003
|
if (response.ok) return {
|
|
@@ -2520,7 +2552,7 @@ function generationOf(version) {
|
|
|
2520
2552
|
//#endregion
|
|
2521
2553
|
//#region src/version.ts
|
|
2522
2554
|
/** The npm package version this build was produced from. */
|
|
2523
|
-
const WORKBUDDY_CONNECT_VERSION = "3.
|
|
2555
|
+
const WORKBUDDY_CONNECT_VERSION = "3.4.1";
|
|
2524
2556
|
//#endregion
|
|
2525
2557
|
//#region src/host-heartbeat.ts
|
|
2526
2558
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -631,6 +631,19 @@ declare class WorkBuddyUpstreamClient {
|
|
|
631
631
|
*
|
|
632
632
|
* The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
|
|
633
633
|
* holds the connection open.
|
|
634
|
+
*
|
|
635
|
+
* ALWAYS bounded in time. The pool's batch runner is serial and passes no
|
|
636
|
+
* signal, so without a ceiling here one stalled connection blocks every account
|
|
637
|
+
* behind it — the batch looks hung with no way to tell which member did it.
|
|
638
|
+
* A supplied signal is COMBINED with the timeout rather than replacing it, so
|
|
639
|
+
* the single-model route keeps its cancellation behaviour and still cannot
|
|
640
|
+
* hang forever.
|
|
641
|
+
*
|
|
642
|
+
* The ceiling covers ONLY the wait for a response — it is cleared the moment
|
|
643
|
+
* `fetch` resolves. Holding a timer over the body would put a healthy but slow
|
|
644
|
+
* model on the same clock as a dead endpoint, and the whole point of the
|
|
645
|
+
* period is to answer "did the upstream answer at all". A probe asks for a
|
|
646
|
+
* single token, so the body that follows is one SSE line.
|
|
634
647
|
*/
|
|
635
648
|
probeChat(credential: WorkBuddyCredential, bodyJson: string, signal?: AbortSignal): Promise<WorkBuddyProbeAnswer>;
|
|
636
649
|
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
@@ -1518,6 +1531,20 @@ interface WorkBuddyPoolProbe {
|
|
|
1518
1531
|
* must never be turned into a locally invented countdown.
|
|
1519
1532
|
*/
|
|
1520
1533
|
retryAtMs?: number;
|
|
1534
|
+
/**
|
|
1535
|
+
* The upstream's own words on the failure, already redacted.
|
|
1536
|
+
*
|
|
1537
|
+
* Kept so the card can say WHY an account left the pool instead of repeating
|
|
1538
|
+
* one generic sentence for a DNS failure, a gateway 502 and a request the user
|
|
1539
|
+
* cancelled alike. Those three used to render as the SAME line, which left the
|
|
1540
|
+
* user with a message they could not act on and no way to tell a broken
|
|
1541
|
+
* account from a broken network.
|
|
1542
|
+
*
|
|
1543
|
+
* ABSENT for a success, and for any measurement written before this field
|
|
1544
|
+
* existed: an `outcome` without a `message` is a complete, valid record, so
|
|
1545
|
+
* old probe files stay readable rather than being discarded.
|
|
1546
|
+
*/
|
|
1547
|
+
message?: string;
|
|
1521
1548
|
}
|
|
1522
1549
|
/** One account's live credit situation, as the pool ranks it. */
|
|
1523
1550
|
interface WorkBuddyPoolCredits {
|
|
@@ -1924,6 +1951,15 @@ interface WorkBuddyWebPoolAccount {
|
|
|
1924
1951
|
atMs: number;
|
|
1925
1952
|
/** Only when the upstream named a time; never invented locally. */
|
|
1926
1953
|
retryAtMs?: number;
|
|
1954
|
+
/**
|
|
1955
|
+
* Why it failed, in the upstream's own redacted words.
|
|
1956
|
+
*
|
|
1957
|
+
* Carried so the pool table can distinguish a DNS failure from a gateway
|
|
1958
|
+
* 502 from a request the user cancelled, instead of repeating one generic
|
|
1959
|
+
* sentence for all three. ABSENT on success and on measurements written
|
|
1960
|
+
* before this field existed, so the card must render the bare outcome then.
|
|
1961
|
+
*/
|
|
1962
|
+
message?: string;
|
|
1927
1963
|
};
|
|
1928
1964
|
/** Set when this account cannot serve right now. */
|
|
1929
1965
|
excludedBy?: WorkBuddyWebPoolExclusion;
|
|
@@ -2193,8 +2229,15 @@ interface WorkBuddyPoolDeps {
|
|
|
2193
2229
|
* reports "unavailable" instead of appearing to succeed.
|
|
2194
2230
|
*/
|
|
2195
2231
|
checkin?(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolCheckinRow[]>;
|
|
2196
|
-
/**
|
|
2197
|
-
|
|
2232
|
+
/**
|
|
2233
|
+
* Test every account of one region against the resolved target model.
|
|
2234
|
+
*
|
|
2235
|
+
* `onRow` is optional and exists so the route can STREAM each account as it
|
|
2236
|
+
* finishes. An older Host ignores it and answers with the whole batch at once;
|
|
2237
|
+
* the route handles both, because a missing reporter must degrade to the
|
|
2238
|
+
* previous behaviour rather than to no rows at all.
|
|
2239
|
+
*/
|
|
2240
|
+
test?(region: WorkBuddyRegion, modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
|
|
2198
2241
|
/** The region's live catalog, used to resolve the free target model. */
|
|
2199
2242
|
catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
|
|
2200
2243
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-
|
|
1
|
+
import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-DSO-07lc.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1171,6 +1171,46 @@ const KIND_STATUS = {
|
|
|
1171
1171
|
server: 502,
|
|
1172
1172
|
client: 400
|
|
1173
1173
|
};
|
|
1174
|
+
/**
|
|
1175
|
+
* The `code` spelling the HOST is expected to classify each upstream failure by.
|
|
1176
|
+
*
|
|
1177
|
+
* The host decides whether a failed request may be retried IN PLACE (5 attempts
|
|
1178
|
+
* with backoff) or must be handed to cross-provider failover by reading English
|
|
1179
|
+
* phrases out of `type + " " + code + " " + message` — see
|
|
1180
|
+
* `isQuotaExceededError` in `@deepseek-ai/dsh-llm`. Only `RATE_LIMIT`, `SERVER`,
|
|
1181
|
+
* `TIMEOUT`, `TRANSPORT` and `EMPTY_RESPONSE` are in its retry set, so the
|
|
1182
|
+
* spelling decides which of two very different things happens next.
|
|
1183
|
+
*
|
|
1184
|
+
* `soft_rate` is the one kind that spelling got wrong. The upstream's 429 says
|
|
1185
|
+
* "usage exceeds the frequency limit, and it resets at 13:37" — hours away, not
|
|
1186
|
+
* two seconds. Sent as `soft_rate` it reads as an ordinary rate limit, enters
|
|
1187
|
+
* the in-place retry set, and spends the whole retry budget on an endpoint that
|
|
1188
|
+
* cannot recover within it; by the time cross-provider failover is offered, the
|
|
1189
|
+
* turn has already failed. The upstream's own words are Chinese, so the
|
|
1190
|
+
* phrase-based classifier cannot see the distinction either — the label is the
|
|
1191
|
+
* only channel left to carry it.
|
|
1192
|
+
*
|
|
1193
|
+
* `quota_exceeded` is the minimal honest translation: it is the WORDS the host
|
|
1194
|
+
* recognises, and it matches what the upstream is actually saying. The HTTP
|
|
1195
|
+
* status stays 429 (that is what the upstream returned — relabelling it 402
|
|
1196
|
+
* would misreport the upstream), and the upstream's own message is still passed
|
|
1197
|
+
* through verbatim for a human to read.
|
|
1198
|
+
*
|
|
1199
|
+
* Every other kind already lands outside the retry set by its status alone
|
|
1200
|
+
* (`hard_credit` 402 → quota, `session_dead` 401 → auth, 502/400 → other), so
|
|
1201
|
+
* none of them is listed: a translation nobody needs is a lie waiting to drift.
|
|
1202
|
+
*/
|
|
1203
|
+
const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
|
|
1204
|
+
/**
|
|
1205
|
+
* The `code` to write for an upstream failure, given its classified kind.
|
|
1206
|
+
*
|
|
1207
|
+
* `type` deliberately stays the plugin's own kind: it is what a human reads in
|
|
1208
|
+
* a log or a bug report, and it keeps the two fields individually meaningful.
|
|
1209
|
+
* Only `code` carries the host-facing translation.
|
|
1210
|
+
*/
|
|
1211
|
+
function hostErrorCode(kind) {
|
|
1212
|
+
return KIND_HOST_CODE[kind] ?? kind;
|
|
1213
|
+
}
|
|
1174
1214
|
function writeJson(res, status, body) {
|
|
1175
1215
|
const payload = JSON.stringify(body);
|
|
1176
1216
|
res.writeHead(status, {
|
|
@@ -1179,11 +1219,19 @@ function writeJson(res, status, body) {
|
|
|
1179
1219
|
});
|
|
1180
1220
|
res.end(payload);
|
|
1181
1221
|
}
|
|
1182
|
-
|
|
1222
|
+
/**
|
|
1223
|
+
* Write a JSON error the way an OpenAI-compatible client expects.
|
|
1224
|
+
*
|
|
1225
|
+
* `code` defaults to `type`, which is right for every error THIS shim raises on
|
|
1226
|
+
* its own (`unauthorized`, `not_found`, …): those are not upstream failures and
|
|
1227
|
+
* must not be disguised as one. The upstream failure path passes an explicit
|
|
1228
|
+
* `code` so the host classifies it correctly — see {@link KIND_HOST_CODE}.
|
|
1229
|
+
*/
|
|
1230
|
+
function writeOpenAIError(res, status, kind, message, code) {
|
|
1183
1231
|
writeJson(res, status, { error: {
|
|
1184
1232
|
message,
|
|
1185
1233
|
type: kind,
|
|
1186
|
-
code: kind
|
|
1234
|
+
code: code ?? kind
|
|
1187
1235
|
} });
|
|
1188
1236
|
}
|
|
1189
1237
|
/** Read a request body with a size cap; over-limit bodies fail the request. */
|
|
@@ -1674,11 +1722,10 @@ function createWorkBuddyShim(options) {
|
|
|
1674
1722
|
});
|
|
1675
1723
|
};
|
|
1676
1724
|
const reportFailures = () => {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
for (const entry of failures.splice(0, failures.length)) {
|
|
1725
|
+
const batch = failures.splice(0, failures.length);
|
|
1726
|
+
if (onAccountFailure === void 0) return;
|
|
1727
|
+
if (controller.signal.aborted) return;
|
|
1728
|
+
for (const entry of batch) {
|
|
1682
1729
|
if (entry.failure.ok) continue;
|
|
1683
1730
|
try {
|
|
1684
1731
|
onAccountFailure(entry.accountId, {
|
|
@@ -1707,7 +1754,7 @@ function createWorkBuddyShim(options) {
|
|
|
1707
1754
|
if (!result.ok) {
|
|
1708
1755
|
const attempts = triedAccountIds.length;
|
|
1709
1756
|
const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
|
|
1710
|
-
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}
|
|
1757
|
+
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
|
|
1711
1758
|
return;
|
|
1712
1759
|
}
|
|
1713
1760
|
res.writeHead(200, {
|
|
@@ -1993,12 +2040,18 @@ const PROBE_MESSAGE_LIMIT = 300;
|
|
|
1993
2040
|
/**
|
|
1994
2041
|
* Redact token-shaped content out of upstream text before it is stored or sent.
|
|
1995
2042
|
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2043
|
+
* Lives here rather than in the route layer because this module is the one that
|
|
2044
|
+
* reads raw failure bodies, so the redaction belongs at the point of capture. A
|
|
2045
|
+
* probe failure body is the one place a raw upstream string from an arbitrary
|
|
2046
|
+
* endpoint enters the plugin's data flow.
|
|
2047
|
+
*
|
|
2048
|
+
* EXPORTED because a failed CHAT request now persists its reason into the pool
|
|
2049
|
+
* store too (`recordAccountFailure` in `src/index.ts`), and that text is a raw
|
|
2050
|
+
* upstream body as well. One implementation, so the two writers cannot drift
|
|
2051
|
+
* into redacting differently — the same "one definition" rule this codebase
|
|
2052
|
+
* applies to `effectiveMembersOf`.
|
|
2000
2053
|
*/
|
|
2001
|
-
function
|
|
2054
|
+
function redactUpstreamText(text) {
|
|
2002
2055
|
return text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, PROBE_MESSAGE_LIMIT);
|
|
2003
2056
|
}
|
|
2004
2057
|
/**
|
|
@@ -2021,7 +2074,7 @@ async function probeModel(input) {
|
|
|
2021
2074
|
return {
|
|
2022
2075
|
modelId: input.modelId,
|
|
2023
2076
|
outcome: "unavailable",
|
|
2024
|
-
message:
|
|
2077
|
+
message: redactUpstreamText(error instanceof Error ? error.message : String(error))
|
|
2025
2078
|
};
|
|
2026
2079
|
}
|
|
2027
2080
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -2051,7 +2104,7 @@ async function probeModel(input) {
|
|
|
2051
2104
|
outcome,
|
|
2052
2105
|
elapsedMs,
|
|
2053
2106
|
status: answer.status,
|
|
2054
|
-
...body === "" ? {} : { message:
|
|
2107
|
+
...body === "" ? {} : { message: redactUpstreamText(body) },
|
|
2055
2108
|
...cooldownOf({
|
|
2056
2109
|
outcome,
|
|
2057
2110
|
retryAfter: answer.retryAfter,
|
|
@@ -2135,12 +2188,21 @@ async function checkinOne(target, deps) {
|
|
|
2135
2188
|
* exactly the silent spend the plan forbids.
|
|
2136
2189
|
*
|
|
2137
2190
|
* Never throws: a failed probe is a ROW, not an aborted batch.
|
|
2191
|
+
*
|
|
2192
|
+
* `onRow` fires the moment one account's row exists, which is what lets a caller
|
|
2193
|
+
* report progress. The loop is SERIAL by design (one account at a time — hitting
|
|
2194
|
+
* the whole pool at once is the fastest way to trip the upstream's volume limit),
|
|
2195
|
+
* so without this a caller cannot say anything until the SLOWEST member answers:
|
|
2196
|
+
* one stuck account made a working batch look like a dead button. The return
|
|
2197
|
+
* value is unchanged, so every existing caller keeps all rows at the end.
|
|
2138
2198
|
*/
|
|
2139
|
-
async function testAllAccounts(targets, modelId, deps) {
|
|
2199
|
+
async function testAllAccounts(targets, modelId, deps, onRow) {
|
|
2140
2200
|
const rows = [];
|
|
2141
2201
|
for (const [index, target] of targets.entries()) {
|
|
2142
2202
|
if (index > 0) await (deps.wait ?? defaultWait)(400);
|
|
2143
|
-
|
|
2203
|
+
const row = await testOne(target, modelId, deps);
|
|
2204
|
+
rows.push(row);
|
|
2205
|
+
onRow?.(row);
|
|
2144
2206
|
}
|
|
2145
2207
|
return rows;
|
|
2146
2208
|
}
|
|
@@ -2186,6 +2248,11 @@ async function testOne(target, modelId, deps) {
|
|
|
2186
2248
|
*
|
|
2187
2249
|
* `atMs` is stamped here, once for the batch, so the card's "tested N minutes
|
|
2188
2250
|
* ago" reflects when the batch finished rather than drifting per row.
|
|
2251
|
+
*
|
|
2252
|
+
* `message` is carried through as well. `probeModel` already redacted it before
|
|
2253
|
+
* returning, so persisting it cannot leak a token, and without it the pool table
|
|
2254
|
+
* could only repeat one generic sentence for a DNS failure, a gateway 502 and a
|
|
2255
|
+
* cancelled request alike.
|
|
2189
2256
|
*/
|
|
2190
2257
|
function probeUpdatesOf(rows) {
|
|
2191
2258
|
const atMs = Date.now();
|
|
@@ -2195,7 +2262,8 @@ function probeUpdatesOf(rows) {
|
|
|
2195
2262
|
updates[row.accountId] = {
|
|
2196
2263
|
outcome: row.result.outcome,
|
|
2197
2264
|
atMs,
|
|
2198
|
-
...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs }
|
|
2265
|
+
...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
|
|
2266
|
+
...row.result.message === void 0 ? {} : { message: row.result.message }
|
|
2199
2267
|
};
|
|
2200
2268
|
}
|
|
2201
2269
|
return updates;
|
|
@@ -2227,18 +2295,37 @@ function messageOf(error) {
|
|
|
2227
2295
|
*
|
|
2228
2296
|
* - A `credential-rejected` account is excluded regardless of any cooldown: its
|
|
2229
2297
|
* token is not accepted, so waiting cannot help and the user must re-auth.
|
|
2230
|
-
* - A limited outcome is checked against its stated (or
|
|
2231
|
-
* an account whose limit has already reset
|
|
2232
|
-
* needing another test
|
|
2298
|
+
* - A limited, unreachable or failed outcome is checked against its stated (or
|
|
2299
|
+
* fallback) cooldown, so an account whose limit has already reset — or whose
|
|
2300
|
+
* gateway blipped once — is back in the pool without needing another test
|
|
2301
|
+
* first.
|
|
2233
2302
|
* - An `ok` measurement, and no measurement at all, are both candidates. The
|
|
2234
2303
|
* latter matters: a freshly discovered account has never been tested and must
|
|
2235
2304
|
* not be invisible.
|
|
2236
2305
|
*
|
|
2306
|
+
* WHY TRANSIENT OUTCOMES GET A COOLDOWN. They are not statements about the
|
|
2307
|
+
* ACCOUNT. A transport failure can be a DNS blip, and — before the shim learned
|
|
2308
|
+
* to skip reporting an aborted request — it was routinely the user closing the
|
|
2309
|
+
* panel mid-flight. A 5xx is the gateway's afternoon, not this account's health.
|
|
2310
|
+
* Excluding them permanently meant one transient event idled a perfectly good
|
|
2311
|
+
* account until the user happened to press "test" again, and with every member
|
|
2312
|
+
* so idled the pool had no candidate at all: failover then had nowhere to go and
|
|
2313
|
+
* the raw upstream error was reported as-is. The cooldown is what makes the pool
|
|
2314
|
+
* RECOVER instead of staying broken.
|
|
2315
|
+
*
|
|
2316
|
+
* `not-found` and `credential-rejected` deliberately get none: one describes a
|
|
2317
|
+
* model the region no longer offers (a catalog fact, not a transient one) and
|
|
2318
|
+
* the other needs the user to sign in again. Neither is fixed by waiting, and
|
|
2319
|
+
* pretending otherwise would re-bill a token the upstream has already refused.
|
|
2320
|
+
*
|
|
2237
2321
|
* Each unusable outcome keeps its OWN value rather than being folded into
|
|
2238
2322
|
* `credential-rejected`. The fold made one row state two contradictory things —
|
|
2239
2323
|
* the name column said "被拒绝" (sign in again) while the probe column, reading
|
|
2240
2324
|
* the same measurement, said "连不上上游——这是网络问题,不是模型问题" — and it
|
|
2241
2325
|
* told a user whose upstream had merely blipped that their sign-in was bad.
|
|
2326
|
+
* (That label has since been made cause-NEUTRAL: it used to assert "not the
|
|
2327
|
+
* model" for a bucket that also holds gateway 5xx and request timeouts, where
|
|
2328
|
+
* the claim is simply unfounded. The recorded `message` now carries the cause.)
|
|
2242
2329
|
* The distinctions are already computed upstream in `outcomeOfFailure`
|
|
2243
2330
|
* (`src/probe.ts:290-299`); this only stops discarding them.
|
|
2244
2331
|
*/
|
|
@@ -2248,14 +2335,22 @@ function exclusionOf(probe, nowMs) {
|
|
|
2248
2335
|
case "ok": return;
|
|
2249
2336
|
case "credential-rejected": return "credential-rejected";
|
|
2250
2337
|
case "not-found": return "not-found";
|
|
2251
|
-
case "unavailable": return "unavailable";
|
|
2252
|
-
case "failed": return "failed";
|
|
2338
|
+
case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
|
|
2339
|
+
case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
|
|
2253
2340
|
case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
|
|
2254
2341
|
case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
|
|
2255
2342
|
default: return "unusable";
|
|
2256
2343
|
}
|
|
2257
2344
|
}
|
|
2258
|
-
/**
|
|
2345
|
+
/**
|
|
2346
|
+
* Whether an account's cooldown has elapsed (or was never stated).
|
|
2347
|
+
*
|
|
2348
|
+
* The fallback is the ONLY thing a transient outcome relies on: `cooldownOf`
|
|
2349
|
+
* attaches `retryAtMs` for a rate limit or a drained quota, never for a
|
|
2350
|
+
* transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
|
|
2351
|
+
* back. That is deliberate — the alternative is a permanent exclusion decided by
|
|
2352
|
+
* an event that said nothing about the account.
|
|
2353
|
+
*/
|
|
2259
2354
|
function retryDue(probe, nowMs) {
|
|
2260
2355
|
return nowMs >= (probe.retryAtMs ?? probe.atMs + 18e5);
|
|
2261
2356
|
}
|
|
@@ -2430,7 +2525,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
|
|
|
2430
2525
|
function isProbe(value) {
|
|
2431
2526
|
if (typeof value !== "object" || value === null) return false;
|
|
2432
2527
|
const candidate = value;
|
|
2433
|
-
return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number");
|
|
2528
|
+
return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string");
|
|
2434
2529
|
}
|
|
2435
2530
|
/**
|
|
2436
2531
|
* One region's path for measured pool facts.
|
|
@@ -2444,9 +2539,11 @@ function workbuddyPoolStorePath(region) {
|
|
|
2444
2539
|
/**
|
|
2445
2540
|
* Read one region's measured facts.
|
|
2446
2541
|
*
|
|
2447
|
-
* Contains NO credentials
|
|
2448
|
-
*
|
|
2449
|
-
*
|
|
2542
|
+
* Contains NO credentials. The one free-text field is `message`, and it is
|
|
2543
|
+
* REDACTED BY THE WRITER before it ever reaches this file (`probe.ts`'s
|
|
2544
|
+
* `redactUpstreamText`), so a token-shaped string cannot survive into storage.
|
|
2545
|
+
* Because of that the file is not written with the credential store's stricter
|
|
2546
|
+
* secrecy requirements, though it still uses the same atomic-write helpers.
|
|
2450
2547
|
*
|
|
2451
2548
|
* Returns an empty map for a missing, unreadable, malformed, or
|
|
2452
2549
|
* version-mismatched file: all four mean "nothing measured yet", which is a
|
|
@@ -2475,7 +2572,8 @@ async function readPoolProbes(region) {
|
|
|
2475
2572
|
probes[accountId] = {
|
|
2476
2573
|
outcome: probe.outcome,
|
|
2477
2574
|
atMs: probe.atMs,
|
|
2478
|
-
...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs }
|
|
2575
|
+
...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
|
|
2576
|
+
...probe.message === void 0 ? {} : { message: probe.message }
|
|
2479
2577
|
};
|
|
2480
2578
|
}
|
|
2481
2579
|
return probes;
|
|
@@ -2980,7 +3078,8 @@ async function workBuddyWebPool(deps, region) {
|
|
|
2980
3078
|
...measured?.probe === void 0 ? {} : { probe: {
|
|
2981
3079
|
outcome: measured.probe.outcome,
|
|
2982
3080
|
atMs: measured.probe.atMs,
|
|
2983
|
-
...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs }
|
|
3081
|
+
...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs },
|
|
3082
|
+
...measured.probe.message === void 0 ? {} : { message: measured.probe.message }
|
|
2984
3083
|
} },
|
|
2985
3084
|
...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
|
|
2986
3085
|
current: account.id === currentAccountId,
|
|
@@ -3175,12 +3274,32 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3175
3274
|
reason: target.source === "stale" ? "target-model-stale" : "no-free-model",
|
|
3176
3275
|
error: target.source === "stale" ? "the saved target model is no longer offered by this region; pick another or switch back to automatic" : "no zero-multiplier model in this region; refresh the catalog or set a target model"
|
|
3177
3276
|
});
|
|
3178
|
-
|
|
3179
|
-
|
|
3277
|
+
res.writeHead(200, {
|
|
3278
|
+
"Content-Type": "application/x-ndjson",
|
|
3279
|
+
"Cache-Control": "no-cache",
|
|
3280
|
+
"Connection": "keep-alive",
|
|
3281
|
+
"X-Accel-Buffering": "no"
|
|
3282
|
+
});
|
|
3283
|
+
const writeLine = (payload) => {
|
|
3284
|
+
res.write(`${JSON.stringify(payload)}\n`);
|
|
3285
|
+
};
|
|
3286
|
+
writeLine({
|
|
3180
3287
|
action: "test",
|
|
3181
|
-
modelId: target.modelId
|
|
3182
|
-
rows
|
|
3288
|
+
modelId: target.modelId
|
|
3183
3289
|
});
|
|
3290
|
+
try {
|
|
3291
|
+
await pool.test(region, target.modelId, (row) => {
|
|
3292
|
+
writeLine({ row });
|
|
3293
|
+
});
|
|
3294
|
+
writeLine({ done: true });
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
writeLine({
|
|
3297
|
+
reason: "pool-failed",
|
|
3298
|
+
error: safeMessage(error)
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
res.end();
|
|
3302
|
+
return;
|
|
3184
3303
|
} catch (error) {
|
|
3185
3304
|
json(res, 500, {
|
|
3186
3305
|
reason: "pool-failed",
|
|
@@ -3825,8 +3944,8 @@ function apply(ctx, config) {
|
|
|
3825
3944
|
}
|
|
3826
3945
|
return rows;
|
|
3827
3946
|
},
|
|
3828
|
-
test: async (region, modelId) => {
|
|
3829
|
-
const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region));
|
|
3947
|
+
test: async (region, modelId, onRow) => {
|
|
3948
|
+
const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region), onRow);
|
|
3830
3949
|
await writePoolProbes(region, probeUpdatesOf(rows));
|
|
3831
3950
|
return rows;
|
|
3832
3951
|
},
|
|
@@ -3868,11 +3987,19 @@ function apply(ctx, config) {
|
|
|
3868
3987
|
* Reads the candidate through `credentialFor()`, never `resolve()`: the saved
|
|
3869
3988
|
* selection and the store's runtime state stay untouched, so this is
|
|
3870
3989
|
* per-request borrowing rather than a silent change of who pays.
|
|
3990
|
+
*
|
|
3991
|
+
* Uses `localPoolMembers`, NOT `poolMembersOf`. This runs on the failure path
|
|
3992
|
+
* of a request the user is already waiting on, and `poolMembersOf` fetches
|
|
3993
|
+
* credits per member — so the expensive version turned one failed request into
|
|
3994
|
+
* N extra upstream calls before the retry even left. The doc above always said
|
|
3995
|
+
* "must not spend further requests"; the code used to contradict it. Credits
|
|
3996
|
+
* still steer the ranking when a recent snapshot exists, because
|
|
3997
|
+
* `localPoolMembers` reads the one the card's poll already paid for.
|
|
3871
3998
|
*/
|
|
3872
3999
|
const failoverAccountFor = async (region, triedAccountIds) => {
|
|
3873
4000
|
if (!poolPreferencesOf(current(), region).enabled) return void 0;
|
|
3874
4001
|
const tried = new Set(triedAccountIds);
|
|
3875
|
-
for (const row of rankPool(await
|
|
4002
|
+
for (const row of rankPool(await localPoolMembers(region), Date.now())) {
|
|
3876
4003
|
if (row.excludedBy !== void 0) continue;
|
|
3877
4004
|
if (tried.has(row.account.id)) continue;
|
|
3878
4005
|
const credential = await stacks[region].store.credentialFor(row.account.id);
|
|
@@ -3940,6 +4067,11 @@ function apply(ctx, config) {
|
|
|
3940
4067
|
* The reset time comes from the upstream's own words — the 429 body carries
|
|
3941
4068
|
* 「将在 … 重置」 — which `cooldownOf` parses. When it states no time, the store's
|
|
3942
4069
|
* own short window applies rather than a guess at a long one.
|
|
4070
|
+
*
|
|
4071
|
+
* The upstream's text is persisted alongside the outcome so the card can name
|
|
4072
|
+
* the reason. It is REDACTED first because this message is a raw upstream body:
|
|
4073
|
+
* without that, a failure containing a token-shaped string would write it into
|
|
4074
|
+
* a file on disk.
|
|
3943
4075
|
*/
|
|
3944
4076
|
const recordAccountFailure = async (region, accountId, failure) => {
|
|
3945
4077
|
if (!poolPreferencesOf(current(), region).enabled) return;
|
|
@@ -3953,10 +4085,12 @@ function apply(ctx, config) {
|
|
|
3953
4085
|
nowMs: Date.now(),
|
|
3954
4086
|
body: failure.message
|
|
3955
4087
|
});
|
|
4088
|
+
const message = failure.message === "" ? "" : redactUpstreamText(failure.message);
|
|
3956
4089
|
await writePoolProbes(region, { [accountId]: {
|
|
3957
4090
|
outcome,
|
|
3958
4091
|
atMs: Date.now(),
|
|
3959
|
-
...retryAtMs === void 0 ? {} : { retryAtMs }
|
|
4092
|
+
...retryAtMs === void 0 ? {} : { retryAtMs },
|
|
4093
|
+
...message === "" ? {} : { message }
|
|
3960
4094
|
} });
|
|
3961
4095
|
};
|
|
3962
4096
|
/**
|
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": "3.
|
|
5
|
+
"version": "3.4.1",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|