dsh-token-total 1.0.0 → 1.0.2

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.
Files changed (37) hide show
  1. package/README.md +5 -4
  2. package/lib/client.js +566 -159
  3. package/lib/index.js +355 -54
  4. package/lib/types/client/ContextUsageEntry.d.ts +22 -0
  5. package/lib/types/client/context-meter-core.d.ts +26 -0
  6. package/lib/types/client/context-takeover.d.ts +4 -0
  7. package/lib/types/client/locales.d.ts +1 -1
  8. package/lib/types/client/session-usage-core.d.ts +9 -0
  9. package/lib/types/client/use-session-usage.d.ts +2 -18
  10. package/lib/types/collector.d.ts +27 -12
  11. package/lib/types/context-types.d.ts +23 -2
  12. package/lib/types/model-names.d.ts +6 -0
  13. package/lib/types/query.d.ts +8 -0
  14. package/lib/types/session-usage.d.ts +8 -9
  15. package/lib/types/store.d.ts +32 -0
  16. package/lib/types/wire.d.ts +30 -1
  17. package/package.json +1 -1
  18. package/src/client/ContextUsageEntry.module.css +20 -0
  19. package/src/client/ContextUsageEntry.tsx +89 -0
  20. package/src/client/StatsLineEnhanced.tsx +17 -35
  21. package/src/client/TokenTotalPanel.module.css +72 -5
  22. package/src/client/TokenTotalPanel.tsx +204 -56
  23. package/src/client/context-meter-core.ts +54 -0
  24. package/src/client/context-takeover.ts +35 -0
  25. package/src/client/index.tsx +21 -3
  26. package/src/client/locales.ts +4 -0
  27. package/src/client/session-usage-core.ts +54 -0
  28. package/src/client/use-session-usage.ts +45 -98
  29. package/src/collector.ts +138 -67
  30. package/src/context-types.ts +17 -2
  31. package/src/index.ts +29 -0
  32. package/src/model-names.ts +34 -0
  33. package/src/query.ts +84 -13
  34. package/src/routes.ts +20 -2
  35. package/src/session-usage.ts +50 -38
  36. package/src/store.ts +136 -0
  37. package/src/wire.ts +24 -1
@@ -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 installStatsRowEntry(ctx: Context): () => void {
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
- return () => { offUsage(); offActivity() }
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
- return () => { offZh(); offEn(); offZhTw() }
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,
@@ -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 的比例',
@@ -0,0 +1,54 @@
1
+ /** Pure billing helpers shared by the session-tree hook and composer pills. */
2
+ import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts'
3
+
4
+ const ZERO: SessionUsageTotals = {
5
+ uncachedInputTokens: 0,
6
+ outputTokens: 0,
7
+ cacheReadTokens: 0,
8
+ cacheWriteTokens: 0,
9
+ }
10
+
11
+ /** Add the root and descendants once; tree.total is already that sum. */
12
+ export function combinedTotals(tree: SessionUsageTree, self: SessionUsageTotals = tree.self): SessionUsageTotals {
13
+ return {
14
+ uncachedInputTokens: self.uncachedInputTokens + tree.subagents.uncachedInputTokens,
15
+ outputTokens: self.outputTokens + tree.subagents.outputTokens,
16
+ cacheReadTokens: self.cacheReadTokens + tree.subagents.cacheReadTokens,
17
+ cacheWriteTokens: self.cacheWriteTokens + tree.subagents.cacheWriteTokens,
18
+ }
19
+ }
20
+
21
+ /** One consistent split for the pill, cache hit rate and every dialog row. */
22
+ export function sessionUsageForDisplay(
23
+ native: SessionUsageTotals | undefined,
24
+ tree: SessionUsageTree | null,
25
+ ): SessionUsageTree {
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 }
32
+ const subagents = tree?.subagents ?? { ...ZERO }
33
+ return {
34
+ self,
35
+ subagents,
36
+ total: tree === null ? self : combinedTotals(tree, self),
37
+ subagentSessions: tree?.subagentSessions ?? 0,
38
+ }
39
+ }
40
+
41
+ export function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean {
42
+ return totals !== undefined && billedTotal(totals) > 0
43
+ }
44
+
45
+ export function billedTotal(totals: SessionUsageTotals): number {
46
+ return totals.uncachedInputTokens + totals.cacheReadTokens
47
+ + totals.cacheWriteTokens + totals.outputTokens
48
+ }
49
+
50
+ export function cacheHitPercentOf(totals: SessionUsageTotals): number | null {
51
+ const hit = totals.cacheReadTokens
52
+ const miss = totals.uncachedInputTokens + totals.cacheWriteTokens
53
+ return hit + miss === 0 ? null : (hit / (hit + miss)) * 100
54
+ }
@@ -1,115 +1,62 @@
1
- /**
2
- * The composer row's window onto the WHOLE session tree.
3
- *
4
- * dsh's native `tokenUsage` projection is the main agent of the current
5
- * session only: a session that delegates to subagents (each subagent is its
6
- * own persisted session, linked by `header.parentSession`) shows a fraction of
7
- * what it actually spent, and nothing in the shipped UI shows the rest. The
8
- * host half answers that question over `/token-total/api/session`, replaying
9
- * the root log plus every descendant's log; this hook is the browser end.
10
- *
11
- * Refresh policy: the request is cheap on the host (its tree cache is
12
- * invalidated by live events), and the native usage figure moves exactly when
13
- * a call settles — so a change in the native signature is the trigger to
14
- * re-ask. There is no timer: an idle session costs nothing.
15
- *
16
- * Failure policy: the pill must never break the composer, so a failed request
17
- * keeps the last good answer and simply does not retry until the next trigger.
18
- */
1
+ /** The composer row's window onto the whole session tree. The native usage
2
+ * projection only moves for main-agent calls, so poll the displayed session
3
+ * even before its first child appears. Requests are serialized and late answers
4
+ * from a previous session are discarded. Failures keep the last good reading. */
19
5
  import { useEffect, useRef, useState } from 'react'
