dsh-plugin-usage 0.1.4 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Incremental aggregation index over state.records.
3
+ *
4
+ * P1-5 LOCKED CONTRACT — query.js consumes exactly these members and fold.js
5
+ * feeds them; do not change the signatures:
6
+ * add(record) — fold calls for every record inserted
7
+ * remove(record) — fold calls when a dedup replacement drops a record
8
+ * recordsInRange(from, to) — iterable of records with from <= time <= to
9
+ *
10
+ * Implementation: a Map<dayOrdinal, records[]> slice index maintained
11
+ * incrementally by add()/remove(). Records are folded chronologically, so each
12
+ * day array preserves insertion order and iterating day ordinals in ascending
13
+ * order reproduces the baseline scan's order while skipping out-of-range days.
14
+ * recordsInRange() additionally sweeps state.records for entries missing from
15
+ * the map (bookkeeping gaps, e.g. records pushed before the index existed) so
16
+ * query results stay identical to the baseline O(N) scan even on partial
17
+ * indexes — each such record is guarded by a per-call Set against duplicates.
18
+ */
19
+ import { DAY_MS, alignFloor } from './time.js';
20
+ const dayOrdinal = (t) => Math.floor(alignFloor(t, 'day') / DAY_MS);
21
+ export function createUsageIndex(state) {
22
+ const days = new Map();
23
+ return {
24
+ add(record) {
25
+ const o = dayOrdinal(record.time);
26
+ let day = days.get(o);
27
+ if (day === undefined) {
28
+ day = [];
29
+ days.set(o, day);
30
+ }
31
+ day.push(record);
32
+ },
33
+ remove(record) {
34
+ const day = days.get(dayOrdinal(record.time));
35
+ if (day === undefined)
36
+ return; // missing record: tolerate silently
37
+ const i = day.indexOf(record);
38
+ if (i === -1)
39
+ return; // not indexed: tolerate silently
40
+ day.splice(i, 1);
41
+ if (day.length === 0)
42
+ days.delete(dayOrdinal(record.time));
43
+ },
44
+ *recordsInRange(from, to) {
45
+ const startOrd = Number.isFinite(from) ? dayOrdinal(from) : null;
46
+ const endOrd = Number.isFinite(to) ? dayOrdinal(to) : null;
47
+ // Finite bounds cover ordinal ranges directly; unbounded sides fall back
48
+ // to scanning the known day keys (never an infinite loop on ±Infinity).
49
+ const ords = [];
50
+ if (startOrd !== null && endOrd !== null) {
51
+ for (let o = startOrd; o <= endOrd; o++)
52
+ ords.push(o);
53
+ }
54
+ else {
55
+ for (const o of days.keys()) {
56
+ if (startOrd === null || o >= startOrd) {
57
+ if (endOrd === null || o <= endOrd)
58
+ ords.push(o);
59
+ }
60
+ }
61
+ ords.sort((a, b) => a - b);
62
+ }
63
+ const seen = new Set();
64
+ for (const o of ords) {
65
+ const day = days.get(o);
66
+ if (day === undefined)
67
+ continue;
68
+ for (const r of day) {
69
+ if (r.time >= from && r.time <= to) {
70
+ seen.add(r);
71
+ yield r;
72
+ }
73
+ }
74
+ }
75
+ // Defensive sweep: surface any record the slice index missed (bookkeeping
76
+ // gaps) without yielding anything twice.
77
+ for (const r of state.records) {
78
+ if (r.time >= from && r.time <= to && !seen.has(r))
79
+ yield r;
80
+ }
81
+ },
82
+ /** Total indexed records (day slices only). */
83
+ size() {
84
+ let n = 0;
85
+ for (const day of days.values())
86
+ n += day.length;
87
+ return n;
88
+ },
89
+ /** Drop whole day slices strictly before ts (keeps the day containing ts). */
90
+ pruneBefore(ts) {
91
+ const cut = dayOrdinal(ts);
92
+ for (const o of days.keys()) {
93
+ if (o < cut)
94
+ days.delete(o);
95
+ }
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Client source loader.
3
+ *
4
+ * The full dashboard UI lives in src/client/index.js (single source of
5
+ * truth). A dynamic plugin cannot import files, so the host reads the file
6
+ * and serves it to the browser half, which evaluates it with new Function.
7
+ *
8
+ * P1-4: candidates are derived from import.meta.url so the dynamic-plugin
9
+ * dev flow works from any checkout location.
10
+ */
11
+ import { fileURLToPath } from 'node:url';
12
+ const CLIENT_SOURCE_CANDIDATES = [
13
+ // src/host -> src/client in dev; lib/host -> lib/client when built.
14
+ fileURLToPath(new URL('../client/index.js', import.meta.url)),
15
+ // Repo source fallback when the built lib runs inside a checkout.
16
+ fileURLToPath(new URL('../../src/client/index.js', import.meta.url)),
17
+ ];
18
+ export async function getClientSource(ctx) {
19
+ const fs = ctx.get('fs');
20
+ if (fs === undefined)
21
+ return { ok: false, error: 'fs service unavailable' };
22
+ for (const p of CLIENT_SOURCE_CANDIDATES) {
23
+ try {
24
+ const target = await fs.resolve(p);
25
+ const body = await fs.readText(target);
26
+ if (typeof body === 'string' && body.length > 0)
27
+ return { ok: true, body };
28
+ }
29
+ catch (error) {
30
+ // try the next candidate
31
+ }
32
+ }
33
+ return { ok: false, error: 'client source not found (tried: ' + CLIENT_SOURCE_CANDIDATES.join(', ') + ')' };
34
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Fold durable session events into usage records.
3
+ *
4
+ * This mirrors the official `tokenUsage` session projection
5
+ * (@deepseek-ai/dsh-token-meter) so a third-party dashboard and the built-in
6
+ * stats agree on what one session consumed:
7
+ *
8
+ * - A **billed settlement** is `assistant/message` or `assistant/attempt`.
9
+ * `assistant/message` carries `data.usage`; a retried/failed attempt has no
10
+ * usage field but can still have reported usage on its exact stream, so the
11
+ * last raw `usage` chunk of `data.stream` is used when the field is absent.
12
+ * - `llm/retry-started` closes the replacement slot for that (turn, step):
13
+ * the retried attempt is a NEW billable attempt and must add, not replace.
14
+ * - Otherwise a later sample for the same (turn, step) replaces the previous
15
+ * one in place (index remove + add), because a settlement re-reports the
16
+ * cumulative usage of its step.
17
+ * - `request/context` and `request/header` provide the route (provider/model)
18
+ * for settlements that carry no `message.source`.
19
+ *
20
+ * Records without turn/step (legacy logs) are never deduped — they always
21
+ * append.
22
+ */
23
+ /**
24
+ * Last raw `usage` chunk of one durable assistant stream.
25
+ *
26
+ * @param {unknown} stream - `AssistantStreamRecord[]` (packed records included).
27
+ * @returns {object | undefined} the reported usage, when the stream has one.
28
+ */
29
+ export function streamUsage(stream) {
30
+ if (!Array.isArray(stream))
31
+ return undefined;
32
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
33
+ const record = stream[index];
34
+ if (record && record.type === 'chunk' && record.chunk && record.chunk.type === 'usage') {
35
+ return record.chunk.usage;
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Usage reported by one durable assistant settlement.
42
+ *
43
+ * @param {{ type?: string, data?: any }} event - session event.
44
+ * @returns {object | undefined} reported usage, when the settlement billed one.
45
+ */
46
+ export function usageOf(event) {
47
+ const data = event && event.data;
48
+ if (event.type === 'assistant/message' && data && data.usage !== undefined)
49
+ return data.usage;
50
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
51
+ return undefined;
52
+ return streamUsage(data && data.stream);
53
+ }
54
+ /** Route carried by a request event, when present. */
55
+ function routeOf(event) {
56
+ const data = event.data || {};
57
+ if (event.type === 'request/context') {
58
+ if (typeof data.provider === 'string' && typeof data.model === 'string') {
59
+ return { provider: data.provider, model: data.model };
60
+ }
61
+ return undefined;
62
+ }
63
+ if (event.type === 'request/header') {
64
+ const config = data.header && data.header.config;
65
+ if (config && typeof config.provider === 'string' && typeof config.model === 'string') {
66
+ return { provider: config.provider, model: config.model };
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ /**
72
+ * Fold one durable event into the usage records.
73
+ *
74
+ * @param {object} state - runtime state capsule (see state.js).
75
+ * @param {string} sessionId - owning session.
76
+ * @param {{ type?: string, seq?: number, time?: number, data?: any }} e - session event.
77
+ * @returns {object | null} the affected record, or null when the event billed nothing.
78
+ */
79
+ export function foldEvent(state, sessionId, e) {
80
+ if (!e || typeof e !== 'object')
81
+ return null;
82
+ // Route tracking: every settlement of a turn is priced/attributed under the
83
+ // route its request ran on.
84
+ const route = routeOf(e);
85
+ if (route !== undefined) {
86
+ state.route.set(sessionId, route);
87
+ return null;
88
+ }
89
+ // Retry boundary: the retried attempt is a new billable attempt.
90
+ if (e.type === 'llm/retry-started') {
91
+ const data = e.data || {};
92
+ const last = state.lastSample.get(sessionId);
93
+ if (last && last.turn === data.turn && last.step === data.step) {
94
+ state.lastSample.delete(sessionId);
95
+ }
96
+ return null;
97
+ }
98
+ if (e.type !== 'assistant/message' && e.type !== 'assistant/attempt')
99
+ return null;
100
+ const usage = usageOf(e);
101
+ if (usage === undefined || usage === null || typeof usage !== 'object')
102
+ return null;
103
+ const data = e.data || {};
104
+ const source = (data.message && data.message.source) || state.route.get(sessionId) || {};
105
+ const u = usage;
106
+ const inputTokens = u.inputTokens || 0;
107
+ const outputTokens = u.outputTokens || 0;
108
+ const cacheReadTokens = u.cacheReadTokens || 0;
109
+ const cacheWriteTokens = u.cacheWriteTokens || 0;
110
+ const rec = {
111
+ time: e.time,
112
+ session: sessionId,
113
+ provider: source.provider || '',
114
+ model: source.model || '',
115
+ turn: data.turn,
116
+ step: data.step,
117
+ attempt: e.type === 'assistant/attempt',
118
+ inputTokens,
119
+ outputTokens,
120
+ cacheReadTokens,
121
+ cacheWriteTokens,
122
+ reasoningTokens: u.reasoningTokens || 0,
123
+ // Prefer the provider's authoritative full-call total when reported.
124
+ totalTokens: typeof u.totalTokens === 'number'
125
+ ? u.totalTokens
126
+ : inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
127
+ };
128
+ const previous = state.lastSample.get(sessionId);
129
+ if (previous !== undefined
130
+ && data.turn !== undefined && data.step !== undefined
131
+ && previous.turn === data.turn && previous.step === data.step) {
132
+ const prev = previous.record;
133
+ if (state.index)
134
+ state.index.remove(prev);
135
+ Object.assign(prev, rec);
136
+ if (state.index)
137
+ state.index.add(prev);
138
+ state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: prev });
139
+ return prev;
140
+ }
141
+ state.records.push(rec);
142
+ if (state.index)
143
+ state.index.add(rec);
144
+ if (data.turn !== undefined && data.step !== undefined) {
145
+ state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: rec });
146
+ }
147
+ else {
148
+ state.lastSample.delete(sessionId);
149
+ }
150
+ return rec;
151
+ }
152
+ /**
153
+ * Fold events with seq > watermark for one session; returns the new watermark.
154
+ *
155
+ * @param {object} state - runtime state capsule.
156
+ * @param {string} sessionId - owning session.
157
+ * @param {ReadonlyArray<object>} events - events in log order.
158
+ * @param {number} wm - current watermark (last folded seq).
159
+ * @returns {number} the new watermark.
160
+ */
161
+ export function foldNew(state, sessionId, events, wm) {
162
+ let max = wm;
163
+ if (!Array.isArray(events))
164
+ return max;
165
+ for (const e of events) {
166
+ if (e && typeof e.seq === 'number' && e.seq > wm) {
167
+ foldEvent(state, sessionId, e);
168
+ if (e.seq > max)
169
+ max = e.seq;
170
+ }
171
+ }
172
+ return max;
173
+ }