@sixzjd/dsh-usage-dashboard 0.0.0-stage → 0.6.1
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/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.en.md +212 -0
- package/README.md +194 -2
- package/client.js +357 -0
- package/cordis.patch.yml +11 -0
- package/lib/aggregate.js +422 -0
- package/lib/backfill.js +116 -0
- package/lib/catalog.js +230 -0
- package/lib/history.js +150 -0
- package/lib/index.js +2 -0
- package/lib/plugin.js +939 -0
- package/lib/pricing.js +232 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
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