dsh-tokenlens 1.1.0 → 1.2.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 +15 -0
- package/PROTOCOL.md +25 -7
- package/README.en.md +5 -5
- package/README.md +6 -6
- package/client.js +568 -126
- package/index.js +10 -1
- package/lib/provider-store.mjs +7 -0
- package/lib/usage-jsonl.mjs +9 -6
- package/lib/usage-opencode.mjs +10 -4
- package/lib/usage-stats.mjs +108 -25
- package/lib/usage-zcode.mjs +23 -8
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { createUsageStats } from './lib/usage-stats.mjs'
|
|
|
25
25
|
import {
|
|
26
26
|
DEFAULT_SETTINGS,
|
|
27
27
|
DETAILS_DEFAULTS,
|
|
28
|
+
DETAILS_WINDOWS,
|
|
28
29
|
DISPLAY_VIEWS,
|
|
29
30
|
LABEL_STYLES,
|
|
30
31
|
STATS_SCOPES,
|
|
@@ -71,7 +72,7 @@ export const inject = []
|
|
|
71
72
|
export const PLUGIN_NAME = 'dsh-tokenlens'
|
|
72
73
|
|
|
73
74
|
/** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
|
|
74
|
-
export const PLUGIN_VERSION = '1.1
|
|
75
|
+
export const PLUGIN_VERSION = '1.2.1'
|
|
75
76
|
|
|
76
77
|
/** Documented defaults; every field of the raw row config is optional. */
|
|
77
78
|
const DEFAULTS = {
|
|
@@ -1655,6 +1656,14 @@ export function apply(ctx, rawConfig) {
|
|
|
1655
1656
|
}
|
|
1656
1657
|
next.detailsExpanded = incoming.detailsExpanded
|
|
1657
1658
|
}
|
|
1659
|
+
if (Object.prototype.hasOwnProperty.call(incoming, 'detailsWindow')) {
|
|
1660
|
+
if (!DETAILS_WINDOWS.includes(incoming.detailsWindow)) {
|
|
1661
|
+
throw new UsageError('unsupported-provider', `settings.detailsWindow must be one of ${DETAILS_WINDOWS.join(', ')}`, {
|
|
1662
|
+
retryable: false,
|
|
1663
|
+
})
|
|
1664
|
+
}
|
|
1665
|
+
next.detailsWindow = incoming.detailsWindow
|
|
1666
|
+
}
|
|
1658
1667
|
providerStore = saveProviderStore(providerStoreFile, {
|
|
1659
1668
|
...providerStore,
|
|
1660
1669
|
settings: next,
|
package/lib/provider-store.mjs
CHANGED
|
@@ -29,6 +29,8 @@ export const LABEL_STYLES = ['short', 'full']
|
|
|
29
29
|
export const DISPLAY_VIEWS = ['chart', 'data']
|
|
30
30
|
export const STATS_SCOPES = ['provider', 'all']
|
|
31
31
|
export const DETAILS_DEFAULTS = ['auto', 'expanded', 'collapsed']
|
|
32
|
+
/** The 明细 donut windows of the chart mode (v1.2.1): 今日 / 最近7天 / 最近X天 / 总计. */
|
|
33
|
+
export const DETAILS_WINDOWS = ['today', 'last7Days', 'second', 'total']
|
|
32
34
|
|
|
33
35
|
/** Default display settings (management 插件设置 tab). */
|
|
34
36
|
export const DEFAULT_SETTINGS = {
|
|
@@ -36,6 +38,8 @@ export const DEFAULT_SETTINGS = {
|
|
|
36
38
|
defaultView: 'chart',
|
|
37
39
|
defaultScope: 'provider',
|
|
38
40
|
detailsExpanded: 'auto',
|
|
41
|
+
// v1.2.1 图表明细窗口默认值: 总计 (the last choice is persisted there).
|
|
42
|
+
detailsWindow: 'total',
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/** The default override file: `$DSH_HOME/dsh-tokenlens/providers.json`. */
|
|
@@ -119,6 +123,9 @@ export function sanitizeSettings(value) {
|
|
|
119
123
|
detailsExpanded: DETAILS_DEFAULTS.includes(source.detailsExpanded)
|
|
120
124
|
? source.detailsExpanded
|
|
121
125
|
: DEFAULT_SETTINGS.detailsExpanded,
|
|
126
|
+
detailsWindow: DETAILS_WINDOWS.includes(source.detailsWindow)
|
|
127
|
+
? source.detailsWindow
|
|
128
|
+
: DEFAULT_SETTINGS.detailsWindow,
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
|
package/lib/usage-jsonl.mjs
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
34
34
|
import { join } from 'node:path'
|
|
35
35
|
import { homedir } from 'node:os'
|
|
36
|
-
import { localDayKey } from './usage-zcode.mjs'
|
|
36
|
+
import { localDayKey, localHour } from './usage-zcode.mjs'
|
|
37
37
|
|
|
38
38
|
const num = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0)
|
|
39
39
|
|
|
@@ -81,11 +81,12 @@ export function jsonlTreeSignature(root) {
|
|
|
81
81
|
.join('|')
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/** Merge one row into a day
|
|
85
|
-
function addGroup(groups, { day, providerId, modelId, input, output, cacheRead, cacheWrite, total }) {
|
|
86
|
-
const key = `${day}\u0000${providerId}\u0000${modelId}`
|
|
84
|
+
/** Merge one row into a (day, hour, provider, model) group map. */
|
|
85
|
+
function addGroup(groups, { day, hour, providerId, modelId, input, output, cacheRead, cacheWrite, total }) {
|
|
86
|
+
const key = `${day}\u0000${hour}\u0000${providerId}\u0000${modelId}`
|
|
87
87
|
const group = groups.get(key) ?? {
|
|
88
88
|
day,
|
|
89
|
+
hour,
|
|
89
90
|
providerId,
|
|
90
91
|
modelId,
|
|
91
92
|
bucket: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, messages: 0 },
|
|
@@ -171,6 +172,7 @@ export function readWorkbuddyUsage(input = {}) {
|
|
|
171
172
|
const model = String(providerData.model ?? providerData.requestModelId ?? 'unknown')
|
|
172
173
|
addGroup(groups, {
|
|
173
174
|
day: localDayKey(time),
|
|
175
|
+
hour: localHour(time),
|
|
174
176
|
providerId: 'workbuddy',
|
|
175
177
|
modelId: model,
|
|
176
178
|
// prompt_tokens includes the cached prefix.
|
|
@@ -181,7 +183,7 @@ export function readWorkbuddyUsage(input = {}) {
|
|
|
181
183
|
total,
|
|
182
184
|
})
|
|
183
185
|
})
|
|
184
|
-
const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day))
|
|
186
|
+
const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day) || a.hour - b.hour)
|
|
185
187
|
return { harness: 'workbuddy', label: 'WorkBuddy', root, groups: list, rows: list.length, requests, skippedZero, durationMs: Date.now() - started }
|
|
186
188
|
}
|
|
187
189
|
|
|
@@ -263,6 +265,7 @@ export function readQoderUsage(input = {}) {
|
|
|
263
265
|
const modelId = custom !== null ? rawModel : rawModel
|
|
264
266
|
addGroup(groups, {
|
|
265
267
|
day: localDayKey(time),
|
|
268
|
+
hour: localHour(time),
|
|
266
269
|
providerId,
|
|
267
270
|
modelId,
|
|
268
271
|
// Qoder's input_tokens include the cached prompt.
|
|
@@ -273,6 +276,6 @@ export function readQoderUsage(input = {}) {
|
|
|
273
276
|
total,
|
|
274
277
|
})
|
|
275
278
|
})
|
|
276
|
-
const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day))
|
|
279
|
+
const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day) || a.hour - b.hour)
|
|
277
280
|
return { harness: 'qoder', label: 'Qoder', root, groups: list, rows: list.length, requests, durationMs: Date.now() - started }
|
|
278
281
|
}
|
package/lib/usage-opencode.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { statSync } from 'node:fs'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
import { homedir } from 'node:os'
|
|
27
|
-
import { localDayKey } from './usage-zcode.mjs'
|
|
27
|
+
import { localDayKey, localHour } from './usage-zcode.mjs'
|
|
28
28
|
|
|
29
29
|
/** The default OpenCode CLI database location. */
|
|
30
30
|
export function defaultOpencodeDbPath(env = process.env, home = homedir()) {
|
|
@@ -52,7 +52,7 @@ export function opencodeDbSignature(dbPath) {
|
|
|
52
52
|
*
|
|
53
53
|
* @param input.dbPath - database file (read-only).
|
|
54
54
|
* @param input.openDatabase - test seam: `(path) => DatabaseSync`-like handle.
|
|
55
|
-
* @returns `{ groups: [{day, providerId, modelId, bucket}], rows, durationMs, dbPath }`.
|
|
55
|
+
* @returns `{ groups: [{day, hour, providerId, modelId, bucket}], rows, durationMs, dbPath }`.
|
|
56
56
|
*/
|
|
57
57
|
export function readOpencodeUsage(input = {}) {
|
|
58
58
|
const dbPath =
|
|
@@ -91,12 +91,16 @@ export function readOpencodeUsage(input = {}) {
|
|
|
91
91
|
const time = typeof row.timeCreated === 'number' ? row.timeCreated : null
|
|
92
92
|
if (time === null) continue
|
|
93
93
|
const day = localDayKey(time)
|
|
94
|
+
// Local clock hour: the chart's hourly drill-down bucket (DST fold hours
|
|
95
|
+
// collapse into one slot, like `getHours()`).
|
|
96
|
+
const hour = localHour(time)
|
|
94
97
|
const providerId =
|
|
95
98
|
typeof message.providerID === 'string' && message.providerID !== '' ? message.providerID : 'unknown'
|
|
96
99
|
const modelId = typeof message.modelID === 'string' && message.modelID !== '' ? message.modelID : 'unknown'
|
|
97
|
-
const key = `${day}\u0000${providerId}\u0000${modelId}`
|
|
100
|
+
const key = `${day}\u0000${hour}\u0000${providerId}\u0000${modelId}`
|
|
98
101
|
const group = groups.get(key) ?? {
|
|
99
102
|
day,
|
|
103
|
+
hour,
|
|
100
104
|
providerId,
|
|
101
105
|
modelId,
|
|
102
106
|
bucket: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, messages: 0 },
|
|
@@ -110,7 +114,9 @@ export function readOpencodeUsage(input = {}) {
|
|
|
110
114
|
group.bucket.messages += 1
|
|
111
115
|
groups.set(key, group)
|
|
112
116
|
}
|
|
113
|
-
|
|
117
|
+
// Day-ascending then hour-ascending keeps the groups deterministic for tests
|
|
118
|
+
// and for the daily merge.
|
|
119
|
+
const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day) || a.hour - b.hour)
|
|
114
120
|
return {
|
|
115
121
|
harness: 'opencode',
|
|
116
122
|
label: 'OpenCode',
|
package/lib/usage-stats.mjs
CHANGED
|
@@ -200,6 +200,34 @@ export function cloneBucket(from) {
|
|
|
200
200
|
return addBucket(emptyBucket(), from)
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** An all-zero 24-slot local-hour token series. */
|
|
204
|
+
export function emptyHourSeries() {
|
|
205
|
+
return new Array(24).fill(0)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Add one local-hour token amount into a 24-slot series; invalid slots are ignored. */
|
|
209
|
+
export function addHourTokens(series, hour, tokens) {
|
|
210
|
+
if (!Array.isArray(series) || !Number.isInteger(hour) || hour < 0 || hour > 23) return series
|
|
211
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) return series
|
|
212
|
+
series[hour] += tokens
|
|
213
|
+
return series
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Accumulate one 24-slot series onto another (in place); false when `from` is not one. */
|
|
217
|
+
export function addHourSeries(target, from) {
|
|
218
|
+
if (!Array.isArray(target) || !Array.isArray(from)) return false
|
|
219
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
220
|
+
const value = from[hour]
|
|
221
|
+
if (typeof value === 'number' && Number.isFinite(value)) target[hour] += value
|
|
222
|
+
}
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A 24-slot series as wire integers (hourly token bars are rounded). */
|
|
227
|
+
export function roundHourSeries(series) {
|
|
228
|
+
return series.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0))
|
|
229
|
+
}
|
|
230
|
+
|
|
203
231
|
/** Fold one decoded session into per-day / per-provider / per-model buckets. */
|
|
204
232
|
export function foldSession(records) {
|
|
205
233
|
const days = Object.create(null)
|
|
@@ -477,8 +505,8 @@ export function createUsageStats(input) {
|
|
|
477
505
|
const daysAggregate = Object.create(null)
|
|
478
506
|
const providersAggregate = Object.create(null)
|
|
479
507
|
const modelsAggregate = Object.create(null)
|
|
480
|
-
|
|
481
|
-
|
|
508
|
+
const hoursAggregate = Object.create(null)
|
|
509
|
+
const workspacesAggregate = Object.create(null)
|
|
482
510
|
for (const entry of Object.values(cache.files)) {
|
|
483
511
|
for (const [day, dayBucket] of Object.entries(entry.days ?? {})) {
|
|
484
512
|
const target = (daysAggregate[day] ??= { providers: Object.create(null) })
|
|
@@ -516,6 +544,23 @@ export function createUsageStats(input) {
|
|
|
516
544
|
}
|
|
517
545
|
}
|
|
518
546
|
}
|
|
547
|
+
// Local-hour series per day, folded from the epoch-hour buckets the session
|
|
548
|
+
// folds already carry: the chart mode drills into one day's 24 bars. Values
|
|
549
|
+
// stay raw here; `daily` rounds them once at the wire boundary.
|
|
550
|
+
const dayHoursAggregate = Object.create(null)
|
|
551
|
+
for (const [epochHour, bucket] of Object.entries(hoursAggregate)) {
|
|
552
|
+
const hourStart = Number(epochHour) * 3600_000
|
|
553
|
+
const date = new Date(hourStart)
|
|
554
|
+
if (!Number.isFinite(date.getTime())) continue
|
|
555
|
+
const slot = date.getHours()
|
|
556
|
+
const dayEntry = (dayHoursAggregate[dayKey(hourStart)] ??= { total: emptyHourSeries(), providers: Object.create(null) })
|
|
557
|
+
for (const [provider, providerBucket] of Object.entries(bucket.providers ?? {})) {
|
|
558
|
+
const tokens = providerBucket?.total
|
|
559
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) continue
|
|
560
|
+
dayEntry.total[slot] += tokens
|
|
561
|
+
addHourTokens((dayEntry.providers[provider] ??= emptyHourSeries()), slot, tokens)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
519
564
|
|
|
520
565
|
const allTime = emptyBucket()
|
|
521
566
|
for (const bucket of Object.values(providersAggregate)) addBucket(allTime, bucket)
|
|
@@ -627,7 +672,10 @@ export function createUsageStats(input) {
|
|
|
627
672
|
})
|
|
628
673
|
.sort((a, b) => b.allTime.total - a.allTime.total)
|
|
629
674
|
|
|
630
|
-
// Compact daily series (bounded) for the client chart view.
|
|
675
|
+
// Compact daily series (bounded) for the client chart view. `hours` is only
|
|
676
|
+
// present for a day with real local-hour data: a very old session's hour
|
|
677
|
+
// buckets are pruned by the fold, and a non-drillable day must not pretend
|
|
678
|
+
// to have an all-zero hour series.
|
|
631
679
|
const daily = Object.entries(daysAggregate)
|
|
632
680
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
633
681
|
.slice(-120)
|
|
@@ -638,7 +686,19 @@ export function createUsageStats(input) {
|
|
|
638
686
|
providers[providerId] = { total: providerBucket.total ?? 0, messages: providerBucket.messages ?? 0 }
|
|
639
687
|
addBucket(totals, providerBucket)
|
|
640
688
|
}
|
|
641
|
-
|
|
689
|
+
const dayHours = dayHoursAggregate[day]
|
|
690
|
+
if (dayHours !== undefined) {
|
|
691
|
+
for (const [providerId, providerHours] of Object.entries(dayHours.providers)) {
|
|
692
|
+
// Only a provider already present in the day bucket gets a series.
|
|
693
|
+
if (providers[providerId] !== undefined) providers[providerId].hours = roundHourSeries(providerHours)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
day,
|
|
698
|
+
total: { total: totals.total, messages: totals.messages },
|
|
699
|
+
...(dayHours === undefined ? {} : { hours: roundHourSeries(dayHours.total) }),
|
|
700
|
+
providers,
|
|
701
|
+
}
|
|
642
702
|
})
|
|
643
703
|
|
|
644
704
|
return {
|
|
@@ -789,7 +849,7 @@ export function createUsageStats(input) {
|
|
|
789
849
|
try {
|
|
790
850
|
const value = readZcodeUsage({ dbPath: zcodeDbPath })
|
|
791
851
|
zcodeMemo = { sig, at: now, value, error: null }
|
|
792
|
-
log.info(`ZCode usage: ${value.rows} grouped day/provider/model rows in ${value.durationMs} ms`)
|
|
852
|
+
log.info(`ZCode usage: ${value.rows} grouped day/hour/provider/model rows in ${value.durationMs} ms`)
|
|
793
853
|
return { enabled: true, dbPath: zcodeDbPath, value, error: null, cached: false }
|
|
794
854
|
} catch (error) {
|
|
795
855
|
const wire = {
|
|
@@ -921,7 +981,7 @@ export function createUsageStats(input) {
|
|
|
921
981
|
try {
|
|
922
982
|
const value = def.read()
|
|
923
983
|
def.setMemo({ sig, at: now, value, error: null })
|
|
924
|
-
log.info(def.label + ' usage: ' + value.rows + ' grouped day/provider/model rows in ' + value.durationMs + ' ms')
|
|
984
|
+
log.info(def.label + ' usage: ' + value.rows + ' grouped day/hour/provider/model rows in ' + value.durationMs + ' ms')
|
|
925
985
|
return { enabled: true, dbPath: def.dbPath, value, error: null, cached: false, installed: true }
|
|
926
986
|
} catch (error) {
|
|
927
987
|
const wire = {
|
|
@@ -1142,15 +1202,26 @@ export function createUsageStats(input) {
|
|
|
1142
1202
|
for (const entry of unmapped) unmappedRows.push({ harnessId: def.id, harnessLabel: def.label, ...entry })
|
|
1143
1203
|
}
|
|
1144
1204
|
|
|
1145
|
-
// Daily series (last 120 days) for the chart view.
|
|
1205
|
+
// Daily series (last 120 days) for the chart view. Each day keeps a bucket
|
|
1206
|
+
// per canonical provider plus a 24-slot local-hour series; `hours` is only
|
|
1207
|
+
// emitted for a day where at least one harness reported an hour, so the
|
|
1208
|
+
// client never has to invent an all-zero drill-down.
|
|
1146
1209
|
const dailyMap = new Map()
|
|
1147
1210
|
for (const entry of dsh.daily ?? []) {
|
|
1148
|
-
const dayEntry = dailyMap.get(entry.day) ?? { total: emptyBucket(), providers: new Map() }
|
|
1211
|
+
const dayEntry = dailyMap.get(entry.day) ?? { total: emptyBucket(), providers: new Map(), hours: null }
|
|
1149
1212
|
addBucket(dayEntry.total, entry.total)
|
|
1213
|
+
if (Array.isArray(entry.hours)) {
|
|
1214
|
+
dayEntry.hours ??= emptyHourSeries()
|
|
1215
|
+
addHourSeries(dayEntry.hours, entry.hours)
|
|
1216
|
+
}
|
|
1150
1217
|
for (const [providerId, totals] of Object.entries(entry.providers ?? {})) {
|
|
1151
|
-
const
|
|
1152
|
-
addBucket(bucket, totals)
|
|
1153
|
-
|
|
1218
|
+
const slot = dayEntry.providers.get(providerId) ?? { bucket: emptyBucket(), hours: null }
|
|
1219
|
+
addBucket(slot.bucket, totals)
|
|
1220
|
+
if (Array.isArray(totals?.hours)) {
|
|
1221
|
+
slot.hours ??= emptyHourSeries()
|
|
1222
|
+
addHourSeries(slot.hours, totals.hours)
|
|
1223
|
+
}
|
|
1224
|
+
dayEntry.providers.set(providerId, slot)
|
|
1154
1225
|
}
|
|
1155
1226
|
dailyMap.set(entry.day, dayEntry)
|
|
1156
1227
|
}
|
|
@@ -1160,28 +1231,40 @@ export function createUsageStats(input) {
|
|
|
1160
1231
|
if (slice.value === null) continue
|
|
1161
1232
|
for (const group of slice.value.groups) {
|
|
1162
1233
|
const mapped = mapHarnessProvider(group.providerId, group.modelId, def.aliases, catalog)
|
|
1234
|
+
// Unmapped usage stays out of `daily` (it never had a provider row);
|
|
1235
|
+
// its tokens only feed the all-harness buckets.
|
|
1163
1236
|
if (mapped === null) continue
|
|
1164
|
-
const dayEntry = dailyMap.get(group.day) ?? { total: emptyBucket(), providers: new Map() }
|
|
1237
|
+
const dayEntry = dailyMap.get(group.day) ?? { total: emptyBucket(), providers: new Map(), hours: null }
|
|
1165
1238
|
addBucket(dayEntry.total, group.bucket)
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1239
|
+
if (Number.isInteger(group.hour)) {
|
|
1240
|
+
addHourTokens((dayEntry.hours ??= emptyHourSeries()), group.hour, group.bucket.total)
|
|
1241
|
+
}
|
|
1242
|
+
const slot = dayEntry.providers.get(mapped.providerId) ?? { bucket: emptyBucket(), hours: null }
|
|
1243
|
+
addBucket(slot.bucket, group.bucket)
|
|
1244
|
+
if (Number.isInteger(group.hour)) {
|
|
1245
|
+
addHourTokens((slot.hours ??= emptyHourSeries()), group.hour, group.bucket.total)
|
|
1246
|
+
}
|
|
1247
|
+
dayEntry.providers.set(mapped.providerId, slot)
|
|
1169
1248
|
dailyMap.set(group.day, dayEntry)
|
|
1170
1249
|
}
|
|
1171
1250
|
}
|
|
1172
1251
|
const daily = [...dailyMap.entries()]
|
|
1173
1252
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
1174
1253
|
.slice(-120)
|
|
1175
|
-
.map(([day, entry]) =>
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1254
|
+
.map(([day, entry]) => {
|
|
1255
|
+
const providers = Object.create(null)
|
|
1256
|
+
for (const [providerId, slot] of entry.providers) {
|
|
1257
|
+
const row = { total: slot.bucket.total, messages: slot.bucket.messages }
|
|
1258
|
+
if (slot.hours !== null) row.hours = roundHourSeries(slot.hours)
|
|
1259
|
+
providers[providerId] = row
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
day,
|
|
1263
|
+
total: { total: entry.total.total, messages: entry.total.messages },
|
|
1264
|
+
...(entry.hours === null ? {} : { hours: roundHourSeries(entry.hours) }),
|
|
1265
|
+
providers,
|
|
1266
|
+
}
|
|
1267
|
+
})
|
|
1185
1268
|
|
|
1186
1269
|
// Earliest record per provider across every contributing harness. This is
|
|
1187
1270
|
// the local "effective date" used for the 最近X天 provider window.
|
package/lib/usage-zcode.mjs
CHANGED
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
* cacheWrite = cache_creation_input_tokens
|
|
21
21
|
* total = computed_total_tokens (= input + output, cache included)
|
|
22
22
|
*
|
|
23
|
-
* Only token counters, provider/model ids and the completion
|
|
23
|
+
* Only token counters, provider/model ids and the completion time are read: no
|
|
24
24
|
* prompt or message content. The database is opened read-only.
|
|
25
25
|
*
|
|
26
|
-
* The result is a flat `groups` list (local day +
|
|
27
|
-
* bucket). Mapping a ZCode provider onto a canonical DSH provider
|
|
28
|
-
* `opencode-go-chat` -> `opencode-go`) is the caller's job, because the
|
|
29
|
-
* needs the Harness model catalog.
|
|
26
|
+
* The result is a flat `groups` list (local day + local hour + ZCode provider id
|
|
27
|
+
* + model id + bucket). Mapping a ZCode provider onto a canonical DSH provider
|
|
28
|
+
* (e.g. `opencode-go-chat` -> `opencode-go`) is the caller's job, because the
|
|
29
|
+
* mapping needs the Harness model catalog.
|
|
30
30
|
*/
|
|
31
31
|
import { statSync } from 'node:fs'
|
|
32
32
|
import { join } from 'node:path'
|
|
@@ -73,12 +73,23 @@ export function localDayKey(ms) {
|
|
|
73
73
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The local clock hour (0..23) of an epoch-ms instant.
|
|
78
|
+
*
|
|
79
|
+
* A DST fall-back day repeats one wall-clock hour, so both instants collapse
|
|
80
|
+
* into the same slot — which is exactly how the chart's 24 bars are defined.
|
|
81
|
+
*/
|
|
82
|
+
export function localHour(ms) {
|
|
83
|
+
const hour = new Date(ms).getHours()
|
|
84
|
+
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 0
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
/**
|
|
77
88
|
* Read ZCode usage groups.
|
|
78
89
|
*
|
|
79
90
|
* @param input.dbPath - database file (read-only).
|
|
80
91
|
* @param input.openDatabase - test seam: `(path) => DatabaseSync`-like handle.
|
|
81
|
-
* @returns `{ groups: [{day, providerId, modelId, bucket}], rows, durationMs, dbPath }`.
|
|
92
|
+
* @returns `{ groups: [{day, hour, providerId, modelId, bucket}], rows, durationMs, dbPath }`.
|
|
82
93
|
*/
|
|
83
94
|
export function readZcodeUsage(input = {}) {
|
|
84
95
|
const dbPath = typeof input.dbPath === 'string' && input.dbPath !== '' ? input.dbPath : defaultZcodeDbPath()
|
|
@@ -89,6 +100,7 @@ export function readZcodeUsage(input = {}) {
|
|
|
89
100
|
const rows = db
|
|
90
101
|
.prepare(
|
|
91
102
|
`SELECT date(completed_at / 1000, 'unixepoch', 'localtime') AS day,
|
|
103
|
+
CAST(strftime('%H', completed_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour,
|
|
92
104
|
provider_id AS providerId,
|
|
93
105
|
model_id AS modelId,
|
|
94
106
|
COUNT(*) AS messages,
|
|
@@ -99,8 +111,8 @@ export function readZcodeUsage(input = {}) {
|
|
|
99
111
|
SUM(computed_total_tokens) AS total
|
|
100
112
|
FROM model_usage
|
|
101
113
|
WHERE computed_total_tokens > 0 AND completed_at IS NOT NULL
|
|
102
|
-
GROUP BY day, provider_id, model_id
|
|
103
|
-
ORDER BY day`,
|
|
114
|
+
GROUP BY day, hour, provider_id, model_id
|
|
115
|
+
ORDER BY day, hour`,
|
|
104
116
|
)
|
|
105
117
|
.all()
|
|
106
118
|
const groups = rows
|
|
@@ -108,8 +120,11 @@ export function readZcodeUsage(input = {}) {
|
|
|
108
120
|
.map((row) => {
|
|
109
121
|
const input = Math.max(0, Number(row.input ?? 0))
|
|
110
122
|
const cacheRead = Math.max(0, Number(row.cacheRead ?? 0))
|
|
123
|
+
const hour = Number(row.hour)
|
|
111
124
|
return {
|
|
112
125
|
day: row.day,
|
|
126
|
+
// SQLite yields '00'..'23'; keep the wire field an integer.
|
|
127
|
+
hour: Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 0,
|
|
113
128
|
providerId: typeof row.providerId === 'string' && row.providerId !== '' ? row.providerId : 'unknown',
|
|
114
129
|
modelId: typeof row.modelId === 'string' && row.modelId !== '' ? row.modelId : 'unknown',
|
|
115
130
|
bucket: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tokenlens",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
|
|
6
6
|
"license": "MIT",
|