dsh-connect-workbuddy 4.6.5 → 4.6.6
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 +24 -0
- package/docs/WINDOWS.md +33 -0
- package/lib/bin.js +1 -1
- package/lib/{host-heartbeat-BpqT3LJe.js → host-heartbeat-NHBxcmkQ.js} +1 -1
- package/lib/index.d.ts +22 -0
- package/lib/index.js +400 -245
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.6 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **patch**:只动诊断(`WORKBUDDY_BODY_REPORT` 的报告内容),不改请求、响应、计费或界面。**默认行为完全不变**——报告默认仍是关的,关着时这版与 4.6.5 行为等价。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **body 报告只记「发了什么」不记「上游算了多少」,导致 #59 的抓取无法回答它要问的问题(issue #59)**
|
|
10
|
+
- **缺口**:`WORKBUDDY_BODY_REPORT` 抓的是**本地组装好、发往上游之前**的 body 构成(`bodyBytes` / `chars` / 各块字符数)。而 #59 要判定的是「同一份历史为什么国际网关算 1.94 倍」。**只有「发出侧」时,两种世界长得一模一样**:
|
|
11
|
+
- **A** 网关对几乎相同的字节算出两倍 → **上游的问题**,插件无能为力;
|
|
12
|
+
- **B** 两边发出的 body 其实不同 → **我们的问题**,要修。
|
|
13
|
+
|
|
14
|
+
报告者 10-16 抓到的配对 body 总量只差 **0.3%**(3,554,248 vs 3,565,415 字节),但仅凭这个数无法区分 A 与 B —— 缺的正是「上游回了多少」。
|
|
15
|
+
- **改了什么**:报告每行新增 `promptTokens` / `completionTokens` / `cachedTokens` 三个**可选**字段,取自上游响应流里的 `usage` 块。至此一行同时给出两半:**发出去的字节** 与 **网关算的 token**,两者一比即可定案。
|
|
16
|
+
- **为什么必须新加一个响应侧钩子**:`usage` 走的是**响应流**,而不是请求。`createWorkBuddyShim` 原先只有 `onRequestBody`(请求发出前),响应侧不记任何东西 —— 而 `usage` 只在上游的**最后一帧**才出现。新增 `onUpstreamUsage`,在 `RecoveryStream` 解析每一帧时读取。
|
|
17
|
+
- **★ 一个位置性缺陷,写完就撞上了**:`usage` 帧**不带 `choices`**(它是流末尾的独立帧),而 `handleFrame()` 里第一个早退分支就是「没有 choices 就原样转发」。把读取放在那个分支之后,代码跑起来一切正常、**永不报错**,只是永远拿不到数。守卫 `tests/issue59-upstream-usage.spec.ts` 的第 1 例专门钉这一条:**变异(把读取挪到 `choices` 检查之后)→ 红 2 例**,正是原始的漏读形态。
|
|
18
|
+
- **「没有」不等于「零」**:上游没报 `usage`(请求失败、流被截断、非流式路径)时字段**缺省**,而不是写 `0`。这是全条的关键区分——把「没测到」记成「没花钱」,会让整个对比失效。守卫用一条显式用例钉住(变异:把缺失记成 `promptTokens: 0` → **5 例全红**)。
|
|
19
|
+
- **每个流只报一次**:`usageReported` 幂等,因为 failover 重试会**另起一个 `RecoveryStream`**,同一个 body 可能被量两次;先到的那次是实际服务的那次。
|
|
20
|
+
- **写回同一行,不新起一行**:两个半边的可观测时刻不同(发出侧在请求前、计费侧在流结束后),所以 `recordBodyReport` 改为返回一个 handle,等 `usage` 到了再**回填它自己那一行**。之所以不能追加第三行:报告者的用法是「取 `cn.jsonl` 与 `global.jsonl` 的**最后一行**对比」,多出来的 usage-only 行会**静默变成被比较的那一行**。
|
|
21
|
+
- **行身份靠四元组匹配**(`at` + `region` + `bodyBytes` + `messageCount`),故意不用 `at` 一个字段:同一毫秒内可能有两笔请求。**这里踩到并修掉了一个测试自身的洞**:初版测试标注的是**最后一行**,而回填是从文件末尾**倒着找**的 —— 于是把 `bodyBytes` 校验整个删掉,测试**照样全绿**。改成标注**靠前**那一行后,该变异立刻转红;`messageCount` 同理(构造了两份**字节数完全相同、消息条数不同**的 body:一条 51 字符的消息 与 两条 11 字符的消息,都是 196 字节)。**最终变异验证**:去掉 `bodyBytes` 校验 → 红 1;去掉 `messageCount` 校验 → 红 1;两者都去掉 → 红 2。
|
|
22
|
+
- **两道幂等闸刻意保留冗余**:handle 内的 `done` 与磁盘上的「已标注就跳过」互为备份,各自覆盖另一种情形(后者专治「同一 body 的第二个 handle」)。只删一道**行为上不可观测**,删两道才红——这一点写进了代码注释,免得后人以为其中一道是死代码而删掉。
|
|
23
|
+
- **缓存命中一并记录**:两个网关的缓存命中**拼写不同**(OpenAI 的 `prompt_tokens_details.cached_tokens` 与国内的 `prompt_cache_hit_tokens`),两种都读、取先命中者,否则同一份对话在两个区域会显示成一个有缓存、一个没有。
|
|
24
|
+
- **对 #59 的实际意义**:这一版让报告者**不必再做重放实验**——同一会话两区各抓一次,直接比 `promptTokens` 两列即可区分 A / B。重放要发的是一整份真实历史,既贵,又会引入「两次请求之间还发生了什么」的新变量。
|
|
25
|
+
- **本条不包含对 1.94 倍的结论**:它补的是**验证手段**。那个结论仍待报告者带新字段重抓后才能下。
|
|
26
|
+
|
|
3
27
|
## 4.6.5 (2026-10-16)
|
|
4
28
|
|
|
5
29
|
> 版本号是 **patch**:两处诊断正确性修复,没有接口或配置变更。
|
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-
|
|
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-NHBxcmkQ.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.
|
|
3952
|
+
const WORKBUDDY_CONNECT_VERSION = "4.6.6";
|
|
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-
|
|
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-NHBxcmkQ.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.
|
|
@@ -5425,6 +5564,17 @@ function apply(ctx, config) {
|
|
|
5425
5564
|
refresh: (credential) => client.refreshToken(credential)
|
|
5426
5565
|
});
|
|
5427
5566
|
const catalog = new WorkBuddyCatalog(region);
|
|
5567
|
+
/**
|
|
5568
|
+
* Where the next upstream `usage` should be written.
|
|
5569
|
+
*
|
|
5570
|
+
* The body report is two-phase (send, then charge), and the two phases are
|
|
5571
|
+
* wired through different callbacks, so the seam between them has to live
|
|
5572
|
+
* somewhere. `noop` rather than `undefined` keeps the usage path free of a
|
|
5573
|
+
* presence check on the hot request path — and a request whose report was
|
|
5574
|
+
* disabled resolves to the same noop, so nothing needs to know whether
|
|
5575
|
+
* reporting is on.
|
|
5576
|
+
*/
|
|
5577
|
+
let pendingUsage = () => {};
|
|
5428
5578
|
stacks[region] = {
|
|
5429
5579
|
store,
|
|
5430
5580
|
catalog,
|
|
@@ -5449,7 +5599,12 @@ function apply(ctx, config) {
|
|
|
5449
5599
|
* setup completes.
|
|
5450
5600
|
*/
|
|
5451
5601
|
onRequestBody: (body) => {
|
|
5452
|
-
recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) })
|
|
5602
|
+
recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) }).then((attach) => {
|
|
5603
|
+
pendingUsage = attach;
|
|
5604
|
+
}).catch((error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error));
|
|
5605
|
+
},
|
|
5606
|
+
onUpstreamUsage: (usage) => {
|
|
5607
|
+
pendingUsage?.(usage);
|
|
5453
5608
|
},
|
|
5454
5609
|
prepareAccount: () => applyPoolSelection(region),
|
|
5455
5610
|
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
|
+
"version": "4.6.6",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|