dsh-token-total 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +5 -4
  2. package/lib/client.js +566 -159
  3. package/lib/index.js +355 -54
  4. package/lib/types/client/ContextUsageEntry.d.ts +22 -0
  5. package/lib/types/client/context-meter-core.d.ts +26 -0
  6. package/lib/types/client/context-takeover.d.ts +4 -0
  7. package/lib/types/client/locales.d.ts +1 -1
  8. package/lib/types/client/session-usage-core.d.ts +9 -0
  9. package/lib/types/client/use-session-usage.d.ts +2 -18
  10. package/lib/types/collector.d.ts +27 -12
  11. package/lib/types/context-types.d.ts +23 -2
  12. package/lib/types/model-names.d.ts +6 -0
  13. package/lib/types/query.d.ts +8 -0
  14. package/lib/types/session-usage.d.ts +8 -9
  15. package/lib/types/store.d.ts +32 -0
  16. package/lib/types/wire.d.ts +30 -1
  17. package/package.json +1 -1
  18. package/src/client/ContextUsageEntry.module.css +20 -0
  19. package/src/client/ContextUsageEntry.tsx +89 -0
  20. package/src/client/StatsLineEnhanced.tsx +17 -35
  21. package/src/client/TokenTotalPanel.module.css +72 -5
  22. package/src/client/TokenTotalPanel.tsx +204 -56
  23. package/src/client/context-meter-core.ts +54 -0
  24. package/src/client/context-takeover.ts +35 -0
  25. package/src/client/index.tsx +21 -3
  26. package/src/client/locales.ts +4 -0
  27. package/src/client/session-usage-core.ts +54 -0
  28. package/src/client/use-session-usage.ts +45 -98
  29. package/src/collector.ts +138 -67
  30. package/src/context-types.ts +17 -2
  31. package/src/index.ts +29 -0
  32. package/src/model-names.ts +34 -0
  33. package/src/query.ts +84 -13
  34. package/src/routes.ts +20 -2
  35. package/src/session-usage.ts +50 -38
  36. package/src/store.ts +136 -0
  37. package/src/wire.ts +24 -1
@@ -0,0 +1,22 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import { type ContextBreakdownLike, type ContextPressureLike } from './context-meter-core.ts';
3
+ export declare const CONTEXT_LOCALE_NS = "tokenTotalContext";
4
+ declare const keys: readonly ["label", "used", "system", "tools", "messages", "aria", "thousand", "million"];
5
+ export type ContextLocaleKey = typeof keys[number];
6
+ export declare const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>>;
7
+ type ProjectionMap = {
8
+ contextPressure: ContextPressureLike;
9
+ contextBreakdown: ContextBreakdownLike;
10
+ };
11
+ type UseContextProjection = <K extends keyof ProjectionMap & string>(key: K) => ProjectionMap[K] | undefined;
12
+ export interface ContextUsageEntryProps extends PropsLocale<typeof CONTEXT_LOCALE_NS> {
13
+ useProjection: UseContextProjection;
14
+ installContextTakeover?: (anchor: HTMLElement) => () => void;
15
+ }
16
+ export declare function ContextUsageEntry({ useProjection, t, installContextTakeover }: ContextUsageEntryProps): JSX.Element | null;
17
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
18
+ interface LocaleNamespaceMap {
19
+ tokenTotalContext: ContextLocaleKey;
20
+ }
21
+ }
22
+ export {};
@@ -0,0 +1,26 @@
1
+ export interface ContextPressureLike {
2
+ projectedTokens?: number;
3
+ pressureTokens?: number;
4
+ contextWindow?: number;
5
+ }
6
+ export interface ContextBreakdownLike {
7
+ systemTokens: number;
8
+ toolsTokens: number;
9
+ messageTokens: number;
10
+ }
11
+ export interface ContextOccupancy {
12
+ percent: number;
13
+ usedTokens: number;
14
+ contextWindow: number;
15
+ }
16
+ /** Mirrors the host meter's occupancy calculation exactly. */
17
+ export declare function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null;
18
+ export declare function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number;
19
+ export declare function contextBreakdownSegments(percent: number, breakdown: ContextBreakdownLike | undefined): Array<{
20
+ key: string;
21
+ width: number;
22
+ }>;
23
+ export declare function formatContextTokens(value: number, units: {
24
+ thousand: string;
25
+ million: string;
26
+ }): string;
@@ -0,0 +1,4 @@
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 declare function installContextTakeover(anchor: HTMLElement): () => void;
@@ -7,7 +7,7 @@
7
7
  /** The locale namespace this plugin owns. */