20
6
  import { fetchSessionUsage } from './api.ts'
7
+ import { combinedTotals } from './session-usage-core.ts'
21
8
  import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts'
22
9
 
23
- const ZERO: SessionUsageTotals = {
24
- uncachedInputTokens: 0,
25
- outputTokens: 0,
26
- cacheReadTokens: 0,
27
- cacheWriteTokens: 0,
28
- }
29
-
30
- function add(into: SessionUsageTotals, from: SessionUsageTotals): SessionUsageTotals {
31
- return {
32
- uncachedInputTokens: into.uncachedInputTokens + from.uncachedInputTokens,
33
- outputTokens: into.outputTokens + from.outputTokens,
34
- cacheReadTokens: into.cacheReadTokens + from.cacheReadTokens,
35
- cacheWriteTokens: into.cacheWriteTokens + from.cacheWriteTokens,
36
- }
37
- }
38
-
39
- /** Fold a tree into the flat shape the pill's dialog renders. */
40
- export function combinedTotals(tree: SessionUsageTree): SessionUsageTotals {
41
- return add(tree.self, tree.subagents)
42
- }
43
-
44
- /** Whether a totals record carries any billable token at all. */
45
- export function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean {
46
- if (totals === undefined) return false
47
- return totals.uncachedInputTokens + totals.outputTokens
48
- + totals.cacheReadTokens + totals.cacheWriteTokens > 0
49
- }
50
-
51
- /** Total billed tokens of a flat totals record (prompt side + completion). */
52
- export function billedTotal(totals: SessionUsageTotals): number {
53
- return totals.uncachedInputTokens + totals.cacheReadTokens
54
- + totals.cacheWriteTokens + totals.outputTokens
55
- }
56
-
57
- /** Cache hit rate over the prompt side, or null when nothing was prompted. */
58
- export function cacheHitPercentOf(totals: SessionUsageTotals): number | null {
59
- const hit = totals.cacheReadTokens
60
- const miss = totals.uncachedInputTokens + totals.cacheWriteTokens
61
- if (hit + miss === 0) return null
62
- return (hit / (hit + miss)) * 100
63
- }
10
+ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts'
64
11
 
65
12
  export interface SessionTreeReading {
66
13
  tree: SessionUsageTree | null
67
- /** main + subagents, or null before the first successful answer. */
14
+ /** Authoritative log-based main + all descendant usage after the tree loads. */
68
15
  totals: SessionUsageTotals | null
69
16
  }
70
17
 
