@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/lib/usage.js CHANGED
@@ -1,36 +1,22 @@
1
- // Как устроена порция расхода в событиях сессии.
2
- //
3
- // Харнесс слушает шину событий сессии и собирает статистику по двум типам
4
- // записей:
5
- //
6
- // 1. Потоковые порции ответа ассистента:
7
- // `assistant/chunk` -> `event.data.chunk.usage`
8
- //
9
- // 2. Финальное сообщение ассистента:
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
- * Единого имени у расхода нет: одни присылают `inputTokens`, другие
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)) bad.push(field)
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 found = usage && typeof usage === 'object' ? borrowed(usage, field) : undefined
178
- fixed[field] = found === undefined ? 0 : found
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.turn)}, step ${String(found.step)}`
221
- const taken = bad
222
- .map((field) => (found.usage && borrowed(found.usage, field) !== undefined ? `${field} recovered via alias` : `${field} zeroed`))
223
- .join('; ')
224
- return `malformed usage sample — ${where}; received: ${safeStringify(found.usage)}; ${taken}`
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.7",
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",