@sixzjd/dsh-usage-dashboard 0.0.0-stage → 0.6.0

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/lib/catalog.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Model display-name resolution for routing aliases.
3
+ *
4
+ * Some providers expose models under opaque route keys instead of real model
5
+ * names. Qoder does this: the durable log records `{provider:"qoder",
6
+ * model:"qfmodel"}`, while the actual model behind that route is
7
+ * `Qwen3.8-Flash`. The route key is what the host writes into `request/header`
8
+ * and what the upstream expects back, so it cannot be replaced at the source
9
+ * without breaking the request — the real name only exists in the plugin's own
10
+ * discovery catalog.
11
+ *
12
+ * This module reads that catalog so the panel can label a call with the model
13
+ * it actually ran. It changes DISPLAY ONLY: grouping, totals and pricing keep
14
+ * using the route key, so relabelling can never move a request between rows or
15
+ * change what is counted.
16
+ *
17
+ * The catalog is another plugin's private cache file, not a published seam.
18
+ * Everything here is therefore written to fail closed: a missing file, an
19
+ * unexpected shape, an unreadable directory or an ambiguous mapping all resolve
20
+ * to "no alias", which leaves the original route key on screen rather than
21
+ * guessing.
22
+ */
23
+
24
+ import { readFile, readdir } from 'node:fs/promises'
25
+ import { homedir } from 'node:os'
26
+ import { join } from 'node:path'
27
+
28
+ // Only files whose name ends in `-catalog.json` are considered. The same
29
+ // directory holds `.qoder-probe.json` and `.qoder-host-heartbeat.json` (which
30
+ // carries a pid), and both also start with `version: 1` — the name filter keeps
31
+ // them out before the structural check even runs.
32
+ const CATALOG_SUFFIX = '-catalog.json'
33
+
34
+ // An alias worth showing must differ from the route key, and a display name
35
+ // this long is not a model name — it guards against a catalog that has been
36
+ // repurposed to carry prose.
37
+ const MAX_ALIAS_LENGTH = 64
38
+ const MAX_NAME_LENGTH = 120
39
+
40
+ function isRecord(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
42
+ }
43
+
44
+ function nonEmptyString(value) {
45
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
46
+ }
47
+
48
+ /**
49
+ * The DSH home, honouring the same override the harness itself documents
50
+ * (`$DSH_HOME`, then `~/.dsh`). A blank value counts as unset so it can never
51
+ * resolve the home to the current working directory.
52
+ */
53
+ export function resolveDshHome(env = process.env) {
54
+ const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
55
+ if (configured !== '') return configured
56
+ const home = typeof env.HOME === 'string' && env.HOME.trim() !== ''
57
+ ? env.HOME
58
+ : (homedir?.() ?? '')
59
+ if (home === '') return null
60
+ return join(home, '.dsh')
61
+ }
62
+
63
+ /** Every profile's Qoder catalog path, across all profiles under the home. */
64
+ export async function findCatalogFiles(home) {
65
+ if (typeof home !== 'string' || home === '') return []
66
+ const profilesRoot = join(home, 'profiles')
67
+ let profiles
68
+ try {
69
+ profiles = await readdir(profilesRoot, { withFileTypes: true })
70
+ } catch {
71
+ return []
72
+ }
73
+ const paths = []
74
+ for (const entry of profiles) {
75
+ if (!entry.isDirectory()) continue
76
+ // A leading dot marks plugin-owned state directories, which is exactly
77
+ // where the catalog lives; they are skipped by ordinary listing helpers.
78
+ const stateDir = join(profilesRoot, entry.name, '.dsh-qoder-connect', 'state')
79
+ let files
80
+ try {
81
+ files = await readdir(stateDir)
82
+ } catch {
83
+ continue
84
+ }
85
+ for (const file of files) {
86
+ if (file.endsWith(CATALOG_SUFFIX)) paths.push(join(stateDir, file))
87
+ }
88
+ }
89
+ return paths
90
+ }
91
+
92
+ /**
93
+ * Pull `id -> name` out of one catalog file's parsed contents.
94
+ *
95
+ * Shape: `{version: 1, entries: {"pat:<hash>": {models: [{id, name, ...}]}}}`.
96
+ * Entries are keyed per account and each holds a `pat:` identifier; only the
97
+ * model rows are read out, so no account material can reach a caller.
98
+ *
99
+ * Returns null when the file is not a catalog at all, which is how the
100
+ * structural check rejects unrelated JSON that happens to sit in the directory.
101
+ */
102
+ export function parseCatalogFile(contents) {
103
+ if (!isRecord(contents) || contents.version !== 1) return null
104
+ if (!isRecord(contents.entries)) return null
105
+ const rows = []
106
+ let sawAnyEntry = false
107
+ for (const account of Object.values(contents.entries)) {
108
+ if (!isRecord(account)) continue
109
+ if (!Array.isArray(account.models)) continue
110
+ sawAnyEntry = true
111
+ const fetchedAt = Number.isFinite(account.fetchedAtMs) ? account.fetchedAtMs : 0
112
+ for (const model of account.models) {
113
+ if (!isRecord(model)) continue
114
+ const id = nonEmptyString(model.id)
115
+ const name = nonEmptyString(model.name)
116
+ if (id === null || name === null) continue
117
+ if (id.length > MAX_ALIAS_LENGTH || name.length > MAX_NAME_LENGTH) continue
118
+ rows.push({ id, name, fetchedAt })
119
+ }
120
+ }
121
+ // `entries` present but no usable model rows is not a catalog we understand.
122
+ return sawAnyEntry ? rows : null
123
+ }
124
+
125
+ /**
126
+ * Merge rows from every catalog into one lookup.
127
+ *
128
+ * The freshest fetch wins per id. Two ids that disagree on the same name with
129
+ * equal freshness is an ambiguous mapping, and an ambiguous mapping is dropped:
130
+ * showing the wrong real model is worse than showing the route key.
131
+ */
132
+ export function buildAliasMap(catalogs) {
133
+ // id -> {name, fetchedAt}
134
+ const best = new Map()
135
+ const ambiguous = new Set()
136
+ for (const rows of catalogs) {
137
+ for (const row of rows) {
138
+ const current = best.get(row.id)
139
+ if (current === undefined) {
140
+ best.set(row.id, { name: row.name, fetchedAt: row.fetchedAt })
141
+ continue
142
+ }
143
+ if (current.name === row.name) {
144
+ if (row.fetchedAt > current.fetchedAt) current.fetchedAt = row.fetchedAt
145
+ continue
146
+ }
147
+ if (row.fetchedAt > current.fetchedAt) {
148
+ // A newer fetch replaced the label; the older one is simply stale.
149
+ best.set(row.id, { name: row.name, fetchedAt: row.fetchedAt })
150
+ ambiguous.delete(row.id)
151
+ continue
152
+ }
153
+ if (row.fetchedAt === current.fetchedAt) ambiguous.add(row.id)
154
+ }
155
+ }
156
+ const map = new Map()
157
+ for (const [id, entry] of best) {
158
+ if (ambiguous.has(id)) continue
159
+ // A name that adds no information is dropped. The comparison is
160
+ // case-insensitive on purpose: Qoder's `auto -> Auto` differs only in
161
+ // capitalisation, and relabelling `auto` as `Auto` is not a real model
162
+ // name - it just hides a key that was already readable.
163
+ if (entry.name.toLowerCase() === id.toLowerCase()) continue
164
+ map.set(id, entry.name)
165
+ }
166
+ return map
167
+ }
168
+
169
+ /**
170
+ * Read every catalog and return `routeKey -> displayName`.
171
+ *
172
+ * Never throws: an unreadable home yields an empty map, so a machine without
173
+ * the Qoder plugin behaves exactly like one with no aliases at all.
174
+ */
175
+ export async function loadModelAliases(options = {}) {
176
+ const home = options.home ?? resolveDshHome(options.env ?? process.env)
177
+ const paths = options.paths ?? await findCatalogFiles(home)
178
+ const read = options.readFile ?? readFile
179
+ // Carry the path alongside the rows so `sources` needs no second read: a
180
+ // re-read would race with the owning plugin rewriting the file, and the
181
+ // answer would then disagree with the rows already in hand.
182
+ const catalogs = await Promise.all(paths.map(async (path) => {
183
+ try {
184
+ const text = await read(path, 'utf8')
185
+ const rows = parseCatalogFile(JSON.parse(text))
186
+ return rows === null ? null : { path, rows }
187
+ } catch {
188
+ // Absent, unparseable or an unexpected shape: this file contributes nothing.
189
+ return null
190
+ }
191
+ }))
192
+ const usable = catalogs.filter((entry) => entry !== null && entry.rows.length > 0)
193
+ return {
194
+ aliases: buildAliasMap(usable.map((entry) => entry.rows)),
195
+ // Which files actually contributed, so the panel can say where a name came
196
+ // from and a stale path stays discoverable in the UI.
197
+ sources: usable.map((entry) => entry.path),
198
+ filesFound: paths.length,
199
+ }
200
+ }
201
+
202
+ /** A resolver that returns the display name for a route key, or null. */
203
+ export function createAliasResolver(initial) {
204
+ let aliases = initial instanceof Map ? initial : new Map()
205
+ return {
206
+ replace(next) {
207
+ aliases = next instanceof Map ? next : new Map()
208
+ },
209
+ size() {
210
+ return aliases.size
211
+ },
212
+ /**
213
+ * Resolve one model id by exact match, case-insensitively.
214
+ *
215
+ * Deliberately not matching the trailing segment of a namespaced id. The
216
+ * catalog carries no provider field, so `vendor/dmodel` matching the Qoder
217
+ * key `dmodel` would label another vendor's model with a Qoder name - a
218
+ * wrong label on screen, which is the one outcome this whole module exists
219
+ * to avoid. Qoder logs its route keys bare, so dropping the tail match
220
+ * costs no real resolution; verified against the live model rows.
221
+ */
222
+ resolve(modelId) {
223
+ if (typeof modelId !== 'string' || modelId === '') return null
224
+ const trimmed = modelId.trim()
225
+ const exact = aliases.get(trimmed)
226
+ if (exact !== undefined) return exact
227
+ return aliases.get(trimmed.toLowerCase()) ?? null
228
+ },
229
+ }
230
+ }
package/lib/history.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Durable per-call usage history for dsh-usage-dashboard.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * The panel's only data source is `sessionQuery`, which by construction only
7
+ * sees the logs that still exist on disk. That makes a DELETION look like a
8
+ * real drop in usage: the moment a conversation is deleted, every token it
9
+ * ever spent disappears from every total. On the machine that motivated this
10
+ * module, 362 deleted sessions were worth ~3.1B tokens against a 6.67B
11
+ * lifetime figure — the panel showed 3.5B and looked like it had lost half
12
+ * the history.
13
+ *
14
+ * The fix is to stop treating "the log still exists" as the definition of
15
+ * history. Every scan records what each call slot cost into a durable store;
16
+ * a later scan that no longer sees a session keeps paying its recorded rows
17
+ * forward. Deleting a conversation stops it from growing, never from counting.
18
+ *
19
+ * Two invariants make that safe:
20
+ *
21
+ * - A retained row is keyed by `${sessionId}:${turn}:${step}` — the same slot
22
+ * the fold uses — so rescanning a surviving session replaces rather than
23
+ * duplicates it.
24
+ * - A live log always wins. Retained rows load first and live log rows are
25
+ * set afterwards, so a session that still exists is measured from its log
26
+ * and never from a possibly stale copy of it.
27
+ *
28
+ * Rows are pruned by age, not by session count, so an idle month cannot push
29
+ * real history out of the store.
30
+ */
31
+
32
+ const TABLE = 'usage_history'
33
+ const VERSION = 1
34
+
35
+ function isRecord(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
37
+ }
38
+
39
+ function safeInt(value) {
40
+ const n = Number(value)
41
+ if (!Number.isFinite(n) || n < 0) return 0
42
+ return Math.trunc(n)
43
+ }
44
+
45
+ function tokensOf(raw) {
46
+ return {
47
+ input: safeInt(raw?.input),
48
+ output: safeInt(raw?.output),
49
+ cacheRead: safeInt(raw?.cacheRead),
50
+ cacheWrite: safeInt(raw?.cacheWrite),
51
+ }
52
+ }
53
+
54
+ function isAllZero(tokens) {
55
+ return tokens.input === 0 && tokens.output === 0 && tokens.cacheRead === 0 && tokens.cacheWrite === 0
56
+ }
57
+
58
+ /** One retained row: the durable form of a single call slot. */
59
+ function toRow(request) {
60
+ if (!isRecord(request)) return null
61
+ const id = typeof request.id === 'string' ? request.id : null
62
+ if (id === null || id === '') return null
63
+ const tokens = tokensOf(request.tokens)
64
+ if (isAllZero(tokens)) return null
65
+ return {
66
+ id,
67
+ sessionId: typeof request.sessionId === 'string' ? request.sessionId : id.split(':')[0],
68
+ time: Number.isFinite(request.time) ? Math.trunc(request.time) : null,
69
+ date: typeof request.date === 'string' ? request.date : null,
70
+ provider: typeof request.provider === 'string' ? request.provider : null,
71
+ modelId: typeof request.modelId === 'string' ? request.modelId : null,
72
+ tokens,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The in-memory index over retained rows.
78
+ *
79
+ * `absorb` takes a snapshot's request rows; `rows` returns what a fresh
80
+ * aggregator should reload through `loadRetained`. Persist `toJSON()` and hand
81
+ * it back to the constructor on the next start.
82
+ */
83
+ export function createHistoryStore(initial = null) {
84
+ const rows = new Map()
85
+
86
+ if (isRecord(initial) && Array.isArray(initial.rows)) {
87
+ for (const raw of initial.rows) {
88
+ const row = toRow({ ...raw, id: raw?.id, sessionId: raw?.sessionId, date: raw?.date })
89
+ if (row !== null) rows.set(row.id, row)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Record every row of one snapshot.
95
+ *
96
+ * A row already present is REPLACED, which is what keeps a growing session
97
+ * from being counted twice: the latest scan of that slot is the truth.
98
+ */
99
+ function absorb(requests) {
100
+ if (!Array.isArray(requests)) return 0
101
+ let changed = 0
102
+ for (const request of requests) {
103
+ const row = toRow(request)
104
+ if (row === null) continue
105
+ const previous = rows.get(row.id)
106
+ if (previous !== undefined && sameRow(previous, row)) continue
107
+ rows.set(row.id, row)
108
+ changed += 1
109
+ }
110
+ return changed
111
+ }
112
+
113
+ function sameRow(a, b) {
114
+ return a.time === b.time && a.date === b.date && a.provider === b.provider
115
+ && a.modelId === b.modelId && a.tokens.input === b.tokens.input
116
+ && a.tokens.output === b.tokens.output && a.tokens.cacheRead === b.tokens.cacheRead
117
+ && a.tokens.cacheWrite === b.tokens.cacheWrite
118
+ }
119
+
120
+ /** Drop rows for sessions that no longer exist AND are older than a cut. */
121
+ function prune(keepSessionIds, beforeMs) {
122
+ const keep = keepSessionIds instanceof Set ? keepSessionIds : new Set(keepSessionIds ?? [])
123
+ let removed = 0
124
+ for (const [id, row] of rows) {
125
+ if (keep.has(row.sessionId)) continue
126
+ if (beforeMs !== null && row.time !== null && row.time >= beforeMs) continue
127
+ rows.delete(id)
128
+ removed += 1
129
+ }
130
+ return removed
131
+ }
132
+
133
+ function rows_() {
134
+ return [...rows.values()].map((row) => ({ ...row, tokens: { ...row.tokens } }))
135
+ }
136
+
137
+ function toJSON() {
138
+ return { version: VERSION, rows: rows_() }
139
+ }
140
+
141
+ function size() {
142
+ return rows.size
143
+ }
144
+
145
+ function sessionIds() {
146
+ return new Set([...rows.values()].map((row) => row.sessionId))
147
+ }
148
+
149
+ return { absorb, prune, rows: rows_, toJSON, size, sessionIds, table: TABLE }
150
+ }
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { name, inject, apply } from './plugin.js'
2
+ export { default } from './plugin.js'