8
8
  export declare const LOCALE_NS = "tokenTotal";
9
9
  /** The typed translator key union for the panel's locale seat. */
10
- export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
10
+ export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
11
11
  export declare const en: Record<TokenTotalKey, string>;
12
12
  export declare const zh: Record<TokenTotalKey, string>;
13
13
  export declare const zhTW: Record<TokenTotalKey, string>;
@@ -0,0 +1,9 @@
1
+ /** Pure billing helpers shared by the session-tree hook and composer pills. */
2
+ import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
3
+ /** Add the root and descendants once; tree.total is already that sum. */
4
+ export declare function combinedTotals(tree: SessionUsageTree, self?: SessionUsageTotals): SessionUsageTotals;
5
+ /** One consistent split for the pill, cache hit rate and every dialog row. */
6
+ export declare function sessionUsageForDisplay(native: SessionUsageTotals | undefined, tree: SessionUsageTree | null): SessionUsageTree;
7
+ export declare function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean;
8
+ export declare function billedTotal(totals: SessionUsageTotals): number;
9
+ export declare function cacheHitPercentOf(totals: SessionUsageTotals): number | null;
@@ -1,24 +1,8 @@
1
1
  import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
2
- /** Fold a tree into the flat shape the pill's dialog renders. */
3
- export declare function combinedTotals(tree: SessionUsageTree): SessionUsageTotals;
4
- /** Whether a totals record carries any billable token at all. */
5
- export declare function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean;
6
- /** Total billed tokens of a flat totals record (prompt side + completion). */
7
- export declare function billedTotal(totals: SessionUsageTotals): number;
8
- /** Cache hit rate over the prompt side, or null when nothing was prompted. */
9
- export declare function cacheHitPercentOf(totals: SessionUsageTotals): number | null;
2
+ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts';
10
3
  export interface SessionTreeReading {
11
4
  tree: SessionUsageTree | null;
12
- /** main + subagents, or null before the first successful answer. */
5
+ /** Authoritative log-based main + all descendant usage after the tree loads. */
13
6
  totals: SessionUsageTotals | null;
14
7
  }
15
- /**
16
- * Subscribe to one session's whole-tree usage.
17
- *
18
- * `signature` is any string that changes when the native usage figure moves;
19
- * passing it in keeps this hook out of the projection seat and lets the caller
20
- * decide what "a new call settled" means for its own pill. A session that turns
21
- * out to have subagents additionally re-asks on a slow beat, because subagent
22
- * spend lands in a different session log and no native figure moves for it.
23
- */
24
8
  export declare function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading;
@@ -3,10 +3,10 @@
3
3
  * Two sources feed the same fold:
4
4
  *
5
5
  * - live: `session/event` subscription — every committed session event
6
- * reaches this plugin after the fact. `assistant/message` carries the
7
- * step's final `usage` (TokenUsage); `assistant/chunk` carries an early
8
- * `usage` sample from the streaming adapter; `request/context` carries the
9
- * provider/model route. A completed call's usage is attributed to the model
6
+ * reaches this plugin after the fact. Assistant messages expose final usage,
7
+ * and modern message/attempt streams retain raw usage even on failed calls.
8
+ * Legacy `assistant/chunk` reports an early sample; `request/context` carries
9
+ * the provider/model route. A completed call's usage is attributed to the model
10
10
  * the request used — in priority order: the message's own `source`
11
11
  * (authoritative per call), then the session's last-known route, which
12
12
  * comes from observed `request/context` events OR is lazily seeded from
@@ -27,11 +27,10 @@
27
27
  * >= 0.1.2-rc.1, 0 for a non-forked session), and the replay skips it —
28
28
  * the parent's own backfill already counted those events.
29
29
  *
30
- * Idempotence: within ONE session, the same (turn, step) may report usage
31
- * more than once (a streaming sample then the final assistant/message;
32
- * compaction replays). The fold emits each (turn, step) key exactly once and
33
- * keeps the newest values in its own state — see the replaceSample note for
34
- * why the store observably keeps the FIRST emission. Cross-session
30
+ * Idempotence: within ONE session, repeated reports for the same (turn, step)
31
+ * emit only signed bucket corrections: the accumulated usage is last-wins.
32
+ * A started retry closes that replacement slot because its predecessor was a
33
+ * separately billed attempt. Cross-session
35
34
  * (turn, step) collisions are NOT deduped: two sessions both own a turn 1 /
