dsh-token-total 1.0.1 → 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.
@@ -0,0 +1,89 @@
1
+ import { createPortal } from 'react-dom'
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import { MEASURE_STYLE } from './stat-dialog.ts'
6
+ import { contextBreakdownSegments, contextOccupancy, formatContextTokens, type ContextBreakdownLike, type ContextPressureLike } from './context-meter-core.ts'
7
+ import css from './ContextUsageEntry.module.css'
8
+
9
+ export const CONTEXT_LOCALE_NS = 'tokenTotalContext'
10
+ const keys = ['label', 'used', 'system', 'tools', 'messages', 'aria', 'thousand', 'million'] as const
11
+ export type ContextLocaleKey = typeof keys[number]
12
+ export const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>> = {
13
+ zh: { label: '上下文', used: '已用上下文', system: '系统提示词', tools: '工具', messages: '对话', aria: '上下文占用 {percent}', thousand: '{value}千', million: '{value}百万' },
14
+ en: { label: 'Context', used: 'Context used', system: 'System', tools: 'Tools', messages: 'Conversation', aria: 'Context usage {percent}', thousand: '{value}K', million: '{value}M' },
15
+ 'zh-TW': { label: '上下文', used: '已用上下文', system: '系統提示詞', tools: '工具', messages: '對話', aria: '上下文使用量 {percent}', thousand: '{value}千', million: '{value}百萬' },
16
+ }
17
+
18
+ type ProjectionMap = {
19
+ contextPressure: ContextPressureLike
20
+ contextBreakdown: ContextBreakdownLike
21
+ }
22
+ type UseContextProjection = <K extends keyof ProjectionMap & string>(key: K) => ProjectionMap[K] | undefined
23
+
24
+ export interface ContextUsageEntryProps extends PropsLocale<typeof CONTEXT_LOCALE_NS> {
25
+ useProjection: UseContextProjection
26
+ installContextTakeover?: (anchor: HTMLElement) => () => void
27
+ }
28
+
29
+ export function ContextUsageEntry({ useProjection, t, installContextTakeover }: ContextUsageEntryProps): JSX.Element | null {
30
+ const pressure = useProjection('contextPressure')
31
+ const breakdown = useProjection('contextBreakdown')
32
+ const context = contextOccupancy(pressure)
33
+ const [open, setOpen] = useState(false)
34
+ const rootRef = useRef<HTMLSpanElement | null>(null)
35
+ const panelRef = useRef<HTMLDivElement | null>(null)
36
+ const position = useAnchoredPosition({ open: open && context !== null, anchorRef: rootRef, panelRef, side: 'top', gap: 8, margin: 12 })
37
+ useDismissOnOutsidePointer(rootRef, open && context !== null, setOpen, panelRef)
38
+ useLayoutEffect(() => {
39
+ const anchor = rootRef.current
40
+ if (anchor === null || context === null || installContextTakeover === undefined) return
41
+ return installContextTakeover(anchor)
42
+ }, [context !== null, installContextTakeover])
43
+ useEffect(() => { if (context === null && open) setOpen(false) }, [context, open])
44
+ useEffect(() => {
45
+ if (!open || context === null) return
46
+ const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
47
+ document.addEventListener('keydown', onKeyDown)
48
+ return () => document.removeEventListener('keydown', onKeyDown)
49
+ }, [context, open])
50
+ if (context === null) return null
51
+ const percent = context.percent
52
+ const reading = `${percent}%`
53
+ const total = breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
54
+ const segments = contextBreakdownSegments(percent, breakdown)
55
+ const format = (value: number): string => formatContextTokens(value, {
56
+ thousand: t('thousand'), million: t('million'),
57
+ })
58
+ const label = (part: 'system' | 'tools' | 'messages'): string => t(part)
59
+ return (
60
+ <span ref={rootRef} className={css.root} data-dsh-token-total-stat="context">
61
+ <button type="button" className={css.trigger} aria-label={t('aria', { percent: reading })} aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(!open)}>
62
+ <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
63
+ <circle className={css.track} cx="7" cy="7" r="5.5" />
64
+ <circle className={css.fill} cx="7" cy="7" r="5.5" strokeDasharray={`${2 * Math.PI * 5.5 * percent / 100} ${2 * Math.PI * 5.5}`} transform="rotate(-90 7 7)" />
65
+ </svg>
66
+ <span>{reading}</span>
67
+ </button>
68
+ {open && createPortal(
69
+ <div ref={panelRef} className={css.panel} style={position ?? MEASURE_STYLE} role="dialog" aria-label={t('used')}>
70
+ <div className={css.header}>
71
+ <span>{t('used')}</span><span className={css.percent}>{reading}</span>
72
+ <span className={css.figures}>{`~${format(context.usedTokens)} / ${format(context.contextWindow)}`}</span>
73
+ </div>
74
+ <div className={css.bar}>{segments.map(segment => <div key={segment.key} className={`${css.segment} ${css[segment.key] ?? ''}`} style={{ width: `${segment.width}%` }} />)}</div>
75
+ {breakdown !== undefined && total > 0 && <dl className={css.rows}>
76
+ {(['systemTokens', 'toolsTokens', 'messageTokens'] as const).map((key, index) => <div className={css.row} key={key}>
77
+ <dt><span className={`${css.swatch} ${css[key]}`} />{label((['system', 'tools', 'messages'] as const)[index])}</dt>
78
+ <dd>{`~${format(breakdown[key])}`}</dd>
79
+ </div>)}
80
+ </dl>}
81
+ </div>, document.body,
82
+ )}
83
+ </span>
84
+ )
85
+ }
86
+
87
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
88
+ interface LocaleNamespaceMap { tokenTotalContext: ContextLocaleKey }
89
+ }
@@ -297,7 +297,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
297
297
  )
