dsh-all-usage 1.1.2 → 1.1.4

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,171 @@
1
+ const TOKEN_FIELDS = Object.freeze({
2
+ input: 'inputTokens',
3
+ output: 'outputTokens',
4
+ cacheRead: 'cacheReadTokens',
5
+ cacheWrite: 'cacheWriteTokens',
6
+ reasoning: 'reasoningTokens',
7
+ })
8
+
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ }
12
+
13
+ export function validEventTime(value) {
14
+ return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 8640000000000000
15
+ }
16
+
17
+ function tokenValue(source, field) {
18
+ const value = source[TOKEN_FIELDS[field]] !== undefined ? source[TOKEN_FIELDS[field]] : source[field]
19
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
20
+ }
21
+
22
+ export function normalizeUsageValues(usage) {
23
+ if (!isRecord(usage)) return null
24
+ return {
25
+ input: tokenValue(usage, 'input'),
26
+ output: tokenValue(usage, 'output'),
27
+ cacheRead: tokenValue(usage, 'cacheRead'),
28
+ cacheWrite: tokenValue(usage, 'cacheWrite'),
29
+ reasoning: tokenValue(usage, 'reasoning'),
30
+ }
31
+ }
32
+
33
+ export function hasUsageValues(values) {
34
+ return values !== null && values !== undefined && (values.input > 0 || values.output > 0 || values.cacheRead > 0 || values.cacheWrite > 0 || values.reasoning > 0)
35
+ }
36
+
37
+ function stepIndex(value) {
38
+ return Number.isSafeInteger(value) && value >= 0 ? value : null
39
+ }
40
+
41
+ /** Extract the provider usage carried by one supported DSH event shape. */
42
+ export function extractUsageEvent(event) {
43
+ if (!isRecord(event) || !isRecord(event.data) || !validEventTime(event.time)) return null
44
+ const data = event.data
45
+ let kind
46
+ let usage
47
+ if (event.type === 'assistant/chunk') {
48
+ if (!isRecord(data.chunk) || data.chunk.type !== 'usage') return null
49
+ kind = 'chunk'
50
+ usage = data.chunk.usage
51
+ } else if (event.type === 'assistant/message') {
52
+ if (!Object.hasOwn(data, 'usage') || data.usage === undefined) return null
53
+ kind = 'message'
54
+ usage = data.usage
55
+ } else {
56
+ return null
57
+ }
58
+ const values = normalizeUsageValues(usage)
59
+ if (!hasUsageValues(values)) return null
60
+ const turn = stepIndex(data.turn)
61
+ const step = stepIndex(data.step)
62
+ if (turn === null || step === null) return null
63
+ return { kind, data, usage, values, turn, step }
64
+ }
65
+
66
+ function stableJson(value, ancestors = new Set()) {
67
+ if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value)
68
+ if (value === undefined) return 'null'
69
+ if (typeof value !== 'object') return JSON.stringify(String(value))
70
+ if (ancestors.has(value)) return '"[Circular]"'
71
+ ancestors.add(value)
72
+ let result
73
+ if (Array.isArray(value)) {
74
+ result = '[' + value.map((item) => stableJson(item, ancestors)).join(',') + ']'
75
+ } else {
76
+ result = '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableJson(value[key], ancestors)).join(',') + '}'
77
+ }
78
+ ancestors.delete(value)
79
+ return result
80
+ }
81
+
82
+ function stableIdentity(value) {
83
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
84
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
85
+ return null
86
+ }
87
+
88
+ /** Build a stable logical usage key across live, scan, and flush materializations. */
89
+ export function usageStepKey(sid, data, seq, fallback) {
90
+ const safeSid = typeof sid === 'string' ? sid : String(sid === undefined || sid === null ? '' : sid)
91
+ const turn = stepIndex(data && data.turn)
92
+ const step = stepIndex(data && data.step)
93
+ if (turn !== null && step !== null) return safeSid + ':step:' + turn + ':' + step
94
+ const message = data && isRecord(data.message) ? data.message : null
95
+ const logicalId = [
96
+ data && data.usageId,
97
+ data && data.sampleId,
98
+ data && data.requestId,
99
+ data && data.callId,
100
+ data && data.stepId,
101
+ data && data.messageId,
102
+ message && message.id,
103
+ ].map(stableIdentity).find((value) => value !== null)
104
+ if (logicalId !== undefined) return safeSid + ':logical:' + logicalId
105
+ if (Number.isSafeInteger(seq) && seq >= 0) return safeSid + ':event:' + seq
106
+ const serialized = stableJson(isRecord(data) ? data : {})
107
+ return safeSid + ':event:' + (serialized === undefined ? String(fallback === undefined ? '' : fallback) : serialized)
108
+ }
109
+
110
+ /** Recalculate the billing instant for a re-priced sample, preferring the recorded request time. */
111
+ export function billingInstantOf(item, oldCost) {
112
+ const oldAt = oldCost != null && Number.isFinite(oldCost.pricingAt) ? oldCost.pricingAt : null
113
+ const at = Number.isFinite(item.pricingAt) ? item.pricingAt : oldAt !== null ? oldAt : validEventTime(item.time) ? item.time : null
114
+ const oldSource = oldCost != null && (oldCost.pricingTimeSource === 'request-context' || oldCost.pricingTimeSource === 'usage-event') ? oldCost.pricingTimeSource : null
115
+ const source = typeof item.pricingTimeSource === 'string' && (item.pricingTimeSource === 'request-context' || item.pricingTimeSource === 'usage-event') ? item.pricingTimeSource : (oldSource || 'usage-event')
116
+ return { at, source }
117
+ }
118
+
119
+ /** Bounded context archive shared by the live fold and the persisted ledger. */
120
+ export const MAX_CONTEXT_TIMES = 512
121
+
122
+ /** Insert-or-refresh a context entry with real LRU eviction (delete + set). */
123
+ export function touchContextTimes(times, key, time) {
124
+ if (typeof key !== 'string' || key === '') return
125
+ if (times.has(key)) times.delete(key)
126
+ times.set(key, time)
127
+ while (times.size > MAX_CONTEXT_TIMES) times.delete(times.keys().next().value)
128
+ }
129
+
130
+ /** Stable per-(turn, step) key for the pricing-time context of a request. */
131
+ export function contextTimeKey(turn, step) {
132
+ const safeTurn = Number.isSafeInteger(turn) && turn >= 0 ? turn : null
133
+ const safeStep = Number.isSafeInteger(step) && step >= 0 ? step : null
134
+ if (safeTurn !== null && safeStep !== null) return 'context:' + safeTurn + ':' + safeStep
135
+ return 'context:__latest__'
136
+ }
137
+
138
+ /**
139
+ * Pick the billing instant for a usage event: the request/context time that
140
+ * matches the same turn/step wins (parallel requests stay per-step), otherwise
141
+ * the usage event time itself is the auditable fallback.
142
+ */
143
+ export function pickPricingTime(contextTimes, eventTime, turn, step) {
144
+ if (contextTimes instanceof Map && validEventTime(eventTime)) {
145
+ const exact = contextTimes.get(contextTimeKey(turn, step))
146
+ const candidate = exact !== undefined ? exact : contextTimes.get(contextTimeKey(null, null))
147
+ if (Number.isFinite(candidate) && candidate <= eventTime) {
148
+ return { time: candidate, source: 'request-context' }
149
+ }
150
+ }
151
+ return { time: Number.isFinite(eventTime) && eventTime >= 0 ? eventTime : null, source: 'usage-event' }
152
+ }
153
+
154
+ /** Replace one logical sample; stale lower-seq replays are ignored. */
155
+ /** Event sequence contract: -1 (missing) or a non-negative safe integer. */
156
+ export function normalizeEventSeq(value) {
157
+ return Number.isSafeInteger(value) && value >= 0 ? value : -1
158
+ }
159
+
160
+ export function upsertUsageSample(samples, sample) {
161
+ if (!(samples instanceof Map) || !isRecord(sample) || typeof sample.key !== 'string' || sample.key === '') return { accepted: false, reason: 'invalid-sample', previous: undefined, next: undefined }
162
+ const previous = samples.get(sample.key)
163
+ const seq = normalizeEventSeq(sample.seq)
164
+ const previousSeq = normalizeEventSeq(previous === undefined ? -1 : previous.seq)
165
+ if (previous !== undefined && seq >= 0 && previousSeq > seq) return { accepted: false, reason: 'stale-sample', key: sample.key, previous, next: previous }
166
+ const next = { ...sample, seq }
167
+ samples.set(sample.key, next)
168
+ return { accepted: true, replaced: previous !== undefined, key: sample.key, previous, next }
169
+ }
170
+
171
+ export { TOKEN_FIELDS }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,8 +8,16 @@
8
8
  },
