@goodandready/dsh-usage-guard 0.1.9 → 0.1.11
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/client.js +43 -12
- package/lib/index.js +57 -21
- package/lib/patch.js +23 -30
- package/lib/usage.js +33 -37
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -58,6 +58,7 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
badge_report_only: '仅审计',
|
|
59
59
|
'settings.loading': '正在加载 Usage Guard 设置…',
|
|
60
60
|
'settings.unavailable': '设置不可用(宿主命名空间尚未就绪)。',
|
|
61
|
+
'settings.unmonitored': '配置监听不可用。当前使用默认配置运行;变更需重启 DSH 生效。',
|
|
61
62
|
'settings.retry': '重试',
|
|
62
63
|
saved: '设置保存成功',
|
|
63
64
|
save: '保存更改',
|
|
@@ -96,9 +97,13 @@ window.__ModuleLoader__.load({
|
|
|
96
97
|
|
|
97
98
|
function getActiveLocale(c) {
|
|
98
99
|
try {
|
|
99
|
-
const
|
|
100
|
+
const snap = (typeof c?.locale?.getLocale === 'function' ? c.locale.getLocale() : null)
|
|
101
|
+
|| (typeof c?.locale?.getSnapshot === 'function' ? c.locale.getSnapshot() : null);
|
|
102
|
+
const cur = snap?.active || snap?.locale || '';
|
|
100
103
|
if (typeof cur === 'string' && cur.toLowerCase().startsWith('zh')) return 'zh';
|
|
101
|
-
} catch (
|
|
104
|
+
} catch (err) {
|
|
105
|
+
void err;
|
|
106
|
+
}
|
|
102
107
|
return 'en';
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -127,7 +132,15 @@ window.__ModuleLoader__.load({
|
|
|
127
132
|
})
|
|
128
133
|
);
|
|
129
134
|
}
|
|
130
|
-
|
|
135
|
+
|
|
136
|
+
let ChevronIcon = null;
|
|
137
|
+
try {
|
|
138
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
139
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
void err;
|
|
142
|
+
}
|
|
143
|
+
const Chevron = ChevronIcon || FallbackChevron;
|
|
131
144
|
|
|
132
145
|
function ensureCss() {
|
|
133
146
|
if (typeof document === 'undefined') return;
|
|
@@ -142,9 +155,9 @@ window.__ModuleLoader__.load({
|
|
|
142
155
|
.ug-head-sub{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:2px}
|
|
143
156
|
|
|
144
157
|
.ug-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
|
|
145
|
-
.ug-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
146
|
-
.ug-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
147
|
-
.ug-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
158
|
+
.ug-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-bg, var(--dsw-alias-bg-layer-2))}
|
|
159
|
+
.ug-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2))}
|
|
160
|
+
.ug-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-layer-2))}
|
|
148
161
|
|
|
149
162
|
.ug-body{display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:16px;margin-top:4px}
|
|
150
163
|
.ug-field-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}
|
|
@@ -167,12 +180,13 @@ window.__ModuleLoader__.load({
|
|
|
167
180
|
.ug-btn-sm{padding:4px 10px;font-size:12px;border-radius:6px}
|
|
168
181
|
.ug-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
|
|
169
182
|
.ug-btn-primary:hover:not(:disabled){opacity:0.9}
|
|
170
|
-
.ug-btn-accent{background:var(--dsw-alias-state-brand-primary);color
|
|
183
|
+
.ug-btn-accent{background:var(--dsw-alias-state-brand-primary);color:var(--dsw-alias-label-invert, var(--dsw-alias-bg-layer-3));border-color:transparent}
|
|
171
184
|
.ug-btn-accent:hover:not(:disabled){opacity:0.9}
|
|
172
185
|
.ug-btn:disabled{opacity:0.5;cursor:not-allowed}
|
|
173
186
|
|
|
174
|
-
.ug-alert-ok{padding:10px 14px;border-radius:8px;background:
|
|
175
|
-
.ug-alert-bad{padding:10px 14px;border-radius:8px;background:
|
|
187
|
+
.ug-alert-ok{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-success-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-success-primary);font-size:13px;border:1px solid var(--dsw-alias-state-success-border, var(--dsw-alias-border-l2))}
|
|
188
|
+
.ug-alert-bad{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary);font-size:13px;border:1px solid var(--dsw-alias-state-error-border, var(--dsw-alias-border-l2))}
|
|
189
|
+
.ug-update-banner{margin-top:8px;display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-radius:6px;background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2));border:1px solid var(--dsw-alias-state-warning-border, var(--dsw-alias-border-l2))}
|
|
176
190
|
.ug-incident-box{margin-top:6px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);font-size:12px;font-family:monospace;color:var(--dsw-alias-label-secondary);overflow-x:auto}
|
|
177
191
|
`;
|
|
178
192
|
document.head.appendChild(style);
|
|
@@ -407,7 +421,7 @@ window.__ModuleLoader__.load({
|
|
|
407
421
|
updateAvailable
|
|
408
422
|
? React.createElement(
|
|
409
423
|
'div',
|
|
410
|
-
{
|
|
424
|
+
{ className: 'ug-update-banner' },
|
|
411
425
|
React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-state-warning-primary)', fontWeight: 500 } }, `New release available: v${latestVer}`),
|
|
412
426
|
React.createElement(
|
|
413
427
|
'button',
|
|
@@ -467,6 +481,9 @@ window.__ModuleLoader__.load({
|
|
|
467
481
|
}
|
|
468
482
|
};
|
|
469
483
|
read();
|
|
484
|
+
if (typeof scope.watch !== 'function' && active) {
|
|
485
|
+
setSnap((prev) => ({ ...prev, unmonitored: true }));
|
|
486
|
+
}
|
|
470
487
|
const unwatch = typeof scope.watch === 'function'
|
|
471
488
|
? scope.watch((next) => {
|
|
472
489
|
if (!active || !next || typeof next !== 'object') return;
|
|
@@ -482,7 +499,9 @@ window.__ModuleLoader__.load({
|
|
|
482
499
|
: () => {};
|
|
483
500
|
return () => {
|
|
484
501
|
active = false;
|
|
485
|
-
try { unwatch(); } catch (_) {
|
|
502
|
+
try { unwatch(); } catch (_) {
|
|
503
|
+
// Intentional cleanup catch: ignore errors if subscription was already disposed
|
|
504
|
+
}
|
|
486
505
|
};
|
|
487
506
|
} catch (_) {
|
|
488
507
|
setSnap({ status: 'unavailable', value: {} });
|
|
@@ -570,6 +589,14 @@ window.__ModuleLoader__.load({
|
|
|
570
589
|
'div',
|
|
571
590
|
{ className: 'ug-body' },
|
|
572
591
|
|
|
592
|
+
snap.unmonitored
|
|
593
|
+
? React.createElement(
|
|
594
|
+
'div',
|
|
595
|
+
{ className: 'ug-alert-bad', style: { marginBottom: '12px' } },
|
|
596
|
+
t('settings.unmonitored')
|
|
597
|
+
)
|
|
598
|
+
: null,
|
|
599
|
+
|
|
573
600
|
// Telemetry / Protection status banner
|
|
574
601
|
React.createElement(
|
|
575
602
|
'div',
|
|
@@ -769,7 +796,11 @@ window.__ModuleLoader__.load({
|
|
|
769
796
|
try {
|
|
770
797
|
const s = (c?.get && c.get('lanSettings')) || c?.settingsScope;
|
|
771
798
|
s?.describe?.()?.load?.();
|
|
772
|
-
} catch (
|
|
799
|
+
} catch (err) {
|
|
800
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
801
|
+
console.warn('[dsh-usage-guard] settings mirror load attempt failed:', err?.message || err);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
773
804
|
}, 1000);
|
|
774
805
|
if (timer && typeof timer.unref === 'function') {
|
|
775
806
|
timer.unref();
|
package/lib/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
// dsh-usage-guard —
|
|
1
|
+
// dsh-usage-guard — Token usage counter sanitizer and session history protector.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// DSH core aggregates token usage into four buckets, two of which are accessed without fallbacks:
|
|
4
4
|
//
|
|
5
|
-
// uncachedInputTokens: usage.inputTokens, //
|
|
6
|
-
// outputTokens: usage.outputTokens, //
|
|
5
|
+
// uncachedInputTokens: usage.inputTokens, // unguarded
|
|
6
|
+
// outputTokens: usage.outputTokens, // unguarded
|
|
7
7
|
// cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
8
8
|
// cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// If a provider returns a usage sample missing either of the first two, accumulation yields NaN.
|
|
11
|
+
// Subsequent schema validation then rejects the entire session history projection.
|
|
12
12
|
|
|
13
13
|
import z from '@deepseek-ai/schemastery'
|
|
14
14
|
|
|
@@ -17,7 +17,8 @@ import { telemetry } from './telemetry.js'
|
|
|
17
17
|
import { registerPluginUpdater } from './updater.js'
|
|
18
18
|
import { complaint, damage, healed, usageOf } from './usage.js'
|
|
19
19
|
|
|
20
|
-
export const name = 'dsh-usage-guard'
|
|
20
|
+
export const name = '@goodandready/dsh-usage-guard'
|
|
21
|
+
export const NS = 'dsh-usage-guard'
|
|
21
22
|
export const inject = ['sessionProjections']
|
|
22
23
|
|
|
23
24
|
export const Config = z.object({
|
|
@@ -48,15 +49,43 @@ export function apply(ctx, config) {
|
|
|
48
49
|
|
|
49
50
|
ctx.inject(['settings'], (sctx) => {
|
|
50
51
|
try {
|
|
51
|
-
const scope = sctx.settings
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
const scope = sctx.settings?.register?.(NS, Config, { base: config })
|
|
53
|
+
if (!scope) {
|
|
54
|
+
const msg = `[dsh-usage-guard] settings service register() unavailable; running with default config`
|
|
55
|
+
if (typeof sctx.logger?.warn === 'function') sctx.logger.warn(msg)
|
|
56
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (typeof scope.get === 'function') {
|
|
61
|
+
try {
|
|
62
|
+
const fresh = scope.get()
|
|
63
|
+
if (fresh && typeof fresh === 'object') live = fresh
|
|
64
|
+
} catch (readErr) {
|
|
65
|
+
const msg = `[dsh-usage-guard] failed to read current settings: ${readErr?.message || readErr}; running with initial config`
|
|
66
|
+
if (typeof sctx.logger?.warn === 'function') sctx.logger.warn(msg)
|
|
67
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const msg = `[dsh-usage-guard] settings scope has no get() method; running with default config`
|
|
71
|
+
if (typeof sctx.logger?.warn === 'function') sctx.logger.warn(msg)
|
|
72
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof scope.watch === 'function') {
|
|
76
|
+
sctx.effect(() => scope.watch((next) => {
|
|
77
|
+
if (next && typeof next === 'object') live = next
|
|
78
|
+
}))
|
|
79
|
+
} else {
|
|
80
|
+
const msg = `[dsh-usage-guard] settings scope has no watch() method; running with default config without live update subscription`
|
|
81
|
+
if (typeof sctx.logger?.warn === 'function') sctx.logger.warn(msg)
|
|
82
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = `[dsh-usage-guard] failed to register settings subscription: ${err?.message || err}; running with default config`
|
|
86
|
+
if (typeof sctx.logger?.warn === 'function') sctx.logger.warn(msg)
|
|
87
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
88
|
+
}
|
|
60
89
|
})
|
|
61
90
|
|
|
62
91
|
// Register one-click updater and telemetry endpoints if webServer is available
|
|
@@ -84,7 +113,11 @@ export function apply(ctx, config) {
|
|
|
84
113
|
res.end()
|
|
85
114
|
},
|
|
86
115
|
}), 'dsh-usage-guard: telemetry route')
|
|
87
|
-
} catch (
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const msg = `[dsh-usage-guard] failed to register routes on webServer: ${err?.message || err}`
|
|
118
|
+
if (typeof wctx.logger?.warn === 'function') wctx.logger.warn(msg)
|
|
119
|
+
else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(msg)
|
|
120
|
+
}
|
|
88
121
|
})
|
|
89
122
|
|
|
90
123
|
const told = new Set()
|
|
@@ -141,10 +174,13 @@ export function apply(ctx, config) {
|
|
|
141
174
|
})
|
|
142
175
|
|
|
143
176
|
if (live && live.report !== false) {
|
|
144
|
-
|
|
177
|
+
const msg = `[dsh-usage-guard] ${complaint(found, bad, { maxStepTokens: live?.maxStepTokens })}`
|
|
178
|
+
if (typeof ctx.logger?.warn === 'function') {
|
|
179
|
+
ctx.logger.warn(msg)
|
|
180
|
+
} else {
|
|
145
181
|
// eslint-disable-next-line no-console
|
|
146
|
-
console.warn(
|
|
147
|
-
}
|
|
182
|
+
console.warn(msg)
|
|
183
|
+
}
|
|
148
184
|
}
|
|
149
185
|
}
|
|
150
186
|
|
|
@@ -156,7 +192,7 @@ export function apply(ctx, config) {
|
|
|
156
192
|
ctx.inject(['sessionProjections'], (pctx) => {
|
|
157
193
|
pctx.effect(
|
|
158
194
|
() => patchRegistry(pctx.sessionProjections, guard),
|
|
159
|
-
'dsh-usage-guard:
|
|
195
|
+
'dsh-usage-guard: sanitize usage counter samples',
|
|
160
196
|
)
|
|
161
197
|
})
|
|
162
198
|
}
|
package/lib/patch.js
CHANGED
|
@@ -1,30 +1,27 @@
|
|
|
1
|
-
//
|
|
1
|
+
// How the patch intercepts before state reduction.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// регистрации.
|
|
3
|
+
// The session projection registry is a service with an exposed registrations map. Each entry
|
|
4
|
+
// holds a sanitized copy of what the owner registered, and calls `apply` on that copy.
|
|
5
|
+
// Therefore, we patch the definition's apply method, not the original object passed at registration.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// настоящего вызова не доезжает. Поэтому цепляемся за саму карту записей —
|
|
12
|
-
// обычный Map, один и тот же для всех, кто до него дотянулся.
|
|
7
|
+
// Load order is not guaranteed, and in practice core projections may register later than our plugin.
|
|
8
|
+
// Patching the service's `register` method is ineffective because callers go through an intermediary.
|
|
9
|
+
// Hence, we hook into the registrations map itself — a standard Map instance shared by all participants.
|
|
13
10
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// обезвреживатель для здорового события возвращает ровно то же событие — тем,
|
|
19
|
-
// кто расхода не касается, он обходится в одно сравнение.
|
|
11
|
+
// All projections are wrapped, not just a single one. Token usage is consumed by multiple systems:
|
|
12
|
+
// context pressure calculations, session token sums, and analytics. Each would fail on a missing
|
|
13
|
+
// counter. Wrapping all projections with a guard that returns the exact object reference for healthy
|
|
14
|
+
// events costs only a single reference comparison for events without usage.
|
|
20
15
|
|
|
21
|
-
/**
|
|
16
|
+
/** Marker on patched functions to prevent double wrapping. */
|
|
22
17
|
export const PATCHED = Symbol.for('dsh-usage-guard.patched')
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
|
-
*
|
|
20
|
+
* Wrap the apply method of a registry definition, preserving `this` context.
|
|
26
21
|
*
|
|
27
|
-
* @
|
|
22
|
+
* @param def - Projection definition entry.
|
|
23
|
+
* @param guard - Event sanitizer function.
|
|
24
|
+
* @returns Cleanup function restoring the original method.
|
|
28
25
|
*/
|
|
29
26
|
export function wrapApply(def, guard) {
|
|
30
27
|
if (!def || typeof def.apply !== 'function' || def.apply[PATCHED]) return () => {}
|
|
@@ -40,27 +37,23 @@ export function wrapApply(def, guard) {
|
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
/**
|
|
43
|
-
*
|
|
40
|
+
* Patch the session projections registry.
|
|
44
41
|
*
|
|
45
|
-
* @param registry -
|
|
46
|
-
* @param guard -
|
|
47
|
-
*
|
|
48
|
-
* @returns функция, снимающая всё поставленное, в обратном порядке.
|
|
42
|
+
* @param registry - `sessionProjections` service instance.
|
|
43
|
+
* @param guard - Event sanitizer function; receives an event and returns either the same event or a repaired copy.
|
|
44
|
+
* @returns Teardown function that removes all installed hooks in reverse order.
|
|
49
45
|
*/
|
|
50
46
|
export function patchRegistry(registry, guard) {
|
|
51
47
|
const undo = []
|
|
52
48
|
const map = registry && typeof registry === 'object' ? registry.registrations : undefined
|
|
53
49
|
if (!map || typeof map.set !== 'function' || typeof map.values !== 'function') return () => {}
|
|
54
50
|
|
|
55
|
-
//
|
|
51
|
+
// Wrap entries already registered before this patch
|
|
56
52
|
for (const entry of map.values()) {
|
|
57
53
|
if (entry && entry.def) undo.push(wrapApply(entry.def, guard))
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
//
|
|
61
|
-
// Save the current map.set (may be the prototype method or a previous patched version).
|
|
62
|
-
// We restore via explicit assignment — relying on `delete` to restore the prototype method
|
|
63
|
-
// is fragile when the environment mutates Map.prototype.
|
|
56
|
+
// Intercept entries registered in the future when inserted into the map
|
|
64
57
|
const originalSet = map.set
|
|
65
58
|
if (!originalSet[PATCHED]) {
|
|
66
59
|
const patched = function (key, value) {
|
|
@@ -78,4 +71,4 @@ export function patchRegistry(registry, guard) {
|
|
|
78
71
|
for (const step of undo.reverse()) step()
|
|
79
72
|
undo.length = 0
|
|
80
73
|
}
|
|
81
|
-
}
|
|
74
|
+
}
|
package/lib/usage.js
CHANGED
|
@@ -9,12 +9,12 @@ export const FIELDS = [
|
|
|
9
9
|
]
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Dictionary of common external provider naming variants for token counters.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
18
|
*/
|
|
19
19
|
export const ALIASES = {
|
|
20
20
|
inputTokens: [
|
|
@@ -48,18 +48,18 @@ export const ALIASES = {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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.
|
|
54
54
|
*/
|
|
55
55
|
export function sound(value) {
|
|
56
56
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isInteger(value)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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.
|
|
63
63
|
*/
|
|
64
64
|
export function coerceNumber(value) {
|
|
65
65
|
if (typeof value === 'number') {
|
|
@@ -76,11 +76,10 @@ export function coerceNumber(value) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
* харнесс.
|
|
79
|
+
* Extracts the usage sample from a session event by checking both standard DSH locations.
|
|
81
80
|
*
|
|
82
|
-
* @returns `{ usage, turn, step, at }
|
|
83
|
-
*
|
|
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.
|
|
84
83
|
*/
|
|
85
84
|
export function usageOf(event) {
|
|
86
85
|
if (!event || typeof event !== 'object') return null
|
|
@@ -97,12 +96,11 @@ export function usageOf(event) {
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
/**
|
|
100
|
-
*
|
|
99
|
+
* Detects which usage counters are malformed or exceed the maxStepTokens ceiling.
|
|
101
100
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* отрицательные значения.
|
|
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.
|
|
106
104
|
*/
|
|
107
105
|
export function damage(usage, options = {}) {
|
|
108
106
|
if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
|
|
@@ -125,33 +123,33 @@ export function damage(usage, options = {}) {
|
|
|
125
123
|
}
|
|
126
124
|
|
|
127
125
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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.
|
|
130
128
|
*/
|
|
131
129
|
export function borrowed(usage, field) {
|
|
132
130
|
if (!usage || typeof usage !== 'object') return undefined
|
|
133
131
|
|
|
134
|
-
// 1.
|
|
132
|
+
// 1. Search known alias names
|
|
135
133
|
for (const alias of ALIASES[field] ?? []) {
|
|
136
134
|
const num = coerceNumber(usage[alias])
|
|
137
135
|
if (num !== undefined) return num
|
|
138
136
|
}
|
|
139
137
|
|
|
140
|
-
// 2.
|
|
138
|
+
// 2. Search nested cache details (OpenAI-style: prompt_tokens_details.cached_tokens / cachedTokens)
|
|
141
139
|
if (field === 'cacheReadTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
|
|
142
140
|
const details = usage.prompt_tokens_details
|
|
143
141
|
const num = coerceNumber(details.cached_tokens ?? details.cachedTokens ?? details.cache_read_tokens)
|
|
144
142
|
if (num !== undefined) return num
|
|
145
143
|
}
|
|
146
144
|
|
|
147
|
-
// 3.
|
|
145
|
+
// 3. Search nested cache creation details (cache_creation_input_tokens)
|
|
148
146
|
if (field === 'cacheWriteTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
|
|
149
147
|
const details = usage.prompt_tokens_details
|
|
150
148
|
const num = coerceNumber(details.cache_creation_tokens ?? details.cacheCreationTokens)
|
|
151
149
|
if (num !== undefined) return num
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
// 4.
|
|
152
|
+
// 4. Check the field itself if passed as a string or float (e.g. "123" or 15.4)
|
|
155
153
|
const selfNum = coerceNumber(usage[field])
|
|
156
154
|
if (selfNum !== undefined) return selfNum
|
|
157
155
|
|
|
@@ -159,11 +157,11 @@ export function borrowed(usage, field) {
|
|
|
159
157
|
}
|
|
160
158
|
|
|
161
159
|
/**
|
|
162
|
-
*
|
|
160
|
+
* Returns a usage object with sanitized counters and optional maxStepTokens clamping.
|
|
163
161
|
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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.
|
|
167
165
|
*/
|
|
168
166
|
export function repaired(usage, bad, options = {}) {
|
|
169
167
|
const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
|
|
@@ -185,12 +183,10 @@ export function repaired(usage, bad, options = {}) {
|
|
|
185
183
|
}
|
|
186
184
|
|
|
187
185
|
/**
|
|
188
|
-
*
|
|
189
|
-
* нечего.
|
|
186
|
+
* Returns an event with sanitized usage, or the exact same event if no repair was needed.
|
|
190
187
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* была.
|
|
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.
|
|
194
190
|
*/
|
|
195
191
|
export function healed(event, bad, options = {}) {
|
|
196
192
|
const found = usageOf(event)
|
|
@@ -206,7 +202,7 @@ export function healed(event, bad, options = {}) {
|
|
|
206
202
|
return { ...event, data: { ...event.data, usage } }
|
|
207
203
|
}
|
|
208
204
|
|
|
209
|
-
/**
|
|
205
|
+
/** Safe JSON stringification for logging: guards against circular references and BigInt values. */
|
|
210
206
|
function safeStringify(value) {
|
|
211
207
|
try {
|
|
212
208
|
return JSON.stringify(value)
|
|
@@ -219,7 +215,7 @@ function safeStringify(value) {
|
|
|
219
215
|
}
|
|
220
216
|
}
|
|
221
217
|
|
|
222
|
-
/**
|
|
218
|
+
/** Formats a descriptive log line identifying the turn, step, raw payload, and applied repairs. */
|
|
223
219
|
export function complaint(found, bad, options = {}) {
|
|
224
220
|
const where = `turn ${String(found?.turn ?? '-')}, step ${String(found?.step ?? '-')}`
|
|
225
221
|
const usage = found?.usage ?? found
|
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.11",
|
|
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",
|