71
- /** How often a session that HAS subagents re-asks. The native figure only moves
72
- * when the main agent's own call settles, so without this a subagent still
73
- * working (or one that just finished) would not update the pill. A session
74
- * with no subagents never polls: nothing exists to miss. */
75
- const SUBAGENT_POLL_MS = 4_000
18
+ const SESSION_POLL_MS = 4_000
76
19
 
77
- /**
78
- * Subscribe to one session's whole-tree usage.
79
- *
80
- * `signature` is any string that changes when the native usage figure moves;
81
- * passing it in keeps this hook out of the projection seat and lets the caller
82
- * decide what "a new call settled" means for its own pill. A session that turns
83
- * out to have subagents additionally re-asks on a slow beat, because subagent
84
- * spend lands in a different session log and no native figure moves for it.
85
- */
86
20
  export function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading {
87
- const [tree, setTree] = useState<SessionUsageTree | null>(null)
88
- const latest = useRef<string>('')
89
- const [beat, setBeat] = useState(0)
90
- const hasSubagents = (tree?.subagentSessions ?? 0) > 0
91
- useEffect(() => {
92
- if (!hasSubagents) return
93
- const handle = setInterval(() => { setBeat(Date.now()) }, SUBAGENT_POLL_MS)
94
- return () => { clearInterval(handle) }
95
- }, [hasSubagents])
21
+ const [reading, setReading] = useState<{ sessionId: string; tree: SessionUsageTree } | null>(null)
22
+ const refresh = useRef<(() => void) | null>(null)
96
23
  useEffect(() => {
97
24
  if (typeof sessionId !== 'string' || sessionId === '') return
98
- const key = `${sessionId}|${signature}|${beat}`
99
- if (latest.current === key) return
100
- latest.current = key
101
- let cancelled = false
102
- void fetchSessionUsage(sessionId).then((value) => {
103
- if (cancelled) return
104
- setTree(value)
105
- }).catch(() => {
106
- // Keep the previous reading; the next trigger retries.
107
- latest.current = ''
108
- })
25
+ let active = true
26
+ let pending = false
27
+ let again = false
28
+ const request = (): void => {
29
+ if (!active) return
30
+ if (pending) {
31
+ again = true
32
+ return
33
+ }
34
+ pending = true
35
+ void fetchSessionUsage(sessionId).then((tree) => {
36
+ if (active) setReading({ sessionId, tree })
37
+ }).catch(() => {
38
+ // Retry on the next beat/signature; do not replace good data with zero.
39
+ }).finally(() => {
40
+ pending = false
41
+ if (active && again) {
42
+ again = false
43
+ request()
44
+ }
45
+ })
46
+ }
47
+ refresh.current = request
48
+ const handle = setInterval(request, SESSION_POLL_MS)
109
49
  return () => {
110
- cancelled = true
50
+ active = false
51
+ refresh.current = null
52
+ clearInterval(handle)
111
53
  }
112
- }, [sessionId, signature, beat])
113
- const totals = tree === null ? null : combinedTotals(tree)
114
- return { tree, totals }
54
+ }, [sessionId])
55
+ useEffect(() => {
56
+ refresh.current?.()
57
+ }, [sessionId, signature])
58
+ // Key the answer on its session, not just effect cleanup: the very first
59
+ // render after switching must not show the previous session's descendants.
60
+ const tree = reading !== null && reading.sessionId === sessionId ? reading.tree : null
61
+ return { tree, totals: tree === null ? null : combinedTotals(tree) }
115
62
  }
package/src/collector.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  * Two sources feed the same fold:
4
4
  *
5
5
  * - live: `session/event` subscription — every committed session event
6
- * reaches this plugin after the fact. `assistant/message` carries the
7
- * step's final `usage` (TokenUsage); `assistant/chunk` carries an early
8
- * `usage` sample from the streaming adapter; `request/context` carries the
9
- * provider/model route. A completed call's usage is attributed to the model
6
+ * reaches this plugin after the fact. Assistant messages expose final usage,
7
+ * and modern message/attempt streams retain raw usage even on failed calls.
8
+ * Legacy `assistant/chunk` reports an early sample; `request/context` carries
9
+ * the provider/model route. A completed call's usage is attributed to the model
10
10
  * the request used — in priority order: the message's own `source`
11
11
  * (authoritative per call), then the session's last-known route, which
12
12
  * comes from observed `request/context` events OR is lazily seeded from
@@ -27,11 +27,10 @@
27
27
  * >= 0.1.2-rc.1, 0 for a non-forked session), and the replay skips it —
