dsh-connect-workbuddy 4.6.5 → 4.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.7 (2026-10-17)
4
+
5
+ > 版本号是 **patch**:只改账号池的排序规则,没有接口或配置变更。但它是**行为变更**——开启账号池的机器上,「这次请求用谁」可能变了,且新行为正是本版要的。
6
+
7
+ ### Fixes
8
+
9
+ - **「快过期的积分优先」从未生效:第 3 个排序键被第 2 个键完全遮蔽,临期积分必然作废(issue #70)**
10
+ - **症状**:开启账号池后,`当前使用` 永远是积分总量最高的那个账号;哪怕另一个账号有积分**当天就要过期**,它也一直排最后,直到临期积分作废。
11
+ - **根因**:`rankPool()` 的排序键是**严格字典序**——先比积分总量(`scoreOf()` = `credits.total`),**只有总量完全相等**才比到期时间。而总量几乎从不恰好相等,所以「快过期优先」这条规则**在数学上不可达**。报告者的真实病例:账号 C 有 **196.4 分当天 17:21 到期**,只因为比 A/B 少约 1,200 分,即永远排最后,那 196.4 分必然烂掉。
12
+ - **为什么这条特别值得记**:README 把这两条写成**并列的第 2、3 条**,读起来像两个同级偏好;代码里却是严格优先级。**一条「不花就作废」的规则被另一条完全压制,等于死代码**,而它从输出上看不出任何异常——顺序永远「有道理」,只是那条规则从不参与。
13
+ - **改了什么**:把临期判据提到总量之前,并拆成三个独立的键——**有临期积分者优先** → **先到期者优先** → **临期量大者优先**,之后才回到积分总量。
14
+ - **一个单位错误顺手修掉**:旧的 `_expiring` 键实际存的是 `nearestExpiryMs`(**时间戳**),而 `expiringSoon`(**积分量**)在排序里从未被读过。现在两者分开:`_expiring` 仍是时间戳(用于「谁先到期」),`_expiringAmount` 才是积分量。直接拿这两个相减会是一个不容易发现的数量级错误。
15
+ - **一个反直觉但正确的后果,写进了代码注释与两份 README**:这条规则会让**临期 1 分**的账号排到**余额 5 万**的账号前面。**这不是缺陷**——积分是通用的,不花那 1 分它**确定**会损失;而余额 5 万的账号下一轮还在。唯一的真实代价是临期账号可能不够跑完一个请求,此时由既有的换号逻辑接手。若按总量优先,则临期积分**确定**作废。
16
+ - **可用性仍然第一**:处于限流冷却/凭据被拒的账号即使有大量临期积分也**不会**被提前——它花不掉,提上去只会让每个请求都撞墙。
17
+ - **测试**:新增 6 例(报告者真实数据 `C → A → B`、无临期时仍按总量、临期 1 分压过 5 万、都在临期时先到期者优先、同到期时量大者优先、不可用账号不被提升)。**变异验证**:① 恢复旧的字典序(总量先判)→ 红 4 例;② 去掉「先到期者优先」→ 红 2;③ `_atRisk` 恒为 false(等价修复前)→ 红 2。
18
+ - **顺带修正一条名不副实的旧测试**:`prefers the account whose credits expire soonest when totals tie` 的两个夹具都只有 `nearestExpiryMs`、`expiringSoon` 为 0,即**两个账号都没有任何临期积分**。它在旧规则下能过,靠的正是「排序看时间戳、不看量」这个缺陷;现已补上真实的 `expiringSoon`,让它真的在测名字里那件事。
19
+ - **README**(中英同步):排序规则把「快过期先花」提到第 2 条、「积分高者优先」降到第 3 条,并就地写明旧行为为何是死代码、以及「临期 1 分压过 5 万」为何是对的。
20
+
21
+ ## 4.6.6 (2026-10-17)
22
+
23
+ > 版本号是 **patch**:只动诊断(`WORKBUDDY_BODY_REPORT` 的报告内容),不改请求、响应、计费或界面。**默认行为完全不变**——报告默认仍是关的,关着时这版与 4.6.5 行为等价。
24
+
25
+ ### Fixes
26
+
27
+ - **body 报告只记「发了什么」不记「上游算了多少」,导致 #59 的抓取无法回答它要问的问题(issue #59)**
28
+ - **缺口**:`WORKBUDDY_BODY_REPORT` 抓的是**本地组装好、发往上游之前**的 body 构成(`bodyBytes` / `chars` / 各块字符数)。而 #59 要判定的是「同一份历史为什么国际网关算 1.94 倍」。**只有「发出侧」时,两种世界长得一模一样**:
29
+ - **A** 网关对几乎相同的字节算出两倍 → **上游的问题**,插件无能为力;
30
+ - **B** 两边发出的 body 其实不同 → **我们的问题**,要修。
31
+
32
+ 报告者 10-16 抓到的配对 body 总量只差 **0.3%**(3,554,248 vs 3,565,415 字节),但仅凭这个数无法区分 A 与 B —— 缺的正是「上游回了多少」。
33
+ - **改了什么**:报告每行新增 `promptTokens` / `completionTokens` / `cachedTokens` 三个**可选**字段,取自上游响应流里的 `usage` 块。至此一行同时给出两半:**发出去的字节** 与 **网关算的 token**,两者一比即可定案。
34
+ - **为什么必须新加一个响应侧钩子**:`usage` 走的是**响应流**,而不是请求。`createWorkBuddyShim` 原先只有 `onRequestBody`(请求发出前),响应侧不记任何东西 —— 而 `usage` 只在上游的**最后一帧**才出现。新增 `onUpstreamUsage`,在 `RecoveryStream` 解析每一帧时读取。
35
+ - **★ 一个位置性缺陷,写完就撞上了**:`usage` 帧**不带 `choices`**(它是流末尾的独立帧),而 `handleFrame()` 里第一个早退分支就是「没有 choices 就原样转发」。把读取放在那个分支之后,代码跑起来一切正常、**永不报错**,只是永远拿不到数。守卫 `tests/issue59-upstream-usage.spec.ts` 的第 1 例专门钉这一条:**变异(把读取挪到 `choices` 检查之后)→ 红 2 例**,正是原始的漏读形态。
36
+ - **「没有」不等于「零」**:上游没报 `usage`(请求失败、流被截断、非流式路径)时字段**缺省**,而不是写 `0`。这是全条的关键区分——把「没测到」记成「没花钱」,会让整个对比失效。守卫用一条显式用例钉住(变异:把缺失记成 `promptTokens: 0` → **5 例全红**)。
37
+ - **每个流只报一次**:`usageReported` 幂等,因为 failover 重试会**另起一个 `RecoveryStream`**,同一个 body 可能被量两次;先到的那次是实际服务的那次。
38
+ - **写回同一行,不新起一行**:两个半边的可观测时刻不同(发出侧在请求前、计费侧在流结束后),所以 `recordBodyReport` 改为返回一个 handle,等 `usage` 到了再**回填它自己那一行**。之所以不能追加第三行:报告者的用法是「取 `cn.jsonl` 与 `global.jsonl` 的**最后一行**对比」,多出来的 usage-only 行会**静默变成被比较的那一行**。
39
+ - **行身份靠四元组匹配**(`at` + `region` + `bodyBytes` + `messageCount`),故意不用 `at` 一个字段:同一毫秒内可能有两笔请求。**这里踩到并修掉了一个测试自身的洞**:初版测试标注的是**最后一行**,而回填是从文件末尾**倒着找**的 —— 于是把 `bodyBytes` 校验整个删掉,测试**照样全绿**。改成标注**靠前**那一行后,该变异立刻转红;`messageCount` 同理(构造了两份**字节数完全相同、消息条数不同**的 body:一条 51 字符的消息 与 两条 11 字符的消息,都是 196 字节)。**最终变异验证**:去掉 `bodyBytes` 校验 → 红 1;去掉 `messageCount` 校验 → 红 1;两者都去掉 → 红 2。
40
+ - **两道幂等闸刻意保留冗余**:handle 内的 `done` 与磁盘上的「已标注就跳过」互为备份,各自覆盖另一种情形(后者专治「同一 body 的第二个 handle」)。只删一道**行为上不可观测**,删两道才红——这一点写进了代码注释,免得后人以为其中一道是死代码而删掉。
41
+ - **缓存命中一并记录**:两个网关的缓存命中**拼写不同**(OpenAI 的 `prompt_tokens_details.cached_tokens` 与国内的 `prompt_cache_hit_tokens`),两种都读、取先命中者,否则同一份对话在两个区域会显示成一个有缓存、一个没有。
42
+ - **对 #59 的实际意义**:这一版让报告者**不必再做重放实验**——同一会话两区各抓一次,直接比 `promptTokens` 两列即可区分 A / B。重放要发的是一整份真实历史,既贵,又会引入「两次请求之间还发生了什么」的新变量。
43
+ - **本条不包含对 1.94 倍的结论**:它补的是**验证手段**。那个结论仍待报告者带新字段重抓后才能下。
44
+
3
45
  ## 4.6.5 (2026-10-16)
4
46
 
5
47
  > 版本号是 **patch**:两处诊断正确性修复,没有接口或配置变更。
package/README.en.md CHANGED
@@ -32,7 +32,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
32
32
  - **Credit readout in the composer toolbar (follows the selected model, per region)** — a clickable readout sits at the left of the composer toolbar (e.g. `WB CN · 1,072`) and **switches region automatically with the model this session is using**: pick a WB CN model and it shows the domestic balance, pick a WB AI model and it shows the international one; with any other provider's model it does not appear at all. It refreshes every 5 minutes and can also be refreshed by hand. Clicking it opens a panel: a **table of balances per account**, where clicking a row switches to that account (the same setting the card's account picker writes), and an account that cannot serve right now is **labelled in its row** (e.g. rate-limited, out of credits) using the same wording as the account-pool table. The per-account figures are fetched only while the panel is open, so the 5-minute background refresh never multiplies by the account count. To turn a side off, untick “Show WB CN / WB AI credits in the composer” in that region's tab on the card; while a region's provider is switched off, the readout disappears and its switch is disabled.
