dsh-token-total 1.0.1 → 1.0.3
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 +2 -1
- package/lib/client.js +482 -98
- package/lib/index.js +196 -11
- package/lib/types/client/ContextUsageEntry.d.ts +22 -0
- package/lib/types/client/context-meter-core.d.ts +26 -0
- package/lib/types/client/context-takeover.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-core.d.ts +1 -2
- package/lib/types/client/stats-icons.d.ts +4 -0
- package/lib/types/client/use-session-usage.d.ts +1 -1
- package/lib/types/collector.d.ts +19 -3
- package/lib/types/query.d.ts +8 -0
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +130 -0
- package/src/client/ContextUsageEntry.tsx +93 -0
- package/src/client/StatsLineEnhanced.module.css +15 -24
- package/src/client/StatsLineEnhanced.tsx +35 -51
- package/src/client/TokenTotalPanel.tsx +5 -3
- package/src/client/context-meter-core.ts +54 -0
- package/src/client/context-takeover.ts +35 -0
- package/src/client/index.tsx +21 -3
- package/src/client/locales.ts +4 -0
- package/src/client/session-usage-core.ts +7 -11
- package/src/client/stats-icons.tsx +83 -0
- package/src/client/use-session-usage.ts +1 -1
- package/src/collector.ts +81 -16
- package/src/index.ts +29 -0
- package/src/query.ts +23 -8
- package/src/routes.ts +6 -0
- package/src/store.ts +136 -0
- package/src/wire.ts +7 -0
package/src/client/index.tsx
CHANGED
|
@@ -34,6 +34,8 @@ import { ArrowLeft } from 'lucide-react'
|
|
|
34
34
|
import { TokenTotalPanel } from './TokenTotalPanel.tsx'
|
|
35
35
|
import { StatsIcon } from './stats-icon.tsx'
|
|
36
36
|
import { StatsActivityEntry, StatsLineEnhanced, StatsUsageEntry } from './StatsLineEnhanced.tsx'
|
|
37
|
+
import { ContextUsageEntry, CONTEXT_LOCALE_NS, CONTEXT_LOCALES } from './ContextUsageEntry.tsx'
|
|
38
|
+
import { installContextTakeover } from './context-takeover.ts'
|
|
37
39
|
import { LOCALE_NS, en, zh, zhTW, type TokenTotalKey } from './locales.ts'
|
|
38
40
|
import { isCompatibleGlass, whenGlassReady } from './glass.ts'
|
|
39
41
|
import css from './TokenTotalPanel.module.css'
|
|
@@ -117,14 +119,27 @@ const COMPOSER_DOCK = 'conversation.composer.dock'
|
|
|
117
119
|
|
|
118
120
|
/** The pre-split takeover: ONE entry at the official 'stats' id, rendering the
|
|
119
121
|
* whole row (DSH <= 0.2.0-rc.2). */
|
|
120
|
-
function
|
|
122
|
+
function installContextEntry(ctx: Context): () => void {
|
|
121
123
|
return ctx.slots.register({
|
|
124
|
+
name: COMPOSER_DOCK,
|
|
125
|
+
id: 'token-total-context',
|
|
126
|
+
order: 2,
|
|
127
|
+
priority: -1,
|
|
128
|
+
locale: CONTEXT_LOCALE_NS,
|
|
129
|
+
inject: () => ({ installContextTakeover }),
|
|
130
|
+
}, ContextUsageEntry)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function installStatsRowEntry(ctx: Context): () => void {
|
|
134
|
+
const offStats = ctx.slots.register({
|
|
122
135
|
name: COMPOSER_DOCK,
|
|
123
136
|
id: 'stats',
|
|
124
137
|
order: 0,
|
|
125
138
|
priority: -1,
|
|
126
139
|
locale: LOCALE_NS,
|
|
127
140
|
}, StatsLineEnhanced)
|
|
141
|
+
const offContext = installContextEntry(ctx)
|
|
142
|
+
return () => { offContext(); offStats() }
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
/** The split takeover: one entry per official pill, each shadowed at its own
|
|
@@ -144,7 +159,8 @@ function installSplitDockEntries(ctx: Context): () => void {
|
|
|
144
159
|
priority: -1,
|
|
145
160
|
locale: LOCALE_NS,
|
|
146
161
|
}, StatsUsageEntry)
|
|
147
|
-
|
|
162
|
+
const offContext = installContextEntry(ctx)
|
|
163
|
+
return () => { offContext(); offUsage(); offActivity() }
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
export function apply(ctx: Context): void {
|
|
@@ -154,7 +170,9 @@ export function apply(ctx: Context): void {
|
|
|
154
170
|
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
|
155
171
|
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
|
156
172
|
const offZhTw = ctx.locale.register(LOCALE_NS, 'zh-TW', zhTW)
|
|
157
|
-
|
|
173
|
+
const offContext = Object.entries(CONTEXT_LOCALES).map(([locale, dict]) =>
|
|
174
|
+
ctx.locale.register(CONTEXT_LOCALE_NS, locale, dict as Record<string, string>))
|
|
175
|
+
return () => { offZh(); offEn(); offZhTw(); for (const off of offContext) off() }
|
|
158
176
|
}, 'dsh-token-total: dictionaries')
|
|
159
177
|
|
|
160
178
|
// Join the background plugin's frosted-glass sheet when it is installed,
|
package/src/client/locales.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type TokenTotalKey =
|
|
|
32
32
|
| 'cachedTokens'
|
|
33
33
|
| 'sessions'
|
|
34
34
|
| 'requests'
|
|
35
|
+
| 'requestsFailed'
|
|
35
36
|
| 'activeDays'
|
|
36
37
|
| 'cacheRate'
|
|
37
38
|
| 'cacheRateHint'
|
|
@@ -100,6 +101,7 @@ export const en: Record<TokenTotalKey, string> = {
|
|
|
100
101
|
cachedTokens: 'cached',
|
|
101
102
|
sessions: 'Sessions',
|
|
102
103
|
requests: 'Requests',
|
|
104
|
+
requestsFailed: 'Failed',
|
|
103
105
|
activeDays: 'Active days',
|
|
104
106
|
cacheRate: 'Avg cache hit rate',
|
|
105
107
|
cacheRateHint: 'Cached input tokens as a share of all input tokens in the range',
|
|
@@ -169,6 +171,7 @@ export const zh: Record<TokenTotalKey, string> = {
|
|
|
169
171
|
cachedTokens: '缓存命中',
|
|
170
172
|
sessions: '会话数量',
|
|
171
173
|
requests: '请求数量',
|
|
174
|
+
requestsFailed: '失败',
|
|
172
175
|
activeDays: '活跃天数',
|
|
173
176
|
cacheRate: '缓存命中率',
|
|
174
177
|
cacheRateHint: '时间段内缓存命中 token 占输入 token 的比例',
|
|
@@ -238,6 +241,7 @@ export const zhTW: Record<TokenTotalKey, string> = {
|
|
|
238
241
|
cachedTokens: '快取命中',
|
|
239
242
|
sessions: '會話數量',
|
|
240
243
|
requests: '請求數量',
|
|
244
|
+
requestsFailed: '失敗',
|
|
241
245
|
activeDays: '活躍天數',
|
|
242
246
|
cacheRate: '快取命中率',
|
|
243
247
|
cacheRateHint: '時間範圍內快取命中 token 佔輸入 token 的比例',
|
|
@@ -8,8 +8,7 @@ const ZERO: SessionUsageTotals = {
|
|
|
8
8
|
cacheWriteTokens: 0,
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* it: that would count the main agent twice. The log is only the fallback. */
|
|
11
|
+
/** Add the root and descendants once; tree.total is already that sum. */
|
|
13
12
|
export function combinedTotals(tree: SessionUsageTree, self: SessionUsageTotals = tree.self): SessionUsageTotals {
|
|
14
13
|
return {
|
|
15
14
|
uncachedInputTokens: self.uncachedInputTokens + tree.subagents.uncachedInputTokens,
|
|
@@ -24,15 +23,12 @@ export function sessionUsageForDisplay(
|
|
|
24
23
|
native: SessionUsageTotals | undefined,
|
|
25
24
|
tree: SessionUsageTree | null,
|
|
26
25
|
): SessionUsageTree {
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
cacheReadTokens: native.cacheReadTokens + extra.cacheReadTokens,
|
|
34
|
-
cacheWriteTokens: native.cacheWriteTokens + extra.cacheWriteTokens,
|
|
35
|
-
}
|
|
26
|
+
// The tree folds the full live/stored log, including failed attempts,
|
|
27
|
+
// retries, legacy chunks and compaction, and excludes fork-inherited usage.
|
|
28
|
+
// Native projections can omit those calls or include an inherited prefix;
|
|
29
|
+
// replacing tree.self (or taking bucket-wise maxima) would lose or double
|
|
30
|
+
// spend. Native is only the initial fallback while the tree is unavailable.
|
|
31
|
+
const self = tree?.self ?? native ?? { ...ZERO }
|
|
36
32
|
const subagents = tree?.subagents ?? { ...ZERO }
|
|
37
33
|
return {
|
|
38
34
|
self,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stat-pill glyphs.
|
|
3
|
+
*
|
|
4
|
+
* The shell freezes its primitives table, and that table's icon names moved
|
|
5
|
+
* with the host line: 0.2.1-rc.2 exports `IconGaugeOutlineRegular` /
|
|
6
|
+
* `IconDatabaseOutlineRegular` (plus the `Medium` weight) and no longer
|
|
7
|
+
* exports the `…Outline16` names this plugin used to import. A missing named
|
|
8
|
+
* export is `undefined` at runtime, and React then throws "Element type is
|
|
9
|
+
* invalid" on the entry's first render — the shell's slot boundary catches it,
|
|
10
|
+
* ABDICATES the entry, and the shadowed official pill silently returns. That
|
|
11
|
+
* failure mode is invisible in types (the package's own declarations still
|
|
12
|
+
* describe the old names) and looks exactly like "the plugin did nothing".
|
|
13
|
+
*
|
|
14
|
+
* So the glyph is resolved from the runtime table by preference order and
|
|
15
|
+
* falls back to an inline drawing of the same shape when no name exists, which
|
|
16
|
+
* keeps one published build rendering on every host line.
|
|
17
|
+
*/
|
|
18
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
|
|
20
|
+
type IconComponent = () => JSX.Element
|
|
21
|
+
|
|
22
|
+
const table = primitives as unknown as Record<string, unknown>
|
|
23
|
+
|
|
24
|
+
/** First exported function component among `names`, or null when the shell
|
|
25
|
+
* exports none of them (an older or newer icon roster). */
|
|
26
|
+
function resolveIcon(...names: readonly string[]): IconComponent | null {
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const candidate = table[name]
|
|
29
|
+
if (typeof candidate === 'function') return candidate as IconComponent
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resolvedGauge = resolveIcon(
|
|
35
|
+
'IconGaugeOutlineRegular',
|
|
36
|
+
'IconGaugeOutlineMedium',
|
|
37
|
+
'IconGaugeOutline16',
|
|
38
|
+
)
|
|
39
|
+
const resolvedDatabase = resolveIcon(
|
|
40
|
+
'IconDatabaseOutlineRegular',
|
|
41
|
+
'IconDatabaseOutlineMedium',
|
|
42
|
+
'IconDatabaseOutline16',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** Gauge glyph for the activity pill (counts + throughput). */
|
|
46
|
+
export function GaugeIcon(): JSX.Element {
|
|
47
|
+
if (resolvedGauge !== null) {
|
|
48
|
+
const Icon = resolvedGauge
|
|
49
|
+
return <Icon />
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
53
|
+
<path
|
|
54
|
+
d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5M8 8l2.6-2.6M2.5 8A5.5 5.5 0 0 1 8 2.5"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth="1.3"
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
/>
|
|
60
|
+
<circle cx="8" cy="8" r="1" fill="currentColor" />
|
|
61
|
+
</svg>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Database glyph for the token-usage pill. */
|
|
66
|
+
export function DatabaseIcon(): JSX.Element {
|
|
67
|
+
if (resolvedDatabase !== null) {
|
|
68
|
+
const Icon = resolvedDatabase
|
|
69
|
+
return <Icon />
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
73
|
+
<ellipse cx="8" cy="3.6" rx="4.6" ry="1.9" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
|
74
|
+
<path
|
|
75
|
+
d="M3.4 3.6v8.8c0 1.05 2.06 1.9 4.6 1.9s4.6-.85 4.6-1.9V3.6M3.4 8c0 1.05 2.06 1.9 4.6 1.9s4.6-.85 4.6-1.9"
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
strokeWidth="1.3"
|
|
79
|
+
strokeLinecap="round"
|
|
80
|
+
/>
|
|
81
|
+
</svg>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -11,7 +11,7 @@ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './
|
|
|
11
11
|
|
|
12
12
|
export interface SessionTreeReading {
|
|
13
13
|
tree: SessionUsageTree | null
|
|
14
|
-
/**
|
|
14
|
+
/** Authoritative log-based main + all descendant usage after the tree loads. */
|
|
15
15
|
totals: SessionUsageTotals | null
|
|
16
16
|
}
|
|
17
17
|
|
package/src/collector.ts
CHANGED
|
@@ -219,6 +219,12 @@ export class UsageFold {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
// `assistant/attempt` is the host's record of a provider call that FAILED
|
|
223
|
+
// (its stream ended with an error or was cancelled before any content
|
|
224
|
+
// was committed); `assistant/message` is every call that committed.
|
|
225
|
+
// They partition the calls exactly, so the attempt event is the failure
|
|
226
|
+
// boundary — no error/retry bookkeeping needed downstream.
|
|
227
|
+
if (ev.type === 'assistant/attempt') return this.attemptSample(ev, usage)
|
|
222
228
|
return usage ? this.replaceSample(ev, usage) : null
|
|
223
229
|
}
|
|
224
230
|
if (ev.type === 'compaction/summary') return this.compactionSample(ev)
|
|
@@ -268,6 +274,33 @@ export class UsageFold {
|
|
|
268
274
|
return emitted
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Fold one `assistant/attempt` — a provider call that failed. Tokens follow
|
|
279
|
+
* the same last-report-wins rule as a committed message (the attempt's
|
|
280
|
+
* stream is the call's final word, so a usage chunk seen mid-stream is
|
|
281
|
+
* replaced or corrected, never added twice), but the call must ALWAYS be
|
|
282
|
+
* counted as one failure: `replaceSample` drops an all-zero first report,
|
|
283
|
+
* which is correct for the token buckets and would silently lose the
|
|
284
|
+
* failure. A failed call that reported no usage therefore contributes a
|
|
285
|
+
* zero-token failure marker instead of a null.
|
|
286
|
+
*/
|
|
287
|
+
private attemptSample(ev: UsageSessionEvent, usage: UsageEventData['usage'] | undefined): UsageSample {
|
|
288
|
+
const sample = this.replaceSample(ev, usage)
|
|
289
|
+
if (sample) {
|
|
290
|
+
sample.failed = true
|
|
291
|
+
return sample
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
day: dayKey(ev.time),
|
|
295
|
+
hour: hourKey(ev.time),
|
|
296
|
+
inputTokens: 0,
|
|
297
|
+
outputTokens: 0,
|
|
298
|
+
cacheReadTokens: 0,
|
|
299
|
+
cacheWriteTokens: 0,
|
|
300
|
+
failed: true,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
271
304
|
/**
|
|
272
305
|
* Fold one `compaction/summary` — the one place a provider call's usage
|
|
273
306
|
* reaches the log OUTSIDE the agent loop. The summarization call produces no
|
|
@@ -419,6 +452,9 @@ export class UsageCollector {
|
|
|
419
452
|
// The hour table keeps its own boundary pair; write it on the same
|
|
420
453
|
// tick so the two never drift apart.
|
|
421
454
|
void this.store.markHoursLiveSequences(batch).catch(() => { this.status.recordFailures++ })
|
|
455
|
+
// The failure counter has its own pair as well (it was added after
|
|
456
|
+
// both token cursors were already full); same tick, same boundary.
|
|
457
|
+
void this.store.markFailuresLiveSequences(batch).catch(() => { this.status.recordFailures++ })
|
|
422
458
|
}
|
|
423
459
|
})
|
|
424
460
|
}
|
|
@@ -593,14 +629,22 @@ export class UsageCollector {
|
|
|
593
629
|
await this.scan(persistence, sessions, signal, 'hours')
|
|
594
630
|
}
|
|
595
631
|
|
|
596
|
-
/**
|
|
597
|
-
*
|
|
598
|
-
*
|
|
632
|
+
/** The failure upgrade pass: replay every session the FAILURE cursors have
|
|
633
|
+
* not seen, counting only failed calls. Neither the day nor the hour
|
|
634
|
+
* cursor covers this counter (both were full when it arrived), and the
|
|
635
|
+
* replay must not re-add tokens or requests. */
|
|
636
|
+
async backfillFailures(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void> {
|
|
637
|
+
await this.scan(persistence, sessions, signal, 'failures')
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Shared replay engine behind {@link backfill}, {@link backfillHours} and
|
|
641
|
+
* {@link backfillFailures}. `target` selects the cursor pair, the write
|
|
642
|
+
* function, and the cursor the completed sessions land in. */
|
|
599
643
|
private async scan(
|
|
600
644
|
persistence: UsageSessionPersistence,
|
|
601
645
|
sessions: UsageSessionStore,
|
|
602
646
|
signal: AbortSignal | undefined,
|
|
603
|
-
target: 'all' | 'hours',
|
|
647
|
+
target: 'all' | 'hours' | 'failures',
|
|
604
648
|
): Promise<void> {
|
|
605
649
|
if (this.status.running) return
|
|
606
650
|
if (signal?.aborted) return
|
|
@@ -626,13 +670,21 @@ export class UsageCollector {
|
|
|
626
670
|
// store already saw: the fold's replace semantics dedupe within one
|
|
627
671
|
// pass only, so a full replay would double every counter. The hour
|
|
628
672
|
// pass uses its own pair for the same reason.
|
|
629
|
-
const seen = target === 'hours'
|
|
673
|
+
const seen = target === 'hours'
|
|
674
|
+
? await this.store.hoursSeenSessions()
|
|
675
|
+
: target === 'failures'
|
|
676
|
+
? await this.store.failuresSeenSessions()
|
|
677
|
+
: await this.store.seenSessions()
|
|
630
678
|
// A pass that starts with an empty day cursor is a first install or a
|
|
631
679
|
// rebuild: it writes both tables, so its completion is also the moment
|
|
632
680
|
// the hour history becomes complete. An upgrade pass starts with a full
|
|
633
681
|
// day cursor and must NOT claim that.
|
|
634
682
|
const dayCursorWasEmpty = seen.size === 0
|
|
635
|
-
const liveSeq = target === 'hours'
|
|
683
|
+
const liveSeq = target === 'hours'
|
|
684
|
+
? await this.store.hoursLiveSequences()
|
|
685
|
+
: target === 'failures'
|
|
686
|
+
? await this.store.failuresLiveSequences()
|
|
687
|
+
: await this.store.liveSequences()
|
|
636
688
|
// seen alone decides target selection: a session enters
|
|
637
689
|
// backfilledSessions only after its whole replayable range was cleanly
|
|
638
690
|
// folded, so selecting it again would multiply that range by the boot
|
|
@@ -644,12 +696,13 @@ export class UsageCollector {
|
|
|
644
696
|
const concurrency = this.options.backfillConcurrency ?? 4
|
|
645
697
|
let next = 0
|
|
646
698
|
const completed: string[] = []
|
|
647
|
-
// Each target writes and marks its own surfaces: the upgrade
|
|
648
|
-
// not touch
|
|
649
|
-
// mark day sessions seen.
|
|
699
|
+
// Each target writes and marks its own surfaces: the upgrade passes must
|
|
700
|
+
// not touch the counters their cursors already account for.
|
|
650
701
|
const writeSample = target === 'hours'
|
|
651
702
|
? (sample: UsageSample): Promise<void> => this.store.recordHours(sample)
|
|
652
|
-
:
|
|
703
|
+
: target === 'failures'
|
|
704
|
+
? (sample: UsageSample): Promise<void> => this.store.recordFailure(sample)
|
|
705
|
+
: (sample: UsageSample): Promise<void> => this.store.record(sample)
|
|
653
706
|
const flushCompleted = async (): Promise<void> => {
|
|
654
707
|
if (completed.length === 0) return
|
|
655
708
|
const batch = completed.splice(0, completed.length)
|
|
@@ -657,10 +710,16 @@ export class UsageCollector {
|
|
|
657
710
|
await this.store.markHoursSeenSessions(batch)
|
|
658
711
|
return
|
|
659
712
|
}
|
|
713
|
+
if (target === 'failures') {
|
|
714
|
+
await this.store.markFailuresSeenSessions(batch)
|
|
715
|
+
return
|
|
716
|
+
}
|
|
660
717
|
await this.store.markSeenSessions(batch)
|
|
661
718
|
// `record()` above wrote both tables, so the hour cursor advances in
|
|
662
719
|
// the same batch.
|
|
663
720
|
await this.store.markHoursSeenSessions(batch)
|
|
721
|
+
// ... and the failure counter was folded by the same replay.
|
|
722
|
+
await this.store.markFailuresSeenSessions(batch)
|
|
664
723
|
}
|
|
665
724
|
const worker = async () => {
|
|
666
725
|
for (;;) {
|
|
@@ -777,15 +836,21 @@ export class UsageCollector {
|
|
|
777
836
|
}
|
|
778
837
|
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, targets.length)) }, worker))
|
|
779
838
|
await flushCompleted()
|
|
780
|
-
// Claim
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
839
|
+
// Claim a surface only when this pass covered what it set out to cover.
|
|
840
|
+
// A pass that starts from an EMPTY cursor is a first install or a
|
|
841
|
+
// rebuild: it folds the whole history, so it completes every counter
|
|
842
|
+
// that rides the samples (the failure counter included, since `record`
|
|
843
|
+
// and `recordHours` both accumulate it). An aborted or erroring scan
|
|
844
|
+
// leaves the flags off, and the seen sets make the retry exact.
|
|
845
|
+
const clean = !signal?.aborted && this.status.error === undefined
|
|
785
846
|
const hourPassCompleted = target === 'hours' || dayCursorWasEmpty
|
|
786
|
-
if (hourPassCompleted &&
|
|
847
|
+
if (hourPassCompleted && clean) {
|
|
787
848
|
await this.store.markHoursReady()
|
|
788
849
|
}
|
|
850
|
+
const failuresPassCompleted = target === 'failures' || dayCursorWasEmpty
|
|
851
|
+
if (failuresPassCompleted && clean) {
|
|
852
|
+
await this.store.markFailuresReady()
|
|
853
|
+
}
|
|
789
854
|
} finally {
|
|
790
855
|
this.status.running = false
|
|
791
856
|
}
|
package/src/index.ts
CHANGED
|
@@ -212,6 +212,34 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
212
212
|
await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal)
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// The failure counter arrived after BOTH token cursors were populated, so
|
|
216
|
+
// an existing store needs one more replay to recover historic failures.
|
|
217
|
+
// Same shape as the hour upgrade: only after the hour pass settled cleanly
|
|
218
|
+
// (a fresh install's passes already folded failures and set the flag, so
|
|
219
|
+
// this returns immediately), and with a live boundary for open sessions.
|
|
220
|
+
const ensureFailureHistory = async (): Promise<void> => {
|
|
221
|
+
await store.readyPromise()
|
|
222
|
+
if (await store.failuresReady()) return
|
|
223
|
+
if (!(await store.hasAnyDayRows())) return
|
|
224
|
+
if (collector.status.error !== undefined) return
|
|
225
|
+
const previous = await store.failuresLiveSequences()
|
|
226
|
+
const boundaries = new Map<string, number>()
|
|
227
|
+
for (const handle of ctx.sessions.list()) {
|
|
228
|
+
const seq = handle.seq
|
|
229
|
+
if (typeof seq === 'number' && Number.isFinite(seq) && seq >= 0) {
|
|
230
|
+
boundaries.set(handle.id, seq)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const old = previous.get(handle.id)
|
|
234
|
+
boundaries.set(handle.id, typeof old === 'number' ? old : -1)
|
|
235
|
+
}
|
|
236
|
+
await store.markFailuresLiveSequences(boundaries)
|
|
237
|
+
scanController?.abort()
|
|
238
|
+
const controller = new AbortController()
|
|
239
|
+
scanController = controller
|
|
240
|
+
await collector.backfillFailures(ctx.sessionPersistence, ctx.sessions, controller.signal)
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
ctx.effect(() => {
|
|
216
244
|
collector.start()
|
|
217
245
|
// Keep the per-session tree cache honest: every committed event of any
|
|
@@ -226,6 +254,7 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
226
254
|
void (async () => {
|
|
227
255
|
await rescan()
|
|
228
256
|
await ensureHourHistory()
|
|
257
|
+
await ensureFailureHistory()
|
|
229
258
|
})()
|
|
230
259
|
return () => {
|
|
231
260
|
scanController?.abort()
|
package/src/query.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface UsageSample {
|
|
|
51
51
|
cacheWriteTokens: number
|
|
52
52
|
turn?: boolean // true for a completed-turn marker
|
|
53
53
|
request?: boolean // true when this sample also counts one provider call
|
|
54
|
+
/** True when this sample also counts one FAILED provider call. The host
|
|
55
|
+
* appends `assistant/attempt` (instead of `assistant/message`) when a
|
|
56
|
+
* call's stream ends with an error or is cancelled before any content is
|
|
57
|
+
* committed, so that event is the exact boundary of a failed call. The
|
|
58
|
+
* marker is ORTHOGONAL to `request` and to the buckets: a failed call was
|
|
59
|
+
* still opened by its own `step/start` (so it is already one request) and
|
|
60
|
+
* a stream that died mid-flight may still report usage. */
|
|
61
|
+
failed?: boolean
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export interface RangeFilter {
|
|
@@ -105,6 +113,7 @@ export function aggregateSamples(
|
|
|
105
113
|
outputTokens: 0,
|
|
106
114
|
cacheTokens: 0,
|
|
107
115
|
requests: 0,
|
|
116
|
+
failures: 0,
|
|
108
117
|
turns: 0,
|
|
109
118
|
cacheHit: 0,
|
|
110
119
|
cacheMiss: 0,
|
|
@@ -137,6 +146,7 @@ export function aggregateSamples(
|
|
|
137
146
|
const bucketByProvider = new Map<string, Map<string, number>>()
|
|
138
147
|
const bucketTotals = new Map<string, number>()
|
|
139
148
|
const bucketRequests = new Map<string, number>()
|
|
149
|
+
const bucketFailures = new Map<string, number>()
|
|
140
150
|
const bucketTurns = new Map<string, number>()
|
|
141
151
|
const bucketCacheHit = new Map<string, number>()
|
|
142
152
|
const bucketCacheMiss = new Map<string, number>()
|
|
@@ -153,18 +163,22 @@ export function aggregateSamples(
|
|
|
153
163
|
// provider billed for this call — uncached input, output, cache reads
|
|
154
164
|
// and writes. The hit/miss rate denominators below stay input-side only.
|
|
155
165
|
const total = sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens
|
|
166
|
+
// UsageFold emits signed bucket corrections. Even a net-zero correction
|
|
167
|
+
// can move prompt tokens between cache hit and miss, so only all-zero
|
|
168
|
+
// buckets are a marker with no token accounting.
|
|
169
|
+
const hasTokenDelta = sample.inputTokens !== 0 || sample.outputTokens !== 0
|
|
170
|
+
|| sample.cacheReadTokens !== 0 || sample.cacheWriteTokens !== 0
|
|
171
|
+
if (sample.failed) {
|
|
172
|
+
out.failures++
|
|
173
|
+
bucketFailures.set(bucket, (bucketFailures.get(bucket) ?? 0) + 1)
|
|
174
|
+
}
|
|
156
175
|
if (sample.request) {
|
|
157
|
-
// One provider call, whether or not it produced tokens — reasonix counts
|
|
158
|
-
// failed calls too. A marker with no buckets is a request and nothing
|
|
159
|
-
// else: it must not create a model row, mark its day active, or open an
|
|
160
|
-
// empty daily bucket. A marker that DOES carry buckets
|
|
161
|
-
// (`compaction/summary` states the call and its usage in one sample) is
|
|
162
|
-
// counted here and again through the token accounting below — the two
|
|
163
|
-
// count different things, so neither doubles the other.
|
|
164
176
|
out.requests++
|
|
165
177
|
bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1)
|
|
166
|
-
if (total <= 0) continue
|
|
167
178
|
}
|
|
179
|
+
// Pure markers must not create a model row or an active day. A marker
|
|
180
|
+
// carrying a correction still updates every billing/cache bucket below.
|
|
181
|
+
if (!hasTokenDelta) continue
|
|
168
182
|
out.tokens += total
|
|
169
183
|
out.inputTokens += sample.inputTokens
|
|
170
184
|
out.outputTokens += sample.outputTokens
|
|
@@ -240,6 +254,7 @@ export function aggregateSamples(
|
|
|
240
254
|
byModel: byModelObj,
|
|
241
255
|
byProvider: byProviderObj,
|
|
242
256
|
requests: bucketRequests.get(bucket) ?? 0,
|
|
257
|
+
failures: bucketFailures.get(bucket) ?? 0,
|
|
243
258
|
turns: bucketTurns.get(bucket) ?? 0,
|
|
244
259
|
cacheHit: bucketCacheHit.get(bucket) ?? 0,
|
|
245
260
|
cacheMiss: bucketCacheMiss.get(bucket) ?? 0,
|
package/src/routes.ts
CHANGED
|
@@ -162,6 +162,12 @@ export async function aggregateRange(
|
|
|
162
162
|
for (let i = 0; i < row.requests; i++) {
|
|
163
163
|
yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, request: true }
|
|
164
164
|
}
|
|
165
|
+
// Failed calls ride the row as a count too. They are folded as their own
|
|
166
|
+
// marker (never as an extra request): the call's request is already
|
|
167
|
+
// counted above, and a zero-token failure must not open a model row.
|
|
168
|
+
for (let i = 0; i < (row.failures ?? 0); i++) {
|
|
169
|
+
yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, failed: true }
|
|
170
|
+
}
|
|
165
171
|
for (let i = 0; i < row.turns; i++) {
|
|
166
172
|
yield { day, hour, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turn: true }
|
|
167
173
|
}
|