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/CHANGELOG.md +15 -0
- package/PROTOCOL.md +28 -3
- package/README.en.md +8 -6
- package/README.md +14 -7
- package/client.js +523 -115
- package/index.js +44 -1
- package/lib/providers.mjs +164 -4
- 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
|
@@ -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
|
|
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: '
|
|
239
|
+
kind: 'windows',
|
|
240
240
|
label: 'Open Design',
|
|
241
241
|
shortLabel: 'OD',
|
|
242
|
-
baseURL: 'https://api.open-design.ai',
|
|
243
|
-
|
|
244
|
-
|
|
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 }) {
|
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.0
|
|
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",
|