36
35
  * step 1, and each must count. The store's per-day rows accumulate, so
37
36
  * re-folding the same log must not add twice: live events are only folded
@@ -75,6 +74,17 @@ export declare class UsageFold {
75
74
  * carries no (new) usage or turn marker. */
76
75
  fold(ev: UsageSessionEvent): UsageSample | null;
77
76
  private replaceSample;
77
+ /**
78
+ * Fold one `assistant/attempt` — a provider call that failed. Tokens follow
79
+ * the same last-report-wins rule as a committed message (the attempt's
80
+ * stream is the call's final word, so a usage chunk seen mid-stream is
81
+ * replaced or corrected, never added twice), but the call must ALWAYS be
82
+ * counted as one failure: `replaceSample` drops an all-zero first report,
83
+ * which is correct for the token buckets and would silently lose the
84
+ * failure. A failed call that reported no usage therefore contributes a
85
+ * zero-token failure marker instead of a null.
86
+ */
87
+ private attemptSample;
78
88
  /**
79
89
  * Fold one `compaction/summary` — the one place a provider call's usage
80
90
  * reaches the log OUTSIDE the agent loop. The summarization call produces no
@@ -201,8 +211,13 @@ export declare class UsageCollector {
201
211
  * untouched, so a user who upgraded never loses day history because a
202
212
  * session log was pruned. */
203
213
  backfillHours(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
204
- /** Shared replay engine behind {@link backfill} and {@link backfillHours}.
205
- * `target` selects the cursor pair, the write function, and the cursor
206
- * the completed sessions land in. */
214
+ /** The failure upgrade pass: replay every session the FAILURE cursors have
215
+ * not seen, counting only failed calls. Neither the day nor the hour
216
+ * cursor covers this counter (both were full when it arrived), and the
217
+ * replay must not re-add tokens or requests. */
218
+ backfillFailures(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
219
+ /** Shared replay engine behind {@link backfill}, {@link backfillHours} and
220
+ * {@link backfillFailures}. `target` selects the cursor pair, the write
221
+ * function, and the cursor the completed sessions land in. */
207
222
  private scan;
208
223
  }
@@ -151,6 +151,22 @@ export interface UsageLlmService {
151
151
  id: string;
152
152
  name: string;
153
153
  }[]>;
154
+ /** The provider catalog: route id -> the display name the model selector
155
+ * shows. Synchronous on every host line this plugin supports (the llm
156
+ * runtime's own `listProviders`, mirrored by the dsh-llm service). Optional:
157
+ * a host without it leaves every ranked provider row labelled by its raw
158
+ * historical route id. */
159
+ listProviders?(): readonly {
160
+ id: string;
161
+ name: string;
162
+ }[];
163
+ /** The configured-provider view (the models page's own list), whose
164
+ * `displayName` is the same label. Read as a fallback for a route that
165
+ * `listProviders` omits. */
166
+ listConfigurableProviders?(): readonly {
167
+ provider: string;
168
+ displayName: string;
169
+ }[];
154
170
  }
155
171
  /** The route a session's log records for its model calls (mirror of
156
172
  * dsh-agent-loop RequestContext). */
@@ -167,8 +183,13 @@ export interface UsageSessionHandle {
167
183
  id: string;
168
184
  cwd?: string;
169
185
  createdAt?: number;
170
- /** The parent session a subagent session was delegated from (mirror of the
171
- * SessionHeader field; absent on a root session). */
186
+ /** Modern hosts retain creation metadata on Session.header, not as getters. */
187
+ header?: UsageSessionHeader;
188
+ /** Exact fork prefix, distinct from the resume/construction seed length. */
189
+ inheritedEventCount?: number;
190
+ /** Immutable current live log; avoids the persistence backend's batching lag. */
191
+ snapshotEvents?(): readonly UsageSessionEvent[];
192
+ /** Legacy direct parent; modern hosts expose header.parentSession instead. */
172
193
  parentSession?: string;
173
194
  /** 0 for a root session; the delegation depth for a subagent. */
174
195
  delegationDepth?: number;
@@ -2,6 +2,12 @@
2
2
  * This is query-time metadata: no store wipe/replay is needed, and a provider
3
3
  * rename applies to both the ranking and every trend bucket on refresh. */
4
4
  import type { UsageLlmService } from './context-types.ts';
5
+ /** Resolve provider route ids to the labels the model selector shows. Both
6
+ * catalogs are presentational and synchronous on the host runtime, so this is
7
+ * a plain in-process read rather than a per-request lookup: a missing service,
8
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
9
+ * every surface falls back to the raw historical route id. */
10
+ export declare function resolveProviderNames(llm?: UsageLlmService): Record<string, string>;
5
11
  /** Resolve each distinct route once per range query. Unknown/deleted providers
6
12
  * fall back to their bare historical model names in aggregateSamples(). */
7
13
  export declare function resolveModelNames(modelRefs: Iterable<string>, llm?: UsageLlmService): Promise<Map<string, string>>;
@@ -41,6 +41,14 @@ export interface UsageSample {
41
41
  cacheWriteTokens: number;
42
42
  turn?: boolean;
43
43
  request?: boolean;
44
+ /** True when this sample also counts one FAILED provider call. The host
45
+ * appends `assistant/attempt` (instead of `assistant/message`) when a
46
+ * call's stream ends with an error or is cancelled before any content is
47
+ * committed, so that event is the exact boundary of a failed call. The
48
+ * marker is ORTHOGONAL to `request` and to the buckets: a failed call was
49
+ * still opened by its own `step/start` (so it is already one request) and
50
+ * a stream that died mid-flight may still report usage. */
51
+ failed?: boolean;
44
52
  }
45
53
  export interface RangeFilter {
46
54
  from: string;
@@ -26,18 +26,17 @@ import type { UsageSessionPersistence, UsageSessionStore } from './context-types
26
26
  import type { SessionUsageTree } from './wire.ts';
27
27
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
28
28
  export declare class SessionUsageService {
29
- /** Cached trees, each remembering every session id it was built from, so a
30
- * live event on ANY of them (a subagent's own event changes its ancestors'
31
- * totals too) can invalidate exactly the affected trees. */
29
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
32
30
  private cache;
33
- /** Drop every cached tree that counted `sessionId` (its own tree, or any
34
- * ancestor's tree that folded it in as a subagent). Called by the collector
35
- * on each live session event. */
36
- invalidate(sessionId: string): void;
31
+ /** Any event can introduce a new descendant while an async read is running. */
32
+ private generation;
33
+ /** Invalidate conservatively: an unseen session may be a newly-created child
34
+ * of ANY cached root, so membership-only invalidation is not safe. */
35
+ invalidate(_sessionId: string): void;
37
36
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
38
37
  * `self` is the root's own usage, `subagents` every descendant's, `total`
39
- * the sum. Unknown roots answer zeroed totals rather than throwing: the pill
40
- * must never break the composer. */
38
+ * the sum. Unreadable logs reject rather than publishing partial totals;
39
+ * callers can retain their last good display while surfacing the error. */
41
40
  tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
42
41
  private listHeaders;
43
42
  }
@@ -23,6 +23,11 @@ export interface UsageCounters {
23
23
  cacheReadTokens: number;
24
24
  cacheWriteTokens: number;
25
25
  requests: number;
26
+ /** Provider calls in the bucket that FAILED (error / cancelled before any
27
+ * content): a subset of `requests`, never larger than it. Rows persisted
28
+ * before the counter existed may lack it; the schema normalizes that to 0
29
+ * and every subsequent fold writes the number back. */
30
+ failures: number;
26
31
  turns: number;
27
32
  lastSeen: number;
28
33
  }
@@ -43,6 +48,7 @@ export declare const usageDayRowSchema: z.ZodObject<{
43
48
  cacheReadTokens: z.ZodNumber;
44
49
  cacheWriteTokens: z.ZodNumber;
45
50
  requests: z.ZodNumber;
51
+ failures: z.ZodDefault<z.ZodNumber>;
46
52
  turns: z.ZodNumber;
47
53
  lastSeen: z.ZodNumber;
48
54
  }, z.core.$strip>;
@@ -55,6 +61,7 @@ export declare const usageHourRowSchema: z.ZodObject<{
55
61
  cacheReadTokens: z.ZodNumber;
56
62
  cacheWriteTokens: z.ZodNumber;
57
63
  requests: z.ZodNumber;
64
+ failures: z.ZodDefault<z.ZodNumber>;
58
65
  turns: z.ZodNumber;
59
66
  lastSeen: z.ZodNumber;
60
67
  }, z.core.$strip>;
@@ -81,12 +88,17 @@ export declare const usageHistoryDomain: {
81
88
  hoursBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
82
89
  hoursLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
83
90
  hoursReady: z.ZodOptional<z.ZodBoolean>;
91
+ failuresBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
92
+ failuresLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
93
+ failuresReady: z.ZodOptional<z.ZodBoolean>;
84
94
  }, z.core.$strip>;
85
95
  initial: {
86
96
  backfilledSessions: string[];
87
97
  liveFirstSeq: {};
88
98
  hoursBackfilledSessions: string[];
89
99
  hoursLiveFirstSeq: {};
100
+ failuresBackfilledSessions: string[];
101
+ failuresLiveFirstSeq: {};
90
102
  };
91
103
  };
92
104
  tables: {
@@ -160,6 +172,21 @@ export declare class UsageStore {
160
172
  hoursReady(): Promise<boolean>;
161
173
  /** Mark the hour history complete; the upgrade pass then never reruns. */
162
174
  markHoursReady(): Promise<void>;
175
+ /** Session ids already replayed into the FAILURE counter (third cursor
176
+ * pair, same reason as the hour pair: the day and hour cursors were
177
+ * already full when the counter arrived). */
178
+ failuresSeenSessions(): Promise<Set<string>>;
179
+ /** Per-session seq of the first LIVE-observed event, failure-counter share. */
180
+ failuresLiveSequences(): Promise<Map<string, number>>;
181
+ /** Persist failure-pass replay progress (merges; serialized on the same
182
+ * chain as every other cursor write). */
183
+ markFailuresSeenSessions(ids: Iterable<string>): Promise<void>;
184
+ /** Record the failure-pass live boundary per session (earliest wins). */
185
+ markFailuresLiveSequences(entries: Iterable<readonly [string, number]>): Promise<void>;
186
+ /** Whether the failure counter covers every replayable session. */
187
+ failuresReady(): Promise<boolean>;
188
+ /** Mark the failure history complete; the upgrade pass then never reruns. */
189
+ markFailuresReady(): Promise<void>;
163
190
  /** Drop every row and the whole cursor, re-bounding each named session at
164
191
  * its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
165
192
  * history or attribution-logic upgrades).
@@ -198,6 +225,11 @@ export declare class UsageStore {
198
225
  * day cursors are already advanced, so replaying through `record()` would
199
226
  * double every day row. */
200
227
  recordHours(sample: UsageSample): Promise<void>;
228
+ /** Fold ONLY the failure count of a sample into the day and hour rows. The
229
+ * failure upgrade pass uses this: its replay walks logs the token passes
230
+ * already consumed, so the tokens and the request marker must not be added
231
+ * a second time — only the failed-call counter may move. */
232
+ recordFailure(sample: UsageSample): Promise<void>;
201
233
  /** The row identity a sample lands on: turn markers use the synthetic
202
234
  * (turns) model, unlabelled calls the (unknown) one. */
203
235
  private targetsOf;
@@ -17,6 +17,7 @@ export interface DailyTokenUsage {
17
17
  byModel: Record<string, number>;
18
18
  byProvider: Record<string, number>;
19
19
  requests: number;
20
+ failures: number;
20
21
  turns: number;
21
22
  cacheHit: number;
22
23
  cacheMiss: number;
@@ -29,6 +30,13 @@ export interface ModelTokenUsage {
29
30
  provider: string;
30
31
  /** All source providers; optional for compatibility with older responses. */
31
32
  providers?: string[];
33
+ /** Per-provider token split of this one model, largest first — the ranked
34
+ * row's hover detail ("which providers served this model"). Optional so a
35
+ * pre-cross-breakdown host still renders. */
36
+ providerTokens?: Array<{
37
+ provider: string;
38
+ tokens: number;
39
+ }>;
32
40
  tokens: number;
33
41
  percent: number;
34
42
  /** Cached input tokens this model served (rate numerator). */
@@ -43,6 +51,13 @@ export interface ProviderTokenUsage {
43
51
  percent: number;
44
52
  cacheHit: number;
45
53
  cacheMiss: number;
54
+ /** Per-model token split this provider served, largest first — the ranked
55
+ * row's hover detail ("which models this provider served"). Optional so a
56
+ * pre-cross-breakdown host still renders. */
57
+ modelTokens?: Array<{
58
+ model: string;
59
+ tokens: number;
60
+ }>;
46
61
  }
47
62
  /** The full aggregate the panel renders for one time range. */
48
63
  export interface TokenTotalRange {
@@ -56,6 +71,12 @@ export interface TokenTotalRange {
56
71
  /** Cached prompt tokens: cache reads + cache writes (the cache bucket of `tokens`). */
57
72
  cacheTokens: number;
58
73
  requests: number;
74
+ /** Provider calls in the range that FAILED: the stream ended with an error
75
+ * or the call was cancelled before any content was committed. A subset of
76
+ * `requests` (a failed call was still one call), and — like `requests` —
77
+ * independent of the token buckets: a stream that died mid-flight may
78
+ * still have reported usage. */
79
+ failures: number;
59
80
  turns: number;
60
81
  cacheHit: number;
61
82
  cacheMiss: number;
@@ -68,6 +89,11 @@ export interface TokenTotalRange {
68
89
  daily: DailyTokenUsage[];
69
90
  models: ModelTokenUsage[];
70
91
  providers: ProviderTokenUsage[];
92
+ /** Provider route id -> the display name the model selector shows. Filled by
93
+ * the host route from the llm service metadata; a provider missing here (a
94
+ * deleted route, an older host) renders as its raw id. Optional so a host
95
+ * that cannot resolve names still serves a valid payload. */
96
+ providerNames?: Record<string, string>;
71
97
  }
72
98
  /** The usage statistics panel aggregate request. */
73
99
  export interface TokenTotalRequest {
@@ -87,8 +113,11 @@ export interface SessionUsageTotals {
87
113
  * The composer stats row renders this so a delegating session shows its whole
88
114
  * spend (the native `tokenUsage` projection only covers the main agent). */
89
115
  export interface SessionUsageTree {
90
- /** The root session's own (main agent) usage. */
116
+ /** The root session's own usage, including logged compaction calls. */
91
117
  self: SessionUsageTotals;
118
+ /** Root-only compaction spend already included in `self`, but absent from
119
+ * the native `tokenUsage` projection. Optional for older host responses. */
120
+ selfCompaction?: SessionUsageTotals;
92
121
  /** Sum over every descendant (subagent) session. */
93
122
  subagents: SessionUsageTotals;
94
123
  /** self + subagents. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -0,0 +1,20 @@
1
+ .root { display: inline-flex; align-items: center; min-width: 0; font: inherit; font-variant-numeric: tabular-nums; line-height: inherit; }
2
+ .trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; max-width: 100%; padding: 1px 8px; border: 0; border-radius: 24px; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; line-height: inherit; white-space: nowrap; cursor: pointer; }
3
+ .trigger svg { flex: none; }
4
+ .track { fill: none; stroke: var(--dsw-alias-separator-primary); stroke-width: 1.5; }
5
+ .fill { fill: none; stroke: var(--dsw-alias-label-tertiary); stroke-width: 2; stroke-linecap: round; }
6
+ .trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
7
+ .panel { position: fixed; z-index: 1100; box-sizing: border-box; width: max-content; min-width: min(300px, calc(100vw - 24px)); max-width: min(440px, calc(100vw - 24px)); padding: 16px; border-radius: 12px; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-prominent); color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
8
+ .header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--dsw-alias-label-primary); font-weight: 500; }
9
+ .percent { font-variant-numeric: tabular-nums; }
10
+ .figures { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; }
11
+ .bar { display: flex; width: 100%; height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-separator-primary); }
12
+ .segment { height: 100%; }
13
+ .systemTokens { background: var(--dsw-alias-label-primary); }
14
+ .toolsTokens { background: var(--dsw-alias-label-secondary); }
15
+ .messageTokens { background: var(--dsw-alias-label-tertiary); }
16
+ .rows { display: grid; gap: 6px; margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); }
17
+ .row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
18
+ .row dt { display: flex; align-items: center; gap: 8px; }
19
+ .row dd { margin: 0; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
20
+ .swatch { width: 8px; height: 8px; border-radius: 2px; }
@@ -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
+ }