@goodandready/dsh-usage-guard 0.1.0
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/LICENSE +21 -0
- package/README.md +133 -0
- package/cordis.patch.yml +7 -0
- package/lib/index.js +88 -0
- package/lib/patch.js +76 -0
- package/lib/usage.js +130 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-voice contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# dsh-usage-guard
|
|
2
|
+
|
|
3
|
+
A one-purpose patch for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh):
|
|
4
|
+
**one malformed token-usage sample should not take a whole session history down with it.**
|
|
5
|
+
|
|
6
|
+
## The failure
|
|
7
|
+
|
|
8
|
+
Open a session and get this instead of your conversation:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
history unavailable for session "session-…":
|
|
12
|
+
{ "expected": "number", "code": "invalid_type", "received": "NaN",
|
|
13
|
+
"path": [ "uncachedInputTokens" ],
|
|
14
|
+
"message": "Invalid input: expected number, received NaN" }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Not a wrong number in a counter — no history at all. And it stays that way,
|
|
18
|
+
because the session summary is recomputed by replaying the log every time.
|
|
19
|
+
|
|
20
|
+
## The cause
|
|
21
|
+
|
|
22
|
+
The harness folds provider-reported usage into four buckets, and guards two of
|
|
23
|
+
the four:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
const bucketsFrom = (usage) => ({
|
|
27
|
+
uncachedInputTokens: usage.inputTokens, // unguarded
|
|
28
|
+
outputTokens: usage.outputTokens, // unguarded
|
|
29
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0, // guarded
|
|
30
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,// guarded
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Cache fields are optional because plenty of providers never send them. The two
|
|
35
|
+
that are not guarded are just as optional in practice: providers disagree about
|
|
36
|
+
the shape of a usage report, and some send only part of it. One sample without
|
|
37
|
+
`inputTokens` turns the running total into `NaN`, the schema refuses it, and the
|
|
38
|
+
whole history goes with it.
|
|
39
|
+
|
|
40
|
+
## What this plugin does
|
|
41
|
+
|
|
42
|
+
It steps in front of the fold. A counter that is missing, `null`, `NaN` or not a
|
|
43
|
+
number at all is repaired before the harness adds it up.
|
|
44
|
+
|
|
45
|
+
Zero is the last resort, not the first move. Providers disagree about what to
|
|
46
|
+
call the same number — `inputTokens`, `input_tokens`, `input`, `promptTokens` —
|
|
47
|
+
and the harness knows one spelling, so the rest read as absent even though the
|
|
48
|
+
figure is sitting right there. The plugin looks under the usual other names
|
|
49
|
+
first and only falls back to zero when there is genuinely nothing to take.
|
|
50
|
+
|
|
51
|
+
Because the fold is also where replay goes, **sessions that are already broken
|
|
52
|
+
open again by themselves** — no session file is rewritten, nothing on disk is
|
|
53
|
+
touched, and removing the plugin puts everything back exactly as it was.
|
|
54
|
+
|
|
55
|
+
Zero in place of a missing counter is a known lie. But the choice is not between
|
|
56
|
+
an exact total and an approximate one — it is between an approximate total and a
|
|
57
|
+
conversation you cannot read.
|
|
58
|
+
|
|
59
|
+
It also names the culprit. Each damaged sample is reported once, with the turn,
|
|
60
|
+
the step and the raw object as it arrived:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
[dsh-usage-guard] usage sample out of shape — turn 1, step 1;
|
|
64
|
+
arrived as: {"outputTokens":14,"cacheReadTokens":1024}; inputTokens zeroed
|
|
65
|
+
[dsh-usage-guard] usage sample out of shape — turn 3, step 1;
|
|
66
|
+
arrived as: {"input":22533,"output":20}; inputTokens taken from an alias; outputTokens taken from an alias
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That line is the point of the reporting half: providers that send incomplete
|
|
70
|
+
usage are worth knowing about, and a bare `NaN` never told you which one it was.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
dsh plugin --profile web add @goodandready/dsh-usage-guard
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Restart the harness afterwards. There is no UI and nothing to configure to get
|
|
79
|
+
the fix — it works from the moment it loads.
|
|
80
|
+
|
|
81
|
+
## Settings
|
|
82
|
+
|
|
83
|
+
In `$DSH_HOME/settings.yaml` under `dsh-usage-guard:`, or in the profile:
|
|
84
|
+
|
|
85
|
+
| Field | Default | Description |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `repair` | `true` | Replace a missing or non-numeric counter with zero before the fold. Off means reporting only — the fold sees the sample as it came, and a broken one keeps the history unreadable. |
|
|
88
|
+
| `report` | `true` | Log the turn, the step and the raw sample when a damaged one arrives. Reported once per turn, step and field set, not once per replay. |
|
|
89
|
+
|
|
90
|
+
Turning `repair` off is for one situation: you want to see the failure happen
|
|
91
|
+
rather than have it papered over.
|
|
92
|
+
|
|
93
|
+
## How it attaches
|
|
94
|
+
|
|
95
|
+
Session projections live in an ordinary registry service, keyed by name, and
|
|
96
|
+
each entry holds the `apply` the harness actually calls. The plugin wraps those.
|
|
97
|
+
|
|
98
|
+
It wraps **all** of them, not one. Usage is read in three separate places —
|
|
99
|
+
`tokenUsage` folds the four buckets, `contextPressure` computes its own sum for
|
|
100
|
+
the prompt size, `contextBreakdown` its own for occupancy — and every one of
|
|
101
|
+
them trips over the same missing counter. Keeping a list of keys in step with
|
|
102
|
+
someone else's code is a standing debt; the guard is an identity for an event
|
|
103
|
+
that carries no damaged usage, so a projection that never looks at usage pays
|
|
104
|
+
one comparison and gets the very same event object it would have got anyway.
|
|
105
|
+
|
|
106
|
+
Load order does not matter: whatever is already registered is wrapped
|
|
107
|
+
immediately, and whatever registers later is caught as it lands in the registry.
|
|
108
|
+
|
|
109
|
+
Nothing is patched by editing files, so a harness upgrade replaces the code this
|
|
110
|
+
plugin wraps rather than fighting with it. If a later version guards those two
|
|
111
|
+
fields itself, this plugin becomes a no-op that only reports — and can be
|
|
112
|
+
removed.
|
|
113
|
+
|
|
114
|
+
## Structure
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
dsh-usage-guard/
|
|
118
|
+
├── package.json # dsh bundle metadata + peerDependencies
|
|
119
|
+
├── cordis.patch.yml # bundle layer: inserts the plugin row
|
|
120
|
+
├── lib/index.js # the plugin: settings, reporting, attachment
|
|
121
|
+
├── lib/usage.js # what counts as damaged and what it becomes — pure
|
|
122
|
+
├── lib/patch.js # how the guard gets in front of the fold — pure
|
|
123
|
+
├── test/ # unit tests, no harness required
|
|
124
|
+
├── README.md
|
|
125
|
+
└── LICENSE # MIT
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Host half only — no browser code. No npm runtime dependencies, no build step,
|
|
129
|
+
plain ESM.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-usage-guard bundle layer: applied automatically when the package is
|
|
2
|
+
# installed as a profile bundle (package.json declares dsh.bundle.patch).
|
|
3
|
+
# Override any field in the profile's own cordis.patch.yml or in
|
|
4
|
+
# $DSH_HOME/settings.yaml under `dsh-usage-guard:`.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-usage-guard
|
|
7
|
+
name: '@goodandready/dsh-usage-guard'
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// dsh-usage-guard — заплатка на счёт токенов.
|
|
2
|
+
//
|
|
3
|
+
// Харнесс складывает расход в четыре ведра, и два из них берёт без оглядки:
|
|
4
|
+
//
|
|
5
|
+
// uncachedInputTokens: usage.inputTokens, // без подстраховки
|
|
6
|
+
// outputTokens: usage.outputTokens, // без подстраховки
|
|
7
|
+
// cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
8
|
+
// cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
9
|
+
//
|
|
10
|
+
// Стоит провайдеру прислать порцию без первых двух, и сложение даёт NaN.
|
|
11
|
+
// Дальше проверка схемы отвергает всю выжимку сессии, а наружу это выходит
|
|
12
|
+
// так:
|
|
13
|
+
//
|
|
14
|
+
// history unavailable for session "...": expected number, received NaN
|
|
15
|
+
//
|
|
16
|
+
// Не «счётчик соврал», а «история не открывается вовсе» — и остаётся такой
|
|
17
|
+
// навсегда, потому что выжимка считается проигрыванием журнала заново.
|
|
18
|
+
//
|
|
19
|
+
// Отсюда и лечение. Плагин не переписывает журналы на диске: он встаёт перед
|
|
20
|
+
// сложением и подставляет ноль вместо пропажи. Проигрывание идёт через ту же
|
|
21
|
+
// точку, поэтому уже испорченные сессии открываются снова — сами, без починки
|
|
22
|
+
// файлов.
|
|
23
|
+
//
|
|
24
|
+
// Ноль вместо пропущенного счётчика — заведомо неправда. Но выбор не между
|
|
25
|
+
// точным счётом и приблизительным, а между приблизительным и нечитаемой
|
|
26
|
+
// историей.
|
|
27
|
+
|
|
28
|
+
import z from '@deepseek-ai/schemastery'
|
|
29
|
+
|
|
30
|
+
import { patchRegistry } from './patch.js'
|
|
31
|
+
import { complaint, damage, healed, usageOf } from './usage.js'
|
|
32
|
+
|
|
33
|
+
export const name = 'dsh-usage-guard'
|
|
34
|
+
export const inject = ['sessionProjections']
|
|
35
|
+
|
|
36
|
+
export const Config = z.object({
|
|
37
|
+
repair: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.default(true)
|
|
40
|
+
.description('Replace a missing or non-numeric token counter with zero before the harness adds it up. '
|
|
41
|
+
+ 'Off means reporting only: the counters reach the fold as they came, and a broken sample keeps the '
|
|
42
|
+
+ 'session history unreadable.'),
|
|
43
|
+
report: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.default(true)
|
|
46
|
+
.description('Log a line naming the turn, the step and the raw sample whenever a damaged one arrives. '
|
|
47
|
+
+ 'Each turn, step and field set is reported once, not once per replay.'),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export function apply(ctx, config) {
|
|
51
|
+
let live = Config(structuredClone(config ?? {})) ?? config
|
|
52
|
+
|
|
53
|
+
ctx.inject(['settings'], (sctx) => {
|
|
54
|
+
const scope = sctx.settings.register(name, Config, { base: config })
|
|
55
|
+
live = scope.get() ?? live
|
|
56
|
+
sctx.effect(() => scope.watch((next) => { live = next ?? live }))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// О каждом сочетании хода, шага и набора полей жалуемся один раз: выжимка
|
|
60
|
+
// считается проигрыванием журнала заново, и без этого одна и та же порция
|
|
61
|
+
// въезжала бы в журнал при каждом открытии истории.
|
|
62
|
+
const told = new Set()
|
|
63
|
+
|
|
64
|
+
const guard = (event) => {
|
|
65
|
+
const found = usageOf(event)
|
|
66
|
+
if (!found) return event
|
|
67
|
+
const bad = damage(found.usage)
|
|
68
|
+
if (bad.length === 0) return event
|
|
69
|
+
|
|
70
|
+
if (live.report !== false) {
|
|
71
|
+
const seen = `${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
|
|
72
|
+
if (!told.has(seen)) {
|
|
73
|
+
told.add(seen)
|
|
74
|
+
// eslint-disable-next-line no-console
|
|
75
|
+
console.warn(`[dsh-usage-guard] ${complaint(found, bad)}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return live.repair === false ? event : healed(event, bad)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ctx.inject(['sessionProjections'], (pctx) => {
|
|
83
|
+
pctx.effect(
|
|
84
|
+
() => patchRegistry(pctx.sessionProjections, guard),
|
|
85
|
+
'dsh-usage-guard: обезвреживание порций расхода',
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
}
|
package/lib/patch.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Как заплатка встаёт перед сложением.
|
|
2
|
+
//
|
|
3
|
+
// Реестр проекций сессии — служба с открытой картой записей. Каждая запись
|
|
4
|
+
// держит обезличенную копию того, что зарегистрировал владелец, и зовёт `apply`
|
|
5
|
+
// именно у копии. Значит, подменять надо её, а не то, что владелец отдал при
|
|
6
|
+
// регистрации.
|
|
7
|
+
//
|
|
8
|
+
// Порядок загрузки не гарантирован, и на деле проекция ядра заводится позже
|
|
9
|
+
// нас. Подменять при этом метод `register` у службы бесполезно: до неё
|
|
10
|
+
// добираются через посредника, и замена, поставленная на посредника, до
|
|
11
|
+
// настоящего вызова не доезжает. Поэтому цепляемся за саму карту записей —
|
|
12
|
+
// обычный Map, один и тот же для всех, кто до него дотянулся.
|
|
13
|
+
//
|
|
14
|
+
// Оборачиваются все проекции, а не одна «нужная». Порцию расхода читает не
|
|
15
|
+
// только счёт токенов: давление на контекст считает её своей формулой, разбор
|
|
16
|
+
// занятости — своей, и каждая спотыкается об один и тот же пропущенный
|
|
17
|
+
// счётчик. Список ключей пришлось бы держать в согласии с чужим кодом, а
|
|
18
|
+
// обезвреживатель для здорового события возвращает ровно то же событие — тем,
|
|
19
|
+
// кто расхода не касается, он обходится в одно сравнение.
|
|
20
|
+
|
|
21
|
+
/** Метка на подменённой функции: второй раз оборачивать нечего. */
|
|
22
|
+
export const PATCHED = Symbol.for('dsh-usage-guard.patched')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Обернуть складывание одной записи реестра.
|
|
26
|
+
*
|
|
27
|
+
* @returns функция возврата к исходному состоянию.
|
|
28
|
+
*/
|
|
29
|
+
export function wrapApply(def, guard) {
|
|
30
|
+
if (!def || typeof def.apply !== 'function' || def.apply[PATCHED]) return () => {}
|
|
31
|
+
const original = def.apply
|
|
32
|
+
const patched = (state, event) => original(state, guard(event))
|
|
33
|
+
patched[PATCHED] = true
|
|
34
|
+
def.apply = patched
|
|
35
|
+
return () => {
|
|
36
|
+
if (def.apply === patched) def.apply = original
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Поставить заплатку на реестр проекций.
|
|
42
|
+
*
|
|
43
|
+
* @param registry - служба `sessionProjections`.
|
|
44
|
+
* @param guard - обезвреживатель события; получает событие, возвращает его же
|
|
45
|
+
* или исправленное.
|
|
46
|
+
* @returns функция, снимающая всё поставленное, в обратном порядке.
|
|
47
|
+
*/
|
|
48
|
+
export function patchRegistry(registry, guard) {
|
|
49
|
+
const undo = []
|
|
50
|
+
const map = registry && typeof registry === 'object' ? registry.registrations : undefined
|
|
51
|
+
if (!map || typeof map.set !== 'function' || typeof map.values !== 'function') return () => {}
|
|
52
|
+
|
|
53
|
+
// Успели завестись раньше нас — оборачиваем прямо сейчас.
|
|
54
|
+
for (const entry of map.values()) {
|
|
55
|
+
if (entry && entry.def) undo.push(wrapApply(entry.def, guard))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Заведутся позже — поймаем на укладке в карту.
|
|
59
|
+
const set = map.set
|
|
60
|
+
if (!set[PATCHED]) {
|
|
61
|
+
const patched = function (key, value) {
|
|
62
|
+
if (value && value.def) undo.push(wrapApply(value.def, guard))
|
|
63
|
+
return set.call(this, key, value)
|
|
64
|
+
}
|
|
65
|
+
patched[PATCHED] = true
|
|
66
|
+
map.set = patched
|
|
67
|
+
undo.push(() => {
|
|
68
|
+
if (map.set === patched) delete map.set
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
for (const step of undo.reverse()) step()
|
|
74
|
+
undo.length = 0
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Чистая часть заплатки: что считать испорченной порцией расхода и во что её
|
|
2
|
+
// превращать. Ни контекста, ни сети — поэтому проверяется тестами целиком.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Поля, из которых харнесс собирает свои четыре ведра.
|
|
6
|
+
*
|
|
7
|
+
* Два первых он берёт как есть, два вторых подстраховывает нулём. Отсюда и весь
|
|
8
|
+
* сыр-бор: провайдер, приславший порцию без `inputTokens`, роняет не свой
|
|
9
|
+
* счётчик, а всю историю сессии.
|
|
10
|
+
*/
|
|
11
|
+
export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Как то же самое число называют другие.
|
|
15
|
+
*
|
|
16
|
+
* Единого имени у расхода нет: одни присылают `inputTokens`, другие
|
|
17
|
+
* `input_tokens`, третьи просто `input`. Харнесс знает одно написание, поэтому
|
|
18
|
+
* остальные для него — пустое место, хотя число лежит рядом. Прежде чем
|
|
19
|
+
* подставлять ноль, стоит поискать его здесь: ноль — это признание поражения, а
|
|
20
|
+
* не первый ход.
|
|
21
|
+
*/
|
|
22
|
+
const ALIASES = {
|
|
23
|
+
inputTokens: ['input_tokens', 'input', 'promptTokens', 'prompt_tokens'],
|
|
24
|
+
outputTokens: ['output_tokens', 'output', 'completionTokens', 'completion_tokens'],
|
|
25
|
+
cacheReadTokens: ['cache_read_tokens', 'cachedTokens', 'cached_tokens', 'cache_read_input_tokens'],
|
|
26
|
+
cacheWriteTokens: ['cache_write_tokens', 'cacheCreationTokens', 'cache_creation_input_tokens'],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Годное значение счётчика: конечное число, а не `undefined`, `null` или `NaN`. */
|
|
30
|
+
function sound(value) {
|
|
31
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Достать порцию расхода из события сессии — по тем же двум местам, где её ищет
|
|
36
|
+
* харнесс.
|
|
37
|
+
*
|
|
38
|
+
* @returns `{ usage, turn, step, at }`, где `at` — путь до порции внутри
|
|
39
|
+
* события, или `null`, если расхода в событии нет.
|
|
40
|
+
*/
|
|
41
|
+
export function usageOf(event) {
|
|
42
|
+
if (!event || typeof event !== 'object') return null
|
|
43
|
+
const data = event.data
|
|
44
|
+
if (!data || typeof data !== 'object') return null
|
|
45
|
+
|
|
46
|
+
if (event.type === 'assistant/chunk' && data.chunk && data.chunk.type === 'usage') {
|
|
47
|
+
return { usage: data.chunk.usage, turn: data.turn, step: data.step, at: 'chunk' }
|
|
48
|
+
}
|
|
49
|
+
if (event.type === 'assistant/message' && data.usage !== undefined) {
|
|
50
|
+
return { usage: data.usage, turn: data.turn, step: data.step, at: 'message' }
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Какие счётчики порции испорчены.
|
|
57
|
+
*
|
|
58
|
+
* Отсутствие кэш-полей нарушением не считается: их не присылает добрая половина
|
|
59
|
+
* провайдеров, и харнесс сам подставляет там ноль. Ругаемся только на то, что
|
|
60
|
+
* действительно ломает сложение, — на пропущенные или нечисловые значения тех
|
|
61
|
+
* полей, которые харнесс берёт без оглядки.
|
|
62
|
+
*/
|
|
63
|
+
export function damage(usage) {
|
|
64
|
+
if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
|
|
65
|
+
const bad = []
|
|
66
|
+
for (const field of FIELDS) {
|
|
67
|
+
const value = usage[field]
|
|
68
|
+
const optional = field !== 'inputTokens' && field !== 'outputTokens'
|
|
69
|
+
if (value === undefined && optional) continue
|
|
70
|
+
if (!sound(value)) bad.push(field)
|
|
71
|
+
}
|
|
72
|
+
return bad
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Число под одним из привычных чужих имён — или `undefined`, если его там нет. */
|
|
76
|
+
export function borrowed(usage, field) {
|
|
77
|
+
for (const alias of ALIASES[field] ?? []) {
|
|
78
|
+
if (sound(usage[alias])) return usage[alias]
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Порция с обезвреженными счётчиками.
|
|
85
|
+
*
|
|
86
|
+
* Сначала ищем число под чужим именем, и только если его нет — ставим ноль.
|
|
87
|
+
* Ноль здесь заведомо неправда, но выбор не между точным счётом и
|
|
88
|
+
* приблизительным, а между приблизительным и нечитаемой историей. Остальные
|
|
89
|
+
* поля порции остаются как были: чиним арифметику, а не переписываем ответ
|
|
90
|
+
* провайдера.
|
|
91
|
+
*/
|
|
92
|
+
export function repaired(usage, bad) {
|
|
93
|
+
const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
|
|
94
|
+
for (const field of bad) {
|
|
95
|
+
const found = usage && typeof usage === 'object' ? borrowed(usage, field) : undefined
|
|
96
|
+
fixed[field] = found === undefined ? 0 : found
|
|
97
|
+
}
|
|
98
|
+
return fixed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Событие с обезвреженной порцией — или то же самое событие, если чинить
|
|
103
|
+
* нечего.
|
|
104
|
+
*
|
|
105
|
+
* Копия поверхностная и ровно по тому пути, где лежит расход: складыванию
|
|
106
|
+
* достаётся исправное событие, а всё остальное в нём — та же ссылка, что и
|
|
107
|
+
* была.
|
|
108
|
+
*/
|
|
109
|
+
export function healed(event, bad) {
|
|
110
|
+
const found = usageOf(event)
|
|
111
|
+
if (!found || bad.length === 0) return event
|
|
112
|
+
const usage = repaired(found.usage, bad)
|
|
113
|
+
|
|
114
|
+
if (found.at === 'chunk') {
|
|
115
|
+
return {
|
|
116
|
+
...event,
|
|
117
|
+
data: { ...event.data, chunk: { ...event.data.chunk, usage } },
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { ...event, data: { ...event.data, usage } }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Короткая строка для журнала: по ней видно, кого звать к ответу. */
|
|
124
|
+
export function complaint(found, bad) {
|
|
125
|
+
const where = `ход ${String(found.turn)}, шаг ${String(found.step)}`
|
|
126
|
+
const taken = bad
|
|
127
|
+
.map((field) => (found.usage && borrowed(found.usage, field) !== undefined ? `${field} взят по синониму` : `${field} обнулён`))
|
|
128
|
+
.join('; ')
|
|
129
|
+
return `порция расхода не по форме — ${where}; пришло: ${JSON.stringify(found.usage)}; ${taken}`
|
|
130
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-usage-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"token-usage",
|
|
10
|
+
"session-history",
|
|
11
|
+
"patch"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-usage-guard.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-usage-guard",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-usage-guard/issues"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./lib/index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./lib/index.js",
|
|
25
|
+
"./package.json": "./package.json",
|
|
26
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
45
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test test/*.test.mjs"
|
|
49
|
+
}
|
|
50
|
+
}
|