@goodandready/dsh-usage-guard 0.1.7 → 0.1.9
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 +347 -36
- package/lib/index.js +75 -41
- package/lib/telemetry.js +111 -0
- package/lib/updater.js +266 -0
- package/lib/usage.js +60 -43
- package/package.json +1 -1
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
|
+
* Словарь привычных чужих имён для счётчиков токенов.
|
|
26
13
|
*
|
|
27
|
-
*
|
|
28
|
-
* `input_tokens`, третьи просто `input`. Харнесс знает одно написание, поэтому
|
|
14
|
+
* Каждый LLM-провайдер называет расход на свой лад. Харнесс ждёт camelCase, а
|
|
29
15
|
* остальные для него — пустое место, хотя число лежит рядом. Прежде чем
|
|
30
16
|
* подставлять ноль, стоит поискать его здесь: ноль — это признание поражения, а
|
|
31
17
|
* не первый ход.
|
|
32
18
|
*/
|
|
33
|
-
const ALIASES = {
|
|
19
|
+
export const ALIASES = {
|
|
34
20
|
inputTokens: [
|
|
35
21
|
'input_tokens',
|
|
36
22
|
'input',
|
|
@@ -66,7 +52,7 @@ const ALIASES = {
|
|
|
66
52
|
* Значения меньше нуля (-1 и т.д.), нечисловые, а также дробные числа (floats)
|
|
67
53
|
* не признаются годными, так как схема ядра DSH требует z.number().int().nonnegative().
|
|
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
|
|
|
@@ -75,7 +61,7 @@ function sound(value) {
|
|
|
75
61
|
* Округляет дробные числа и парсит числовые строки (в т.ч. с десятичной точкой).
|
|
76
62
|
* Исключает NaN, Infinity, отрицательные величины и мусорные строки.
|
|
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
|
}
|
|
@@ -111,21 +97,29 @@ export function usageOf(event) {
|
|
|
111
97
|
}
|
|
112
98
|
|
|
113
99
|
/**
|
|
114
|
-
* Какие счётчики порции
|
|
100
|
+
* Какие счётчики порции испорчены или превышают допустимый порог maxStepTokens.
|
|
115
101
|
*
|
|
116
102
|
* Отсутствие кэш-полей нарушением не считается: их не присылает добрая половина
|
|
117
103
|
* провайдеров, и харнесс сам подставляет там ноль. Ругаемся только на то, что
|
|
118
104
|
* действительно ломает сложение, — на пропущенные, нечисловые, нецелые или
|
|
119
105
|
* отрицательные значения.
|
|
120
106
|
*/
|
|
121
|
-
export function damage(usage) {
|
|
107
|
+
export function damage(usage, options = {}) {
|
|
122
108
|
if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
|
|
123
109
|
const bad = []
|
|
110
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
111
|
+
|
|
124
112
|
for (const field of FIELDS) {
|
|
125
113
|
const value = usage[field]
|
|
126
114
|
const optional = field !== 'inputTokens' && field !== 'outputTokens'
|
|
127
115
|
if (value === undefined && optional) continue
|
|
128
|
-
if (!sound(value))
|
|
116
|
+
if (!sound(value)) {
|
|
117
|
+
bad.push(field)
|
|
118
|
+
} else if (maxStepTokens > 0 && value > maxStepTokens) {
|
|
119
|
+
bad.push(field)
|
|
120
|
+
bad.clamped = bad.clamped || {}
|
|
121
|
+
bad.clamped[field] = { from: value, to: maxStepTokens }
|
|
122
|
+
}
|
|
129
123
|
}
|
|
130
124
|
return bad
|
|
131
125
|
}
|
|
@@ -165,17 +159,27 @@ export function borrowed(usage, field) {
|
|
|
165
159
|
}
|
|
166
160
|
|
|
167
161
|
/**
|
|
168
|
-
* Порция с обезвреженными
|
|
162
|
+
* Порция с обезвреженными счётчиками и опциональным ограничением maxStepTokens.
|
|
169
163
|
*
|
|
170
164
|
* Сначала ищем число под чужим именем или нормализуем само поле (округление float/string),
|
|
171
165
|
* и только если его нет — ставим ноль.
|
|
172
166
|
* Чиним арифметику, а не переписываем остальной ответ провайдера.
|
|
173
167
|
*/
|
|
174
|
-
export function repaired(usage, bad) {
|
|
168
|
+
export function repaired(usage, bad, options = {}) {
|
|
175
169
|
const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
|
|
170
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
171
|
+
|
|
176
172
|
for (const field of bad) {
|
|
177
|
-
const
|
|
178
|
-
|
|
173
|
+
const rawVal = usage && typeof usage === 'object' ? usage[field] : undefined
|
|
174
|
+
let found = usage && typeof usage === 'object' ? borrowed(usage, field) : undefined
|
|
175
|
+
let candidate = found === undefined ? 0 : found
|
|
176
|
+
|
|
177
|
+
if (maxStepTokens > 0 && candidate > maxStepTokens) {
|
|
178
|
+
candidate = maxStepTokens
|
|
179
|
+
} else if (maxStepTokens > 0 && typeof rawVal === 'number' && rawVal > maxStepTokens && candidate === 0) {
|
|
180
|
+
candidate = maxStepTokens
|
|
181
|
+
}
|
|
182
|
+
fixed[field] = candidate
|
|
179
183
|
}
|
|
180
184
|
return fixed
|
|
181
185
|
}
|
|
@@ -188,10 +192,10 @@ export function repaired(usage, bad) {
|
|
|
188
192
|
* достаётся исправное событие, а всё остальное в нём — та же ссылка, что и
|
|
189
193
|
* была.
|
|
190
194
|
*/
|
|
191
|
-
export function healed(event, bad) {
|
|
195
|
+
export function healed(event, bad, options = {}) {
|
|
192
196
|
const found = usageOf(event)
|
|
193
197
|
if (!found || bad.length === 0) return event
|
|
194
|
-
const usage = repaired(found.usage, bad)
|
|
198
|
+
const usage = repaired(found.usage, bad, options)
|
|
195
199
|
|
|
196
200
|
if (found.at === 'chunk') {
|
|
197
201
|
return {
|
|
@@ -216,10 +220,23 @@ function safeStringify(value) {
|
|
|
216
220
|
}
|
|
217
221
|
|
|
218
222
|
/** Короткая строка для журнала: по ней видно, кого звать к ответу. */
|
|
219
|
-
export function complaint(found, bad) {
|
|
220
|
-
const where = `turn ${String(found
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
export function complaint(found, bad, options = {}) {
|
|
224
|
+
const where = `turn ${String(found?.turn ?? '-')}, step ${String(found?.step ?? '-')}`
|
|
225
|
+
const usage = found?.usage ?? found
|
|
226
|
+
const maxStepTokens = typeof options === 'number' ? options : (Number(options?.maxStepTokens) > 0 ? Number(options.maxStepTokens) : 0)
|
|
227
|
+
const taken = Array.isArray(bad)
|
|
228
|
+
? bad
|
|
229
|
+
.map((field) => {
|
|
230
|
+
if (bad?.clamped?.[field]) {
|
|
231
|
+
return `${field} clamped abnormally large (${bad.clamped[field].from} -> ${bad.clamped[field].to})`
|
|
232
|
+
}
|
|
233
|
+
const val = usage?.[field]
|
|
234
|
+
if (maxStepTokens > 0 && typeof val === 'number' && val > maxStepTokens) {
|
|
235
|
+
return `${field} clamped to ${maxStepTokens}`
|
|
236
|
+
}
|
|
237
|
+
return usage && borrowed(usage, field) !== undefined ? `${field} recovered via alias` : `${field} zeroed`
|
|
238
|
+
})
|
|
239
|
+
.join('; ')
|
|
240
|
+
: ''
|
|
241
|
+
return `malformed usage sample — ${where}; received: ${safeStringify(usage)}; ${taken}`
|
|
225
242
|
}
|
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.9",
|
|
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",
|