28
28
  * the parent's own backfill already counted those events.
29
29
  *
30
- * Idempotence: within ONE session, the same (turn, step) may report usage
31
- * more than once (a streaming sample then the final assistant/message;
32
- * compaction replays). The fold emits each (turn, step) key exactly once and
33
- * keeps the newest values in its own state — see the replaceSample note for
34
- * why the store observably keeps the FIRST emission. Cross-session
30
+ * Idempotence: within ONE session, repeated reports for the same (turn, step)
31
+ * emit only signed bucket corrections: the accumulated usage is last-wins.
32
+ * A started retry closes that replacement slot because its predecessor was a
33
+ * separately billed attempt. Cross-session
35
34
  * (turn, step) collisions are NOT deduped: two sessions both own a turn 1 /
36
35
  * step 1, and each must count. The store's per-day rows accumulate, so
37
36
  * re-folding the same log must not add twice: live events are only folded
@@ -51,6 +50,8 @@ interface UsageEventData {
51
50
  turn?: number
52
51
  step?: number
53
52
  usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number }
53
+ /** v2+ durable Assistant stream: usage is a raw (never packed) chunk. */
54
+ stream?: readonly { type?: string; chunk?: { type?: string; usage?: UsageEventData['usage'] } }[]
54
55
  }
55
56
 
56
57
  /** The assistant/message payload. */
@@ -170,6 +171,11 @@ export class UsageFold {
170
171
  // reasonix emits TurnDone regardless of the run's error.
171
172
  return { day: dayKey(ev.time), hour: hourKey(ev.time), inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turn: true }
172
173
  }
174
+ if (ev.type === 'llm/retry-started') {
175
+ // A retry is a separate billed attempt even though it shares turn/step.
176
+ const key = this.keyOf(ev)
177
+ if (key !== null) this.seen.delete(key)
178
+ }
173
179
  if (ev.type === 'step/start' || ev.type === 'llm/retry-started') {
174
180
  // Attribution note: agent-loop appends step/start BEFORE request/context
175
181
  // (core/agent-loop agent.ts), so the FIRST request marker of a fresh —
@@ -194,17 +200,32 @@ export class UsageFold {
194
200
  request: true,
195
201
  }
196
202
  }
197
- // `assistant/chunk` only exists on DSH <= 0.1.2 (streaming usage sample);
198
- // 0.1.3+ logs attempts as `assistant/attempt` (no usage) and the final
199
- // `assistant/message` stays authoritative. Kept as a dead branch for rc.1.
203
+ // Legacy streaming event. Modern hosts commit the complete stream on an
204
+ // assistant/message OR assistant/attempt, including failed/aborted calls.
200
205
  if (ev.type === 'assistant/chunk') {
201
206
  const data = ev.data as AssistantChunkData
202
207
  const usage = data.chunk?.type === 'usage' ? data.chunk.usage : undefined
203
208
  return usage ? this.replaceSample(ev, usage) : null
204
209
  }
205
- if (ev.type === 'assistant/message') {
210
+ if (ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
206
211
  const data = ev.data as AssistantMessageData
207
- return data.usage ? this.replaceSample(ev, data.usage) : null
212
+ let usage = ev.type === 'assistant/message' ? data.usage : undefined
213
+ if (usage === undefined && Array.isArray(data.stream)) {
214
+ for (let i = data.stream.length - 1; i >= 0; i--) {
215
+ const record = data.stream[i]
216
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage') {
217
+ usage = record.chunk.usage
218
+ break
219
+ }
220
+ }
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)
228
+ return usage ? this.replaceSample(ev, usage) : null
208
229
  }
209
230
  if (ev.type === 'compaction/summary') return this.compactionSample(ev)
210
231
  return null