298
298
  }
299
299
  return (
300
- <span ref={rootRef} className={anchorClass(dock)}>
300
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
301
301
  <button
302
302
  type="button"
303
303
  className={css.pill}
@@ -383,7 +383,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
383
383
  const exactCount = (value: number): string =>
384
384
  t('message.turnUsage.count', { count: formatTokens(value) })
385
385
  return (
386
- <span ref={rootRef} className={anchorClass(dock)}>
386
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
387
387
  <button
388
388
  type="button"
389
389
  className={css.pill}
@@ -282,7 +282,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
282
282
  * (zero input/output but real cache hits) or a failed-call day (requests
283
283
  * but no tokens) both carry real usage and must not read as "no data". */
284
284
  function isEmptyRange(stats: TokenTotalRange): boolean {
285
- return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.turns === 0
285
+ return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.failures === 0 && stats.turns === 0
286
286
  }
287
287
 
288
288
  function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
@@ -299,8 +299,10 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
299
299
  { icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}` },
300
300
  // 缓存命中率 —cached share of all prompt tokens in the range.
301
301
  { icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}` },
302
- // 请求数量 —provider calls (tokens or not).
303
- { icon: MessagesSquare, label: t('requests'), value: String(stats.requests) },
302
+ // 请求数量 —provider calls (tokens or not). The failed share sits
303
+ // underneath, and only when there were any: a healthy range stays a
304
+ // single line. Failed calls are a SUBSET of requests, never additive.
305
+ { icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
304
306
  ]
305
307
  return (
306
308
  <div className={css.cards}>
@@ -0,0 +1,54 @@
1
+ export interface ContextPressureLike {
2
+ projectedTokens?: number
3
+ pressureTokens?: number
4
+ contextWindow?: number
5
+ }
6
+
7
+ export interface ContextBreakdownLike {
8
+ systemTokens: number
9
+ toolsTokens: number
10
+ messageTokens: number
11
+ }
12
+
13
+ export interface ContextOccupancy {
14
+ percent: number
15
+ usedTokens: number
16
+ contextWindow: number
17
+ }
18
+
19
+ /** Mirrors the host meter's occupancy calculation exactly. */
20
+ export function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null {
21
+ const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
22
+ if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
23
+ return {
24
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
25
+ usedTokens,
26
+ contextWindow: pressure.contextWindow,
27
+ }
28
+ }
29
+
30
+ export function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number {
31
+ return breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
32
+ }
33
+
34
+ export function contextBreakdownSegments(
35
+ percent: number,
36
+ breakdown: ContextBreakdownLike | undefined,
37
+ ): Array<{ key: string; width: number }> {
38
+ const total = contextBreakdownTotal(breakdown)
39
+ if (breakdown === undefined || total === 0) return [{ key: 'total', width: percent }]
40
+ return [
41
+ { key: 'systemTokens', width: percent * breakdown.systemTokens / total },
42
+ { key: 'toolsTokens', width: percent * breakdown.toolsTokens / total },
43
+ { key: 'messageTokens', width: percent * breakdown.messageTokens / total },
44
+ ].filter(segment => segment.width > 0)
45
+ }
46
+
47
+ export function formatContextTokens(value: number, units: { thousand: string; million: string }): string {
48
+ const scaled = (candidate: number): string => candidate >= 100
49
+ ? String(Math.round(candidate))
50
+ : String(Math.round(candidate * 10) / 10)
51
+ if (value < 1e3) return String(value)
52
+ if (value < 1e6) return units.thousand.replace('{value}', scaled(value / 1e3))
53
+ return units.million.replace('{value}', scaled(value / 1e6))
54
+ }
@@ -0,0 +1,35 @@
1
+ /** The host ContextMeter is hard-coded beside composer.dock, not a slot.
2
+ * Suppress only that recognizable sibling while our replacement is mounted;
3
+ * never hide a whole dock, slot wrapper, or another plugin's readout. */
4
+ export function installContextTakeover(anchor: HTMLElement): () => void {
5
+ const dock = anchor.closest<HTMLElement>('[data-composer-dock]')
6
+ const view = anchor.ownerDocument.defaultView
7
+ if (dock === null || view === null) return () => {}
8
+ const hidden = new Map<HTMLElement, { display: string; priority: string }>()
9
+ const reconcile = (): void => {
10
+ for (const sibling of Array.from(dock.children)) {
11
+ if (!(sibling instanceof view.HTMLElement) || sibling.tagName !== 'SPAN'
12
+ || sibling.contains(anchor) || sibling.hasAttribute('data-slot-entry')
13
+ || sibling.hasAttribute('data-dsh-token-total-stat')
14
+ || hidden.has(sibling)) continue
15
+ // ContextMeter's stable SVG geometry, not a private CSS-module hash or
16
+ // a translated label. Current hosts mount its span directly in the dock.
17
+ if (sibling.querySelector('button[aria-haspopup="dialog"] svg[viewBox="0 0 14 14"] circle[cx="7"][cy="7"][r="5.5"]') === null) continue
18
+ hidden.set(sibling, { display: sibling.style.getPropertyValue('display'), priority: sibling.style.getPropertyPriority('display') })
19
+ sibling.setAttribute('data-dsh-token-total-native-context', '')
20
+ sibling.style.setProperty('display', 'none', 'important')
21
+ }
22
+ }
23
+ reconcile()
24
+ const observer = new view.MutationObserver(reconcile)
25
+ observer.observe(dock, { childList: true, subtree: true })
26
+ return () => {
27
+ observer.disconnect()
28
+ for (const [element, previous] of hidden) {
29
+ element.removeAttribute('data-dsh-token-total-native-context')
30
+ if (previous.display === '') element.style.removeProperty('display')
31
+ else element.style.setProperty('display', previous.display, previous.priority)
32
+ }
33
+ hidden.clear()
34
+ }
35
+ }
@@ -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 的比例',
@@ -8,8 +8,7 @@ const ZERO: SessionUsageTotals = {
8
8
  cacheWriteTokens: 0,
9
9
  }
10
10
 
11
- /** Use the live main-agent projection when available. Never add tree.total to
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
- const extra = tree?.selfCompaction ?? ZERO
28
- // Native tokenUsage counts message/attempt calls, but not compaction calls.
29
- // tree.self already includes compaction, so add the supplement ONLY to native.
30
- const self = native === undefined ? tree?.self ?? { ...ZERO } : {
31
- uncachedInputTokens: native.uncachedInputTokens + extra.uncachedInputTokens,
32
- outputTokens: native.outputTokens + extra.outputTokens,
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,
@@ -11,7 +11,7 @@ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './
11
11
 
12
12
  export interface SessionTreeReading {
13
13
  tree: SessionUsageTree | null
14
- /** Log-based main + subagents. The pill substitutes the live main projection. */
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
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
597
- * `target` selects the cursor pair, the write function, and the cursor
598
- * 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. */
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' ? 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()
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' ? 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()
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 pass must
648
- // not touch day rows (their cursors are already advanced) and must not
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
- : (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)
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 the hour history only when this pass covered what it set out
781
- // to cover. A first install / rebuild starts with an empty day cursor
782
- // and double-writes; the upgrade pass is the hour pass itself. An
783
- // aborted or erroring scan leaves the flag off, and the seen sets make
784
- // 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
785
846
  const hourPassCompleted = target === 'hours' || dayCursorWasEmpty
786
- if (hourPassCompleted && !signal?.aborted && this.status.error === undefined) {
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,