@goodandready/dsh-usage-guard 0.1.7 → 0.1.10
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/README.md +45 -1
- package/README.ru.md +33 -0
- package/README.zh.md +10 -0
- package/lib/client.js +386 -44
- package/lib/index.js +127 -57
- package/lib/patch.js +23 -30
- package/lib/telemetry.js +111 -0
- package/lib/updater.js +266 -0
- package/lib/usage.js +89 -76
- package/package.json +1 -1
package/lib/updater.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// lib/updater.js — Host-side one-click updater for @goodandready/dsh-usage-guard
|
|
2
|
+
// Implements canonical DSH plugin-updater specification.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update';
|
|
12
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
const VERSION_CACHE_MS = 5 * 60_000;
|
|
14
|
+
let latestCache;
|
|
15
|
+
|
|
16
|
+
function header(request, name) {
|
|
17
|
+
const value = request?.headers?.[name];
|
|
18
|
+
return Array.isArray(value) ? value[0] : value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isLoopback(value) {
|
|
22
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
|
|
23
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
24
|
+
|| address?.startsWith('127.') === true
|
|
25
|
+
|| address?.startsWith('::ffff:127.') === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrustedUpdateRequest(request) {
|
|
29
|
+
if (header(request, UPDATE_HEADER) !== '1') return false;
|
|
30
|
+
if (!isLoopback(request.socket?.remoteAddress)) return false;
|
|
31
|
+
const site = header(request, 'sec-fetch-site');
|
|
32
|
+
if (site !== undefined && site !== 'same-origin') return false;
|
|
33
|
+
const origin = header(request, 'origin');
|
|
34
|
+
const host = header(request, 'host');
|
|
35
|
+
if (origin === undefined || host === undefined) return false;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(origin);
|
|
38
|
+
return (url.protocol === 'http:' || url.protocol === 'https:')
|
|
39
|
+
&& isLoopback(url.hostname) && url.host === host;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validProfileName(value) {
|
|
46
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
47
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileNameFromArgv(argv) {
|
|
51
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
52
|
+
if (argv[index] === '--profile') return argv[index + 1];
|
|
53
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
|
|
54
|
+
}
|
|
55
|
+
return argv[2] === 'web' ? 'web' : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findDshCliEntry() {
|
|
59
|
+
const value = process.argv[1];
|
|
60
|
+
if (value === undefined || value === '') return undefined;
|
|
61
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
|
|
62
|
+
if (!existsSync(entry)) return undefined;
|
|
63
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
64
|
+
const manifestPath = resolve(directory, 'package.json');
|
|
65
|
+
if (existsSync(manifestPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
68
|
+
const bin = typeof manifest.bin === 'string'
|
|
69
|
+
? manifest.bin
|
|
70
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
71
|
+
? manifest.bin.dsh
|
|
72
|
+
: undefined;
|
|
73
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
74
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
|
|
75
|
+
} catch {
|
|
76
|
+
// Continue searching parent package directories.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(directory);
|
|
80
|
+
if (parent === directory) return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runtime() {
|
|
85
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
86
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'));
|
|
87
|
+
const selected = profileNameFromArgv(process.argv);
|
|
88
|
+
const profileName = validProfileName(selected)
|
|
89
|
+
? selected
|
|
90
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
|
|
91
|
+
const cliEntry = findDshCliEntry();
|
|
92
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseSemver(value) {
|
|
96
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
97
|
+
if (match === null) return undefined;
|
|
98
|
+
return {
|
|
99
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
100
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function comparePrerelease(left, right) {
|
|
105
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
106
|
+
const length = Math.max(left.length, right.length);
|
|
107
|
+
for (let index = 0; index < length; index += 1) {
|
|
108
|
+
const a = left[index];
|
|
109
|
+
const b = right[index];
|
|
110
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
111
|
+
if (a === b) continue;
|
|
112
|
+
const aNumeric = /^\d+$/.test(a);
|
|
113
|
+
const bNumeric = /^\d+$/.test(b);
|
|
114
|
+
if (aNumeric && bNumeric) {
|
|
115
|
+
const aNumber = BigInt(a);
|
|
116
|
+
const bNumber = BigInt(b);
|
|
117
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
121
|
+
return a > b ? 1 : -1;
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
127
|
+
const current = parseSemver(currentValue);
|
|
128
|
+
const candidate = parseSemver(candidateValue);
|
|
129
|
+
if (current === undefined || candidate === undefined) return false;
|
|
130
|
+
for (let index = 0; index < 3; index += 1) {
|
|
131
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
|
|
132
|
+
}
|
|
133
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function latestVersion(packageName, registry) {
|
|
137
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version;
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
140
|
+
signal: AbortSignal.timeout(8_000),
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) return undefined;
|
|
143
|
+
const value = await response.json();
|
|
144
|
+
if (typeof value.version !== 'string' || value.version === '') return undefined;
|
|
145
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
|
|
146
|
+
return value.version;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function currentVersion(manifestUrl) {
|
|
153
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
|
|
154
|
+
if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
|
|
155
|
+
return value.version;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getUpdateStatus(options, target = runtime()) {
|
|
159
|
+
const current = await currentVersion(options.manifestUrl);
|
|
160
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
|
|
161
|
+
return {
|
|
162
|
+
packageName: options.packageName,
|
|
163
|
+
currentVersion: current,
|
|
164
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
165
|
+
latestCheckFailed: latest === undefined,
|
|
166
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
167
|
+
profileName: target.profileName,
|
|
168
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function installExact(target, packageSpec, options) {
|
|
173
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
|
|
174
|
+
await new Promise((resolvePromise, reject) => {
|
|
175
|
+
const child = spawn(process.execPath, [
|
|
176
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
177
|
+
'--config.minimumReleaseAge=0', packageSpec,
|
|
178
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
179
|
+
], {
|
|
180
|
+
cwd: target.profileDir,
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
184
|
+
});
|
|
185
|
+
let detail = '';
|
|
186
|
+
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
187
|
+
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
child.kill();
|
|
190
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'));
|
|
191
|
+
}, UPDATE_TIMEOUT_MS);
|
|
192
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
193
|
+
child.once('exit', code => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (code === 0) resolvePromise();
|
|
196
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function json(response, statusCode, value) {
|
|
202
|
+
response.writeHead(statusCode, {
|
|
203
|
+
'content-type': 'application/json; charset=utf-8',
|
|
204
|
+
'cache-control': 'no-store',
|
|
205
|
+
});
|
|
206
|
+
response.end(JSON.stringify(value));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerPluginUpdater(ctx, options) {
|
|
210
|
+
const host = ctx;
|
|
211
|
+
let installing = false;
|
|
212
|
+
return host.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: options.endpoint,
|
|
215
|
+
handler: async (request, response) => {
|
|
216
|
+
try {
|
|
217
|
+
const target = runtime();
|
|
218
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
219
|
+
const payload = await getUpdateStatus(options, target);
|
|
220
|
+
response.writeHead(200, {
|
|
221
|
+
'content-type': 'application/json; charset=utf-8',
|
|
222
|
+
'cache-control': 'no-store',
|
|
223
|
+
});
|
|
224
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (request.method !== 'POST') {
|
|
228
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' });
|
|
229
|
+
response.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
233
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (installing) {
|
|
237
|
+
json(response, 409, { error: 'This plugin is already updating.' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
installing = true;
|
|
241
|
+
try {
|
|
242
|
+
const before = await getUpdateStatus(options, target);
|
|
243
|
+
if (before.latestVersion === undefined) {
|
|
244
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!before.updateAvailable) {
|
|
248
|
+
json(response, 200, before);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
|
|
252
|
+
json(response, 200, {
|
|
253
|
+
...before,
|
|
254
|
+
updatedVersion: before.latestVersion,
|
|
255
|
+
restartRequired: true,
|
|
256
|
+
});
|
|
257
|
+
} finally {
|
|
258
|
+
installing = false;
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
|
|
262
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' });
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
package/lib/usage.js
CHANGED
|
@@ -1,36 +1,22 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// `assistant/message` -> `event.data.usage`
|
|
11
|
-
//
|
|
12
|
-
// В обоих случаях ядро ждёт четыре поля:
|
|
13
|
-
//
|
|
14
|
-
// uncachedInputTokens: usage.inputTokens,
|
|
15
|
-
// outputTokens: usage.outputTokens,
|
|
16
|
-
// cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
17
|
-
// cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
18
|
-
//
|
|
19
|
-
// Если первые два поля пропущены, не являются числом или содержат нечисловую строку,
|
|
20
|
-
// их сложение с нулём даёт NaN. Из-за этого одна битая порция валит не просто
|
|
21
|
-
// счётчик, а всю историю сессии.
|
|
22
|
-
export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
|
|
1
|
+
// lib/usage.js — Pure functions for token-usage sanitization.
|
|
2
|
+
// Zero imports, zero side-effects, testable without runtime context.
|
|
3
|
+
|
|
4
|
+
export const FIELDS = [
|
|
5
|
+
'inputTokens',
|
|
6
|
+
'outputTokens',
|
|
7
|
+
'cacheReadTokens',
|
|
8
|
+
'cacheWriteTokens',
|
|
9
|
+
]
|
|
23
10
|
|
|
24
11
|
/**
|
|
25
|
-
*
|
|
12
|
+
* Dictionary of common external provider naming variants for token counters.
|
|
26
13
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* не первый ход.
|
|
14
|
+
* Each LLM provider formats token usage differently. DSH core expects camelCase,
|
|
15
|
+
* and ignores other conventions even if the token count is present under a different key.
|
|
16
|
+
* Instead of immediately falling back to zero, we check known provider aliases first.
|
|
17
|
+
* Setting zero is a last resort to preserve arithmetic, not the primary choice.
|
|
32
18
|
*/
|
|
33
|
-
const ALIASES = {
|
|
19
|
+
export const ALIASES = {
|
|
34
20
|
inputTokens: [
|
|
35
21
|
'input_tokens',
|
|
36
22
|
'input',
|
|
@@ -62,20 +48,20 @@ const ALIASES = {
|
|
|
62
48
|
}
|
|
63
49
|
|
|
64
50
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
51
|
+
* Valid counter value: a finite, non-negative integer.
|
|
52
|
+
* Negative values, non-numeric values, and floating point numbers are considered invalid
|
|
53
|
+
* as the DSH core schema expects integer counters.
|
|
68
54
|
*/
|
|
69
|
-
function sound(value) {
|
|
55
|
+
export function sound(value) {
|
|
70
56
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isInteger(value)
|
|
71
57
|
}
|
|
72
58
|
|
|
73
59
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
60
|
+
* Safely coerces a value to a valid non-negative integer.
|
|
61
|
+
* Rounds floating point numbers and parses numeric strings (including decimals).
|
|
62
|
+
* Filters out NaN, Infinity, negative numbers, and non-numeric strings.
|
|
77
63
|
*/
|
|
78
|
-
function coerceNumber(value) {
|
|
64
|
+
export function coerceNumber(value) {
|
|
79
65
|
if (typeof value === 'number') {
|
|
80
66
|
return Number.isFinite(value) && value >= 0 ? Math.round(value) : undefined
|
|
81
67
|
}
|
|
@@ -90,11 +76,10 @@ function coerceNumber(value) {
|
|
|
90
76
|
}
|
|
91
77
|
|
|
92
78
|
/**
|
|
93
|
-
*
|
|
94
|
-
* харнесс.
|
|
79
|
+
* Extracts the usage sample from a session event by checking both standard DSH locations.
|
|
95
80
|
*
|
|
96
|
-
* @returns `{ usage, turn, step, at }
|
|
97
|
-
*
|
|
81
|
+
* @returns `{ usage, turn, step, at }` where `at` indicates the path within the event ('chunk' or 'message'),
|
|
82
|
+
* or `null` if the event carries no usage data.
|
|
98
83
|
*/
|
|
99
84
|
export function usageOf(event) {
|
|
100
85
|
if (!event || typeof event !== 'object') return null
|
|
@@ -111,53 +96,60 @@ export function usageOf(event) {
|
|
|
111
96
|
}
|
|
112
97
|
|
|
113
98
|
/**
|
|
114
|
-
*
|
|
99
|
+
* Detects which usage counters are malformed or exceed the maxStepTokens ceiling.
|
|
115
100
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* отрицательные значения.
|
|
101
|
+
* Omitted cache fields are not considered defects: many providers do not report them,
|
|
102
|
+
* and the harness automatically defaults them to zero. We only flag values that actually
|
|
103
|
+
* break arithmetic operations — missing required counters, non-numbers, non-integers, or negatives.
|
|
120
104
|
*/
|
|
121
|
-
export function damage(usage) {
|
|
105
|
+
export function damage(usage, options = {}) {
|
|
122
106
|
if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
|
|
123
107
|
const bad = []
|
|
108
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
109
|
+
|
|
124
110
|
for (const field of FIELDS) {
|
|
125
111
|
const value = usage[field]
|
|
126
112
|
const optional = field !== 'inputTokens' && field !== 'outputTokens'
|
|
127
113
|
if (value === undefined && optional) continue
|
|
128
|
-
if (!sound(value))
|
|
114
|
+
if (!sound(value)) {
|
|
115
|
+
bad.push(field)
|
|
116
|
+
} else if (maxStepTokens > 0 && value > maxStepTokens) {
|
|
117
|
+
bad.push(field)
|
|
118
|
+
bad.clamped = bad.clamped || {}
|
|
119
|
+
bad.clamped[field] = { from: value, to: maxStepTokens }
|
|
120
|
+
}
|
|
129
121
|
}
|
|
130
122
|
return bad
|
|
131
123
|
}
|
|
132
124
|
|
|
133
125
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
126
|
+
* Attempts to borrow a valid counter number from known aliases, nested structures, or numeric strings.
|
|
127
|
+
* Returns a valid non-negative integer or `undefined` if none is found.
|
|
136
128
|
*/
|
|
137
129
|
export function borrowed(usage, field) {
|
|
138
130
|
if (!usage || typeof usage !== 'object') return undefined
|
|
139
131
|
|
|
140
|
-
// 1.
|
|
132
|
+
// 1. Search known alias names
|
|
141
133
|
for (const alias of ALIASES[field] ?? []) {
|
|
142
134
|
const num = coerceNumber(usage[alias])
|
|
143
135
|
if (num !== undefined) return num
|
|
144
136
|
}
|
|
145
137
|
|
|
146
|
-
// 2.
|
|
138
|
+
// 2. Search nested cache details (OpenAI-style: prompt_tokens_details.cached_tokens / cachedTokens)
|
|
147
139
|
if (field === 'cacheReadTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
|
|
148
140
|
const details = usage.prompt_tokens_details
|
|
149
141
|
const num = coerceNumber(details.cached_tokens ?? details.cachedTokens ?? details.cache_read_tokens)
|
|
150
142
|
if (num !== undefined) return num
|
|
151
143
|
}
|
|
152
144
|
|
|
153
|
-
// 3.
|
|
145
|
+
// 3. Search nested cache creation details (cache_creation_input_tokens)
|
|
154
146
|
if (field === 'cacheWriteTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
|
|
155
147
|
const details = usage.prompt_tokens_details
|
|
156
148
|
const num = coerceNumber(details.cache_creation_tokens ?? details.cacheCreationTokens)
|
|
157
149
|
if (num !== undefined) return num
|
|
158
150
|
}
|
|
159
151
|
|
|
160
|
-
// 4.
|
|
152
|
+
// 4. Check the field itself if passed as a string or float (e.g. "123" or 15.4)
|
|
161
153
|
const selfNum = coerceNumber(usage[field])
|
|
162
154
|
if (selfNum !== undefined) return selfNum
|
|
163
155
|
|
|
@@ -165,33 +157,41 @@ export function borrowed(usage, field) {
|
|
|
165
157
|
}
|
|
166
158
|
|
|
167
159
|
/**
|
|
168
|
-
*
|
|
160
|
+
* Returns a usage object with sanitized counters and optional maxStepTokens clamping.
|
|
169
161
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
162
|
+
* We search for a value under known aliases or normalize the existing field first,
|
|
163
|
+
* and only substitute zero if no numeric value can be recovered.
|
|
164
|
+
* This restores arithmetic consistency without mutating unrelated provider response fields.
|
|
173
165
|
*/
|
|
174
|
-
export function repaired(usage, bad) {
|
|
166
|
+
export function repaired(usage, bad, options = {}) {
|
|
175
167
|
const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
|
|
168
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
169
|
+
|
|
176
170
|
for (const field of bad) {
|
|
177
|
-
const
|
|
178
|
-
|
|
171
|
+
const rawVal = usage && typeof usage === 'object' ? usage[field] : undefined
|
|
172
|
+
let found = usage && typeof usage === 'object' ? borrowed(usage, field) : undefined
|
|
173
|
+
let candidate = found === undefined ? 0 : found
|
|
174
|
+
|
|
175
|
+
if (maxStepTokens > 0 && candidate > maxStepTokens) {
|
|
176
|
+
candidate = maxStepTokens
|
|
177
|
+
} else if (maxStepTokens > 0 && typeof rawVal === 'number' && rawVal > maxStepTokens && candidate === 0) {
|
|
178
|
+
candidate = maxStepTokens
|
|
179
|
+
}
|
|
180
|
+
fixed[field] = candidate
|
|
179
181
|
}
|
|
180
182
|
return fixed
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
/**
|
|
184
|
-
*
|
|
185
|
-
* нечего.
|
|
186
|
+
* Returns an event with sanitized usage, or the exact same event if no repair was needed.
|
|
186
187
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* была.
|
|
188
|
+
* Performs a shallow copy only along the exact path holding usage data:
|
|
189
|
+
* state reduction receives the repaired counters while all other event references remain untouched.
|
|
190
190
|
*/
|
|
191
|
-
export function healed(event, bad) {
|
|
191
|
+
export function healed(event, bad, options = {}) {
|
|
192
192
|
const found = usageOf(event)
|
|
193
193
|
if (!found || bad.length === 0) return event
|
|
194
|
-
const usage = repaired(found.usage, bad)
|
|
194
|
+
const usage = repaired(found.usage, bad, options)
|
|
195
195
|
|
|
196
196
|
if (found.at === 'chunk') {
|
|
197
197
|
return {
|
|
@@ -202,7 +202,7 @@ export function healed(event, bad) {
|
|
|
202
202
|
return { ...event, data: { ...event.data, usage } }
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
/**
|
|
205
|
+
/** Safe JSON stringification for logging: guards against circular references and BigInt values. */
|
|
206
206
|
function safeStringify(value) {
|
|
207
207
|
try {
|
|
208
208
|
return JSON.stringify(value)
|
|
@@ -215,11 +215,24 @@ function safeStringify(value) {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
/**
|
|
219
|
-
export function complaint(found, bad) {
|
|
220
|
-
const where = `turn ${String(found
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
218
|
+
/** Formats a descriptive log line identifying the turn, step, raw payload, and applied repairs. */
|
|
219
|
+
export function complaint(found, bad, options = {}) {
|
|
220
|
+
const where = `turn ${String(found?.turn ?? '-')}, step ${String(found?.step ?? '-')}`
|
|
221
|
+
const usage = found?.usage ?? found
|
|
222
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
223
|
+
const taken = Array.isArray(bad)
|
|
224
|
+
? bad
|
|
225
|
+
.map((field) => {
|
|
226
|
+
if (bad?.clamped?.[field]) {
|
|
227
|
+
return `${field} clamped abnormally large (${bad.clamped[field].from} -> ${bad.clamped[field].to})`
|
|
228
|
+
}
|
|
229
|
+
const val = usage?.[field]
|
|
230
|
+
if (maxStepTokens > 0 && typeof val === 'number' && val > maxStepTokens) {
|
|
231
|
+
return `${field} clamped to ${maxStepTokens}`
|
|
232
|
+
}
|
|
233
|
+
return usage && borrowed(usage, field) !== undefined ? `${field} recovered via alias` : `${field} zeroed`
|
|
234
|
+
})
|
|
235
|
+
.join('; ')
|
|
236
|
+
: ''
|
|
237
|
+
return `malformed usage sample — ${where}; received: ${safeStringify(usage)}; ${taken}`
|
|
225
238
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-usage-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Keeps a malformed token-usage sample from taking a whole session history down with it: a missing or non-numeric counter becomes zero before the harness adds it up, so the fold never yields NaN and already-broken sessions open again.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|