@@ -213,53 +234,71 @@ export class UsageFold {
213
234
  private replaceSample(ev: UsageSessionEvent, usage: UsageEventData['usage']): UsageSample | null {
214
235
  const usage2 = usage
215
236
  if (!usage2) return null
216
- // A sample with nothing at all is noise; a pure-cache call (zero
217
- // uncached input AND zero output but real cache traffic) still counts —
218
- // its cacheRead/cacheWrite buckets are real provider-reported tokens.
219
- if (
220
- (usage2.inputTokens ?? 0)
221
- + (usage2.outputTokens ?? 0)
222
- + (usage2.cacheReadTokens ?? 0)
223
- + (usage2.cacheWriteTokens ?? 0)
224
- <= 0
225
- ) {
226
- return null
227
- }
228
237
  const key = this.keyOf(ev)
229
238
  const sample: UsageSample = {
230
239
  day: dayKey(ev.time),
231
240
  hour: hourKey(ev.time),
232
- inputTokens: usage2.inputTokens,
233
- outputTokens: usage2.outputTokens,
241
+ inputTokens: usage2.inputTokens ?? 0,
242
+ outputTokens: usage2.outputTokens ?? 0,
234
243
  cacheReadTokens: usage2.cacheReadTokens ?? 0,
235
244
  cacheWriteTokens: usage2.cacheWriteTokens ?? 0,
236
245
  }
237
- if (key === null) {
238
- // No (turn, step) marker: every emission counts (no dedupe slot). The
239
- // shipped adapters always stamp both on usage events, so this path is
240
- // a defensive tail, not a live double-count source. ponytail: a
241
- // synthetic key (e.g. by time+usage hash) could dedupe it if an
242
- // adapter ever reports usage without turn/step.
243
- return sample
244
- }
245
- const prev = this.seen.get(key)
246
- // Track the newest report internally either way; the fold must NEVER
247
- // mutate an object the store already received.
246
+ const prev = key === null ? undefined : this.seen.get(key)
247
+ // A first all-zero report carries no traffic. A later zero report may
248
+ // replace a nonzero sample, so it must emit a negative correction.
249
+ if (!prev && sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens <= 0) return null
250
+ if (key === null) return sample
248
251
  this.seen.set(key, sample)
249
252
  if (prev) {
250
- // Duplicate report for a known (turn, step): swallow it. The shipped
251
- // adapters emit the identical TokenUsage on the streaming chunk and
252
- // the final message (llm-deepseek yields one `pendingUsage` at DONE;
253
- // llm-pi-ai only on done/error), so first == last observationally.
254
- // ponytail: true last-wins would need store-side delta corrections
255
- // keyed by (session, turn, step); add them only if an adapter ever
256
- // reports differing values for one call.
257
- return null
253
+ const correction: UsageSample = {
254
+ day: prev.day,
255
+ hour: prev.hour,
256
+ model: prev.model,
257
+ inputTokens: sample.inputTokens - prev.inputTokens,
258
+ outputTokens: sample.outputTokens - prev.outputTokens,
259
+ cacheReadTokens: sample.cacheReadTokens - prev.cacheReadTokens,
260
+ cacheWriteTokens: sample.cacheWriteTokens - prev.cacheWriteTokens,
261
+ }
262
+ // Keep the original bucket identity across cumulative samples.
263
+ sample.day = prev.day
264
+ sample.hour = prev.hour
265
+ sample.model = prev.model
266
+ if (correction.inputTokens === 0 && correction.outputTokens === 0 && correction.cacheReadTokens === 0 && correction.cacheWriteTokens === 0) return null
267
+ return correction
268
+ }
269
+ // The collector fills in model attribution on this returned copy. Retain
270
+ // the same identity privately for any correction, without ever mutating
271
+ // a sample already handed to the store.
272
+ const emitted = { ...sample }
273
+ this.seen.set(key, emitted)
274
+ return emitted
275
+ }
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,
258
301
  }
259
- // First emission: hand the store its own copy. The internal object stays
260
- // fold-owned — mutating it on a later duplicate would rewrite the sample
261
- // already sitting in the store's write path through the shared reference.
262
- return { ...sample }
263
302
  }
264
303
 
