dsh-all-usage 1.1.2 → 1.1.3
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 +66 -0
- package/README.md +193 -19
- package/fixtures/usage-events.json +172 -0
- package/lib/aggregation.js +1002 -0
- package/lib/balance.js +112 -0
- package/lib/client.js +1 -2906
- package/lib/http.js +305 -0
- package/lib/index.js +2 -2119
- package/lib/ledger.js +464 -0
- package/lib/plugin.js +276 -0
- package/lib/pricing-runtime.js +282 -0
- package/lib/pricing.js +299 -36
- package/lib/session-sync.js +589 -0
- package/lib/usage-core.js +127 -0
- package/package.json +28 -3
- package/scripts/replay-fixture.mjs +155 -0
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
/** Replace one logical sample; stale lower-seq replays are ignored. */
|
|
111
|
+
/** Event sequence contract: -1 (missing) or a non-negative safe integer. */
|
|
112
|
+
export function normalizeEventSeq(value) {
|
|
113
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : -1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function upsertUsageSample(samples, sample) {
|
|
117
|
+
if (!(samples instanceof Map) || !isRecord(sample) || typeof sample.key !== 'string' || sample.key === '') return { accepted: false, reason: 'invalid-sample', previous: undefined, next: undefined }
|
|
118
|
+
const previous = samples.get(sample.key)
|
|
119
|
+
const seq = normalizeEventSeq(sample.seq)
|
|
120
|
+
const previousSeq = normalizeEventSeq(previous === undefined ? -1 : previous.seq)
|
|
121
|
+
if (previous !== undefined && seq >= 0 && previousSeq > seq) return { accepted: false, reason: 'stale-sample', key: sample.key, previous, next: previous }
|
|
122
|
+
const next = { ...sample, seq }
|
|
123
|
+
samples.set(sample.key, next)
|
|
124
|
+
return { accepted: true, replaced: previous !== undefined, key: sample.key, previous, next }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { TOKEN_FIELDS }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-all-usage",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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
|
-
"
|
|
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()
|