dsh-plugin-usage 0.1.4 → 0.2.2

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/lib/host/index.js CHANGED
@@ -1,444 +1,87 @@
1
1
  /**
2
- * usage — Token usage statistics host half.
2
+ * usage — Token usage statistics host half (entry module).
3
3
  *
4
- * Strategy (dynamic-plugin safe):
5
- * - DSH already persists every loop model call's token usage durably in each
6
- * session log as `assistant/message` events carrying `usage` (TokenUsage) and
7
- * `message.source.{provider,model}`. We fold those events into an in-memory
8
- * index instead of tapping `llm/stream` (unreachable from an agent-scoped
9
- * dynamic plugin) and instead of writing our own files.
4
+ * Wires the submodules (fold / scan / query / settings / aggregate / rpc /
5
+ * client-source) into the cordis plugin lifecycle. Strategy notes:
6
+ * - DSH persists every loop model call's token usage in each session log as
7
+ * an `assistant/message` (or `assistant/attempt`) settlement carrying
8
+ * `usage`; this plugin folds those events with the same replacement /
9
+ * retry semantics as the official `tokenUsage` projection instead of
10
+ * tapping `llm/stream` (unreachable from an agent-scoped dynamic plugin).
10
11
  * - Live sessions are scanned from the in-memory store every poll tick;
11
12
  * persisted sessions are backfilled at startup and re-scanned incrementally
12
- * on a slower cadence (watermark per session, `seq >= fromSeq`).
13
- * - Aggregation, cost (own user-configurable price table — the runtime has no
14
- * pricing concept) and model list are served to the Client via harness RPC.
13
+ * (watermark + revision per session).
14
+ * - Aggregation, cost (user-configurable price table) and model list are
15
+ * served through src/host/rpc.js.
15
16
  *
16
- * The same file doubles as the cordis.yml host plugin module (exports name /
17
- * inject / apply) and as the `code.host` function body for a dynamic plugin.
17
+ * The price table is the plugin's own config: the exported `Config` schema is
18
+ * the official, durable form surface (`Plugins` settings tab -> profile
19
+ * patch), replacing the 0.1.x `settings.register`/`settings.yaml` path that
20
+ * does not exist in DSH 0.2.x.
21
+ *
22
+ * The same entry doubles as the cordis.yml host plugin module (exports name /
23
+ * inject / Config / apply) and as the `code.host` function body for a dynamic
24
+ * plugin.
18
25
  */
26
+ import Schema from '@deepseek-ai/schemastery';
27
+ import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, mergePrices } from './settings.js';
28
+ import { createState } from './state.js';
29
+ import { createUsageIndex } from './aggregate.js';
30
+ import { scanLive, scanPersisted, runBackfill } from './scan.js';
31
+ import { query } from './query.js';
32
+ import { registerRPC } from './rpc.js';
33
+ import { getClientSource } from './client-source.js';
19
34
  export const name = 'usage-host';
20
35
  export const inject = ['timer'];