265
304
  /**
@@ -413,6 +452,9 @@ export class UsageCollector {
413
452
  // The hour table keeps its own boundary pair; write it on the same
414
453
  // tick so the two never drift apart.
415
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++ })
416
458
  }
417
459
  })
418
460
  }
@@ -587,14 +629,22 @@ export class UsageCollector {
587
629
  await this.scan(persistence, sessions, signal, 'hours')
588
630
  }
589
631
 
590
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
591
- * `target` selects the cursor pair, the write function, and the cursor
592
- * the completed sessions land in. */
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. */
593
643
  private async scan(
594
644
  persistence: UsageSessionPersistence,
595
645
  sessions: UsageSessionStore,
596
646
  signal: AbortSignal | undefined,
597
- target: 'all' | 'hours',
647
+ target: 'all' | 'hours' | 'failures',
598
648
  ): Promise<void> {
599
649
  if (this.status.running) return
600
650
  if (signal?.aborted) return
@@ -620,13 +670,21 @@ export class UsageCollector {
620
670
  // store already saw: the fold's replace semantics dedupe within one
621
671
  // pass only, so a full replay would double every counter. The hour
622
672
  // pass uses its own pair for the same reason.
623
- const seen = target === 'hours' ? await this.store.hoursSeenSessions() : await this.store.seenSessions()
673
+ const seen = target === 'hours'
674
+ ? await this.store.hoursSeenSessions()
675
+ : target === 'failures'
676
+ ? await this.store.failuresSeenSessions()
677
+ : await this.store.seenSessions()
624
678
  // A pass that starts with an empty day cursor is a first install or a
625
679
  // rebuild: it writes both tables, so its completion is also the moment
626
680
  // the hour history becomes complete. An upgrade pass starts with a full
627
681
  // day cursor and must NOT claim that.
628
682
  const dayCursorWasEmpty = seen.size === 0
629
- const liveSeq = target === 'hours' ? await this.store.hoursLiveSequences() : await this.store.liveSequences()
683
+ const liveSeq = target === 'hours'
684
+ ? await this.store.hoursLiveSequences()
685
+ : target === 'failures'
686
+ ? await this.store.failuresLiveSequences()
687
+ : await this.store.liveSequences()
630
688
  // seen alone decides target selection: a session enters
631
689
  // backfilledSessions only after its whole replayable range was cleanly
632
690
  // folded, so selecting it again would multiply that range by the boot
@@ -638,12 +696,13 @@ export class UsageCollector {
638
696
  const concurrency = this.options.backfillConcurrency ?? 4
639
697
  let next = 0
640
698
  const completed: string[] = []
641
- // Each target writes and marks its own surfaces: the upgrade pass must
642
- // not touch day rows (their cursors are already advanced) and must not
643
- // 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.
644
701
  const writeSample = target === 'hours'
645
702
  ? (sample: UsageSample): Promise<void> => this.store.recordHours(sample)
646
- : (sample: UsageSample): Promise<void> => this.store.record(sample)
703
+ : target === 'failures'
704
+ ? (sample: UsageSample): Promise<void> => this.store.recordFailure(sample)
705
+ : (sample: UsageSample): Promise<void> => this.store.record(sample)
647
706
  const flushCompleted = async (): Promise<void> => {
648
707
  if (completed.length === 0) return
649
708
  const batch = completed.splice(0, completed.length)
@@ -651,10 +710,16 @@ export class UsageCollector {
651
710
  await this.store.markHoursSeenSessions(batch)
652
711
  return
653
712
  }
713
+ if (target === 'failures') {
714
+ await this.store.markFailuresSeenSessions(batch)
715
+ return
716
+ }
654
717
  await this.store.markSeenSessions(batch)
655
718
  // `record()` above wrote both tables, so the hour cursor advances in
656
719
  // the same batch.
657
720
  await this.store.markHoursSeenSessions(batch)
721
+ // ... and the failure counter was folded by the same replay.
722
+ await this.store.markFailuresSeenSessions(batch)
658
723
  }
659
724
  const worker = async () => {
660
725
  for (;;) {
@@ -771,15 +836,21 @@ export class UsageCollector {
771
836
  }
772
837
  await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, targets.length)) }, worker))
773
838
  await flushCompleted()
774
- // Claim the hour history only when this pass covered what it set out
775
- // to cover. A first install / rebuild starts with an empty day cursor
776
- // and double-writes; the upgrade pass is the hour pass itself. An
777
- // aborted or erroring scan leaves the flag off, and the seen sets make
778
- // the retry exact.
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
779
846
  const hourPassCompleted = target === 'hours' || dayCursorWasEmpty
780
- if (hourPassCompleted && !signal?.aborted && this.status.error === undefined) {
847
+ if (hourPassCompleted && clean) {
781
848
  await this.store.markHoursReady()
782
849
  }
850
+ const failuresPassCompleted = target === 'failures' || dayCursorWasEmpty
851
+ if (failuresPassCompleted && clean) {
852
+ await this.store.markFailuresReady()
853
+ }
783
854
  } finally {
784
855
  this.status.running = false
785
856
  }