dsh-tokenlens 1.0.9 → 1.2.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/index.js CHANGED
@@ -71,7 +71,7 @@ export const inject = []
71
71
  export const PLUGIN_NAME = 'dsh-tokenlens'
72
72
 
73
73
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
74
- export const PLUGIN_VERSION = '1.0.9'
74
+ export const PLUGIN_VERSION = '1.2.0'
75
75
 
76
76
  /** Documented defaults; every field of the raw row config is optional. */
77
77
  const DEFAULTS = {
@@ -926,6 +926,49 @@ export function apply(ctx, rawConfig) {
926
926
  }
927
927
  provider = { ...provider, accessKeyId: accessValue, secretAccessKey: secretValue }
928
928
  keyInfo = { ref: accessRef, source: access.source, detail: access.detail }
929
+ } else if (adapterId === 'open-design') {
930
+ const workspaceId =
931
+ String(provider.workspaceId ?? '').trim() || String(process.env.OPENDESIGN_WORKSPACE_ID ?? '').trim()
932
+ const refs = []
933
+ for (const ref of [provider.controlKeyRef, 'OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY']) {
934
+ if (typeof ref === 'string' && ref.trim() !== '' && !refs.includes(ref.trim())) refs.push(ref.trim())
935
+ }
936
+ // The management page's per-provider key field holds the Control Key here
937
+ // (the inference API key cannot query billing).
938
+ let controlKey = typeof provider.apiKey === 'string' ? provider.apiKey.trim() : ''
939
+ let controlRef = refs[0] ?? 'OPENDESIGN_CONTROL_KEY'
940
+ let controlSource = controlKey === '' ? 'none' : 'config'
941
+ let controlDetail = controlKey === '' ? 'no OpenDesign control key found' : 'plugin config'
942
+ if (controlKey === '') {
943
+ for (const ref of refs) {
944
+ const resolved = await resolveApiKey({
945
+ config: { apiKeyRef: ref },
946
+ credentials: ctx.get('credentials'),
947
+ env: process.env,
948
+ })
949
+ const value = typeof resolved.value === 'string' ? resolved.value.trim() : ''
950
+ if (value === '') continue
951
+ controlKey = value
952
+ controlRef = resolved.ref ?? ref
953
+ controlSource = resolved.source
954
+ controlDetail = resolved.detail
955
+ break
956
+ }
957
+ }
958
+ if (workspaceId === '') {
959
+ throw new UsageError(
960
+ 'missing-key',
961
+ 'OpenDesign quota needs a workspace id (providers.open-design.workspaceId or OPENDESIGN_WORKSPACE_ID)',
962
+ { retryable: false },
963
+ )
964
+ }
965
+ if (controlKey === '') {
966
+ throw new UsageError('missing-key', `OpenDesign quota needs a Control Key (${refs.join(' / ')})`, {
967
+ retryable: false,
968
+ })
969
+ }
970
+ provider = { ...provider, workspaceId, controlKey }
971
+ keyInfo = { ref: controlRef, source: controlSource, detail: controlDetail }
929
972
  } else if (typeof provider.apiKeyRef === 'string' && provider.apiKeyRef.trim() !== '') {
930
973
  const key = await resolveApiKey({
931
974
  config: { apiKeyRef: provider.apiKeyRef, apiKey: provider.apiKey ?? '' },
package/lib/providers.mjs CHANGED
@@ -236,12 +236,16 @@ export const PROVIDER_DEFAULTS = {
236
236
  consoleUrl: 'https://agnes.ai',
237
237
  },
238
238
  'open-design': {
239
- kind: 'balance',
239
+ kind: 'windows',
240
240
  label: 'Open Design',
241
241
  shortLabel: 'OD',
242
- baseURL: 'https://api.open-design.ai',
243
- apiKeyRef: 'OPEN_DESIGN_API_KEY',
244
- consoleUrl: 'https://open-design.ai',
242
+ baseURL: 'https://amr-api.open-design.ai',
243
+ // The read-only billing API is scoped to one workspace and authenticated
244
+ // with a Control Key (not the inference API key used for model calls).
245
+ workspaceId: '',
246
+ controlKeyRef: 'OPENDESIGN_CONTROL_KEY',
247
+ apiKeyRef: 'OPENDESIGN_CONTROL_KEY',
248
+ consoleUrl: 'https://open-design.ai/cloud/dashboard',
245
249
  },
246
250
  hunyuan: {
247
251
  kind: 'balance',
@@ -753,6 +757,119 @@ export function parseVolcenginePlan(payload, now = Date.now()) {
753
757
  return { windows, plan: tier === null ? null : { id: tier, label: tier } }
754
758
  }
755
759
 
760
+ /**
761
+ * OpenDesign Design Plan window seconds → the canonical window id.
762
+ *
763
+ * Design Plan allowances are independent simultaneous limits; Go has both a
764
+ * 5-hour and a 7-day window, while Plus/Pro/Max only have the 7-day one. Any
765
+ * other duration is not one of the plugin's canonical windows.
766
+ */
767
+ const OPEN_DESIGN_WINDOW_SECONDS = {
768
+ 18000: 'rolling',
769
+ 604800: 'weekly',
770
+ 2592000: 'monthly',
771
+ }
772
+
773
+ /** A non-negative integer credit amount serialized as a decimal string. */
774
+ function openDesignCredits(value) {
775
+ return typeof value === 'string' && /^\d{1,40}$/.test(value) ? BigInt(value) : null
776
+ }
777
+
778
+ /**
779
+ * Parse the OpenDesign `billing/coding-plan-usage` document.
780
+ *
781
+ * Shape (v1, verified against the official Vela billing adapter):
782
+ * `{ workspaceId, workspaceMemberId?, eligible, windows: [{ policyId,
783
+ * durationSeconds, usedCredits, remainingCredits, limitCredits, resetsAt }] }`
784
+ * where the credits are decimal strings and `resetsAt` is ISO (or null).
785
+ * A window's percentage is `used / limit`, capped at 100%; unknown durations
786
+ * and repeated durations keep only the first canonical window.
787
+ */
788
+ export function parseOpenDesignUsage(payload, workspaceId, now = Date.now()) {
789
+ const document = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : null
790
+ if (document === null) throw new UsageError('parse', 'the OpenDesign quota response is not an object')
791
+ const expected = String(workspaceId ?? '').trim()
792
+ if (typeof document.workspaceId !== 'string' || document.workspaceId !== expected) {
793
+ throw new UsageError('parse', 'the OpenDesign quota response belongs to a different workspace')
794
+ }
795
+ if (typeof document.eligible !== 'boolean') {
796
+ throw new UsageError('parse', 'the OpenDesign quota response has no eligibility flag')
797
+ }
798
+ if (!Array.isArray(document.windows) || document.windows.length > 32) {
799
+ throw new UsageError('parse', 'the OpenDesign quota response has an invalid windows array')
800
+ }
801
+ const inputs = {}
802
+ const seenPolicies = new Set()
803
+ for (const row of document.windows) {
804
+ if (row === null || typeof row !== 'object') {
805
+ throw new UsageError('parse', 'the OpenDesign quota response has a malformed window')
806
+ }
807
+ if (typeof row.policyId !== 'string' || row.policyId === '' || seenPolicies.has(row.policyId)) {
808
+ throw new UsageError('parse', 'the OpenDesign quota response has an empty or duplicate policy id')
809
+ }
810
+ seenPolicies.add(row.policyId)
811
+ if (!Number.isSafeInteger(row.durationSeconds) || row.durationSeconds <= 0) {
812
+ throw new UsageError('parse', 'the OpenDesign quota window has no duration')
813
+ }
814
+ const used = openDesignCredits(row.usedCredits)
815
+ const limit = openDesignCredits(row.limitCredits)
816
+ const remaining = openDesignCredits(row.remainingCredits)
817
+ if (used === null || limit === null || remaining === null) {
818
+ throw new UsageError('parse', 'the OpenDesign quota window has invalid credit amounts')
819
+ }
820
+ const id = OPEN_DESIGN_WINDOW_SECONDS[row.durationSeconds]
821
+ if (id === undefined || inputs[id] !== undefined) continue
822
+ if (limit <= 0n) continue
823
+ const cappedUsed = used > limit ? limit : used
824
+ const percent = (Number(cappedUsed) / Number(limit)) * 100
825
+ inputs[id] = {
826
+ percent,
827
+ status: 'ok',
828
+ resetsAt:
829
+ typeof row.resetsAt === 'string' || typeof row.resetsAt === 'number' ? row.resetsAt : undefined,
830
+ }
831
+ }
832
+ const windows = windowSet(inputs, now)
833
+ const usable = WINDOW_IDS.some((id) => windows[id] !== null)
834
+ if (document.eligible === false) {
835
+ if (usable) throw new UsageError('parse', 'the OpenDesign quota response reports an ineligible plan with windows')
836
+ return { windows: windowSet({}, now), plan: null }
837
+ }
838
+ if (!usable) throw new UsageError('parse', 'the OpenDesign quota response has no usable allowance windows')
839
+ return { windows, plan: null }
840
+ }
841
+
842
+ /**
843
+ * Parse the OpenDesign `wallet/balance` document.
844
+ *
845
+ * Shape (v2): `{ billingScopeVersion: 2, workspaceId, workspaceMemberId,
846
+ * balanceUsd }` where `balanceUsd` is a decimal string (negative = debt).
847
+ */
848
+ export function parseOpenDesignWallet(payload, workspaceId) {
849
+ const document = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : null
850
+ if (document === null) throw new UsageError('parse', 'the OpenDesign wallet response is not an object')
851
+ if (document.billingScopeVersion !== 2) {
852
+ throw new UsageError('parse', 'the OpenDesign wallet response has an unsupported scope version')
853
+ }
854
+ const expected = String(workspaceId ?? '').trim()
855
+ if (typeof document.workspaceId !== 'string' || document.workspaceId !== expected) {
856
+ throw new UsageError('parse', 'the OpenDesign wallet response belongs to a different workspace')
857
+ }
858
+ if (typeof document.workspaceMemberId !== 'string' || document.workspaceMemberId === '') {
859
+ throw new UsageError('parse', 'the OpenDesign wallet response has no member identity')
860
+ }
861
+ const raw = typeof document.balanceUsd === 'string' && /^-?\d+(?:\.\d+)?$/.test(document.balanceUsd)
862
+ ? Number(document.balanceUsd)
863
+ : null
864
+ if (raw === null || !Number.isFinite(raw)) {
865
+ throw new UsageError('parse', 'the OpenDesign wallet response has no usable balance')
866
+ }
867
+ return {
868
+ balance: { amount: raw, currency: 'USD' },
869
+ workspaceMemberId: document.workspaceMemberId,
870
+ }
871
+ }
872
+
756
873
  /**
757
874
  * The provider adapter table. `read()` performs the required network reads and
758
875
  * returns the normalized result; optional reads (subscription/whoami) are
@@ -791,6 +908,49 @@ export const ADAPTERS = {
791
908
  return { kind: 'windows', ...parseVolcenginePlan(payload, now) }
792
909
  },
793
910
  },
911
+ 'open-design': {
912
+ kind: 'windows',
913
+ async read({ providerId = 'open-design', provider, httpJson, now }) {
914
+ const config = providerConfig(providerId, provider)
915
+ const workspaceId = String(config.workspaceId ?? '').trim()
916
+ if (workspaceId === '') {
917
+ throw new UsageError(
918
+ 'missing-key',
919
+ 'OpenDesign quota needs a workspace id (providers.open-design.workspaceId or OPENDESIGN_WORKSPACE_ID)',
920
+ { retryable: false },
921
+ )
922
+ }
923
+ const controlKey = typeof provider.controlKey === 'string' ? provider.controlKey : ''
924
+ if (controlKey === '') {
925
+ throw new UsageError('missing-key', 'OpenDesign quota needs a Control Key (OPENDESIGN_CONTROL_KEY or VELA_CONTROL_KEY)', {
926
+ retryable: false,
927
+ })
928
+ }
929
+ const workspaceBase = joinUrl(config.baseURL, `/api/v1/workspaces/${encodeURIComponent(workspaceId)}`)
930
+ const headers = {
931
+ accept: 'application/json',
932
+ authorization: `Bearer ${controlKey}`,
933
+ 'x-vela-workspace-id': workspaceId,
934
+ }
935
+ const usage = await httpJson(`${workspaceBase}/billing/coding-plan-usage`, { method: 'GET', headers })
936
+ const parsed = parseOpenDesignUsage(usage, workspaceId, now)
937
+ const wallet = await httpJson(`${workspaceBase}/wallet/balance`, { method: 'GET', headers })
938
+ const usageMember =
939
+ usage !== null && typeof usage === 'object' && typeof usage.workspaceMemberId === 'string'
940
+ ? usage.workspaceMemberId
941
+ : undefined
942
+ const walletParsed = parseOpenDesignWallet(wallet, workspaceId)
943
+ if (usageMember !== undefined && usageMember !== walletParsed.workspaceMemberId) {
944
+ throw new UsageError('parse', 'the OpenDesign wallet and quota responses name different workspace members')
945
+ }
946
+ return {
947
+ kind: 'windows',
948
+ windows: parsed.windows,
949
+ plan: parsed.plan,
950
+ balance: walletParsed.balance,
951
+ }
952
+ },
953
+ },
794
954
  'zhipu-cn': {
795
955
  kind: 'windows',
796
956
  async read({ providerId = 'zhipu-cn', provider, fetchJson, now }) {
@@ -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/provider/model group map. */
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
  }
@@ -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
- const list = [...groups.values()].sort((a, b) => a.day.localeCompare(b.day))
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',
@@ -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
- const hoursAggregate = Object.create(null)
481
- const workspacesAggregate = Object.create(null)
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
- return { day, total: { total: totals.total, messages: totals.messages }, providers }
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 bucket = dayEntry.providers.get(providerId) ?? emptyBucket()
1152
- addBucket(bucket, totals)
1153
- dayEntry.providers.set(providerId, bucket)
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
- const bucket = dayEntry.providers.get(mapped.providerId) ?? emptyBucket()
1167
- addBucket(bucket, group.bucket)
1168
- dayEntry.providers.set(mapped.providerId, bucket)
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
- day,
1177
- total: { total: entry.total.total, messages: entry.total.messages },
1178
- providers: Object.fromEntries(
1179
- [...entry.providers.entries()].map(([providerId, bucket]) => [
1180
- providerId,
1181
- { total: bucket.total, messages: bucket.messages },
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.
@@ -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 day are read: no
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 + ZCode provider id + model id +
27
- * bucket). Mapping a ZCode provider onto a canonical DSH provider (e.g.
28
- * `opencode-go-chat` -> `opencode-go`) is the caller's job, because the mapping
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.0.9",
3
+ "version": "1.2.0",
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",