33
33
 
34
34
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
35
- - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then higher credits, soonest-expiring, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
35
+ - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
36
36
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
37
37
  - **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Add account by QR code** (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
38
38
  - **Credits live in the account pool** — the member table shows each account's remaining credits and nearest expiry, and credits are the ranking's second key (more credits serves first). The queries are **read-only** and consume no credits.
@@ -118,11 +118,15 @@ The last two rows are deliberate: check-in and testing are MANUAL actions over t
118
118
  ### The ranking (what decides who serves)
119
119
 
120
120
  1. **Usable first** — an account a measurement rules out (limited with its cooldown unexpired, credential rejected, upstream unreachable) sorts last and is not chosen;
121
- 2. **Higher credit balance first.** Credits take part **only when a fresh reading exists** (what the card's poll, a batch test or a save collected; valid for 10 minutes). The request path **never** fetches credits — that is an upstream call per member, and one page of chat would turn into N extra requests;
122
- 3. **Soonest-expiring credits first** — spend what lapses in three days before it is lost;
121
+ 2. **Expiring credits first** — points lapsing within three days are lost if unspent, so an account holding any comes first, the soonest-lapsing one ahead of the rest, and between two of them the larger amount at risk wins;
122
+ 3. **Higher credit balance first.** Among accounts with nothing expiring, the largest balance goes first. Credits take part **only when a fresh reading exists** (what the card's poll, a batch test or a save collected; valid for 10 minutes). The request path **never** fetches credits — that is an upstream call per member, and one page of chat would turn into N extra requests;
123
123
  4. **Freshest credential first**;
124
124
  5. The account id closes the order, making it **total** and reproducible rather than jittering with the roster's order.
125
125
 
126
+ > **Why #2 outranks #3** (issue #70): these used to be strict lexicographic keys — total first, expiry consulted only when totals were **exactly** equal. Totals are almost never exactly equal, so the expiry rule **never actually fired**: a "use it or lose it" rule wholly shadowed by another, i.e. dead code. In the reported case an account held **196.4 points lapsing that same day** and still ranked last, merely because it held ~1,200 fewer points than the other two — so all 196.4 were lost.
127
+ >
128
+ > The cost is worth stating plainly: this also puts an account with **1 expiring point** ahead of one holding **50,000**. That is not a defect — credits are fungible, so leaving the 1 point unspent loses it with certainty, whereas the 50,000 account is still there on the next request. The one real cost is a failover round-trip when the expiring account cannot cover a whole request, which the existing failover path absorbs.
129
+
126
130
  ### Why an account leaves the pool, and when it comes back
127
131
 
128
132
  | Probe outcome | What it means for failover |
package/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
  - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 里取消勾选「在对话框显示 WB CN / WB AI 积分」;该区域的供应商被关闭时,读数不显示、开关也置灰。
40
40
 
41
41
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
42
- - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次积分高、快过期的积分包、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
42
+ - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
43
43
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
44
44
  - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码添加账号」(国际版为「网页登录」——那边是浏览器登录页,没有二维码)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
45
45
  - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
@@ -94,7 +94,7 @@ DSH PiAiAdapter(每个 provider 一套)
94
94
 
95
95
  | | 账号池 **开启** | 账号池 **关闭** |
96
96
  |---|---|---|
97
- | 谁服务 | 由排序**自动**选出(可用优先 → 积分高 → 快过期 → 凭据新) | **你在账号池里手动选**「使用哪个账号」 |
97
+ | 谁服务 | 由排序**自动**选出(可用优先 → 快过期先花 → 积分高 → 凭据新) | **你在账号池里手动选**「使用哪个账号」 |
98
98
  | 请求失败 | **自动**换下一个可用账号重试 | 不换号,如实报错 |
99
99
  | 一键签到 / 一键测试 | 可用 | **仍然可用** |
100
100
  | 目标测试模型 | 可改 | **仍然可改** |
@@ -108,11 +108,15 @@ DSH PiAiAdapter(每个 provider 一套)
108
108
  ### 排序规则(开启账号池后决定谁服务)
109
109
 
110
110
  1. **可用优先** —— 被测量判定为不可用(限流未到恢复时间、凭据被拒、连通性失败等)的排到最后,不参与选择;
111
- 2. **积分高者优先** —— 在可用的账号里选积分最多的。**积分只在有新鲜读数时参与**(卡片轮询、批量测试或保存时读到的那份,10 分钟内有效):请求路径**不会**为了排序去打上游,否则一页对话就会变成 N 个额外请求;
112
- 3. **快过期的积分包优先** —— 3 天内到期的先用,过期即作废;
111
+ 2. **快过期的积分先花** —— 3 天内到期的积分用不掉就作废,所以**有临期积分的账号排在前面**,**先到期的先排**;两个都在临期时,**临期积分多的优先**;
112
+ 3. **积分高者优先** —— 在没有临期积分的账号里,选积分最多的。**积分只在有新鲜读数时参与**(卡片轮询、批量测试或保存时读到的那份,10 分钟内有效):请求路径**不会**为了排序去打上游,否则一页对话就会变成 N 个额外请求;
113
113
  4. **凭据更晚过期的优先**;
114
114
  5. 最后按账号 id 收尾,保证顺序是**全序**、可复现,不会因为账号表顺序变化而抖动。
115
115
 
116
+ > **第 2 条为什么排到第 3 条前面**(issue #70):这两条此前是**严格字典序**——先比积分总量,只有总量**完全相等**才比到期时间。而总量几乎从不恰好相等,于是「快过期优先」**实际上从未生效过**:一条「不花就作废」的规则被另一条完全遮蔽,等于死代码。真实病例里,某账号有 **196.4 分当天作废**,只因为比另两个账号少约 1,200 分就永远排在最后,那 196 分必然烂掉。
117
+ >
118
+ > 代价也要说清:这条规则会让**临期 1 分**的账号排到**余额 5 万**的账号前面。这不是缺陷——积分是通用的,不花那 1 分它**确定**会损失;而余额 5 万的账号下一轮还在。唯一的真实代价是临期账号可能不够跑完一个请求,这时由既有的换号逻辑接手。
119
+
116
120
  ### 两个批量动作
117
121
 
118
122
  - **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
package/docs/WINDOWS.md CHANGED
@@ -49,8 +49,41 @@ Windows 在本项目里不是「顺带能跑」的平台,而是**有专属分
49
49
  > 现已把测试改名为「derives a 16-hex-char id, which is the shape the real envelopes carry」并在注释里写明它不证明什么;**推导规则**(哈希 base64 串而非解码字节、取 `sha256(key)` 前 16 位十六进制)由它上面的两条测试真正钉住 —— 那两条才是否证得动的。
50
50
  >
51
51
  > 若要真正钉住这个向量,需要**连同推导它的一把 secret 一起取样**(或至少记录 `sha256(secret)` 的输入形式);在那之前,① 仍是**观察记录**,不是**回归守卫**。登记在此,免得下一个人再把它当成守卫。
52
+ >
53
+ > **2026-10-16 再更正(issue #68 结案,A 档)**:这条「需要连同 secret 一起取样」的待办**已在 macOS 上一台装着两个版本的真机上完成**,三个 keyId 全部对上、且**全部由同一把 secret 推出**。结论与探针见 §2.1。**要点:`keyId` 不是「哪把 key 在加密」的证据** —— 同一个 secret 在不同哈希输入下会给出**三个都合法**的 keyId,而三者互不相等。此前把「exe 报的 keyId ≠ 凭据要求」读成「推导还没定案」,是把身份标识别当成密钥标识用了。
52
54
  | **D 未验证** | 无任何上述支撑 | —— |
53
55
 
56
+ ### 2.1 keyId 到底是什么:三值同源,已结案(issue #68,2026-10-16)
57
+
58
+ **争议**:解密路径上出现三个互不相等的 16 位十六进制值,看起来像「有三把不同的 key,不知道哪把对」:
59
+
60
+ | 值 | 出现处 |
61
+ | --- | --- |
62
+ | `9127dea1b44020a7` | 凭据信封**要求**的 `keyId`(`accessToken` / `nickname` / `refreshToken` 全部一致) |
63
+ | `c74aaf9df09104a5` | `deriveAtRestKeyId()` 对 `Electron` 进程取回的 payload 算出的值 |
64
+ | `66ef584820ad33e7` | 把 `atRestSecretKey` 先 base64 解码、再对**解码字节**做 sha256 得到的值 |
65
+
66
+ 三者不等,于是被读成「protector key 的推导没定案」。**这个读法是错的**,因为三个值来自**同一把 secret**,只是哈希输入不同:
67
+
68
+ ```text
69
+ secret = <payload>.atRestSecretKey (base64 文本,32 字节明文)
70
+ key = sha256(secret as utf8) ← 插件采用的形式
71
+ keyId = hex(sha256(key))[0..16]
72
+ ```
73
+
74
+ - `c74aaf9df09104a5` = `hex(sha256(secret))[0..16]` —— **秘密本身**的摘要,不是 key 的 keyId。它长得像 keyId,所以极易被误认为「另一把候选 key」。
75
+ - `66ef584820ad33e7` = `hex(sha256(base64_decode(secret)))[0..16]` —— 对**解码后的字节**做哈希。这是最自然的错误猜法(文档里那句「哈希 base64 串而非解码字节」正是为它写的),但它**不打开任何信封**。
76
+ - `9127dea1b44020a7` = `hex(sha256(sha256(secret as utf8)))[0..16]` —— **只有它**能让 GCM 标签通过。
77
+
78
+ **验证(macOS 真机,`/Applications` 下同时装着两个版本)**:对 `workbuddy-desktop.info`(国内)与 `workbuddy-desktop-ai.info`(国际)的 8 个信封逐一开箱,`nickname` 与 `accessToken` 全部解出;`accounts[].nickname` / `allAccounts[].nickname`(长度 16/16/36 字节,都不是令牌)也一致。**两个区域的文件用同一把 key** —— 区域差别在文件与账户,不在密钥。
79
+
80
+ **两个此前被当成「另一条加密路径」的东西,都不是**:
81
+
82
+ - `~/.workbuddy/keyblob` 与 `~/.workbuddy-ai/keyblob` 里的 `keyId`(`1ade3d8d51807afa` / `1d071c4d12239dda`)是**另一个层级**:keyblob 自己的 `keyId` 标识它包裹的那把 data key,而 `slot[0].protectorKeyId` 仍是 `9127dea1b44020a7`(即上面的保护者)。**它们从未出现在凭据链上**,未被也不应被当作凭据 key 的候选。
83
+ - keyblob 的 `wrapped`(32 字节密文)与凭据信封的 16/36 字节 `nickname` 不是同一种载荷,混在一张表里会继续生出假候选。
84
+
85
+ **给下一个人**:判定「哪把 key 对」**不要比 keyId**,要看 GCM 标签是否通过 —— keyId 只是**身份标签**,同一份密钥材料在三种输入形式下有三种合法拼法。要正确定位,跑 `doctor` 拿到的 `atRestDecryption` 只说明**能不能拿到 payload**;`nickname` 是否解出才是**这把 key 对不对**的判据。本机实测的判据链与本节的探针脚本见提交信息(`scripts/` 不入包)。
86
+
54
87
  ### A 档取证:§1-5 的控制台闪窗(2026-09-26,Windows 11 22621)
55
88
 
56
89
  **缺陷**:`src/host-heartbeat.ts` 的 `processStartTimeMs()` 以 `execFileSync('powershell', …, { encoding: 'utf8' })` 启动子进程,**未设 `windowsHide`**;同仓库的 `src/at-rest.ts` 却设了。两处不一致。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BpqT3LJe.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-D7a100yu.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -3949,7 +3949,7 @@ function generationOf(version) {
3949
3949
  //#endregion
3950
3950
  //#region src/version.ts
3951
3951
  /** The npm package version this build was produced from. */
3952
- const WORKBUDDY_CONNECT_VERSION = "4.6.5";
3952
+ const WORKBUDDY_CONNECT_VERSION = "4.6.7";
3953
3953
  //#endregion
3954
3954
  //#region src/host-heartbeat.ts
3955
3955
  /**
package/lib/index.d.ts CHANGED
@@ -1166,6 +1166,28 @@ interface WorkBuddyShimOptions {
1166
1166
  * callee must treat the string as read-only.
1167
1167
  */
1168
1168
  onRequestBody?: (bodyJson: string) => void;
1169
+ /**
1170
+ * Called with the token counts the UPSTREAM reported for the request whose
1171
+ * body was just handed to {@link onRequestBody} (issue #59).
1172
+ *
1173
+ * `onRequestBody` records what we SENT; this records what the gateway
1174
+ * CHARGED. The issue cannot be settled with only one of them: a difference
1175
+ * between two captures is ambiguous until both sides are known, because
1176
+ * "the gateway counted the same bytes differently" and "the two bodies were
1177
+ * not the same" look identical from the sent side alone.
1178
+ *
1179
+ * May fire zero times (the upstream reported no `usage`, or the stream was
1180
+ * cut before its final frame) or more than once per body (a failover retry
1181
+ * reaches a second upstream). The callee is expected to keep the FIRST
1182
+ * real measurement, since that is the one the report's line belongs to.
1183
+ *
1184
+ * Fired without awaiting, and never allowed to fail the request.
1185
+ */
1186
+ onUpstreamUsage?: (usage: {
1187
+ promptTokens?: number;
1188
+ completionTokens?: number;
1189
+ cachedTokens?: number;
1190
+ }) => void;
1169
1191
  }
1170
1192
  /**
1171
1193
  * Start the loopback endpoint. Requests must carry the shim's shared secret;
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BpqT3LJe.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-D7a100yu.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { appendFile, mkdir, readFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { join } from "node:path";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
@@ -412,6 +412,354 @@ function createWorkBuddyAdapter(options) {
412
412
  }
413
413
  };
414
414
  }
415
+ //#endregion
416
+ //#region src/body-report.ts
417
+ /**
418
+ * Body-composition report: WHAT was sent upstream, in counts (issue #59).
419
+ *
420
+ * WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
421
+ * ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
422
+ * which turns a region switch into an instant `code 11115 prompt is too long`.
423
+ * Two worlds explain that, and they have opposite owners:
424
+ *
425
+ * - the gateways count identical bytes differently (upstream's business); or
426
+ * - the two requests did not carry the same messages (ours to fix).
427
+ *
428
+ * The second world was left standing by two independent measurements. The
429
+ * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
430
+ * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
431
+ * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
432
+ * identically. And a code trace showed historical `reasoning` never reaches the
433
+ * wire at all: pi-ai only forwards `thinking` blocks as text when
434
+ * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
435
+ * `reasoning_details` route needs a signature the stream parser never writes.
436
+ * With the counting difference excluded and reasoning excluded, the remaining
437
+ * candidate is that the two bodies differ in the first place.
438
+ *
439
+ * This module exists to settle exactly that, and it can do it WITHOUT either
440
+ * region having credit: the body is assembled locally, before the upstream ever
441
+ * gets a chance to answer `14018 Credits exhausted`. Capture one composition per
442
+ * region in the same session and diff them.
443
+ *
444
+ * It records COUNTS ONLY — never a byte of message text, no account name, no
445
+ * token. That is a hard rule, not a default: the point is to compare shape
446
+ * (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
447
+ * and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
448
+ * asserts the report cannot leak a fixture's text.
449
+ */
450
+ /**
451
+ * Environment variable that enables the report.
452
+ *
453
+ * Unset or blank means OFF — a diagnostic that writes files must be opt-in.
454
+ * `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
455
+ * directory path, so a user can point it at an existing folder (and, on
456
+ * Windows, one with spaces).
457
+ */
458
+ const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
459
+ /**
460
+ * Character ranges that tokenize (and therefore bill) like CJK.
461
+ *
462
+ * Deliberately generous: the caller's question is "how much of this is the ~3×
463
+ * kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
464
+ * class that is borderline costs far less than mis-attributing a whole bucket.
465
+ * Ranges follow Unicode blocks, plus the compatibility forms and the two
466
+ * extension planes where rare Han characters live.
467
+ */
468
+ const CJK_RANGES = [
469
+ [11904, 12031],
470
+ [12288, 12351],
471
+ [12352, 12543],
472
+ [13312, 19903],
473
+ [19968, 40959],
474
+ [63744, 64255],
475
+ [65072, 65103],
476
+ [65280, 65519],
477
+ [131072, 195103]
478
+ ];
479
+ /** Whether one code point bills like CJK. */
480
+ function isCjkCodePoint(code) {
481
+ return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
482
+ }
483
+ /** Count characters (code points) and how many of them are CJK. */
484
+ function countChars(text) {
485
+ let chars = 0;
486
+ let cjkChars = 0;
487
+ for (const character of text) {
488
+ chars += 1;
489
+ const code = character.codePointAt(0);
490
+ if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
491
+ }
492
+ return {
493
+ chars,
494
+ cjkChars
495
+ };
496
+ }
497
+ /** Build one block report from already-extracted text. */
498
+ function block(type, text) {
499
+ return {
500
+ type,
501
+ ...countChars(text)
502
+ };
503
+ }
504
+ /** Whether a value is a plain object (not null, not an array). */
505
+ function isRecord(value) {
506
+ return typeof value === "object" && value !== null && !Array.isArray(value);
507
+ }
508
+ /**
509
+ * Describe one message's blocks.
510
+ *
511
+ * The role decides what a content string IS, and that is not cosmetic: a
512
+ * `tool` message's payload is a tool result, so counting it as `text` would hide
513
+ * the very bucket a long agentic session is mostly made of.
514
+ */
515
+ function describeMessage(message) {
516
+ const role = typeof message["role"] === "string" ? message["role"] : "unknown";
517
+ const blocks = [];
518
+ const content = message["content"];
519
+ /**
520
+ * The block type a plain string carries, by the ROLE carrying it.
521
+ *
522
+ * A `tool` message's string is a tool RESULT and a `system` one is the system
523
+ * prompt; calling both `text` would fold the biggest bucket of a long agentic
524
+ * session into the smallest one, which is the opposite of what a composition
525
+ * report is for.
526
+ */
527
+ const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
528
+ if (typeof content === "string") {
529
+ if (content !== "") blocks.push(block(stringType, content));
530
+ } else if (Array.isArray(content)) for (const part of content) {
531
+ if (!isRecord(part)) continue;
532
+ const type = typeof part["type"] === "string" ? part["type"] : "unknown";
533
+ if (type === "text") {
534
+ const text = part["text"];
535
+ if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
536
+ continue;
537
+ }
538
+ blocks.push({
539
+ type: type === "image_url" ? "image" : type,
540
+ chars: 0,
541
+ cjkChars: 0
542
+ });
543
+ }
544
+ const toolCalls = message["tool_calls"];
545
+ if (Array.isArray(toolCalls)) for (const call of toolCalls) {
546
+ if (!isRecord(call)) continue;
547
+ const fn = call["function"];
548
+ const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
549
+ const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
550
+ blocks.push(block("tool-call", `${name}${args}`));
551
+ }
552
+ for (const field of [
553
+ "reasoning_content",
554
+ "reasoning",
555
+ "reasoning_text"
556
+ ]) {
557
+ const value = message[field];
558
+ if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
559
+ }
560
+ return {
561
+ role,
562
+ chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
563
+ cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
564
+ blocks
565
+ };
566
+ }
567
+ /**
568
+ * Describe one serialized request body.
569
+ *
570
+ * Returns `undefined` when the body is not a JSON object — a diagnostic must
571
+ * never be the reason a chat request fails, so every caller treats `undefined`
572
+ * as "nothing to report" rather than an error.
573
+ */
574
+ function describeBody(bodyJson, region, nowMs) {
575
+ let parsed;
576
+ try {
577
+ parsed = JSON.parse(bodyJson);
578
+ } catch {
579
+ return;
580
+ }
581
+ if (!isRecord(parsed)) return void 0;
582
+ const rawMessages = parsed["messages"];
583
+ const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
584
+ const rawTools = parsed["tools"];
585
+ const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
586
+ type: "tools",
587
+ chars: 0,
588
+ cjkChars: 0
589
+ };
590
+ const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
591
+ const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
592
+ return {
593
+ at: new Date(nowMs).toISOString(),
594
+ region,
595
+ ...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
596
+ bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
597
+ messageCount: messages.length,
598
+ chars,
599
+ cjkChars,
600
+ tools,
601
+ messages
602
+ };
603
+ }
604
+ /** A finite, non-negative token count, or undefined for anything else. */
605
+ function tokenCount(value) {
606
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
607
+ }
608
+ /**
609
+ * Read the token counts out of one already-parsed SSE chunk.
610
+ *
611
+ * Returns undefined when the chunk carries no `usage` — which is every chunk but
612
+ * the last one on a streamed answer, so this is called for each frame and must
613
+ * stay cheap and total. The cache-hit count is looked for under BOTH spellings
614
+ * observed on the two gateways (`prompt_tokens_details.cached_tokens`, OpenAI's,
615
+ * and `prompt_cache_hit_tokens`, which the domestic gateway also emits); taking
616
+ * the first one present keeps the report comparable across regions instead of
617
+ * showing 0 on whichever side uses the other name.
618
+ */
619
+ function usageOfChunk(chunk) {
620
+ const usage = chunk["usage"];
621
+ if (typeof usage !== "object" || usage === null || Array.isArray(usage)) return void 0;
622
+ const record = usage;
623
+ const promptTokens = tokenCount(record["prompt_tokens"]);
624
+ const completionTokens = tokenCount(record["completion_tokens"]);
625
+ const details = record["prompt_tokens_details"];
626
+ const cachedTokens = (typeof details === "object" && details !== null && !Array.isArray(details) ? tokenCount(details["cached_tokens"]) : void 0) ?? tokenCount(record["prompt_cache_hit_tokens"]);
627
+ if (promptTokens === void 0 && completionTokens === void 0 && cachedTokens === void 0) return void 0;
628
+ return {
629
+ ...promptTokens === void 0 ? {} : { promptTokens },
630
+ ...completionTokens === void 0 ? {} : { completionTokens },
631
+ ...cachedTokens === void 0 ? {} : { cachedTokens }
632
+ };
633
+ }
634
+ /**
635
+ * Resolve the directory the report writes to, or `undefined` when disabled.
636
+ *
637
+ * Exported for the same reason the other env-var resolvers in this plugin are:
638
+ * the behaviour is worth a unit test, and the test must not have to set a real
639
+ * process-wide variable to get one.
640
+ */
641
+ function bodyReportDirectory(env = process.env) {
642
+ const configured = env[WORKBUDDY_BODY_REPORT_ENV];
643
+ if (configured === void 0) return void 0;
644
+ const trimmed = configured.trim();
645
+ if (trimmed === "") return void 0;
646
+ if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
647
+ return trimmed;
648
+ }
649
+ /**
650
+ * Append one composition to `<dir>/<region>.jsonl`.
651
+ *
652
+ * One file per region is the whole point: the reporter captures a session, then
653
+ * diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
654
+ * one file per request because a single turn makes many upstream calls (one per
655
+ * tool step) and the LAST one is the interesting one.
656
+ *
657
+ * Read-modify-write is avoided on purpose — a long session would rewrite a
658
+ * growing file on every step. The lock keeps two concurrent requests from
659
+ * interleaving a line.
660
+ */
661
+ async function appendBodyReport(directory, report) {
662
+ const file = join(directory, `${report.region}.jsonl`);
663
+ await mkdir(directory, { recursive: true });
664
+ const line = `${JSON.stringify(report)}\n`;
665
+ await withFileLock(file, () => appendFile(file, line, "utf8"));
666
+ }
667
+ /**
668
+ * Record one outgoing body, if the report is enabled, and return a handle that
669
+ * can carry the upstream's `usage` back onto the SAME line.
670
+ *
671
+ * NEVER THROWS and never awaits long: this is called on the request path, and a
672
+ * chat request must not fail (or stall) because a diagnostic could not write a
673
+ * file. A failure is reported through `onError` so the caller can log it.
674
+ *
675
+ * The returned function exists because the two halves of the #59 comparison are
676
+ * only available at different times: what we SENT is known before the request
677
+ * leaves, what the gateway CHARGED only after the stream ends. Writing a second
678
+ * line for the usage would be worse than useless — the reporter diffs "the last
679
+ * line of cn.jsonl against the last line of global.jsonl", and a trailing
680
+ * usage-only line would silently become the one being compared.
681
+ */
682
+ async function recordBodyReport(region, bodyJson, options = {}) {
683
+ const directory = bodyReportDirectory(options.env);
684
+ if (directory === void 0) return () => {};
685
+ try {
686
+ const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
687
+ if (report === void 0) return () => {};
688
+ await appendBodyReport(directory, report);
689
+ return attachUsage(directory, report, options.onError);
690
+ } catch (error) {
691
+ options.onError?.(error);
692
+ return () => {};
693
+ }
694
+ }
695
+ /**
696
+ * How many trailing lines are searched for the line this handle wrote.
697
+ *
698
+ * One would be enough for a single-threaded request, but a turn can have several
699
+ * requests in flight (parallel tool steps) and their lines interleave, so the
700
+ * target is not necessarily last. A small bounded window keeps the read cheap on
701
+ * a file that grows to tens of megabytes, while still finding a line that landed
702
+ * a couple of appends ago.
703
+ */
704
+ const USAGE_ATTACH_WINDOW = 32;
705
+ /**
706
+ * Return a callback that rewrites its own line with the upstream's counts.
707
+ *
708
+ * Matched by `at` + `region` + `bodyBytes` + `messageCount`, all four being
709
+ * stable for a finished request: `at` alone can collide when two requests are
710
+ * timestamped in the same millisecond, and `bodyBytes` tells apart the two
711
+ * different bodies a failover or a retry can produce. A line that is already
712
+ * annotated is left alone, so a second call cannot double-apply.
713
+ */
714
+ function attachUsage(directory, report, onError) {
715
+ /**
716
+ * Two independent idempotence guards, kept redundant on purpose.
717
+ *
718
+ * `done` avoids a second disk read within one handle's lifetime; the
719
+ * "already annotated" check inside the loop catches the OTHER case — a second
720
+ * handle for the same body, which is what a failover retry produces (it opens
721
+ * its own stream, and therefore its own handle). Either alone is enough for
722
+ * the common path, so removing just one is not observable behaviourally;
723
+ * removing BOTH is, and `tests/issue59-body-report.spec.ts` pins that.
724
+ */
725
+ let done = false;
726
+ return (usage) => {
727
+ if (usage === void 0 || done) return;
728
+ done = true;
729
+ const file = join(directory, `${report.region}.jsonl`);
730
+ (async () => {
731
+ try {
732
+ await withFileLock(file, async () => {
733
+ const lines = (await readFile(file, "utf8")).split("\n");
734
+ for (let index = lines.length - 1; index >= 0 && index >= lines.length - USAGE_ATTACH_WINDOW; index -= 1) {
735
+ const line = lines[index];
736
+ if (line === void 0 || line === "") continue;
737
+ let parsed;
738
+ try {
739
+ parsed = JSON.parse(line);
740
+ } catch {
741
+ continue;
742
+ }
743
+ if (!isRecord(parsed)) continue;
744
+ if (parsed["at"] !== report.at) continue;
745
+ if (parsed["region"] !== report.region) continue;
746
+ if (parsed["bodyBytes"] !== report.bodyBytes) continue;
747
+ if (parsed["messageCount"] !== report.messageCount) continue;
748
+ if (parsed["promptTokens"] !== void 0) return;
749
+ lines[index] = JSON.stringify({
750
+ ...parsed,
751
+ ...usage
752
+ });
753
+ await writeFile(file, lines.join("\n"), "utf8");
754
+ return;
755
+ }
756
+ });
757
+ } catch (error) {
758
+ onError?.(error);
759
+ }
760
+ })();
761
+ };
762
+ }
415
763
  /** One or two full-width bars — the count is a spelling variable. */
416
764
  const BARS = `||?`;
417
765
  new RegExp(`${BARS}DSML${BARS}`, "g");
@@ -1337,6 +1685,7 @@ function readBody(req) {
1337
1685
  var RecoveryStream = class {
1338
1686
  res;
1339
1687
  logger;
1688
+ onUsage;
1340
1689
  carry = "";
1341
1690
  decoder = new TextDecoder("utf-8");
1342
1691
  /** Frames received after the hold began, in arrival order. */
@@ -1377,9 +1726,10 @@ var RecoveryStream = class {
1377
1726
  * a stray line behind.
1378
1727
  */
1379
1728
  notice;
1380
- constructor(res, gate, logger, notice) {
1729
+ constructor(res, gate, logger, notice, onUsage) {
1381
1730
  this.res = res;
1382
1731
  this.logger = logger;
1732
+ this.onUsage = onUsage;
1383
1733
  this.buffer = gate === void 0 ? void 0 : new DsmlStreamBuffer(gate);
1384
1734
  this.notice = notice;
1385
1735
  }
@@ -1510,6 +1860,33 @@ var RecoveryStream = class {
1510
1860
  start = newline + 1;
1511
1861
  }
1512
1862
  }
1863
+ /** Whether a usage measurement has already been reported for this stream. */
1864
+ usageReported = false;
1865
+ /**
1866
+ * Read the upstream's token counts out of one parsed chunk and report them.
1867
+ *
1868
+ * FIRES AT MOST ONCE per stream, and only when there is a real number to
1869
+ * report. Two reasons, both load-bearing:
1870
+ *
1871
+ * - a streamed answer's `usage` arrives on its final frame, but a failover
1872
+ * retry starts a NEW `RecoveryStream`, so a second call here would be a
1873
+ * second measurement for the same line;
1874
+ * - a chunk with no `usage` (which is every chunk but the last) must not be
1875
+ * reported as zero — an absent measurement and a measurement of zero are
1876
+ * different facts, and the #59 comparison turns entirely on that
1877
+ * difference.
1878
+ */
1879
+ noteUsage(chunk) {
1880
+ if (this.usageReported || this.onUsage === void 0) return;
1881
+ const usage = usageOfChunk(chunk);
1882
+ if (usage === void 0) return;
1883
+ this.usageReported = true;
1884
+ try {
1885
+ this.onUsage(usage);
1886
+ } catch (error) {
1887
+ this.logger?.warn("dsh-connect-workbuddy: usage report failed", error);
1888
+ }
1889
+ }
1513
1890
  /** Handle one complete frame, rewriting it only when recovery produced something. */
1514
1891
  handleFrame(frame) {
1515
1892
  const payload = dataPayload(frame);
@@ -1530,6 +1907,7 @@ var RecoveryStream = class {
1530
1907
  return;
1531
1908
  }
1532
1909
  const choices = chunk["choices"];
1910
+ this.noteUsage(chunk);
1533
1911
  const choice = Array.isArray(choices) ? choices[0] : void 0;
1534
1912
  if (typeof choice !== "object" || choice === null || Array.isArray(choice)) {
1535
1913
  this.send(frame, false);
@@ -1932,6 +2310,7 @@ function createWorkBuddyShim(options) {
1932
2310
  const prepareAccount = options.prepareAccount;
1933
2311
  const onAccountFailure = options.onAccountFailure;
1934
2312
  const onRequestBody = options.onRequestBody;
2313
+ const onUpstreamUsage = options.onUpstreamUsage;
1935
2314
  const SHARED_SECRET = randomBytes(32).toString("base64url");
1936
2315
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
1937
2316
  function bearerOk(req) {
@@ -2181,7 +2560,7 @@ function createWorkBuddyShim(options) {
2181
2560
  ...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
2182
2561
  };
2183
2562
  const notice = switchedFrom === void 0 || switchedBecause === void 0 ? void 0 : failoverNoticeText(switchedFrom, switchedBecause);
2184
- const writer = new RecoveryStream(res, gate, logger, notice);
2563
+ const writer = new RecoveryStream(res, gate, logger, notice, onUpstreamUsage);
2185
2564
  await writer.consume(result.response.body);
2186
2565
  if (gate !== void 0 && !writer.delivered && writer.residueSeen && !controller.signal.aborted) {
2187
2566
  logger?.warn("dsh-connect-workbuddy: the turn produced DSML markup only; retrying it once");
@@ -2223,246 +2602,6 @@ function createWorkBuddyShim(options) {
2223
2602
  };
2224
2603
  }
2225
2604
  //#endregion
2226
- //#region src/body-report.ts
2227
- /**
2228
- * Body-composition report: WHAT was sent upstream, in counts (issue #59).
2229
- *
2230
- * WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
2231
- * ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
2232
- * which turns a region switch into an instant `code 11115 prompt is too long`.
2233
- * Two worlds explain that, and they have opposite owners:
2234
- *
2235
- * - the gateways count identical bytes differently (upstream's business); or
2236
- * - the two requests did not carry the same messages (ours to fix).
2237
- *
2238
- * The second world was left standing by two independent measurements. The
2239
- * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
2240
- * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
2241
- * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
2242
- * identically. And a code trace showed historical `reasoning` never reaches the
2243
- * wire at all: pi-ai only forwards `thinking` blocks as text when
2244
- * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
2245
- * `reasoning_details` route needs a signature the stream parser never writes.
2246
- * With the counting difference excluded and reasoning excluded, the remaining
2247
- * candidate is that the two bodies differ in the first place.
2248
- *
2249
- * This module exists to settle exactly that, and it can do it WITHOUT either
2250
- * region having credit: the body is assembled locally, before the upstream ever
2251
- * gets a chance to answer `14018 Credits exhausted`. Capture one composition per
2252
- * region in the same session and diff them.
2253
- *
2254
- * It records COUNTS ONLY — never a byte of message text, no account name, no
2255
- * token. That is a hard rule, not a default: the point is to compare shape
2256
- * (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
2257
- * and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
2258
- * asserts the report cannot leak a fixture's text.
2259
- */
2260
- /**
2261
- * Environment variable that enables the report.
2262
- *
2263
- * Unset or blank means OFF — a diagnostic that writes files must be opt-in.
2264
- * `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
2265
- * directory path, so a user can point it at an existing folder (and, on
2266
- * Windows, one with spaces).
2267
- */
2268
- const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
2269
- /**
2270
- * Character ranges that tokenize (and therefore bill) like CJK.
2271
- *
2272
- * Deliberately generous: the caller's question is "how much of this is the ~3×
2273
- * kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
2274
- * class that is borderline costs far less than mis-attributing a whole bucket.
2275
- * Ranges follow Unicode blocks, plus the compatibility forms and the two
2276
- * extension planes where rare Han characters live.
2277
- */
2278
- const CJK_RANGES = [
2279
- [11904, 12031],
2280
- [12288, 12351],
2281
- [12352, 12543],
2282
- [13312, 19903],
2283
- [19968, 40959],
2284
- [63744, 64255],
2285
- [65072, 65103],
2286
- [65280, 65519],
2287
- [131072, 195103]
2288
- ];
2289
- /** Whether one code point bills like CJK. */
2290
- function isCjkCodePoint(code) {
2291
- return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
2292
- }
2293
- /** Count characters (code points) and how many of them are CJK. */
2294
- function countChars(text) {
2295
- let chars = 0;
2296
- let cjkChars = 0;
2297
- for (const character of text) {
2298
- chars += 1;
2299
- const code = character.codePointAt(0);
2300
- if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
2301
- }
2302
- return {
2303
- chars,
2304
- cjkChars
2305
- };
2306
- }
2307
- /** Build one block report from already-extracted text. */
2308
- function block(type, text) {
2309
- return {
2310
- type,
2311
- ...countChars(text)
2312
- };
2313
- }
2314
- /** Whether a value is a plain object (not null, not an array). */
2315
- function isRecord(value) {
2316
- return typeof value === "object" && value !== null && !Array.isArray(value);
2317
- }
2318
- /**
2319
- * Describe one message's blocks.
2320
- *
2321
- * The role decides what a content string IS, and that is not cosmetic: a
2322
- * `tool` message's payload is a tool result, so counting it as `text` would hide
2323
- * the very bucket a long agentic session is mostly made of.
2324
- */
2325
- function describeMessage(message) {
2326
- const role = typeof message["role"] === "string" ? message["role"] : "unknown";
2327
- const blocks = [];
2328
- const content = message["content"];
2329
- /**
2330
- * The block type a plain string carries, by the ROLE carrying it.
2331
- *
2332
- * A `tool` message's string is a tool RESULT and a `system` one is the system
2333
- * prompt; calling both `text` would fold the biggest bucket of a long agentic
2334
- * session into the smallest one, which is the opposite of what a composition
2335
- * report is for.
2336
- */
2337
- const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
2338
- if (typeof content === "string") {
2339
- if (content !== "") blocks.push(block(stringType, content));
2340
- } else if (Array.isArray(content)) for (const part of content) {
2341
- if (!isRecord(part)) continue;
2342
- const type = typeof part["type"] === "string" ? part["type"] : "unknown";
2343
- if (type === "text") {
2344
- const text = part["text"];
2345
- if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
2346
- continue;
2347
- }
2348
- blocks.push({
2349
- type: type === "image_url" ? "image" : type,
2350
- chars: 0,
2351
- cjkChars: 0
2352
- });
2353
- }
2354
- const toolCalls = message["tool_calls"];
2355
- if (Array.isArray(toolCalls)) for (const call of toolCalls) {
2356
- if (!isRecord(call)) continue;
2357
- const fn = call["function"];
2358
- const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
2359
- const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
2360
- blocks.push(block("tool-call", `${name}${args}`));
2361
- }
2362
- for (const field of [
2363
- "reasoning_content",
2364
- "reasoning",
2365
- "reasoning_text"
2366
- ]) {
2367
- const value = message[field];
2368
- if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
2369
- }
2370
- return {
2371
- role,
2372
- chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
2373
- cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
2374
- blocks
2375
- };
2376
- }
2377
- /**
2378
- * Describe one serialized request body.
2379
- *
2380
- * Returns `undefined` when the body is not a JSON object — a diagnostic must
2381
- * never be the reason a chat request fails, so every caller treats `undefined`
2382
- * as "nothing to report" rather than an error.
2383
- */
2384
- function describeBody(bodyJson, region, nowMs) {
2385
- let parsed;
2386
- try {
2387
- parsed = JSON.parse(bodyJson);
2388
- } catch {
2389
- return;
2390
- }
2391
- if (!isRecord(parsed)) return void 0;
2392
- const rawMessages = parsed["messages"];
2393
- const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
2394
- const rawTools = parsed["tools"];
2395
- const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
2396
- type: "tools",
2397
- chars: 0,
2398
- cjkChars: 0
2399
- };
2400
- const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
2401
- const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
2402
- return {
2403
- at: new Date(nowMs).toISOString(),
2404
- region,
2405
- ...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
2406
- bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
2407
- messageCount: messages.length,
2408
- chars,
2409
- cjkChars,
2410
- tools,
2411
- messages
2412
- };
2413
- }
2414
- /**
2415
- * Resolve the directory the report writes to, or `undefined` when disabled.
2416
- *
2417
- * Exported for the same reason the other env-var resolvers in this plugin are:
2418
- * the behaviour is worth a unit test, and the test must not have to set a real
2419
- * process-wide variable to get one.
2420
- */
2421
- function bodyReportDirectory(env = process.env) {
2422
- const configured = env[WORKBUDDY_BODY_REPORT_ENV];
2423
- if (configured === void 0) return void 0;
2424
- const trimmed = configured.trim();
2425
- if (trimmed === "") return void 0;
2426
- if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
2427
- return trimmed;
2428
- }
2429
- /**
2430
- * Append one composition to `<dir>/<region>.jsonl`.
2431
- *
2432
- * One file per region is the whole point: the reporter captures a session, then
2433
- * diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
2434
- * one file per request because a single turn makes many upstream calls (one per
2435
- * tool step) and the LAST one is the interesting one.
2436
- *
2437
- * Read-modify-write is avoided on purpose — a long session would rewrite a
2438
- * growing file on every step. The lock keeps two concurrent requests from
2439
- * interleaving a line.
2440
- */
2441
- async function appendBodyReport(directory, report) {
2442
- const file = join(directory, `${report.region}.jsonl`);
2443
- await mkdir(directory, { recursive: true });
2444
- const line = `${JSON.stringify(report)}\n`;
2445
- await withFileLock(file, () => appendFile(file, line, "utf8"));
2446
- }
2447
- /**
2448
- * Record one outgoing body, if the report is enabled.
2449
- *
2450
- * NEVER THROWS and never awaits long: this is called on the request path, and a
2451
- * chat request must not fail (or stall) because a diagnostic could not write a
2452
- * file. A failure is reported through `onError` so the caller can log it.
2453
- */
2454
- async function recordBodyReport(region, bodyJson, options = {}) {
2455
- const directory = bodyReportDirectory(options.env);
2456
- if (directory === void 0) return;
2457
- try {
2458
- const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
2459
- if (report === void 0) return;
2460
- await appendBodyReport(directory, report);
2461
- } catch (error) {
2462
- options.onError?.(error);
2463
- }
2464
- }
2465
- //#endregion
2466
2605
  //#region src/probe.ts
2467
2606
  /**
2468
2607
  * How long to bench a MODEL that answered 14003 with no stated time.
@@ -3142,16 +3281,36 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
3142
3281
  *
3143
3282
  * 1. **Usability.** Only accounts that can be billed right now are candidates;
3144
3283
  * they always outrank every excluded one.
3145
- * 2. **Credits, highest first.** Spend the account that has the most, so an
3146
- * account running low is conserved rather than drained first.
3147
- * 3. **Credits expiring soonest first.** Points about to expire are worth
3148
- * exactly zero after they do, so using them earlier is strictly better than
3149
- * saving them.
3150
- * 4. **Freshest credential.** Tie-break so a stale token never wins a draw.
3151
- * 5. **Account id.** Final tie-break, making the order TOTAL: two accounts
3284
+ * 2. **Expiring credits first, soonest first.** Points about to expire are
3285
+ * worth exactly zero after they do, so spending them before they lapse is
3286
+ * strictly better than saving them.
3287
+ * 3. **Largest expiring amount.** Between two accounts that both have expiring
3288
+ * points, the one with more at risk goes first.
3289
+ * 4. **Credits, highest first.** Among accounts with nothing expiring, spend the
3290
+ * one with the most, so a nearly-drained account is conserved.
3291
+ * 5. **Freshest credential.** Tie-break so a stale token never wins a draw.
3292
+ * 6. **Account id.** Final tie-break, making the order TOTAL: two accounts
3152
3293
  * equal on every ranking key would otherwise keep the caller's array order,
3153
3294
  * so the same pool could rotate differently between two runs.
3154
3295
  *
3296
+ * WHY EXPIRY OUTRANKS SIZE (issue #70). These used to be strict lexicographic
3297
+ * keys: total, THEN expiry. Since totals are almost never exactly equal, the
3298
+ * expiry rule was unreachable — a live report had an account with 196 points
3299
+ * lapsing that day ranked LAST, behind two accounts that expire weeks later,
3300
+ * simply because it held ~1,200 fewer points overall. It was never selected, so
3301
+ * the 196 points always lapsed. A "use it or lose it" rule that another rule
3302
+ * completely shadows is not a tie-break, it is dead code.
3303
+ *
3304
+ * WHY SPENDING THE SMALL EXPIRING ACCOUNT IS STILL RIGHT. Ordering by expiry
3305
+ * does mean an account with 1 point lapsing outranks one holding 50,000. That
3306
+ * looks wrong and is not: credits are fungible, so using the rich account
3307
+ * instead does not preserve the 1 point — it spends the 1 point's chance and
3308
+ * loses it. Using the expiring account spends a point that was going to be lost
3309
+ * anyway, and the rich account is still there for the next request. The only
3310
+ * real cost is a failover round-trip if the expiring account cannot cover a
3311
+ * whole request, which the exclusion/failover path already absorbs. Ordering by
3312
+ * total instead loses the expiring points with certainty.
3313
+ *
3155
3314
  * Excluded accounts are still RETURNED (sorted after the candidates) so the
3156
3315
  * card can explain why each is out; they differ from candidates by
3157
3316
  * `excludedBy` being set on them, never by being silently dropped.
@@ -3168,14 +3327,18 @@ function rankPool(members, nowMs) {
3168
3327
  score: scoreOf(member),
3169
3328
  ...excludedBy === void 0 ? {} : { excludedBy },
3170
3329
  _usable: excludedBy === void 0,
3330
+ _atRisk: (member.credits?.expiringSoon ?? 0) > 0,
3331
+ _expiringAmount: member.credits?.expiringSoon ?? 0,
3171
3332
  _expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
3172
3333
  _tokenExpiresAtMs: member.tokenExpiresAtMs ?? 0
3173
3334
  };
3174
3335
  });
3175
3336
  rows.sort((left, right) => {
3176
3337
  if (left._usable !== right._usable) return left._usable ? -1 : 1;
3338
+ if (left._atRisk !== right._atRisk) return left._atRisk ? -1 : 1;
3339
+ if (left._atRisk && right._atRisk && left._expiring !== right._expiring) return left._expiring - right._expiring;
3340
+ if (left._expiringAmount !== right._expiringAmount) return right._expiringAmount - left._expiringAmount;
3177
3341
  if (left.score !== right.score) return right.score - left.score;
3178
- if (left._expiring !== right._expiring) return left._expiring - right._expiring;
3179
3342
  if (left._tokenExpiresAtMs !== right._tokenExpiresAtMs) return right._tokenExpiresAtMs - left._tokenExpiresAtMs;
3180
3343
  return left.account.id < right.account.id ? -1 : left.account.id > right.account.id ? 1 : 0;
3181
3344
  });
@@ -5425,6 +5588,17 @@ function apply(ctx, config) {
5425
5588
  refresh: (credential) => client.refreshToken(credential)
5426
5589
  });
5427
5590
  const catalog = new WorkBuddyCatalog(region);
5591
+ /**
5592
+ * Where the next upstream `usage` should be written.
5593
+ *
5594
+ * The body report is two-phase (send, then charge), and the two phases are
5595
+ * wired through different callbacks, so the seam between them has to live
5596
+ * somewhere. `noop` rather than `undefined` keeps the usage path free of a
5597
+ * presence check on the hot request path — and a request whose report was
5598
+ * disabled resolves to the same noop, so nothing needs to know whether
5599
+ * reporting is on.
5600
+ */
5601
+ let pendingUsage = () => {};
5428
5602
  stacks[region] = {
5429
5603
  store,
5430
5604
  catalog,
@@ -5449,7 +5623,12 @@ function apply(ctx, config) {
5449
5623
  * setup completes.
5450
5624
  */
5451
5625
  onRequestBody: (body) => {
5452
- recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) });
5626
+ recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) }).then((attach) => {
5627
+ pendingUsage = attach;
5628
+ }).catch((error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error));
5629
+ },
5630
+ onUpstreamUsage: (usage) => {
5631
+ pendingUsage?.(usage);
5453
5632
  },
5454
5633
  prepareAccount: () => applyPoolSelection(region),
5455
5634
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
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": "4.6.5",
5
+ "version": "4.6.7",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",