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.
- package/README.md +5 -4
- package/lib/client.js +566 -159
- package/lib/index.js +355 -54
- 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 +9 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +27 -12
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/query.d.ts +8 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +30 -1
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +20 -0
- package/src/client/ContextUsageEntry.tsx +89 -0
- package/src/client/StatsLineEnhanced.tsx +17 -35
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +204 -56
- 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 +54 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +138 -67
- package/src/context-types.ts +17 -2
- package/src/index.ts +29 -0
- package/src/model-names.ts +34 -0
- package/src/query.ts +84 -13
- package/src/routes.ts +20 -2
- package/src/session-usage.ts +50 -38
- package/src/store.ts +136 -0
- package/src/wire.ts +24 -1
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 的比例',
|
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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 +
|
|
14
|
+
/** Authoritative log-based main + all descendant usage after the tree loads. */
|
|
68
15
|
totals: SessionUsageTotals | null
|
|
69
16
|
}
|
|
70
17
|
|
|
71
|
-
|
|
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 [
|
|
88
|
-
const
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
50
|
+
active = false
|
|
51
|
+
refresh.current = null
|
|
52
|
+
clearInterval(handle)
|
|
111
53
|
}
|
|
112
|
-
}, [sessionId
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
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'
|
|
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'
|
|
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
|
|
642
|
-
// not touch
|
|
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
|
-
:
|
|
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
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
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 &&
|
|
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
|
}
|