dsh-xdpool 2.6.1 → 2.6.3
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 +110 -6
- package/lib/bin.js +271 -18
- package/lib/client.js +1496 -33
- package/lib/index.d.ts +86 -2
- package/lib/index.js +874 -20
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +91 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,119 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
本项目的所有重要变更都会记录在此文件。
|
|
3
3
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
-
## 2.6.
|
|
4
|
+
## 2.6.3 — 修掉 2.6.2 装上后插件起不来的问题
|
|
5
5
|
|
|
6
|
-
>
|
|
6
|
+
> 2.6.2 从 npm 安装后,插件导入失败(`Cannot find package '@earendil-works/pi-ai'`),
|
|
7
|
+
> 于是它的所有 web 路由都没注册 —— 积分接口 404,用量面板空白,并显示
|
|
8
|
+
> 「网关尚未上报 token 数」。这句话把问题指向了网关,实际是插件自己没加载起来。
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
### 缺 7 个 peer 依赖
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
12
|
+
2.6.2 有 7 个 peerDependency(`cordis`、`dsh-llm`、`dsh-llm-pi-ai`、`dsh-credentials`、
|
|
13
|
+
`dsh-settings`、`schemastery`、`pi-ai`),这些由宿主提供。手动复制目录时它们从宿主的
|
|
14
|
+
`node_modules` 可见;改用包管理器安装后,解析链断了。
|
|
15
|
+
|
|
16
|
+
现在 `postinstall` 会把这些包从宿主链接进 profile,版本与宿主完全一致,不额外占用空间。
|
|
17
|
+
装完自带一次导入自检,失败会直接把原因写进日志,而不是等用户看到空白面板才发现。
|
|
18
|
+
|
|
19
|
+
### 今日用量的三个缺陷
|
|
20
|
+
|
|
21
|
+
**日期用了 UTC**:`toISOString()` 取的是 UTC 日期,在 UTC+8 意味着凌晨 0–8 点算成前一天。
|
|
22
|
+
现在按本地日历取值。
|
|
23
|
+
|
|
24
|
+
**`days: 1` 实际覆盖两天**:窗口下界原先减了整个 `days`,于是「今日」包含了昨天。
|
|
25
|
+
改成 `days - 1`,标签与口径一致。
|
|
26
|
+
|
|
27
|
+
**重启后归零**:`UsageRecorder` 只存内存,重启即空。现在启动时从渠道包的持久账本
|
|
28
|
+
(保留 90 天)回填当天用量 —— 那些数据一直在磁盘上,只是从没被读回来。
|
|
29
|
+
回填是尽力而为的:读不到只损失重启前的数字,不影响插件。
|
|
30
|
+
|
|
31
|
+
## 2.6.2 — 积分统计、账号备份、输入框积分显示
|
|
32
|
+
|
|
33
|
+
> 这一版把「用了多少积分」「还剩多少」「账号怎么搬走」三件事做完了,并修掉了几个把正确数据说错的地方。
|
|
34
|
+
|
|
35
|
+
### 积分消耗:按官方的三段系数算,不再用倍率当单价
|
|
36
|
+
|
|
37
|
+
模型选择器上的 `x0.11` 是一个**显示倍率**,不是每百万 token 的单价。真实计费是三段系数:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
积分 = 未命中缓存的输入 × 输入系数 + 缓存命中 × 缓存系数 + 输出 × 输出系数
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
系数单位是「积分 / 百万 token」,同一模型三段差别很大(DeepSeek-V4.1-Flash 是 14.29 / 0.29 / 57.1)。
|
|
44
|
+
|
|
45
|
+
按旧算法(总 token × 倍率),一天 355 万输入 + 2.88 亿缓存 + 36 万输出会被算成 **0.43 积分**,实际是 **155 积分** —— 差 360 倍。原因有两个:把显示倍率当成了单价,以及**完全没读缓存字段**。agent 类使用里缓存命中常占 95% 以上,忽略它就等于漏掉大部分账单。
|
|
46
|
+
|
|
47
|
+
- 13 个模型的实测系数写进 `credits.ts`,标注测量日期(`2026-11-06`)
|
|
48
|
+
- 读取账本里的 `cacheReadTokens`,按缓存档单独计费
|
|
49
|
+
- 未收录的模型显示「未定价」,不按猜的价格计费
|
|
50
|
+
- 模型 id 大小写、厂商前缀都能匹配(`DeepSeek-V4.1-Flash`、`nvidia/xxx`)
|
|
51
|
+
- 只统计账号池自己的用量(`direct`),网关反代的流量单独计数、不计入
|
|
52
|
+
|
|
53
|
+
### 输入框旁加了积分显示
|
|
54
|
+
|
|
55
|
+
位置:**输入框那一行,模型名旁边**。一个池子图标,绿色表示还有积分,灰色表示用完;点开看剩余积分、今日已用和当前模型。
|
|
56
|
+
|
|
57
|
+
### 账号备份:独立的第七个功能页
|
|
58
|
+
|
|
59
|
+
导出加密文件,导入时整体替换。
|
|
60
|
+
|
|
61
|
+
- 加密在浏览器里完成:PBKDF2-SHA256(31 万轮)+ AES-GCM-256,salt 和 IV 每次重新生成
|
|
62
|
+
- 密码至少 8 位,不足直接拒绝导出
|
|
63
|
+
- 密码错和文件损坏合并成一句话 —— 区分会泄露「猜对了没有」
|
|
64
|
+
- 导入前弹确认,用 `backup.status` 报出将替换几个账号
|
|
65
|
+
- 仍接受旧的未加密文件,不让早期导出的人被卡住
|
|
66
|
+
- 一个按钮完成「选文件 + 导入」,不再是两步
|
|
67
|
+
|
|
68
|
+
### 商汤的登录奖励
|
|
69
|
+
|
|
70
|
+
`credits.claimAll` 对 Raccoon 明确拒绝,原文是「每日积分由服务端自动发放;登录奖励请在「新手任务」中领取」—— 但它只是把提示当错误返回。真正的入口是独立端点 `onboarding.claim`,pack 里早已实现,只是客户端从未调用。
|
|
71
|
+
|
|
72
|
+
现在有「领取一次性奖励」按钮,对 Raccoon 和 Loomy 生效,按账号逐个领取。
|
|
73
|
+
|
|
74
|
+
### CodeArts:三个缺陷
|
|
75
|
+
|
|
76
|
+
**403 被当成凭据问题**(沿用 2.6.0 的修复并延续):网关对 User-Agent 的 WAF 判定也会回 403,而 `403 → AUTH` 的规则会让一个请求头问题被报成「API 密钥无效」。CodeArts 那条已按 body 区分,其余厂商不动(对多数厂商 403 确实是凭据问题)。
|
|
77
|
+
|
|
78
|
+
**被用掉的 refresh token 没有归入「凭据失效」**:`requestToken` 只认 `invalid_grant` 和 `ExpiredRefreshToken`,`STS5.1806`(`the refresh token has been used`)落到普通错误分支。而代码里**早就写好了**处理这种情况的逻辑 —— `instanceof RefreshTokenExpiredError` 会重读凭据,token 已被别处轮换时对账并**不**标记为死。错误没带着正确的类型到达那里,所以用户看到的只是一串看不懂的 400。
|
|
79
|
+
|
|
80
|
+
华为 STS 的 refresh token 是**一次性**的:用过即作废并签发新的,所以「已被使用」意味着并发重放,或新 token 没存住。
|
|
81
|
+
|
|
82
|
+
**过期的凭据不自愈**:STS 凭据寿命约 2 小时,refresh token 约 30 天。一个因模型请求失败而被 failover 走的账号,永远不再进入调度器「1 小时内到期」的续期窗口,于是一直躺在一个可续期的过期状态里。现在自愈挂在三条读凭据的 RPC 共享入口(余额、签到状态、领取),走与调度器同一条 per-ref 串行队列,避免并发消费同一个 token。
|
|
83
|
+
|
|
84
|
+
### 路由
|
|
85
|
+
|
|
86
|
+
- **限流靠后**:还有额度却被 429 的账号,排到所有未限流的后面。之前它和「额度耗尽」被同等对待,而两者该给的回应不同
|
|
87
|
+
- **休息不丢弃**:被拒的账号排最后,但**只剩它时仍然尝试**,而不是直接失败
|
|
88
|
+
- **未知账户先试一次**:从未读过余额的账号在 `priority` 模式下优先一次,让它的答案把额度填上
|
|
89
|
+
- **只认 `ready` 档**:策略在 `ready` 池里选,`limited`/`resting` 只有在 `ready` 空时才成为候选 —— 否则按权重抽取的策略可能跳过一个健康账号去选冷却中的
|
|
90
|
+
- 修掉了「均衡模式下、有健康账号时仍可能选到冷却账号」
|
|
91
|
+
- 链可以选到扫码登录渠道(`<channel>:<model>`),走宿主适配器,与聚合渠道严格区分(凭据、错误词汇、代码路径都不同)
|
|
92
|
+
- 两级结构:先选平台,平台内再排模型
|
|
93
|
+
|
|
94
|
+
### 网关
|
|
95
|
+
|
|
96
|
+
- **模型 id 可点击复制**,点它不会误触勾选框(那一行是 `<label>`,不阻止就会把刚复制的模型取消勾选)
|
|
97
|
+
- **base URL 提供两种写法** —— 客户端自己补 `/v1` 的和要填完整前缀的,各自标注适用场景。之前只给带 `/v1` 的,遇到自己补 `/v1` 的客户端就成了 `/v1/v1/responses`
|
|
98
|
+
- 明确写出只支持 Chat Completions;用 Responses 或 Anthropic 协议会拿到 404
|
|
99
|
+
- 按渠道分组时显示每个模型的倍率
|
|
100
|
+
- **`EADDRINUSE` 翻译成可操作的说明**:`listen EADDRINUSE` 本身只说现象。最常见原因不是「别的程序占了端口」,而是**上一个 DSH 还没完全退出**,仍占着自己开的网关端口 —— 所以提示是「稍等重试,或换个端口」
|
|
101
|
+
- 复制按钮带降级:`navigator.clipboard` 在非安全上下文(局域网 http)里是 `undefined` 而非拒绝,所以有 `execCommand` 兜底,两条都不行就明确报失败
|
|
102
|
+
|
|
103
|
+
### 界面
|
|
104
|
+
|
|
105
|
+
- 交流群二维码改成**就地展开**在标签栏下方,不再是弹窗
|
|
106
|
+
- 密码字段、复制按钮、进度条等控件的样式统一
|
|
107
|
+
- 样式按页面分组导致的失效问题修复了两处(圆点、备份面板)—— 跨页面组件现在自带样式表
|
|
108
|
+
|
|
109
|
+
### 其它
|
|
110
|
+
|
|
111
|
+
- 渠道池首屏 `provider.status` 加 30 秒缓存(一次请求要数 13 个渠道的账号)
|
|
112
|
+
- 免密 lane 默认关闭
|
|
113
|
+
- 同模型多线路优先(限流时换线路拿同一模型,而不是换模型)
|
|
114
|
+
- 熔断后的账号靠探测复活,探测成功才解除
|
|
115
|
+
- 自定义 OpenAI 兼容端点
|
|
116
|
+
- 凭据文件权限收紧到仅本人可读
|
|
13
117
|
|
|
14
118
|
## 2.6.0 — 一次收掉 2.5.1 之后的全部改动
|
|
15
119
|
|
package/lib/bin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
3
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, unlink, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
6
|
import { execFile, execFileSync } from "node:child_process";
|
|
@@ -3404,12 +3404,46 @@ async function cheapIdentityIdFromFile(path) {
|
|
|
3404
3404
|
}
|
|
3405
3405
|
}
|
|
3406
3406
|
/**
|
|
3407
|
+
* The `uin` of a credential file, read WITHOUT the expiry gate.
|
|
3408
|
+
*
|
|
3409
|
+
* `parseWorkBuddyAuth` deliberately refuses an expired document, which is exactly the
|
|
3410
|
+
* state this is asked about — so the identity has to come from the raw JSON. `uin` and not
|
|
3411
|
+
* `uid`: `uin` is the billing identity, so two sign-ins of one person match even when the
|
|
3412
|
+
* desktop app wrote them from different installs.
|
|
3413
|
+
*/
|
|
3414
|
+
async function cheapUinFromFile(path) {
|
|
3415
|
+
let parsed;
|
|
3416
|
+
try {
|
|
3417
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
3418
|
+
} catch {
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
3422
|
+
const document = parsed;
|
|
3423
|
+
const uin = (typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document)["uin"];
|
|
3424
|
+
return typeof uin === "string" && uin !== "" ? uin : void 0;
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3407
3427
|
* Why a file could not become an account.
|
|
3408
3428
|
*
|
|
3409
|
-
*
|
|
3410
|
-
*
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
3429
|
+
* The distinctions matter because the REMEDIES differ, and a wrong label sends the reader
|
|
3430
|
+
* after a fix that cannot work:
|
|
3431
|
+
*
|
|
3432
|
+
* - `encrypted` — the desktop app holds the key; start it, or set the executable
|
|
3433
|
+
* override. Actionable.
|
|
3434
|
+
* - `expired` — the file IS a credential, correctly formed, whose refresh window has
|
|
3435
|
+
* closed. Nothing to fix: sign in again, or delete the snapshot. This
|
|
3436
|
+
* used to be reported as `malformed`, which reads as "this file is not
|
|
3437
|
+
* a credential" and sends the user looking for a format problem that
|
|
3438
|
+
* does not exist — the reported confusion over three 2026-10-08
|
|
3439
|
+
* snapshots whose tokens had simply aged out.
|
|
3440
|
+
* - `unreadable` — the bytes could not be read at all.
|
|
3441
|
+
* - `malformed` — genuine: unparseable, or parseable but not a credential shape.
|
|
3442
|
+
*
|
|
3443
|
+
* `expired` is decided by re-parsing the document the same way `parseWorkBuddyAuth` does,
|
|
3444
|
+
* which is the only way to tell "a credential that aged out" from "not a credential". A
|
|
3445
|
+
* document that parses as JSON but carries no token stays `malformed`: those are three
|
|
3446
|
+
* different facts and collapsing any two of them costs the reader the diagnosis.
|
|
3413
3447
|
*/
|
|
3414
3448
|
async function skipReasonFor(path) {
|
|
3415
3449
|
let text;
|
|
@@ -3419,12 +3453,28 @@ async function skipReasonFor(path) {
|
|
|
3419
3453
|
return "unreadable";
|
|
3420
3454
|
}
|
|
3421
3455
|
if (text.includes("\"$wbEncrypted\"")) return "encrypted";
|
|
3456
|
+
let parsed;
|
|
3422
3457
|
try {
|
|
3423
|
-
JSON.parse(text);
|
|
3424
|
-
return "malformed";
|
|
3458
|
+
parsed = JSON.parse(text);
|
|
3425
3459
|
} catch {
|
|
3426
3460
|
return "malformed";
|
|
3427
3461
|
}
|
|
3462
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "malformed";
|
|
3463
|
+
const document = parsed;
|
|
3464
|
+
const auth = typeof document["auth"] === "object" && document["auth"] !== null ? document["auth"] : document;
|
|
3465
|
+
const token = auth["accessToken"];
|
|
3466
|
+
if (typeof token !== "string" || token === "") return "malformed";
|
|
3467
|
+
/**
|
|
3468
|
+
* It HAS a token, so the only remaining reason to skip it is a closed refresh window.
|
|
3469
|
+
* Mirrors the guard in `parseWorkBuddyAuth`, and falls back to `malformed` when the
|
|
3470
|
+
* window cannot be read — an unknown expiry is not evidence of expiry.
|
|
3471
|
+
*/
|
|
3472
|
+
const refreshRaw = auth["refreshExpiresAt"];
|
|
3473
|
+
if (typeof refreshRaw === "number") {
|
|
3474
|
+
const refreshAt = expiryToMs(refreshRaw);
|
|
3475
|
+
if (refreshAt > 0 && refreshAt < Date.now()) return "expired";
|
|
3476
|
+
}
|
|
3477
|
+
return "malformed";
|
|
3428
3478
|
}
|
|
3429
3479
|
/**
|
|
3430
3480
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
@@ -3726,6 +3776,46 @@ var WorkBuddyAccountPool = class {
|
|
|
3726
3776
|
skippedFilesInOrder() {
|
|
3727
3777
|
return this.skippedFiles;
|
|
3728
3778
|
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Delete expired snapshots that a newer credential for the same account supersedes.
|
|
3781
|
+
*
|
|
3782
|
+
* Returns the paths actually removed. See the call site for the full safety rule; the
|
|
3783
|
+
* short version is that it touches ONLY files classified `expired`, ONLY when the same
|
|
3784
|
+
* `uin` is already served by a different (loaded) file, and never a live file.
|
|
3785
|
+
*
|
|
3786
|
+
* `uin` rather than `uid`: it is the billing identity, so two sign-ins of one person
|
|
3787
|
+
* match even when the desktop app wrote them from different installs. A file whose
|
|
3788
|
+
* identity cannot be read is left alone — deleting a credential on a guess is worse than
|
|
3789
|
+
* keeping a dead one on the card.
|
|
3790
|
+
*/
|
|
3791
|
+
async pruneExpiredSnapshots(skipped, loaded) {
|
|
3792
|
+
const expired = skipped.filter((entry) => entry.reason === "expired");
|
|
3793
|
+
if (expired.length === 0) return [];
|
|
3794
|
+
const liveNames = /* @__PURE__ */ new Set([WORKBUDDY_LIVE_FILENAME, "workbuddy-desktop-ai.info"]);
|
|
3795
|
+
/**
|
|
3796
|
+
* The identities already served by a file that loaded in this same scan.
|
|
3797
|
+
*
|
|
3798
|
+
* Taken from the parsed credentials rather than by re-reading each file: `found`
|
|
3799
|
+
* already carries `uin`, and a second read of a file that just parsed would only add a
|
|
3800
|
+
* chance to fail.
|
|
3801
|
+
*/
|
|
3802
|
+
const servedUins = /* @__PURE__ */ new Set();
|
|
3803
|
+
for (const credential of loaded) if (credential.uin !== void 0 && credential.uin !== "") servedUins.add(credential.uin);
|
|
3804
|
+
if (servedUins.size === 0) return [];
|
|
3805
|
+
const removed = [];
|
|
3806
|
+
for (const entry of expired) {
|
|
3807
|
+
if (liveNames.has(basename(entry.path))) continue;
|
|
3808
|
+
const uin = await cheapUinFromFile(entry.path);
|
|
3809
|
+
if (uin === void 0 || !servedUins.has(uin)) continue;
|
|
3810
|
+
try {
|
|
3811
|
+
await unlink(entry.path);
|
|
3812
|
+
removed.push(entry.path);
|
|
3813
|
+
} catch (error) {
|
|
3814
|
+
this.logger?.warn?.(`dsh-xdpool: could not remove expired snapshot ${entry.path}: ${String(error)}`);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
return removed;
|
|
3818
|
+
}
|
|
3729
3819
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
3730
3820
|
async scan() {
|
|
3731
3821
|
const found = [];
|
|
@@ -3752,6 +3842,33 @@ var WorkBuddyAccountPool = class {
|
|
|
3752
3842
|
}
|
|
3753
3843
|
this.skippedFiles = skipped;
|
|
3754
3844
|
if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
|
|
3845
|
+
/**
|
|
3846
|
+
* Remove expired snapshots whose account is served by a newer credential.
|
|
3847
|
+
*
|
|
3848
|
+
* A rotated sign-in leaves the old snapshot on disk forever: the desktop app writes a
|
|
3849
|
+
* new `workbuddy-desktop.<ts>.<pid>.<uuid>.info` each time and never deletes the
|
|
3850
|
+
* previous one. Within a month every one of them passes its `refreshExpiresAt` and the
|
|
3851
|
+
* card starts listing a growing pile of "expired" files the user cannot act on —
|
|
3852
|
+
* they are dead weight from a sign-in that already succeeded.
|
|
3853
|
+
*
|
|
3854
|
+
* The safety rule is narrow on purpose, because deleting a credential file is
|
|
3855
|
+
* destructive and the auth directory belongs to another application:
|
|
3856
|
+
*
|
|
3857
|
+
* - ONLY files this scan classified as `expired` — never `encrypted`, `unreadable`
|
|
3858
|
+
* or `malformed`, which may be the very file holding a working key;
|
|
3859
|
+
* - ONLY when the SAME account (by `uin`, the billing identity) was loaded from a
|
|
3860
|
+
* DIFFERENT file, i.e. a newer credential exists to serve it;
|
|
3861
|
+
* - never the live `workbuddy-desktop.info` / `workbuddy-desktop-ai.info`, which the
|
|
3862
|
+
* running app owns and rewrites.
|
|
3863
|
+
*
|
|
3864
|
+
* Anything skipped for not meeting all three is left alone and keeps its entry on the
|
|
3865
|
+
* card, so the user still learns it is there.
|
|
3866
|
+
*/
|
|
3867
|
+
const cleaned = await this.pruneExpiredSnapshots(skipped, found);
|
|
3868
|
+
if (cleaned.length > 0) {
|
|
3869
|
+
this.skippedFiles = skipped.filter((entry) => !cleaned.includes(entry.path));
|
|
3870
|
+
this.logger?.info?.(`dsh-xdpool: removed ${cleaned.length} expired credential snapshot(s) superseded by a newer sign-in`);
|
|
3871
|
+
}
|
|
3755
3872
|
if (this.externalSource !== void 0) try {
|
|
3756
3873
|
const external = await this.externalSource();
|
|
3757
3874
|
for (const credential of external) {
|
|
@@ -3855,6 +3972,86 @@ var WorkBuddyAccountPool = class {
|
|
|
3855
3972
|
});
|
|
3856
3973
|
}
|
|
3857
3974
|
/**
|
|
3975
|
+
* The pool ordered into three bands, so a resting account is deferred instead of
|
|
3976
|
+
* discarded.
|
|
3977
|
+
*
|
|
3978
|
+
* `available()` answers a binary question, and a binary answer loses the
|
|
3979
|
+
* distinction that matters most when a pool is under pressure:
|
|
3980
|
+
*
|
|
3981
|
+
* - an account that still HAS credits but was rate-limited a moment ago is
|
|
3982
|
+
* healthy — it should be tried AFTER the others, not treated as gone;
|
|
3983
|
+
* - an account resting after a failure is the last resort, but it is still a
|
|
3984
|
+
* resort: when it is the only one, trying it beats failing the request.
|
|
3985
|
+
*
|
|
3986
|
+
* A pool that drops both from the list turns "every account is cooling for
|
|
3987
|
+
* twenty more seconds" into an error the user reads as "no account available",
|
|
3988
|
+
* which is a different and much more alarming statement.
|
|
3989
|
+
*
|
|
3990
|
+
* The bands:
|
|
3991
|
+
*
|
|
3992
|
+
* 1. `ready` — passes every gate `available()` checks.
|
|
3993
|
+
* 2. `limited` — region-eligible and not disabled/reserved, but cooling for
|
|
3994
|
+
* this model or account-wide. Ordered by when it comes back,
|
|
3995
|
+
* so the account that recovers first is spent first.
|
|
3996
|
+
* 3. `resting` — sign-in was rejected by the upstream. It never heals on a
|
|
3997
|
+
* timer, so it is only tried when 1 and 2 are both empty.
|
|
3998
|
+
*
|
|
3999
|
+
* `disabled` and `reserve` accounts appear in NO band: those are user choices,
|
|
4000
|
+
* and overriding them is not resilience, it is ignoring an instruction.
|
|
4001
|
+
*
|
|
4002
|
+
* Kept separate from `available()` rather than replacing it, so the binary
|
|
4003
|
+
* question still has a binary answer for callers that want one (diagnostics,
|
|
4004
|
+
* the status document) without either copy drifting from the other.
|
|
4005
|
+
*/
|
|
4006
|
+
rankedPool(now, modelId, region, reserveExempt) {
|
|
4007
|
+
const ready = [];
|
|
4008
|
+
const limited = [];
|
|
4009
|
+
const resting = [];
|
|
4010
|
+
for (const account of this.accounts) {
|
|
4011
|
+
if (this.disabledIds.has(account.id)) continue;
|
|
4012
|
+
const reserve = this.creditReserves.get(account.id);
|
|
4013
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
4014
|
+
const balance = this.creditBalances.get(account.id);
|
|
4015
|
+
if (balance !== void 0 && balance <= reserve) continue;
|
|
4016
|
+
}
|
|
4017
|
+
if (region !== void 0 && regionOf(account.credential.domain) !== region) continue;
|
|
4018
|
+
const deadUntil = account.credentialDeadUntilMs ?? 0;
|
|
4019
|
+
const coolUntil = account.cooldownUntilMs;
|
|
4020
|
+
const modelUntil = modelId === void 0 ? 0 : account.modelCooldowns[modelId] ?? 0;
|
|
4021
|
+
if (deadUntil > now) {
|
|
4022
|
+
resting.push(account);
|
|
4023
|
+
continue;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Only a MODEL-level cooldown earns a retry; an account-wide one does not.
|
|
4027
|
+
*
|
|
4028
|
+
* The distinction is what keeps the tier honest. A 429 on one model leaves
|
|
4029
|
+
* the account working for every other model, so trying it again for a
|
|
4030
|
+
* different model is reasonable and often succeeds. An account-wide cooldown
|
|
4031
|
+
* (credits exhausted, quota wall) means the gateway has already said no to
|
|
4032
|
+
* this account as a whole — retrying it spends a round trip to be told the
|
|
4033
|
+
* same thing, and the caller then reports 502 where the truthful answer was
|
|
4034
|
+
* 429. That degradation is worse than the resilience is worth, so the
|
|
4035
|
+
* account-wide case stays out of play entirely.
|
|
4036
|
+
*/
|
|
4037
|
+
if (coolUntil > now) continue;
|
|
4038
|
+
if (modelUntil > now) {
|
|
4039
|
+
limited.push({
|
|
4040
|
+
account,
|
|
4041
|
+
backAt: modelUntil
|
|
4042
|
+
});
|
|
4043
|
+
continue;
|
|
4044
|
+
}
|
|
4045
|
+
ready.push(account);
|
|
4046
|
+
}
|
|
4047
|
+
limited.sort((a, b) => a.backAt - b.backAt);
|
|
4048
|
+
return {
|
|
4049
|
+
ready,
|
|
4050
|
+
limited: limited.map((entry) => entry.account),
|
|
4051
|
+
resting
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
3858
4055
|
* Why no account is available right now, for an accurate error.
|
|
3859
4056
|
*
|
|
3860
4057
|
* The pool can be empty for reasons that need OPPOSITE remedies: nobody is
|
|
@@ -4139,27 +4336,82 @@ var WorkBuddyAccountPool = class {
|
|
|
4139
4336
|
if (this.accounts.length === 0) await this.scan();
|
|
4140
4337
|
const maxTries = this.accounts.length + 1;
|
|
4141
4338
|
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
4142
|
-
let
|
|
4143
|
-
if (
|
|
4339
|
+
let bands = this.rankedPool(Date.now(), modelId, region, reserveExempt);
|
|
4340
|
+
if (bands.ready.length === 0 && bands.limited.length === 0 && bands.resting.length === 0 && attempt === 0) {
|
|
4144
4341
|
await this.scan();
|
|
4145
|
-
|
|
4342
|
+
bands = this.rankedPool(Date.now(), modelId, region, reserveExempt);
|
|
4146
4343
|
}
|
|
4147
|
-
|
|
4344
|
+
/**
|
|
4345
|
+
* Fall through the bands, in order — but the fall-through is a STAGE, not a
|
|
4346
|
+
* concatenation, and only `ready` is offered to the strategy when it is
|
|
4347
|
+
* non-empty.
|
|
4348
|
+
*
|
|
4349
|
+
* This distinction is the whole correctness of the change. A strategy like
|
|
4350
|
+
* `balanced` draws from the pool by weight and does not read the order at
|
|
4351
|
+
* all, so handing it `[...ready, ...limited]` would let it pick a cooling
|
|
4352
|
+
* account while a healthy one was sitting right there — silently breaking
|
|
4353
|
+
* the promise "a cooling account is never chosen while a healthy one
|
|
4354
|
+
* exists".
|
|
4355
|
+
*
|
|
4356
|
+
* The wider rule: a band is only tried when every earlier band is EMPTY.
|
|
4357
|
+
* That keeps the truthful error intact — "every account is cooling for this
|
|
4358
|
+
* model" must stay a 429 answered without touching the transport, not a
|
|
4359
|
+
* round trip that comes back 502. Resilience is a last resort, not a
|
|
4360
|
+
* preference, and a pool where nothing is ready has already told the caller
|
|
4361
|
+
* exactly what is wrong.
|
|
4362
|
+
*/
|
|
4363
|
+
const stages = bands.ready.length > 0 ? [bands.ready] : [];
|
|
4364
|
+
if (stages.length === 0) return;
|
|
4148
4365
|
if (this.preferredAccountId !== void 0) {
|
|
4149
|
-
const preferred =
|
|
4366
|
+
const preferred = bands.ready.find((account) => account.id === this.preferredAccountId);
|
|
4150
4367
|
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
4151
4368
|
}
|
|
4152
4369
|
if (this.distribution === "sticky") {
|
|
4153
|
-
const bound = this.affinityAccount(
|
|
4370
|
+
const bound = this.affinityAccount(bands.ready, conversationKey);
|
|
4154
4371
|
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
4155
4372
|
}
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4373
|
+
/**
|
|
4374
|
+
* An account whose balance has never been read goes first, ONCE — but only
|
|
4375
|
+
* under `priority`.
|
|
4376
|
+
*
|
|
4377
|
+
* The reasoning is `priority`-specific: that mode drains one account at a
|
|
4378
|
+
* time, so a fresh account appended to the pool would sit untouched behind an
|
|
4379
|
+
* account that is nearly spent, and the pool would never learn what the new
|
|
4380
|
+
* one has. Trying it once makes its own answer fill in the blank.
|
|
4381
|
+
*
|
|
4382
|
+
* `round-robin`, `balanced`, `sticky` and `expiry` already reach a new
|
|
4383
|
+
* account by their own rule — the cursor walks to it, the weight draw will
|
|
4384
|
+
* eventually pick it, a new conversation moves on to it — so jumping the
|
|
4385
|
+
* queue here would not add information, it would only override the
|
|
4386
|
+
* distribution the user chose. That was a real regression: it pinned every
|
|
4387
|
+
* rotation to the first unprobed account.
|
|
4388
|
+
*
|
|
4389
|
+
* Restricted to `ready` as well: an account that is cooling is not made more
|
|
4390
|
+
* attractive by being unknown, and probing it would spend a request to learn
|
|
4391
|
+
* a number that does not change whether it can answer.
|
|
4392
|
+
*/
|
|
4393
|
+
const probe = this.distribution === "priority" && bands.ready.length > 1 ? bands.ready.find((account) => !this.creditBalances.has(account.id)) : void 0;
|
|
4394
|
+
if (probe !== void 0 && this.isStageEligible(probe, bands)) {
|
|
4395
|
+
if (await this.ensureFresh(probe)) return probe;
|
|
4396
|
+
}
|
|
4397
|
+
for (const pool of stages) {
|
|
4398
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : this.distribution === "expiry" ? this.pickByExpiry(pool) : pool[0];
|
|
4399
|
+
if (account === void 0) continue;
|
|
4400
|
+
if (!await this.ensureFresh(account)) continue;
|
|
4401
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
4402
|
+
return account;
|
|
4403
|
+
}
|
|
4161
4404
|
}
|
|
4162
4405
|
}
|
|
4406
|
+
/**
|
|
4407
|
+
* Whether an account belongs to a band that is currently in play.
|
|
4408
|
+
*
|
|
4409
|
+
* Only `ready` accounts are eligible for the probe shortcut: picking a resting
|
|
4410
|
+
* account ahead of a healthy one is the very thing the bands exist to prevent.
|
|
4411
|
+
*/
|
|
4412
|
+
isStageEligible(account, bands) {
|
|
4413
|
+
return bands.ready.includes(account);
|
|
4414
|
+
}
|
|
4163
4415
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
4164
4416
|
currentDistribution() {
|
|
4165
4417
|
return this.distribution;
|
|
@@ -11637,6 +11889,7 @@ Object.freeze({
|
|
|
11637
11889
|
"Drop pleasantries, repetition and superseded attempts.",
|
|
11638
11890
|
"Write the briefing only — no preamble, no markdown fence."
|
|
11639
11891
|
].join("\n");
|
|
11892
|
+
join("channel-pack", "token-ledger.json");
|
|
11640
11893
|
//#endregion
|
|
11641
11894
|
//#region src/status.ts
|
|
11642
11895
|
/** Format the per-model credit multipliers. */
|