dsh-tacit 0.2.2 → 0.3.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/README.md +24 -3
- package/client/client.js +1822 -108
- package/docs/README.md +3 -3
- package/docs/README.zh.md +7 -6
- package/lib/analyze.js +289 -49
- package/lib/index.js +16 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +15 -2
- package/lib/schema.js +187 -1
- package/lib/service.js +542 -103
- package/lib/store.js +164 -3
- package/lib/usage.js +708 -0
- package/package.json +11 -5
package/lib/usage.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — the usage/cost tracker.
|
|
5
|
+
*
|
|
6
|
+
* Groups metered model calls into *runs* (one bootstrap batch, one
|
|
7
|
+
* auto-analysis, one ✨ Improve, ...) and each underlying `run()` of
|
|
8
|
+
* `callCoachModel` into an *attempt*. Attempts arrive through
|
|
9
|
+
* `attemptSink(runId, tag)`, the `onUsage` callback the analyzer calls.
|
|
10
|
+
*
|
|
11
|
+
* Two hard rules shape the design:
|
|
12
|
+
* - **Synchronous.** Every mutation here is plain JS with no `await`, so the
|
|
13
|
+
* four bootstrap workers calling sinks concurrently interleave safely and
|
|
14
|
+
* no model call ever waits on the ledger. Disk writes happen on `endRun`
|
|
15
|
+
* and on a debounced, `unref()`'d flush timer that never holds the process
|
|
16
|
+
* open.
|
|
17
|
+
* - **Content-free.** A run carries ids, counts, tokens and money — never
|
|
18
|
+
* prompts, responses, tool arguments or full paths (`workspace` is the
|
|
19
|
+
* label, not the path).
|
|
20
|
+
*
|
|
21
|
+
* Totals are kept twice: on the run (written into its day file) and in the
|
|
22
|
+
* in-memory summary (loaded once at creation, delta-applied at record time,
|
|
23
|
+
* flushed to `usage/summary.json`) so reports never re-scan every day file.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { dayKey } from './store.js'
|
|
27
|
+
import { USAGE_OPS, USAGE_RUN_TYPES } from './schema.js'
|
|
28
|
+
|
|
29
|
+
const ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
|
|
30
|
+
const RUN_STATUSES = ['running', 'success', 'partial', 'failed']
|
|
31
|
+
const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
|
|
32
|
+
/** Finished runs kept addressable for `runSummary()` after they leave `live`. */
|
|
33
|
+
const MAX_REMEMBERED_RUNS = 50
|
|
34
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
35
|
+
/** Report defaults for the wire-optional filter fields. */
|
|
36
|
+
const DEFAULT_RANGE = '30d'
|
|
37
|
+
const DEFAULT_PAGE_SIZE = 20
|
|
38
|
+
/** Days each `range` looks back over (`month`/`all` are computed instead). */
|
|
39
|
+
const RANGE_DAYS = { today: 1, '7d': 7, '30d': 30 }
|
|
40
|
+
/** Spend at or above this share of the limit is a warning (below the limit itself). */
|
|
41
|
+
const WARN_AT = 0.8
|
|
42
|
+
/** The one honest claim the cost cards may make: real usage, list-price arithmetic. */
|
|
43
|
+
const PRICING_LABEL = 'Measured usage · list-price cost'
|
|
44
|
+
|
|
45
|
+
/** A non-negative finite number, or 0. */
|
|
46
|
+
function count(value) {
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPlainObject(value) {
|
|
51
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emptyTokens() {
|
|
55
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyTotals() {
|
|
59
|
+
return { attempts: 0, billedCalls: 0, unmeteredCalls: 0, unpricedCalls: 0, tokens: emptyTokens(), usdKnown: 0 }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emptyDayTotals() {
|
|
63
|
+
return { ...emptyTotals(), byType: {} }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Billed token count: uncached input + cache reads + cache writes + output.
|
|
68
|
+
* `reasoningTokens` is a subset of `outputTokens` (DeepSeek adapter) and is
|
|
69
|
+
* deliberately NOT added — summing it would double count every reasoning
|
|
70
|
+
* token in every total.
|
|
71
|
+
*/
|
|
72
|
+
export function totalTokens(tokens) {
|
|
73
|
+
if (!isPlainObject(tokens)) return 0
|
|
74
|
+
return count(tokens.inputTokens) + count(tokens.outputTokens) + count(tokens.cacheReadTokens) + count(tokens.cacheWriteTokens)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A `tokenBucketsSchema` object from an untrusted usage record, or `null` for an unmetered call. */
|
|
78
|
+
function narrowUsage(usage) {
|
|
79
|
+
if (!isPlainObject(usage)) return null
|
|
80
|
+
const out = emptyTokens()
|
|
81
|
+
for (const key of TOKEN_KEYS) out[key] = count(usage[key])
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Add `delta` into `target` in place (both are `usageTotalsSchema` shaped). */
|
|
86
|
+
function addTotals(target, delta) {
|
|
87
|
+
target.attempts += delta.attempts
|
|
88
|
+
target.billedCalls += delta.billedCalls
|
|
89
|
+
target.unmeteredCalls += delta.unmeteredCalls
|
|
90
|
+
target.unpricedCalls += delta.unpricedCalls
|
|
91
|
+
target.usdKnown += delta.usdKnown
|
|
92
|
+
for (const key of TOKEN_KEYS) target.tokens[key] += delta.tokens[key]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `record[key]`, creating it from `factory` the first time (and repairing a corrupt bucket). */
|
|
96
|
+
function bucketOf(record, key, factory) {
|
|
97
|
+
const existing = record[key]
|
|
98
|
+
if (!isPlainObject(existing)) {
|
|
99
|
+
record[key] = factory()
|
|
100
|
+
return record[key]
|
|
101
|
+
}
|
|
102
|
+
// A summary read back from disk is schema-validated, but a bucket that
|
|
103
|
+
// predates a field still needs it before `+=` turns it into NaN.
|
|
104
|
+
const filled = { ...factory(), ...existing }
|
|
105
|
+
filled.tokens = { ...emptyTokens(), ...(isPlainObject(existing.tokens) ? existing.tokens : {}) }
|
|
106
|
+
record[key] = filled
|
|
107
|
+
return filled
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Only finite numbers may reach `results` (`z.record(z.number())` would drop the whole run otherwise). */
|
|
111
|
+
function narrowResults(results) {
|
|
112
|
+
const out = {}
|
|
113
|
+
if (!isPlainObject(results)) return out
|
|
114
|
+
for (const [key, value] of Object.entries(results)) {
|
|
115
|
+
if (typeof value === 'number' && Number.isFinite(value)) out[key] = value
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** no attempts → failed; any failure → partial (all failed → failed); otherwise success. */
|
|
121
|
+
function deriveStatus(attempts) {
|
|
122
|
+
if (attempts.length === 0) return 'failed'
|
|
123
|
+
const failed = attempts.filter((attempt) => attempt.status === 'failed').length
|
|
124
|
+
if (failed === 0) return 'success'
|
|
125
|
+
return failed === attempts.length ? 'failed' : 'partial'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The `count` day keys ending on `endMs`'s own day, ascending. Anchored at
|
|
130
|
+
* local noon so a DST shift can never drop or repeat a calendar day.
|
|
131
|
+
*/
|
|
132
|
+
function dayKeysEnding(endMs, count) {
|
|
133
|
+
const anchor = new Date(endMs)
|
|
134
|
+
anchor.setHours(12, 0, 0, 0)
|
|
135
|
+
const out = []
|
|
136
|
+
for (let back = count - 1; back >= 0; back -= 1) out.push(dayKey(anchor.getTime() - back * MS_PER_DAY))
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Middle value (mean of the two middles when even); `null` for an empty list. */
|
|
141
|
+
function median(values) {
|
|
142
|
+
if (values.length === 0) return null
|
|
143
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
144
|
+
const mid = sorted.length >> 1
|
|
145
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A `usageTotalsSchema`-shaped value from an untrusted bucket (a report must never add `undefined`). */
|
|
149
|
+
function safeTotals(value) {
|
|
150
|
+
const source = isPlainObject(value) ? value : {}
|
|
151
|
+
const out = { ...emptyTotals(), ...source }
|
|
152
|
+
out.tokens = { ...emptyTokens(), ...(isPlainObject(source.tokens) ? source.tokens : {}) }
|
|
153
|
+
for (const key of ['attempts', 'billedCalls', 'unmeteredCalls', 'unpricedCalls', 'usdKnown']) out[key] = count(out[key])
|
|
154
|
+
for (const key of TOKEN_KEYS) out.tokens[key] = count(out.tokens[key])
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The totals delta one stored attempt contributed — the same arithmetic `recordAttempt` applied live. */
|
|
159
|
+
function attemptDelta(attempt) {
|
|
160
|
+
const usage = narrowUsage(attempt.usage)
|
|
161
|
+
const priced = isPlainObject(attempt.priced) ? attempt.priced : null
|
|
162
|
+
return {
|
|
163
|
+
attempts: 1,
|
|
164
|
+
billedCalls: usage !== null ? 1 : 0,
|
|
165
|
+
unmeteredCalls: usage === null ? 1 : 0,
|
|
166
|
+
unpricedCalls: usage !== null && priced === null ? 1 : 0,
|
|
167
|
+
tokens: usage ?? emptyTokens(),
|
|
168
|
+
usdKnown: count(priced?.usd),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Cache-hit share of the billed input: `cacheRead / (input + cacheRead)`.
|
|
174
|
+
* `null` when nothing was billed for input at all (0 % would be a lie).
|
|
175
|
+
*/
|
|
176
|
+
function cachedInputRateOf(tokens) {
|
|
177
|
+
const billedInput = count(tokens.inputTokens) + count(tokens.cacheReadTokens)
|
|
178
|
+
return billedInput === 0 ? null : count(tokens.cacheReadTokens) / billedInput
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `'none'` with no limit set; `'exceeded'` at or over it; `'warn'` from 80 % up. */
|
|
182
|
+
function warningLevel(limit, spent) {
|
|
183
|
+
if (!(limit > 0)) return 'none'
|
|
184
|
+
if (spent >= limit) return 'exceeded'
|
|
185
|
+
return spent >= WARN_AT * limit ? 'warn' : 'none'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The run/attempt ledger. `config` is the effective-config getter (only
|
|
190
|
+
* `costHistoryDays` is read); `pricing` is anything with `priceCall`
|
|
191
|
+
* (`lib/pricing-source.js`); `now`/`flushDelayMs` are injectable for tests.
|
|
192
|
+
*/
|
|
193
|
+
export function createUsageTracker({ store, config, pricing, now = Date.now, flushDelayMs = 250 }) {
|
|
194
|
+
/** Runs that have begun and not yet ended, by runId. */
|
|
195
|
+
const live = new Map()
|
|
196
|
+
/** Finished run summaries, so `runSummary()` still answers after `endRun`. */
|
|
197
|
+
const remembered = new Map()
|
|
198
|
+
/** Day keys whose file has unwritten live-run state. */
|
|
199
|
+
const dirtyDays = new Set()
|
|
200
|
+
let summaryDirty = false
|
|
201
|
+
let timer = null
|
|
202
|
+
let seq = 0
|
|
203
|
+
/** '' until the first prune; then the day key it last ran on. */
|
|
204
|
+
let lastPruneDay = ''
|
|
205
|
+
|
|
206
|
+
// Reassigned by `clear()`, which reloads the (fresh) summary the store wrote.
|
|
207
|
+
let summary = store.readUsageSummary()
|
|
208
|
+
if (isFreshSummary(summary)) {
|
|
209
|
+
summary.trackingSince = now()
|
|
210
|
+
store.writeUsageSummary(summary)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A summary with nothing recorded yet was just created by the store — persist it so `trackingSince` sticks. */
|
|
214
|
+
function isFreshSummary(value) {
|
|
215
|
+
return count(value?.lifetime?.attempts) === 0
|
|
216
|
+
&& Object.keys(value?.days ?? {}).length === 0
|
|
217
|
+
&& Object.keys(value?.byType ?? {}).length === 0
|
|
218
|
+
&& Object.keys(value?.byModel ?? {}).length === 0
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The day file a run belongs to (its start, so a run never splits across two files). */
|
|
222
|
+
function runDay(run) {
|
|
223
|
+
return dayKey(run.startedAt)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scheduleFlush() {
|
|
227
|
+
if (timer !== null) return
|
|
228
|
+
timer = setTimeout(() => {
|
|
229
|
+
timer = null
|
|
230
|
+
flush()
|
|
231
|
+
}, flushDelayMs)
|
|
232
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Read the day file, upsert `runs` by runId, write it back. */
|
|
236
|
+
function upsertDay(day, runs) {
|
|
237
|
+
if (runs.length === 0) return
|
|
238
|
+
const file = store.readUsageDay(day)
|
|
239
|
+
const list = Array.isArray(file?.runs) ? [...file.runs] : []
|
|
240
|
+
for (const run of runs) {
|
|
241
|
+
const at = list.findIndex((existing) => existing?.runId === run.runId)
|
|
242
|
+
if (at >= 0) list[at] = run
|
|
243
|
+
else list.push(run)
|
|
244
|
+
}
|
|
245
|
+
store.writeUsageDay(day, { version: 1, day, runs: list })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Expire old day files at most once per calendar day. */
|
|
249
|
+
function pruneIfNewDay() {
|
|
250
|
+
const today = dayKey(now())
|
|
251
|
+
if (today === lastPruneDay) return
|
|
252
|
+
lastPruneDay = today
|
|
253
|
+
const keepDays = count(config?.().costHistoryDays)
|
|
254
|
+
store.pruneUsageDays(keepDays > 0 ? keepDays : 30, today)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function beginRun({ type, trigger = '', sessionId = '', turn = null, workspace = '', model = '', provider = '' } = {}) {
|
|
258
|
+
const startedAt = now()
|
|
259
|
+
const runId = `u${startedAt.toString(36)}-${(seq++).toString(36)}`
|
|
260
|
+
if (!USAGE_RUN_TYPES.includes(type)) {
|
|
261
|
+
// Never track a run the day-file schema would reject: one bad row makes
|
|
262
|
+
// the whole day unreadable. The id stays valid; every sink ignores it.
|
|
263
|
+
console.warn(`[tacit] usage: unknown run type ${JSON.stringify(type)}, not tracked`)
|
|
264
|
+
return runId
|
|
265
|
+
}
|
|
266
|
+
live.set(runId, {
|
|
267
|
+
runId,
|
|
268
|
+
type,
|
|
269
|
+
trigger: String(trigger ?? ''),
|
|
270
|
+
startedAt,
|
|
271
|
+
endedAt: 0,
|
|
272
|
+
status: 'running',
|
|
273
|
+
sessionId: String(sessionId ?? ''),
|
|
274
|
+
turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
|
|
275
|
+
workspace: String(workspace ?? ''),
|
|
276
|
+
model: String(model ?? ''),
|
|
277
|
+
provider: String(provider ?? ''),
|
|
278
|
+
results: {},
|
|
279
|
+
attempts: [],
|
|
280
|
+
totals: emptyTotals(),
|
|
281
|
+
})
|
|
282
|
+
return runId
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Fold one attempt into the run totals and every summary bucket. Synchronous by contract. */
|
|
286
|
+
function recordAttempt(runId, { op, sessionId = '', turn = null } = {}, record) {
|
|
287
|
+
const run = live.get(runId)
|
|
288
|
+
if (run === undefined || !isPlainObject(record)) return
|
|
289
|
+
if (!USAGE_OPS.includes(op)) return
|
|
290
|
+
|
|
291
|
+
const usage = narrowUsage(record.usage)
|
|
292
|
+
const startedAt = count(record.startedAt) > 0 ? record.startedAt : now()
|
|
293
|
+
const model = typeof record.model === 'string' ? record.model : ''
|
|
294
|
+
const provider = typeof record.provider === 'string' ? record.provider : ''
|
|
295
|
+
const priced = usage === null ? null : (pricing?.priceCall({ model, provider, atMs: startedAt, usage }) ?? null)
|
|
296
|
+
|
|
297
|
+
const attempt = {
|
|
298
|
+
id: `${runId}:${run.attempts.length}`,
|
|
299
|
+
op,
|
|
300
|
+
startedAt,
|
|
301
|
+
durationMs: count(record.durationMs),
|
|
302
|
+
model,
|
|
303
|
+
provider,
|
|
304
|
+
reasoningEffort: typeof record.reasoningEffort === 'string' ? record.reasoningEffort : null,
|
|
305
|
+
finish: typeof record.finish === 'string' ? record.finish : '',
|
|
306
|
+
status: ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
|
|
307
|
+
code: typeof record.code === 'string' ? record.code : '',
|
|
308
|
+
sessionId: String(sessionId ?? ''),
|
|
309
|
+
turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
|
|
310
|
+
usage,
|
|
311
|
+
priced: isPlainObject(priced) ? priced : null,
|
|
312
|
+
}
|
|
313
|
+
run.attempts.push(attempt)
|
|
314
|
+
|
|
315
|
+
const delta = {
|
|
316
|
+
attempts: 1,
|
|
317
|
+
billedCalls: usage !== null ? 1 : 0,
|
|
318
|
+
unmeteredCalls: usage === null ? 1 : 0,
|
|
319
|
+
unpricedCalls: usage !== null && attempt.priced === null ? 1 : 0,
|
|
320
|
+
tokens: usage ?? emptyTokens(),
|
|
321
|
+
usdKnown: count(attempt.priced?.usd),
|
|
322
|
+
}
|
|
323
|
+
addTotals(run.totals, delta)
|
|
324
|
+
addTotals(bucketOf(summary, 'lifetime', emptyTotals), delta)
|
|
325
|
+
addTotals(bucketOf(summary.byType, run.type, emptyTotals), delta)
|
|
326
|
+
if (model.length > 0) addTotals(bucketOf(summary.byModel, model, emptyTotals), delta)
|
|
327
|
+
const day = bucketOf(summary.days, dayKey(startedAt), emptyDayTotals)
|
|
328
|
+
if (!isPlainObject(day.byType)) day.byType = {}
|
|
329
|
+
addTotals(day, delta)
|
|
330
|
+
addTotals(bucketOf(day.byType, run.type, emptyTotals), delta)
|
|
331
|
+
|
|
332
|
+
dirtyDays.add(runDay(run))
|
|
333
|
+
summaryDirty = true
|
|
334
|
+
scheduleFlush()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The `onUsage` callback for one op: a closed-over `recordAttempt`. Unknown runs are ignored. */
|
|
338
|
+
function attemptSink(runId, tag = {}) {
|
|
339
|
+
return (record) => recordAttempt(runId, tag, record)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Close a run: derive its status, write its day file, evict it, and expire old days. */
|
|
343
|
+
function endRun(runId, { results = {}, status } = {}) {
|
|
344
|
+
const run = live.get(runId)
|
|
345
|
+
if (run === undefined) return null
|
|
346
|
+
run.endedAt = now()
|
|
347
|
+
run.status = RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
|
|
348
|
+
run.results = narrowResults(results)
|
|
349
|
+
upsertDay(runDay(run), [run])
|
|
350
|
+
live.delete(runId)
|
|
351
|
+
remember(run)
|
|
352
|
+
pruneIfNewDay()
|
|
353
|
+
return runSummary(runId)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Keep the newest finished runs addressable (bounded, FIFO). */
|
|
357
|
+
function remember(run) {
|
|
358
|
+
remembered.set(run.runId, summarize(run))
|
|
359
|
+
if (remembered.size > MAX_REMEMBERED_RUNS) {
|
|
360
|
+
const oldest = remembered.keys().next()
|
|
361
|
+
if (!oldest.done) remembered.delete(oldest.value)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function summarize(run) {
|
|
366
|
+
return {
|
|
367
|
+
runId: run.runId,
|
|
368
|
+
type: run.type,
|
|
369
|
+
status: run.status,
|
|
370
|
+
attempts: run.totals.attempts,
|
|
371
|
+
billedCalls: run.totals.billedCalls,
|
|
372
|
+
unmeteredCalls: run.totals.unmeteredCalls,
|
|
373
|
+
unpricedCalls: run.totals.unpricedCalls,
|
|
374
|
+
tokens: { ...run.totals.tokens },
|
|
375
|
+
usdKnown: run.totals.usdKnown,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Counters for a live or recently finished run; `null` when the id is unknown. */
|
|
380
|
+
function runSummary(runId) {
|
|
381
|
+
const run = live.get(runId)
|
|
382
|
+
if (run !== undefined) return summarize(run)
|
|
383
|
+
return remembered.get(runId) ?? null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Write every dirty day file (live runs included, still `running`) and the summary. Synchronous. */
|
|
387
|
+
function flush() {
|
|
388
|
+
if (timer !== null) {
|
|
389
|
+
clearTimeout(timer)
|
|
390
|
+
timer = null
|
|
391
|
+
}
|
|
392
|
+
const byDay = new Map()
|
|
393
|
+
for (const run of live.values()) {
|
|
394
|
+
const day = runDay(run)
|
|
395
|
+
const runs = byDay.get(day)
|
|
396
|
+
if (runs === undefined) byDay.set(day, [run])
|
|
397
|
+
else runs.push(run)
|
|
398
|
+
}
|
|
399
|
+
for (const day of dirtyDays) upsertDay(day, byDay.get(day) ?? [])
|
|
400
|
+
dirtyDays.clear()
|
|
401
|
+
if (summaryDirty) {
|
|
402
|
+
store.writeUsageSummary(summary)
|
|
403
|
+
summaryDirty = false
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── the read side (reports, one run, clear) ──────────────────────────────
|
|
408
|
+
|
|
409
|
+
/** How many days of detail are on disk at all — the hard bound on every range. */
|
|
410
|
+
function retentionDays() {
|
|
411
|
+
const keepDays = count(config?.().costHistoryDays)
|
|
412
|
+
return keepDays > 0 ? keepDays : 30
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Ascending day keys one `range` covers, never reaching past retention. */
|
|
416
|
+
function rangeKeys(range, keepDays, available) {
|
|
417
|
+
if (range === 'all') {
|
|
418
|
+
// "All" is still only as far back as detail is kept — bound by day key,
|
|
419
|
+
// not by file count, so a sparse history cannot smuggle in older days.
|
|
420
|
+
// The window is `keepDays + 1` keys wide because that is exactly what
|
|
421
|
+
// `pruneUsageDays` keeps (day keys `>= today - keepDays`, today included);
|
|
422
|
+
// a narrower "all" would leave the oldest surviving file unreachable.
|
|
423
|
+
const oldest = dayKeysEnding(now(), keepDays + 1)[0]
|
|
424
|
+
return available.filter((day) => day >= oldest)
|
|
425
|
+
}
|
|
426
|
+
if (range === 'month') {
|
|
427
|
+
const today = dayKey(now())
|
|
428
|
+
const dayOfMonth = Number(today.slice(-2))
|
|
429
|
+
return dayKeysEnding(now(), Math.min(dayOfMonth, keepDays))
|
|
430
|
+
}
|
|
431
|
+
return dayKeysEnding(now(), Math.min(RANGE_DAYS[range] ?? RANGE_DAYS[DEFAULT_RANGE], keepDays))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `day → runs` for the day files inside `keys` that `available` says exist —
|
|
436
|
+
* one `readdir` up front means only the reads that can pay off happen.
|
|
437
|
+
* This runs on every 10 s poll of the cost panel.
|
|
438
|
+
*/
|
|
439
|
+
function loadDays(keys, available) {
|
|
440
|
+
const out = new Map()
|
|
441
|
+
for (const day of keys) {
|
|
442
|
+
if (!available.has(day)) continue
|
|
443
|
+
const file = store.readUsageDay(day)
|
|
444
|
+
if (Array.isArray(file?.runs)) out.set(day, file.runs.filter(isPlainObject))
|
|
445
|
+
}
|
|
446
|
+
return out
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Summary day buckets summed over `keys` (missing days simply contribute nothing). */
|
|
450
|
+
function totalsOver(keys) {
|
|
451
|
+
const out = emptyTotals()
|
|
452
|
+
for (const day of keys) {
|
|
453
|
+
const bucket = summary.days?.[day]
|
|
454
|
+
if (isPlainObject(bucket)) addTotals(out, safeTotals(bucket))
|
|
455
|
+
}
|
|
456
|
+
return out
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One period card: the summary's own totals plus the two derived figures
|
|
461
|
+
* the panel shows. `avgAnalysisUsd` is a median (a single bootstrap batch
|
|
462
|
+
* must not drag the typical cost of one analysis upwards) over the priced
|
|
463
|
+
* `analysis` attempts of the day files that were loaded — so it is only as
|
|
464
|
+
* deep as the retention window, which is exactly how deep detail goes.
|
|
465
|
+
*/
|
|
466
|
+
function periodOf(totals, attempts) {
|
|
467
|
+
const usds = []
|
|
468
|
+
for (const attempt of attempts) {
|
|
469
|
+
if (attempt.op !== 'analysis') continue
|
|
470
|
+
if (!isPlainObject(attempt.priced)) continue
|
|
471
|
+
usds.push(count(attempt.priced.usd))
|
|
472
|
+
}
|
|
473
|
+
return { ...totals, avgAnalysisUsd: median(usds), cachedInputRate: cachedInputRateOf(totals.tokens) }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* What an analysis has actually cost lately: the median priced `analysis`
|
|
478
|
+
* attempt over the last 30 days of day files (`samples` is how many it rests
|
|
479
|
+
* on), plus the median priced directive distillation a batch also pays for.
|
|
480
|
+
* Both are `null` with nothing to measure. Read-side only — this reads day
|
|
481
|
+
* files, so it never sits on a model-call path.
|
|
482
|
+
*/
|
|
483
|
+
function analysisCostSample() {
|
|
484
|
+
const available = store.listUsageDays()
|
|
485
|
+
const loaded = loadDays(rangeKeys('30d', retentionDays(), available), new Set(available))
|
|
486
|
+
const analyses = []
|
|
487
|
+
const distillations = []
|
|
488
|
+
for (const dayRuns of loaded.values()) {
|
|
489
|
+
for (const run of dayRuns) {
|
|
490
|
+
if (!Array.isArray(run.attempts)) continue
|
|
491
|
+
for (const attempt of run.attempts) {
|
|
492
|
+
if (!isPlainObject(attempt) || !isPlainObject(attempt.priced)) continue
|
|
493
|
+
if (attempt.op === 'analysis') analyses.push(count(attempt.priced.usd))
|
|
494
|
+
else if (attempt.op === 'directive-distillation') distillations.push(count(attempt.priced.usd))
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { samples: analyses.length, perAnalysisUsd: median(analyses), distillationUsd: median(distillations) }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `[{day, usdKnown, billedCalls}]`, zero-filled, ending on today. */
|
|
502
|
+
function seriesOf(days) {
|
|
503
|
+
return dayKeysEnding(now(), days).map((day) => {
|
|
504
|
+
const bucket = summary.days?.[day]
|
|
505
|
+
const totals = isPlainObject(bucket) ? safeTotals(bucket) : emptyTotals()
|
|
506
|
+
return { day, usdKnown: totals.usdKnown, billedCalls: totals.billedCalls }
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A run row without its attempt array: the counters plus the identity the list shows. */
|
|
511
|
+
function runItem(run) {
|
|
512
|
+
return {
|
|
513
|
+
...summarize({ ...run, totals: safeTotals(run.totals) }),
|
|
514
|
+
trigger: typeof run.trigger === 'string' ? run.trigger : '',
|
|
515
|
+
startedAt: count(run.startedAt),
|
|
516
|
+
endedAt: count(run.endedAt),
|
|
517
|
+
sessionId: typeof run.sessionId === 'string' ? run.sessionId : '',
|
|
518
|
+
turn: typeof run.turn === 'number' ? run.turn : null,
|
|
519
|
+
workspace: typeof run.workspace === 'string' ? run.workspace : '',
|
|
520
|
+
model: typeof run.model === 'string' ? run.model : '',
|
|
521
|
+
provider: typeof run.provider === 'string' ? run.provider : '',
|
|
522
|
+
results: narrowResults(run.results),
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Every filter is an exact match; an absent filter matches everything. */
|
|
527
|
+
function matchesFilters(run, filters) {
|
|
528
|
+
if (filters.type !== undefined && run.type !== filters.type) return false
|
|
529
|
+
if (filters.status !== undefined && run.status !== filters.status) return false
|
|
530
|
+
if (filters.model !== undefined && run.model !== filters.model) return false
|
|
531
|
+
if (filters.workspace !== undefined && run.workspace !== filters.workspace) return false
|
|
532
|
+
if (filters.sessionId !== undefined && run.sessionId !== filters.sessionId) return false
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The whole cost panel in one read: period cards, the two sparkline series,
|
|
538
|
+
* the type/model breakdowns, the budget warnings and one page of runs.
|
|
539
|
+
*
|
|
540
|
+
* Cheap by construction — every total comes from the in-memory summary, and
|
|
541
|
+
* the only disk work is one `readdir` plus the day files of the *detail
|
|
542
|
+
* window*: the requested range unioned with the last 30 days and the current
|
|
543
|
+
* month, every part of it capped by `costHistoryDays`. The union matters —
|
|
544
|
+
* the period medians, `byModel` and `byType` describe fixed windows and must
|
|
545
|
+
* not shrink just because the run list was narrowed to `range: 'today'`. For
|
|
546
|
+
* the default `30d` request the union is the same set of files the range
|
|
547
|
+
* alone would have loaded.
|
|
548
|
+
*/
|
|
549
|
+
function report({ config: effective = {}, pricingStatus = {}, pricingRates = {}, filters = {} } = {}) {
|
|
550
|
+
// The caller's effective config wins (it is the same getter in production);
|
|
551
|
+
// the injected one is the fallback for a bare `report()`.
|
|
552
|
+
const keepDays = count(effective?.costHistoryDays) > 0 ? Math.floor(effective.costHistoryDays) : retentionDays()
|
|
553
|
+
const range = typeof filters.range === 'string' ? filters.range : DEFAULT_RANGE
|
|
554
|
+
const page = count(filters.page) > 0 ? Math.floor(filters.page) : 1
|
|
555
|
+
const pageSize = count(filters.pageSize) > 0 ? Math.floor(filters.pageSize) : DEFAULT_PAGE_SIZE
|
|
556
|
+
|
|
557
|
+
const available = store.listUsageDays()
|
|
558
|
+
const availableSet = new Set(available)
|
|
559
|
+
// The run list follows `range`; every fixed-window figure below reads the
|
|
560
|
+
// union, so a narrow range can never shrink a 30-day breakdown or median.
|
|
561
|
+
const listKeys = rangeKeys(range, keepDays, available)
|
|
562
|
+
const detailKeys = [...new Set([
|
|
563
|
+
...listKeys,
|
|
564
|
+
...rangeKeys('30d', keepDays, available),
|
|
565
|
+
...rangeKeys('month', keepDays, available),
|
|
566
|
+
])].sort()
|
|
567
|
+
const loaded = loadDays(detailKeys, availableSet)
|
|
568
|
+
|
|
569
|
+
const attempts = []
|
|
570
|
+
/** Loaded attempts by the day they were billed on, so each period medians only its own. */
|
|
571
|
+
const attemptsByDay = new Map()
|
|
572
|
+
for (const dayRuns of loaded.values()) {
|
|
573
|
+
for (const run of dayRuns) {
|
|
574
|
+
if (!Array.isArray(run.attempts)) continue
|
|
575
|
+
for (const attempt of run.attempts) {
|
|
576
|
+
if (!isPlainObject(attempt)) continue
|
|
577
|
+
attempts.push(attempt)
|
|
578
|
+
const day = dayKey(count(attempt.startedAt))
|
|
579
|
+
const list = attemptsByDay.get(day)
|
|
580
|
+
if (list === undefined) attemptsByDay.set(day, [attempt])
|
|
581
|
+
else list.push(attempt)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const attemptsIn = (keys) => keys.flatMap((day) => attemptsByDay.get(day) ?? [])
|
|
586
|
+
|
|
587
|
+
const todayKeys = dayKeysEnding(now(), 1)
|
|
588
|
+
const last7Keys = dayKeysEnding(now(), 7)
|
|
589
|
+
const last30Keys = dayKeysEnding(now(), 30)
|
|
590
|
+
const monthPrefix = dayKey(now()).slice(0, 7)
|
|
591
|
+
const monthKeys = Object.keys(summary.days ?? {}).filter((day) => day.startsWith(monthPrefix)).sort()
|
|
592
|
+
|
|
593
|
+
// byType comes straight from the summary's own per-day buckets; byModel is
|
|
594
|
+
// folded from the loaded attempts because the day buckets carry no model
|
|
595
|
+
// split. Both cover the same last-30-days window, whatever `range` asked
|
|
596
|
+
// for — the detail window above always includes those 30 days.
|
|
597
|
+
const byType = {}
|
|
598
|
+
for (const day of last30Keys) {
|
|
599
|
+
const buckets = summary.days?.[day]?.byType
|
|
600
|
+
if (!isPlainObject(buckets)) continue
|
|
601
|
+
for (const [type, totals] of Object.entries(buckets)) addTotals(bucketOf(byType, type, emptyTotals), safeTotals(totals))
|
|
602
|
+
}
|
|
603
|
+
const byModel = {}
|
|
604
|
+
for (const attempt of attemptsIn(last30Keys)) {
|
|
605
|
+
const model = typeof attempt.model === 'string' ? attempt.model : ''
|
|
606
|
+
if (model.length === 0) continue
|
|
607
|
+
addTotals(bucketOf(byModel, model, emptyTotals), attemptDelta(attempt))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const today = periodOf(totalsOver(todayKeys), attemptsIn(todayKeys))
|
|
611
|
+
const month = periodOf(totalsOver(monthKeys), attemptsIn(monthKeys))
|
|
612
|
+
const dailyLimit = count(effective?.costWarnDailyUsd)
|
|
613
|
+
const monthlyLimit = count(effective?.costWarnMonthlyUsd)
|
|
614
|
+
|
|
615
|
+
const listRuns = []
|
|
616
|
+
for (const day of listKeys) listRuns.push(...(loaded.get(day) ?? []))
|
|
617
|
+
const matched = listRuns
|
|
618
|
+
.filter((run) => matchesFilters(run, filters))
|
|
619
|
+
.sort((a, b) => count(b.startedAt) - count(a.startedAt))
|
|
620
|
+
const from = (page - 1) * pageSize
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
ok: true,
|
|
624
|
+
trackingSince: count(summary.trackingSince),
|
|
625
|
+
pricing: { ...pricingStatus, rates: pricingRates, label: PRICING_LABEL },
|
|
626
|
+
today,
|
|
627
|
+
month,
|
|
628
|
+
last7: periodOf(totalsOver(last7Keys), attemptsIn(last7Keys)),
|
|
629
|
+
last30: periodOf(totalsOver(last30Keys), attemptsIn(last30Keys)),
|
|
630
|
+
lifetime: periodOf(safeTotals(summary.lifetime), attempts),
|
|
631
|
+
byType,
|
|
632
|
+
byModel,
|
|
633
|
+
series7: seriesOf(7),
|
|
634
|
+
series30: seriesOf(30),
|
|
635
|
+
warnings: {
|
|
636
|
+
daily: { limit: dailyLimit, spent: today.usdKnown, level: warningLevel(dailyLimit, today.usdKnown) },
|
|
637
|
+
monthly: { limit: monthlyLimit, spent: month.usdKnown, level: warningLevel(monthlyLimit, month.usdKnown) },
|
|
638
|
+
},
|
|
639
|
+
runs: {
|
|
640
|
+
items: matched.slice(from, from + pageSize).map(runItem),
|
|
641
|
+
page,
|
|
642
|
+
pageSize,
|
|
643
|
+
total: matched.length,
|
|
644
|
+
},
|
|
645
|
+
code: '',
|
|
646
|
+
detail: '',
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* One run with its attempts — the live copy first (so a run is addressable
|
|
652
|
+
* before it is ever written), then the day files newest-first. `null` when
|
|
653
|
+
* the id is unknown or its day has expired. A live run is deep-copied: the
|
|
654
|
+
* caller gets a snapshot it can hold and serialise while the run keeps
|
|
655
|
+
* recording, and nothing it does can reach the ledger's own state.
|
|
656
|
+
*/
|
|
657
|
+
function run(runId) {
|
|
658
|
+
const liveRun = live.get(runId)
|
|
659
|
+
if (liveRun !== undefined) return structuredClone(liveRun)
|
|
660
|
+
for (const day of store.listUsageDays().reverse()) {
|
|
661
|
+
const file = store.readUsageDay(day)
|
|
662
|
+
const found = Array.isArray(file?.runs) ? file.runs.find((entry) => entry?.runId === runId) : undefined
|
|
663
|
+
if (found !== undefined) return found
|
|
664
|
+
}
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Delete the ledger: every day file (never anything else in `usage/`) and
|
|
670
|
+
* the summary, which the store replaces with a fresh one. Live runs are
|
|
671
|
+
* deliberately kept — a bootstrap batch mid-flight keeps recording, into the
|
|
672
|
+
* new tracking window. One consequence is deliberate: a surviving run carries
|
|
673
|
+
* its pre-clear attempts, so the day file it is eventually written to holds
|
|
674
|
+
* attempts the fresh summary never counted.
|
|
675
|
+
*/
|
|
676
|
+
function clear() {
|
|
677
|
+
if (timer !== null) {
|
|
678
|
+
clearTimeout(timer)
|
|
679
|
+
timer = null
|
|
680
|
+
}
|
|
681
|
+
// Nothing pending may resurrect what was just deleted.
|
|
682
|
+
dirtyDays.clear()
|
|
683
|
+
summaryDirty = false
|
|
684
|
+
lastPruneDay = ''
|
|
685
|
+
const { removed } = store.clearUsage()
|
|
686
|
+
summary = store.readUsageSummary()
|
|
687
|
+
// The store stamps the new window with its own `Date.now()`; the tracker's
|
|
688
|
+
// injected clock is the one every other timestamp here comes from.
|
|
689
|
+
summary.trackingSince = now()
|
|
690
|
+
store.writeUsageSummary(summary)
|
|
691
|
+
return { removed, trackingSince: summary.trackingSince }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
beginRun,
|
|
696
|
+
attemptSink,
|
|
697
|
+
recordAttempt,
|
|
698
|
+
endRun,
|
|
699
|
+
runSummary,
|
|
700
|
+
flush,
|
|
701
|
+
report,
|
|
702
|
+
analysisCostSample,
|
|
703
|
+
run,
|
|
704
|
+
clear,
|
|
705
|
+
summary: () => summary,
|
|
706
|
+
liveRuns: () => [...live.values()],
|
|
707
|
+
}
|
|
708
|
+
}
|