21
- export function apply(ctx) {
22
- // ---------------------------------------------------------------- constants
23
- const RPC_QUERY = 'usage/query';
24
- const RPC_PRICES = 'usage/prices';
25
- const DEFAULT_CURRENCY = '¥';
26
- const DEFAULT_PRICES = {
27
- 'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
28
- 'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
29
- 'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
30
- 'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
31
- 'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
32
- 'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
33
- 'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
34
- };
35
- const LIVE_POLL_MS = 2000;
36
- const PERSISTED_POLL_MS = 30000;
37
- // ------------------------------------------------------------------- state
38
- /** Folded usage records, one per assistant/message with usage. */
39
- const records = [];
40
- /** sessionId -> last folded event seq. */
41
- const watermarks = new Map();
42
- /** User-overridable price table (model -> ModelPrice). */
43
- const prices = Object.assign({}, DEFAULT_PRICES);
44
- let currency = DEFAULT_CURRENCY;
45
- const backfill = { status: 'idle', total: 0, done: 0 };
46
- // -------------------------------------------------------------------- fold
47
- function foldEvent(sessionId, e) {
48
- if (!e || e.type !== 'assistant/message' || !e.data || !e.data.usage)
49
- return;
50
- const source = (e.data.message && e.data.message.source) || {};
51
- const u = e.data.usage;
52
- records.push({
53
- time: e.time,
54
- session: sessionId,
55
- provider: source.provider || '',
56
- model: source.model || '',
57
- inputTokens: u.inputTokens || 0,
58
- outputTokens: u.outputTokens || 0,
59
- cacheReadTokens: u.cacheReadTokens || 0,
60
- cacheWriteTokens: u.cacheWriteTokens || 0,
61
- reasoningTokens: u.reasoningTokens || 0,
62
- totalTokens: (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheWriteTokens || 0),
63
- });
64
- }
65
- /** Fold events with seq > watermark for one session; returns new watermark. */
66
- function foldNew(sessionId, events, wm) {
67
- let max = wm;
68
- for (const e of events) {
69
- if (e && typeof e.seq === 'number' && e.seq > wm) {
70
- foldEvent(sessionId, e);
71
- if (e.seq > max)
72
- max = e.seq;
73
- }
74
- }
75
- return max;
76
- }
77
- // ------------------------------------------------------------------ scans
78
- /** Scan live in-memory sessions (cheap, every tick). */
79
- function scanLive() {
80
- const sessions = ctx.get('sessions');
81
- if (sessions === undefined)
82
- return;
83
- for (const s of sessions.list()) {
84
- if (!s || !s.id)
85
- continue;
86
- const wm = watermarks.get(s.id) ?? -1;
87
- const next = foldNew(s.id, s.events || [], wm);
88
- if (next > wm)
89
- watermarks.set(s.id, next);
90
- }
91
- }
92
- /** Incremental scan of persisted sessions (slow cadence). */
93
- async function scanPersisted() {
94
- const sp = ctx.get('sessionPersistence');
95
- if (sp === undefined)
96
- return;
97
- const headers = await sp.list();
98
- if (!Array.isArray(headers))
99
- return;
100
- backfill.total = headers.length;
101
- for (const h of headers) {
102
- if (!h || !h.id)
103
- continue;
104
- const wm = watermarks.get(h.id) ?? -1;
105
- try {
106
- const page = await sp.readFrom(h.id, wm + 1);
107
- const events = (page && page.events) || [];
108
- const next = foldNew(h.id, events, wm);
109
- if (next > wm)
110
- watermarks.set(h.id, next);
111
- }
112
- catch (error) {
113
- // A session may be gone or mid-rewrite; skip and retry next pass.
114
- }
115
- if (backfill.status === 'running')
116
- backfill.done += 1;
117
- }
118
- }
119
- /** Full history scan, once at startup. */
120
- async function runBackfill() {
121
- const sp = ctx.get('sessionPersistence');
122
- if (sp === undefined) {
123
- backfill.status = 'done';
124
- return;
125
- }
126
- backfill.status = 'running';
127
- backfill.done = 0;
36
+ /** Durable price table + currency, materialized as the plugin's config form. */
37
+ export const Config = Schema.object({
38
+ currency: Schema.string().default(DEFAULT_CURRENCY),
39
+ prices: Schema.dict(Schema.object({
40
+ inputPerM: Schema.number().default(0),
41
+ outputPerM: Schema.number().default(0),
42
+ cacheReadPerM: Schema.number().default(0),
43
+ cacheWritePerM: Schema.number().default(0),
44
+ })).default(DEFAULT_PRICES),
45
+ });
46
+ const LIVE_POLL_MS = 2000;
47
+ const PERSISTED_POLL_MS = 30000;
48
+ export function apply(ctx, config) {
49
+ const resolved = config && typeof config === 'object' ? config : {};
50
+ const priceStore = createPriceStore(mergePrices(resolved.prices), resolved.currency || DEFAULT_CURRENCY);
51
+ const state = createState(priceStore);
52
+ state.index = createUsageIndex(state);
53
+ const handlers = new Map();
54
+ handlers.set('query', (args) => query(state, args || {}));
55
+ handlers.set('prices', (args) => {
56
+ const store = state.prices;
57
+ store.setPrices(args && args.prices);
58
+ if (args && typeof args.currency === 'string')
59
+ store.setCurrency(args.currency);
60
+ // Secondary durability for hosts where the client cannot reach its config
61
+ // form: write the same values through the official SettingsForms API.
62
+ // The entry id is the bundle patch row id (`usage`).
128
63
  try {
129
- await scanPersisted();
130
- backfill.status = 'done';
131
- }
132
- catch (error) {
133
- backfill.status = 'done';
134
- }
135
- }
136
- // ------------------------------------------------------------------- cost
137
- /** Look up a price by `provider/model` composite first, then bare model. */
138
- function priceOf(r) {
139
- const composite = r.provider ? r.provider + '/' + r.model : '';
140
- return prices[composite] || prices[r.model];
141
- }
142
- function costOf(r) {
143
- const p = priceOf(r);
144
- if (!p)
145
- return 0;
146
- return (r.inputTokens * p.inputPerM +
147
- r.outputTokens * p.outputPerM +
148
- r.cacheReadTokens * (p.cacheReadPerM || 0) +
149
- r.cacheWriteTokens * (p.cacheWritePerM || 0)) / 1e6;
150
- }
151
- // ------------------------------------------------------------------ query
152
- function dayKey(t) {
153
- const d = new Date(t);
154
- const pad = (n) => String(n).padStart(2, '0');
155
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
156
- }
157
- const DAY_MS = 86400000;
158
- /** Ordinal day number (local calendar day -> UTC-day index), for step alignment. */
159
- function dayIndex(t) {
160
- const d = new Date(t);
161
- return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY_MS);
162
- }
163
- /** Bucket key for one instant under a granularity (day buckets may span stepDays). */
164
- function bucketKeyFor(t, g, stepDays) {
165
- const pad = (n) => String(n).padStart(2, '0');
166
- if (g === 'day') {
167
- const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
168
- const d = new Date(block * DAY_MS); // UTC midnight; UTC components == local calendar day
169
- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
170
- }
171
- const d = new Date(t);
172
- const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
173
- const hm = pad(d.getHours()) + ':' + (g === 'hour' ? '00' : pad(d.getMinutes()));
174
- return date + ' ' + hm;
175
- }
176
- function stepMs(g) {
177
- return g === 'day' ? DAY_MS : g === 'hour' ? 3600000 : 60000;
178
- }
179
- /** Floor an instant to its granularity boundary. */
180
- function alignFloor(t, g) {
181
- const d = new Date(t);
182
- if (g === 'day') {
183
- d.setHours(0, 0, 0, 0);
184
- return d.getTime();
185
- }
186
- if (g === 'hour') {
187
- d.setMinutes(0, 0, 0);
188
- return d.getTime();
189
- }
190
- d.setSeconds(0, 0);
191
- return d.getTime();
192
- }
193
- /** Floor an instant to its stepDays block start (UTC midnight of the block). */
194
- function alignBlock(t, stepDays) {
195
- const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
196
- return block * DAY_MS;
197
- }
198
- /**
199
- * Bucket key under a day granularity with a range-anchored step grid:
200
- * blocks start at the range's first day, so a custom range reads naturally
201
- * (e.g. 90 days -> 18 x 5-day buckets starting on the range's first day).
202
- */
203
- function stepDayKey(r, dayStart, stepDays) {
204
- const k = Math.floor((alignFloor(r.time, 'day') - dayStart) / (stepDays * DAY_MS));
205
- return dayKey(dayStart + k * stepDays * DAY_MS);
206
- }
207
- const MAX_BUCKETS = 2000;
208
- function emptyBucket(key) {
209
- return {
210
- key,
211
- calls: 0,
212
- inputTokens: 0,
213
- outputTokens: 0,
214
- cacheReadTokens: 0,
215
- cacheWriteTokens: 0,
216
- totalTokens: 0,
217
- cost: 0,
218
- };
219
- }
220
- function addToBucket(b, r, cost) {
221
- b.calls += 1;
222
- b.inputTokens += r.inputTokens;
223
- b.outputTokens += r.outputTokens;
224
- b.cacheReadTokens += r.cacheReadTokens;
225
- b.cacheWriteTokens += r.cacheWriteTokens;
226
- b.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
227
- b.cost += cost;
228
- return b;
229
- }
230
- function query(args) {
231
- const from = typeof args.from === 'number' ? args.from : -Infinity;
232
- const to = typeof args.to === 'number' ? args.to : Infinity;
233
- const granularity = args.granularity === 'minute' || args.granularity === 'hour' ? args.granularity : 'day';
234
- const stepDays = granularity === 'day' && Number.isInteger(args.stepDays) && args.stepDays > 0 ? args.stepDays : 1;
235
- // Zero-filled step series require a finite range.
236
- const fill = Boolean(args.fill) && Number.isFinite(from) && Number.isFinite(to);
237
- const modelSet = Array.isArray(args.models) && args.models.length > 0
238
- ? new Set(args.models)
239
- : null;
240
- /** Match either a bare model id or a `provider/model` composite key. */
241
- const modelMatches = (r) => {
242
- if (!modelSet)
243
- return true;
244
- if (modelSet.has(r.model))
245
- return true;
246
- return Boolean(r.provider) && modelSet.has(r.provider + '/' + r.model);
247
- };
248
- const dailyMap = new Map();
249
- const modelMap = new Map();
250
- const total = emptyBucket('total');
251
- const seenModels = new Map(); // model -> {provider, model} (first provider wins)
252
- let unpricedCalls = 0;
253
- // Range-anchored day-step grid: blocks start at the range's first day.
254
- const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
255
- const useStepGrid = granularity === 'day' && stepDays > 1 && dayAnchor !== null;
256
- for (const r of records) {
257
- if (r.time < from || r.time > to)
258
- continue;
259
- if (!modelMatches(r))
260
- continue;
261
- const cost = costOf(r);
262
- if (cost === 0 && r.totalTokens > 0)
263
- unpricedCalls += 1;
264
- if (!seenModels.has(r.model))
265
- seenModels.set(r.model, { provider: r.provider, model: r.model });
266
- const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
267
- let db = dailyMap.get(dk);
268
- if (db === undefined) {
269
- db = emptyBucket(dk);
270
- dailyMap.set(dk, db);
64
+ const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
65
+ if (settings && typeof settings.update === 'function') {
66
+ settings.update('usage', { prices: Object.assign({}, store.prices), currency: store.currency })
67
+ .catch(() => { });
271
68
  }
272
- addToBucket(db, r, cost);
273
- let mb = modelMap.get(r.model);
274
- if (mb === undefined) {
275
- mb = emptyBucket(r.model);
276
- modelMap.set(r.model, mb);
277
- }
278
- addToBucket(mb, r, cost);
279
- addToBucket(total, r, cost);
280
69
  }
281
- let daily;
282
- if (fill) {
283
- let start;
284
- let step;
285
- let end;
286
- if (granularity === 'day') {
287
- step = stepDays * DAY_MS;
288
- start = dayAnchor !== null ? dayAnchor : alignFloor(from, 'day');
289
- end = alignFloor(to, 'day') + step;
290
- }
291
- else {
292
- step = stepMs(granularity);
293
- start = alignFloor(from, granularity);
294
- end = alignFloor(to, granularity) + step;
295
- }
296
- const count = Math.round((end - start) / step);
297
- if (count <= MAX_BUCKETS) {
298
- daily = [];
299
- for (let i = 0; i < count; i++) {
300
- const t = start + i * step;
301
- const k = useStepGrid ? dayKey(t) : bucketKeyFor(t, granularity, stepDays);
302
- daily.push(dailyMap.get(k) || emptyBucket(k));
303
- }
304
- }
305
- else {
306
- // Degenerate: too many steps; fall back to present-only buckets.
307
- daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
308
- }
309
- }
310
- else {
311
- daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
70
+ catch {
71
+ // settings is optional; the in-memory store still answers this session.
312
72
  }
313
- const byModel = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens);
314
- const models = [...seenModels.values()];
315
- // GitHub-style yearly heatmap: every calendar day of the current year up to
316
- // today, zero-filled.
317
- let heatmap = null;
318
- if (args.heatmap) {
319
- const now = Date.now();
320
- const year = new Date(now).getFullYear();
321
- const hmMap = new Map();
322
- const yearStart = new Date(year, 0, 1).getTime();
323
- for (const r of records) {
324
- if (r.time < yearStart || r.time > now)
325
- continue;
326
- if (!modelMatches(r))
327
- continue;
328
- const dk = dayKey(r.time);
329
- let b = hmMap.get(dk);
330
- if (b === undefined) {
331
- b = emptyBucket(dk);
332
- hmMap.set(dk, b);
333
- }
334
- addToBucket(b, r, costOf(r));
335
- }
336
- heatmap = [];
337
- const todayStart = alignFloor(now, 'day');
338
- for (let d = new Date(year, 0, 1); d.getTime() <= todayStart; d.setDate(d.getDate() + 1)) {
339
- const k = dayKey(d.getTime());
340
- heatmap.push(hmMap.get(k) || emptyBucket(k));
341
- }
342
- }
343
- return {
344
- daily,
345
- byModel,
346
- total,
347
- models,
348
- unpricedCalls,
349
- ...heatmap === null ? {} : { heatmap },
350
- backfill: { status: backfill.status, total: backfill.total, done: backfill.done },
351
- };
352
- }
353
- // ------------------------------------------------------------------- RPC
354
- // Static (bundle) installs expose the generic Connection RPC bridge (the
355
- // browser half calls `connection.rpc.call('/usage', endpoint, args)`);
356
- // dynamic-plugin runs expose `harness` directly. Support both from one file.
357
- const usageHandlers = new Map();
358
- function usageHandle(method, fn) {
359
- const name = String(method);
360
- usageHandlers.set(name.indexOf('/') === -1 ? name : name.slice(name.indexOf('/') + 1), fn);
361
- }
362
- usageHandle(RPC_QUERY, (args) => query(args || {}));
363
- usageHandle(RPC_PRICES, (args) => {
364
- const incoming = (args && args.prices) || null;
365
- if (incoming && typeof incoming === 'object') {
366
- for (const [model, p] of Object.entries(incoming)) {
367
- if (p && typeof p === 'object') {
368
- prices[model] = {
369
- inputPerM: Number(p.inputPerM) || 0,
370
- outputPerM: Number(p.outputPerM) || 0,
371
- cacheReadPerM: Number(p.cacheReadPerM) || 0,
372
- cacheWritePerM: Number(p.cacheWritePerM) || 0,
373
- };
374
- }
375
- }
376
- }
377
- if (args && typeof args.currency === 'string')
378
- currency = args.currency;
379
- return { prices: Object.assign({}, prices), currency };
73
+ return store.snapshot();
380
74
  });
381
- // ------------------------------------------------- client source loader
382
- // The full dashboard UI lives in src/client/index.js (single source of
383
- // truth). A dynamic plugin cannot import files, so the host reads the file
384
- // and serves it to the browser half, which evaluates it with new Function.
385
- const CLIENT_SOURCE_CANDIDATES = [
386
- '/Users/ruanbw/projects/usage/src/client/index.js',
387
- '/Users/ruanbw/projects/usage/lib/client/index.js',
388
- ];
389
- usageHandle('usage/client-source', async () => {
390
- const fs = ctx.get('fs');
391
- if (fs === undefined)
392
- return { ok: false, error: 'fs service unavailable' };
393
- for (const p of CLIENT_SOURCE_CANDIDATES) {
394
- try {
395
- const target = await fs.resolve(p);
396
- const body = await fs.readText(target);
397
- if (typeof body === 'string' && body.length > 0)
398
- return { ok: true, body };
399
- }
400
- catch (error) {
401
- // try the next candidate
402
- }
403
- }
404
- return { ok: false, error: 'client source not found (tried: ' + CLIENT_SOURCE_CANDIDATES.join(', ') + ')' };
405
- });
406
- const dynamicHarness = (typeof harness !== 'undefined' && harness && typeof harness.handle === 'function') ? harness : null;
407
- if (dynamicHarness !== null) {
408
- for (const [endpoint, fn] of usageHandlers) {
409
- dynamicHarness.handle('usage/' + endpoint, fn);
410
- }
411
- }
412
- else {
413
- // Static (bundle) installs: the `connection` service is provided
414
- // asynchronously by the client-connection plugin, so wait for it before
415
- // registering the /usage channel (the official api-gateway pattern).
416
- ctx.inject(['connection'], (connectionCtx) => {
417
- const connection = connectionCtx.connection;
418
- if (connection === undefined || connection.rpc === undefined)
419
- return;
420
- connection.rpc.handle('/usage', async (endpoint, args) => {
421
- const fn = usageHandlers.get(endpoint);
422
- if (fn === undefined) {
423
- return { ok: false, error: { code: 'bad-request', message: `unknown endpoint: ${endpoint}`, details: { issues: [] } } };
424
- }
425
- try {
426
- return { ok: true, value: await fn(args) };
427
- }
428
- catch (error) {
429
- return { ok: false, error: { code: 'internal', message: String((error && error.message) || error), details: {} } };
430
- }
431
- }, { authority: 'loopback' });
432
- });
433
- }
434
- // ---------------------------------------------------------------- startup
435
- runBackfill();
75
+ handlers.set('client-source', () => getClientSource(ctx));
76
+ registerRPC(ctx, handlers);
77
+ // Full history scan once at startup.
78
+ runBackfill(ctx, state);
436
79
  // Live sessions every tick; persisted sessions on a slow cadence to keep
437
- // per-session JSONL re-parsing cheap.
80
+ // per-session log reads cheap (unchanged revisions are skipped).
438
81
  ctx.interval(() => {
439
- scanLive();
82
+ scanLive(ctx, state);
440
83
  }, LIVE_POLL_MS);
441
84
  ctx.interval(() => {
442
- scanPersisted().catch(() => { });
85
+ scanPersisted(ctx, state).catch(() => { });
443
86
  }, PERSISTED_POLL_MS);
444
87
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Aggregation/query logic — pure over the runtime state.
3
+ *
4
+ * P1-7 (unpriced detection) and P1-8 (provider/model composite keys in the
5
+ * by-model table and model candidate list) are owned here.
6
+ */
7
+ import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js';
8
+ function emptyBucket(key) {
9
+ return {
10
+ key,
11
+ calls: 0,
12
+ inputTokens: 0,
13
+ outputTokens: 0,
14
+ cacheReadTokens: 0,
15
+ cacheWriteTokens: 0,
16
+ totalTokens: 0,
17
+ cost: 0,
18
+ };
19
+ }
20
+ function addToBucket(b, r, cost) {
21
+ b.calls += 1;
22
+ b.inputTokens += r.inputTokens;
23
+ b.outputTokens += r.outputTokens;
24
+ b.cacheReadTokens += r.cacheReadTokens;
25
+ b.cacheWriteTokens += r.cacheWriteTokens;
26
+ b.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
27
+ b.cost += cost;
28
+ return b;
29
+ }
30
+ /** Composite model key: `provider/model`, or the bare model when provider is empty. */
31
+ function compositeModelKey(r) {
32
+ return r.provider ? r.provider + '/' + r.model : r.model;
33
+ }
34
+ export function query(state, args) {
35
+ const from = typeof args.from === 'number' ? args.from : -Infinity;
36
+ const to = typeof args.to === 'number' ? args.to : Infinity;
37
+ const granularity = args.granularity === 'minute' || args.granularity === 'hour' ? args.granularity : 'day';
38
+ const stepDays = granularity === 'day' && Number.isInteger(args.stepDays) && args.stepDays > 0 ? args.stepDays : 1;
39
+ // Zero-filled step series require a finite range.
40
+ const fill = Boolean(args.fill) && Number.isFinite(from) && Number.isFinite(to);
41
+ const modelSet = Array.isArray(args.models) && args.models.length > 0
42
+ ? new Set(args.models)
43
+ : null;
44
+ /** Match either a bare model id or a `provider/model` composite key. */
45
+ const modelMatches = (r) => {
46
+ if (!modelSet)
47
+ return true;
48
+ if (modelSet.has(r.model))
49
+ return true;
50
+ return Boolean(r.provider) && modelSet.has(r.provider + '/' + r.model);
51
+ };
52
+ const dailyMap = new Map();
53
+ const modelMap = new Map();
54
+ const total = emptyBucket('total');
55
+ const seenModels = new Map(); // composite key -> {provider, model} (first composite wins)
56
+ let unpricedCalls = 0;
57
+ // Range-anchored day-step grid: blocks start at the range's first day.
58
+ const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
59
+ const useStepGrid = granularity === 'day' && stepDays > 1 && dayAnchor !== null;
60
+ for (const r of state.index ? state.index.recordsInRange(from, to) : state.records) {
61
+ if (r.time < from || r.time > to)
62
+ continue;
63
+ if (!modelMatches(r))
64
+ continue;
65
+ const cost = state.prices.costOf(r);
66
+ if (r.totalTokens > 0 && !state.prices.hasPrice(r))
67
+ unpricedCalls += 1;
68
+ const modelKey = compositeModelKey(r);
69
+ if (!seenModels.has(modelKey))
70
+ seenModels.set(modelKey, { provider: r.provider, model: r.model });
71
+ const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
72
+ let db = dailyMap.get(dk);
73
+ if (db === undefined) {
74
+ db = emptyBucket(dk);
75
+ dailyMap.set(dk, db);
76
+ }
77
+ addToBucket(db, r, cost);
78
+ let mb = modelMap.get(modelKey);
79
+ if (mb === undefined) {
80
+ mb = emptyBucket(modelKey);
81
+ modelMap.set(modelKey, mb);
82
+ }
83
+ addToBucket(mb, r, cost);
84
+ addToBucket(total, r, cost);
85
+ }
86
+ let daily;
87
+ if (fill) {
88
+ let start;
89
+ let step;
90
+ let end;
91
+ if (granularity === 'day') {
92
+ step = stepDays * DAY_MS;
93
+ start = dayAnchor !== null ? dayAnchor : alignFloor(from, 'day');
94
+ end = alignFloor(to, 'day') + step;
95
+ }
96
+ else {
97
+ step = stepMs(granularity);
98
+ start = alignFloor(from, granularity);
99
+ end = alignFloor(to, granularity) + step;
100
+ }
101
+ const count = Math.round((end - start) / step);
102
+ if (count <= MAX_BUCKETS) {
103
+ daily = [];
104
+ for (let i = 0; i < count; i++) {
105
+ const t = start + i * step;
106
+ const k = useStepGrid ? dayKey(t) : bucketKeyFor(t, granularity, stepDays);
107
+ daily.push(dailyMap.get(k) || emptyBucket(k));
108
+ }
109
+ }
110
+ else {
111
+ // Degenerate: too many steps; fall back to present-only buckets.
112
+ daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
113
+ }
114
+ }
115
+ else {
116
+ daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
117
+ }
118
+ const byModel = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens);
119
+ const models = [...seenModels.values()];
120
+ // GitHub-style yearly heatmap: every calendar day of the current year up to
121
+ // today, zero-filled.
122
+ let heatmap = null;
123
+ if (args.heatmap) {
124
+ const now = Date.now();
125
+ const year = new Date(now).getFullYear();
126
+ const hmMap = new Map();
127
+ const yearStart = new Date(year, 0, 1).getTime();
128
+ for (const r of state.index ? state.index.recordsInRange(yearStart, now) : state.records) {
129
+ if (r.time < yearStart || r.time > now)
130
+ continue;
131
+ if (!modelMatches(r))
132
+ continue;
133
+ const dk = dayKey(r.time);
134
+ let b = hmMap.get(dk);
135
+ if (b === undefined) {
136
+ b = emptyBucket(dk);
137
+ hmMap.set(dk, b);
138
+ }
139
+ addToBucket(b, r, state.prices.costOf(r));
140
+ }
141
+ heatmap = [];
142
+ const todayStart = alignFloor(now, 'day');
143
+ for (let d = new Date(year, 0, 1); d.getTime() <= todayStart; d.setDate(d.getDate() + 1)) {
144
+ const k = dayKey(d.getTime());
145
+ heatmap.push(hmMap.get(k) || emptyBucket(k));
146
+ }
147
+ }
148
+ return {
149
+ daily,
150
+ byModel,
151
+ total,
152
+ models,
153
+ unpricedCalls,
154
+ ...heatmap === null ? {} : { heatmap },
155
+ backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done },
156
+ };
157
+ }