9
9
  "homepage": "https://github.com/ParticleLight/dsh-all-usage#readme",
10
10
  "type": "module",
11
+ "engines": {
12
+ "node": ">=22 <25"
13
+ },
11
14
  "scripts": {
12
- "test": "node --test"
15
+ "build:client": "node scripts/build-client.mjs",
16
+ "test": "node --test",
17
+ "fixture:check": "node scripts/replay-fixture.mjs fixtures/usage-events.json",
18
+ "test:runtime": "node --test test/dsh-runtime.test.js",
19
+ "check:pack": "node scripts/check-package.mjs",
20
+ "prepack": "npm run build:client"
13
21
  },
14
22
  "main": "lib/index.js",
15
23
  "exports": {
@@ -22,6 +30,10 @@
22
30
  "./package.json": "./package.json"
23
31
  },
24
32
  "dsh": {
33
+ "compatibility": {
34
+ "runtime": ">=0.1.1-rc.1 <0.1.2",
35
+ "verified": ["0.1.1-rc.2", "0.1.1-rc.1"]
36
+ },
25
37
  "bundle": {
26
38
  "patch": "./cordis.patch.yml"
27
39
  },
@@ -36,11 +48,21 @@
36
48
  },
37
49
  "files": [
38
50
  "lib/index.js",
51
+ "lib/plugin.js",
52
+ "lib/aggregation.js",
53
+ "lib/ledger.js",
54
+ "lib/session-sync.js",
55
+ "lib/pricing-runtime.js",
56
+ "lib/balance.js",
57
+ "lib/http.js",
39
58
  "lib/client.js",
40
59
  "lib/pricing.js",
60
+ "lib/usage-core.js",
41
61
  "CHANGELOG.md",
42
62
  "cordis.patch.yml",
43
63
  "screenshots.json",
64
+ "fixtures/usage-events.json",
65
+ "scripts/replay-fixture.mjs",
44
66
  "assets"
45
67
  ],
