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