46
68
  "keywords": [
@@ -53,5 +75,8 @@
53
75
  "tokens",
54
76
  "balance"
55
77
  ],
56
- "license": "MIT"
78
+ "license": "MIT",
79
+ "devDependencies": {
80
+ "terser": "5.51.2"
81
+ }
57
82
  }
@@ -0,0 +1,155 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { apply } from '../lib/index.js'
6
+
7
+ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
8
+
9
+ function makeResponse() {
10
+ let body = ''
11
+ const headers = {}
12
+ return {
13
+ res: {
14
+ statusCode: 200,
15
+ setHeader(name, value) {
16
+ headers[String(name).toLowerCase()] = value
17
+ },
18
+ end(value = '') {
19
+ body += String(value)
20
+ },
21
+ },
22
+ body: () => body,
23
+ }
24
+ }
25
+
26
+ function makeRequest(url) {
27
+ return {
28
+ method: 'GET',
29
+ url,
30
+ headers: { host: '127.0.0.1:3080' },
31
+ socket: { remoteAddress: '127.0.0.1' },
32
+ on(event, callback) {
33
+ if (event === 'end') callback()
34
+ return this
35
+ },
36
+ }
37
+ }
38
+
39
+ function createHarness(fixture) {
40
+ const routes = new Map()
41
+ const listeners = new Map()
42
+ const cleanups = []
43
+ const webServer = {
44
+ register(route) {
45
+ routes.set(route.path, route.handler)
46
+ return () => routes.delete(route.path)
47
+ },
48
+ }
49
+ const ctx = {
50
+ sessionQuery: {
51
+ async listSessions() {
52
+ return fixture.sessions
53
+ },
54
+ async readSession(sessionId) {
55
+ return { events: fixture.events[sessionId] || [] }
56
+ },
57
+ },
58
+ workspaceRegistry: {
59
+ list() {
60
+ return fixture.workspaces
61
+ },
62
+ },
63
+ async timeout() {},
64
+ on(name, handler) {
65
+ const current = listeners.get(name) || []
66
+ current.push(handler)
67
+ listeners.set(name, current)
68
+ },
69
+ effect(factory) {
70
+ const cleanup = factory()
71
+ if (typeof cleanup === 'function') cleanups.push(cleanup)
72
+ return cleanup
73
+ },
74
+ get(name) {
75
+ return name === 'webServer' ? webServer : undefined
76
+ },
77
+ }
78
+ return { ctx, routes, cleanups, listeners }
79
+ }
80
+
81
+ async function invoke(harness, url) {
82
+ const pathname = new URL(url || '/', 'http://fixture.local').pathname
83
+ const handler = harness.routes.get(pathname)
84
+ assert.equal(typeof handler, 'function', 'fixture route should be registered: ' + pathname)
85
+ const response = makeResponse()
86
+ await handler(makeRequest(url), response.res)
87
+ return { status: response.res.statusCode, body: JSON.parse(response.body()) }
88
+ }
89
+
90
+ function projectModel(row) {
91
+ return {
92
+ provider: row.provider,
93
+ requestedModel: row.requestedModel,
94
+ actualModel: row.actualModel,
95
+ calls: row.calls,
96
+ input: row.input,
97
+ output: row.output,
98
+ cacheRead: row.cacheRead,
99
+ cacheWrite: row.cacheWrite,
100
+ reasoning: row.reasoning,
101
+ }
102
+ }
103
+
104
+ function assertExpected(fixture, snapshot, records) {
105
+ const expected = fixture.expected
106
+ const totals = snapshot.totals
107
+ assert.deepEqual({
108
+ turns: totals.turns,
109
+ sessions: totals.sessions,
110
+ input: totals.input,
111
+ output: totals.output,
112
+ cacheRead: totals.cacheRead,
113
+ cacheWrite: totals.cacheWrite,
114
+ reasoning: totals.reasoning,
115
+ }, expected.totals)
116
+ assert.equal(records.items.length, expected.records)
117
+ assert.deepEqual(snapshot.perModel.map(projectModel).sort((left, right) => String(left.provider).localeCompare(String(right.provider))), expected.models)
118
+ assert.equal(totals.cost.unpricedCalls + totals.cost.ambiguousCalls + totals.cost.unsupportedCalls + totals.cost.pricedCalls, expected.records)
119
+ }
120
+
121
+ export async function runFixture(fixture) {
122
+ assert.equal(fixture.schemaVersion, 1)
123
+ const harness = createHarness(fixture)
124
+ apply(harness.ctx)
125
+ try {
126
+ let snapshot = null
127
+ for (let attempt = 0; attempt < 200; attempt += 1) {
128
+ snapshot = await invoke(harness, '/api/all-usage')
129
+ if (snapshot.status === 200 && snapshot.body.scan.done === true) break
130
+ await new Promise((resolvePromise) => setImmediate(resolvePromise))
131
+ }
132
+ assert.ok(snapshot && snapshot.body && snapshot.body.scan.done === true, 'fixture scan did not complete')
133
+ const records = await invoke(harness, '/api/all-usage/records?start=' + fixture.query.start + '&end=' + fixture.query.end + '&utc=' + fixture.query.utc + '&limit=200')
134
+ assert.equal(records.status, 200)
135
+ assertExpected(fixture, snapshot.body, records.body)
136
+ return {
137
+ fixture: fixture.name,
138
+ totals: snapshot.body.totals,
139
+ models: snapshot.body.perModel.map(projectModel),
140
+ records: records.body.items.length,
141
+ }
142
+ } finally {
143
+ for (const cleanup of harness.cleanups.slice().reverse()) await cleanup()
144
+ }
145
+ }
146
+
147
+ async function main() {
148
+ const fixturePath = process.argv[2] === undefined ? join(PACKAGE_ROOT, 'fixtures', 'usage-events.json') : resolve(process.argv[2])
149
+ const fixture = JSON.parse(await readFile(fixturePath, 'utf8'))
150
+ const result = await runFixture(fixture)
151
+ console.log(JSON.stringify(result, null, 2))
152
+ }
153
+
154
+ const invokedPath = process.argv[1] === undefined ? null : pathToFileURL(resolve(process.argv[1])).href
155
+ if (invokedPath === import.meta.url) await main()