dsh-plugin-usage 0.4.0 → 0.5.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/lib/host/scan.js CHANGED
@@ -15,10 +15,14 @@
15
15
  * cut, so folding starts there and the parent is never billed twice.
16
16
  *
17
17
  * Optimization lives here: bounded-concurrency backfill, per-session failure
18
- * backoff, revision-based skip (unchanged logs are not re-read), progress
19
- * callbacks, and watermark repair when a log shrank.
18
+ * backoff, revision-based skip (unchanged logs are not re-read), the on-disk fold
19
+ * cache (unchanged logs are not even opened after a restart), progress callbacks,
20
+ * and watermark repair when a log shrank.
20
21
  */
21
22
  import { foldNew } from './fold.js';
23
+ import { cacheDir, readEntry, writeEntry } from './fold-cache.js';
24
+ import { writeFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
22
26
  /** How many persisted headers are read concurrently per batch. */
23
27
  const BATCH_SIZE = 8;
24
28
  /**
@@ -109,6 +113,114 @@ export async function readSessionEvents(persistence, id, fromSeq) {
109
113
  }
110
114
  return { events: [], inherited: 0 };
111
115
  }
116
+ /* -------------------------------------------------------------------------- *
117
+ * Fold cache plumbing
118
+ * -------------------------------------------------------------------------- */
119
+ /**
120
+ * Open one scan pass.
121
+ *
122
+ * The cache is optional and never required: a pass without it behaves exactly as
123
+ * before, which is what a failing cache degrades to.
124
+ *
125
+ * @param {{ cache?: boolean, cacheDir?: string }} [options]
126
+ * @returns {{ dir: string | null, scanned: Set<string>, hydrated: number }}
127
+ */
128
+ function beginPass(options) {
129
+ const enabled = !(options && options.cache === false);
130
+ return {
131
+ dir: enabled ? (options && options.cacheDir) || cacheDir() : null,
132
+ scanned: new Set(),
133
+ hydrated: 0,
134
+ startedAt: Date.now(),
135
+ listMs: 0,
136
+ };
137
+ }
138
+ /**
139
+ * One line per pass, so a slow start explains itself instead of being guessed at.
140
+ *
141
+ * The line goes to the logger AND into `state.lastPass`, which the dashboard
142
+ * response carries: on this host the plugin logger does not reliably reach the
143
+ * desktop log file, and a probe nobody can read is not a probe.
144
+ */
145
+ function reportPass(pass, counts, log) {
146
+ const ms = Date.now() - pass.startedAt;
147
+ const summary = {
148
+ at: pass.startedAt,
149
+ sessions: counts.total,
150
+ hydrated: counts.hydrated,
151
+ folded: counts.folded,
152
+ listMs: pass.listMs,
153
+ totalMs: ms,
154
+ };
155
+ return summary;
156
+ }
157
+ /** Records grouped by owning session, in one pass over the record array. */
158
+ function groupRecordsBySession(records) {
159
+ const by = new Map();
160
+ for (const record of records) {
161
+ const id = record && record.session;
162
+ if (typeof id !== 'string')
163
+ continue;
164
+ let list = by.get(id);
165
+ if (list === undefined) {
166
+ list = [];
167
+ by.set(id, list);
168
+ }
169
+ list.push(record);
170
+ }
171
+ return by;
172
+ }
173
+ /**
174
+ * Cache one hydrated session's records and bookkeeping into the live state.
175
+ *
176
+ * Refuses an entry whose records do not belong to `id`: a mixed-up file would
177
+ * otherwise attribute one session's tokens to another.
178
+ *
179
+ * @returns {boolean} whether the entry was usable (and therefore consumed).
180
+ */
181
+ function hydrateFromCache(state, id, entry, revision) {
182
+ for (const record of entry.records) {
183
+ if (!record || record.session !== id)
184
+ return false;
185
+ }
186
+ for (const record of entry.records) {
187
+ state.records.push(record);
188
+ if (state.index)
189
+ state.index.add(record);
190
+ }
191
+ state.watermarks.set(id, entry.watermark);
192
+ if (revision !== undefined)
193
+ state.revisions.set(id, revision);
194
+ return true;
195
+ }
196
+ /**
197
+ * Persist what this pass folded, then drop sessions that no longer exist.
198
+ *
199
+ * Only sessions this pass actually folded are rewritten: an untouched session's
200
+ * entry stays valid because its revision did not change.
201
+ */
202
+ function finishPass(state, headers, pass) {
203
+ if (pass.dir === null)
204
+ return;
205
+ if (pass.scanned.size > 0) {
206
+ const bySession = groupRecordsBySession(state.records);
207
+ for (const id of pass.scanned) {
208
+ writeEntry(pass.dir, id, {
209
+ revision: state.revisions.get(id),
210
+ watermark: state.watermarks.get(id) ?? -1,
211
+ records: bySession.get(id) || [],
212
+ });
213
+ }
214
+ }
215
+ // ⚠️ Do NOT prune entries missing from this listing. `sp.list()` returns a
216
+ // PARTIAL list while the session store is still warming up after a start, and a
217
+ // prune keyed on "absent here" therefore deletes entries that are perfectly
218
+ // valid. That is not hypothetical: it wiped 257 of 258 entries on a live
219
+ // instance at 00:18 and every following start had to fold the whole history
220
+ // again — the "numbers climb for 30 s" symptom. A stale entry costs disk and
221
+ // nothing else (it is only read by id from a listing), so it is never worth
222
+ // deleting speculatively.
223
+ }
112
224
  /**
113
225
  * Incremental scan of persisted sessions (slow cadence). Headers are read in
114
226
  * small concurrent batches; unchanged revisions are skipped; a failing session
@@ -118,18 +230,44 @@ export async function readSessionEvents(persistence, id, fromSeq) {
118
230
  export async function scanPersisted(ctx, state, options) {
119
231
  const sp = ctx.get('sessionPersistence');
120
232
  if (sp === undefined || sp === null)
121
- return;
233
+ return false;
122
234
  if (persistedInFlight)
123
- return; // previous pass still running: skip this tick
235
+ return false; // previous pass still running: skip this tick
124
236
  persistedInFlight = true;
237
+ const pass = beginPass(options);
125
238
  try {
239
+ const listedAt = Date.now();
126
240
  const headers = await sp.list();
241
+ pass.listMs = Date.now() - listedAt;
127
242
  if (!Array.isArray(headers))
128
- return;
243
+ return true;
129
244
  state.backfill.total = headers.length;
130
245
  for (let i = 0; i < headers.length; i += BATCH_SIZE) {
131
246
  const batch = headers.slice(i, i + BATCH_SIZE);
132
- await Promise.all(batch.map((h) => processHeader(sp, state, h, options)));
247
+ await Promise.all(batch.map((h) => processHeader(sp, state, h, options, pass)));
248
+ }
249
+ finishPass(state, headers, pass);
250
+ // The counters must agree at the end, WHOEVER started the pass: a cadence
251
+ // pass runs while status is already 'done', and gating on status left
252
+ // `{status: done, total: 258, done: 0}` in the response — which reads as
253
+ // "nothing was scanned" while every record was in fact loaded.
254
+ state.backfill.done = headers.length;
255
+ const summary = reportPass(pass, { total: headers.length, hydrated: pass.hydrated, folded: pass.scanned.size });
256
+ state.lastPass = summary;
257
+ // Also leave it on disk: the log line needs a logger that reaches the host
258
+ // log (this host does not always), while the file is readable without a
259
+ // browser. Best effort: a failed diagnostic must never fail the pass.
260
+ if (pass.dir !== null) {
261
+ try {
262
+ writeFileSync(join(dirname(pass.dir), 'last-pass.json'), JSON.stringify(summary, null, 2));
263
+ }
264
+ catch {
265
+ // diagnostics only
266
+ }
267
+ }
268
+ if (options && typeof options.log === 'function') {
269
+ safeLog(options.log, '[usage] scan pass: ' + summary.sessions + ' sessions, ' + summary.hydrated
270
+ + ' from cache, ' + summary.folded + ' folded, list ' + summary.listMs + 'ms, total ' + summary.totalMs + 'ms');
133
271
  }
134
272
  }
135
273
  finally {
@@ -144,7 +282,7 @@ export async function scanPersisted(ctx, state, options) {
144
282
  }
145
283
  }
146
284
  /** Read + fold one persisted session header; bounded by the batch caller. */
147
- async function processHeader(sp, state, snap, options) {
285
+ async function processHeader(sp, state, snap, options, pass) {
148
286
  const id = snapshotId(snap);
149
287
  if (id === undefined)
150
288
  return;
@@ -160,6 +298,18 @@ async function processHeader(sp, state, snap, options) {
160
298
  backoffBySession.delete(id);
161
299
  return;
162
300
  }
301
+ // Cache hit: same change token as the snapshot on disk, so the folded
302
+ // records are still exactly what a fresh fold would produce. This is the
303
+ // whole point of the cache — after a restart, an unchanged session costs one
304
+ // JSON read instead of a full log read + inflate + fold.
305
+ if (pass !== undefined && pass !== null && pass.dir !== null && revision !== undefined) {
306
+ const entry = readEntry(pass.dir, id);
307
+ if (entry !== null && entry.revision === revision && hydrateFromCache(state, id, entry, revision)) {
308
+ pass.hydrated += 1;
309
+ backoffBySession.delete(id);
310
+ return;
311
+ }
312
+ }
163
313
  let wm = state.watermarks.get(id) ?? -1;
164
314
  // A crash-repaired or migrated log can shrink below our watermark; the
165
315
  // backend reports the current event count, so repair instead of stalling.
@@ -174,6 +324,8 @@ async function processHeader(sp, state, snap, options) {
174
324
  state.watermarks.set(id, next);
175
325
  if (revision !== undefined)
176
326
  state.revisions.set(id, revision);
327
+ if (pass !== undefined && pass !== null)
328
+ pass.scanned.add(id);
177
329
  backoffBySession.delete(id); // success resets the consecutive-failure chain
178
330
  }
179
331
  catch (error) {
@@ -197,7 +349,40 @@ async function processHeader(sp, state, snap, options) {
197
349
  }
198
350
  }
199
351
  }
200
- /** Full history scan, once at startup. */
352
+ /**
353
+ * Start the full-history scan on demand, from the first query.
354
+ *
355
+ * Why not at activation: the pass is the only moment "缓存加载" is visible, and it
356
+ * takes ~0.8 s warm (12 s cold). Started from `apply` it finishes before anyone
357
+ * opens the panel, so the loading state can never be seen — the user restarted a
358
+ * dozen times and saw nothing. Started from the first query, the panel's own
359
+ * opening *is* the computation window, so the skeleton and the progress line are
360
+ * on screen for exactly as long as the work takes.
361
+ *
362
+ * Returns true when this call started it, false when it was already started.
363
+ */
364
+ export function startBackfillOnDemand(ctx, state, options) {
365
+ if (state.backfill.started === true)
366
+ return false;
367
+ // Flip the visible state first: the caller renders the response for this very
368
+ // request, and it must already say "running" or the client shows the empty
369
+ // dashboard for one frame. `runBackfill` flips `started` synchronously.
370
+ state.backfill.status = 'running';
371
+ state.backfill.done = 0;
372
+ const log = options && typeof options.log === 'function' ? options.log : undefined;
373
+ // Fire-and-forget **with** a catch. An unhandled rejection here is fatal on this
374
+ // host, and `runBackfill`'s own try only begins after its `ctx.get(...)` probe,
375
+ // so a throwing context access would otherwise crash the process from a plain
376
+ // read request. Same failure class as the `logSink(...).warn` crash: background
377
+ // work must never be able to take the host down.
378
+ void runBackfill(ctx, state, options).catch((error) => {
379
+ state.backfill.status = 'done';
380
+ if (log !== undefined)
381
+ safeLog(log, '[usage] backfill rejected: ' + (error && error.message ? error.message : String(error)));
382
+ });
383
+ return true;
384
+ }
385
+ /** Full history scan, once per process (lazily, from `startBackfillOnDemand`). */
201
386
  export async function runBackfill(ctx, state, options) {
202
387
  const sp = ctx.get('sessionPersistence');
203
388
  if (sp === undefined || sp === null) {
@@ -205,23 +390,57 @@ export async function runBackfill(ctx, state, options) {
205
390
  fireBackfillDone(options, state);
206
391
  return;
207
392
  }
393
+ // "只跑一次"由 `started` 记,不由 `status` 记:按需启动时调用方会先把 status
394
+ // 置成 running(好让发起查询的那一帧就带上加载态),用 status 当守卫会把真正
395
+ // 要干的活挡在门外。
396
+ if (state.backfill.started === true)
397
+ return;
398
+ state.backfill.started = true;
208
399
  state.backfill.status = 'running';
209
400
  state.backfill.done = 0;
210
401
  // Delegate progress while the pass runs; onDone is fired right here exactly
211
402
  // once so the pass callback does not double-report backfill completion.
212
- const scanOptions = options && typeof options.onProgress === 'function'
213
- ? { onProgress: options.onProgress }
214
- : undefined;
403
+ const log = options && typeof options.log === 'function' ? options.log : undefined;
404
+ // Forward every scan option (cache / cacheDir included) instead of rebuilding
405
+ // the object: rebuilding dropped `cache: false` and quietly used the real cache
406
+ // directory in tests. `onDone` stays here — runBackfill fires it exactly once.
407
+ const { onDone: ignoredOnDone, ...scanOptions } = options || {};
408
+ if (log !== undefined)
409
+ scanOptions.log = log;
215
410
  try {
216
- await scanPersisted(ctx, state, scanOptions);
411
+ let ran = await scanPersisted(ctx, state, scanOptions);
412
+ // A cadence pass may already be in flight when the plugin activates. That pass
413
+ // does the work, but "done" must not be announced before anything was read —
414
+ // that is how the response ended up saying `done: 0` while every record was
415
+ // loaded.
416
+ for (let attempt = 0; ran === false && attempt < 40; attempt += 1) {
417
+ await new Promise((resolve) => setTimeout(resolve, 250));
418
+ ran = await scanPersisted(ctx, state, scanOptions);
419
+ }
217
420
  state.backfill.status = 'done';
218
421
  fireBackfillDone(options, state);
219
422
  }
220
423
  catch (error) {
424
+ // A failed pass must not wedge the plugin in `running` — but it must not
425
+ // vanish either: "the dashboard never filled in" is unanswerable if the
426
+ // reason was swallowed here.
427
+ // Logging is best-effort: a broken sink must cost one line, never the host.
428
+ // (This catch used to re-throw through a bad logger and took dsh down.)
221
429
  state.backfill.status = 'done';
430
+ if (log !== undefined)
431
+ safeLog(log, '[usage] backfill failed: ' + (error && error.message ? error.message : String(error)));
222
432
  fireBackfillDone(options, state);
223
433
  }
224
434
  }
435
+ /** Call a diagnostic sink without ever letting it throw into the work. */
436
+ function safeLog(sink, message) {
437
+ try {
438
+ sink(message);
439
+ }
440
+ catch {
441
+ // A logger that throws is a logging problem, not a scanning problem.
442
+ }
443
+ }
225
444
  function fireBackfillDone(options, state) {
226
445
  if (options && typeof options.onDone === 'function') {
227
446
  options.onDone({
@@ -1,26 +1,37 @@
1
1
  /**
2
- * Price table + cost computation.
2
+ * Currency + the user's own price table + the in-memory price store.
3
3
  *
4
- * Persistence is the official plugin-config path: `index.js` exports a
5
- * schemastery `Config`, so DSH materializes a form under the plugin entry's
6
- * id (`usage`) in the Plugins settings tab and stores values in the profile
7
- * patch. There is deliberately no private `settings.yaml` / settings-service
8
- * registration here — DSH 0.2.x `ctx.settings` (SettingsForms) only offers
9
- * `describe/update/replace/mutate/configure`, and hand-rolled namespaces are
10
- * not reachable from the client config surface.
4
+ * There is exactly one pricing policy in this plugin, and it lives in
5
+ * pricing.js: the ladder. This file owns the two things around it that are not
6
+ * policy —
11
7
  *
12
- * The store below stays the in-memory view the aggregation code reads.
13
- */
14
- export const DEFAULT_CURRENCY = '¥';
15
- /**
16
- * Currency the price table is denominated in.
8
+ * - the display currency, converted once at the query boundary;
9
+ * - the user's explicit price rows, which are an *override*, never a catalogue.
10
+ *
11
+ * That last point is the whole reason this file lost its `DEFAULT_PRICES`: a
12
+ * hard-coded copy of a vendor price is a second source of truth that outranks
13
+ * the generated table (the ladder checks the user table first), so it silently
14
+ * wins even when it is stale. It was: one row carried a cached-input price 13.3×
15
+ * the official list, and seven of ten rows named ids the vendor had retired. A
16
+ * price the reference table can answer belongs in the generation script; a price
17
+ * only the user knows (an odd relay id) belongs here.
18
+ *
19
+ * Spelling: the config form persists `inputPerM`-style fields because that is
20
+ * what older profiles already contain, and renaming them would zero every saved
21
+ * price on the next save. `normalizePrices` / `configPrices` are the only two
22
+ * places that know this, and the store works in the canonical
23
+ * `{ input, output, cacheRead, cacheWrite }` shape that `ratesOf` reads.
17
24
  *
18
- * DEFAULT_PRICES carries DeepSeek's USD list price per 1M tokens (0.27 input /
19
- * 1.10 output / 0.07 cache-read for `deepseek-chat`, which is exactly 2 / 8 /
20
- * 0.5 CNY at the fixed 7:1). The table is never rewritten at runtime: a display
21
- * switch converts on the way out, so a query can never pollute the next one.
25
+ * Persistence stays the official plugin-config path: `index.js` exports a
26
+ * schemastery `Config`, so DSH materializes a form under the plugin entry's id
27
+ * (`usage`) in the Plugins settings tab. There is deliberately no private
28
+ * `settings.yaml` / settings-service registration here — DSH 0.2.x
29
+ * `ctx.settings` (SettingsForms) only offers `describe/update/replace/mutate/
30
+ * configure`, and hand-rolled namespaces are not reachable from the client
31
+ * config surface.
22
32
  */
23
- export const PRICE_UNIT_CURRENCY = 'USD';
33
+ import { costOf, resolvePrice } from './pricing.js';
34
+ export const DEFAULT_CURRENCY = '¥';
24
35
  /** Display currencies; `perUsd` is how many units of it one USD buys. */
25
36
  export const CURRENCIES = {
26
37
  USD: { code: 'USD', symbol: '$', perUsd: 1 },
@@ -51,85 +62,117 @@ export function currencyOf(value, fallback) {
51
62
  return fb;
52
63
  return CURRENCIES[currencyCodeOf(DEFAULT_CURRENCY)] || CURRENCIES.CNY;
53
64
  }
54
- export const DEFAULT_PRICES = {
55
- 'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
56
- 'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
57
- 'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
58
- 'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
59
- 'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
60
- 'deepseek-v4.1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
61
- 'deepseek-v4.1-flash-1017': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
62
- // DSH's routed default model id (`magpie` provider) aliases the same route;
63
- // without this entry every session bills as "unpriced" on a stock install.
64
- 'group/auto-deepseek-v4-1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
65
- 'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
66
- 'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
67
- };
65
+ /** Rate field on the canonical row, and the name the config form persists. */
66
+ const RATE_FIELDS = [
67
+ ['input', 'inputPerM'],
68
+ ['output', 'outputPerM'],
69
+ ['cacheRead', 'cacheReadPerM'],
70
+ ['cacheWrite', 'cacheWritePerM'],
71
+ ];
68
72
  /**
69
- * Shallow-merge configured prices over the defaults.
73
+ * Turn configured rows into the canonical rate shape the ladder reads.
74
+ *
75
+ * Accepts both spellings, drops entries that are not objects, and coerces every
76
+ * rate to a number (a form field saved as a string must not become NaN and then
77
+ * a zero cost). Unknown keys are dropped: a typo must not become a price.
70
78
  *
71
- * @param {unknown} incoming - configured per-model prices.
72
- * @returns {Record<string, object>} complete price table.
79
+ * @param {unknown} incoming - rows from the plugin config or the `prices` RPC.
80
+ * @returns {Record<string, { input: number, output: number, cacheRead: number, cacheWrite: number }>}
73
81
  */
74
- export function mergePrices(incoming) {
75
- const merged = Object.assign({}, DEFAULT_PRICES);
76
- if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
77
- for (const [model, price] of Object.entries(incoming)) {
78
- if (price && typeof price === 'object') {
79
- merged[model] = {
80
- inputPerM: Number(price.inputPerM) || 0,
81
- outputPerM: Number(price.outputPerM) || 0,
82
- cacheReadPerM: Number(price.cacheReadPerM) || 0,
83
- cacheWritePerM: Number(price.cacheWritePerM) || 0,
84
- };
85
- }
82
+ export function normalizePrices(incoming) {
83
+ const out = {};
84
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming))
85
+ return out;
86
+ for (const [key, row] of Object.entries(incoming)) {
87
+ if (!row || typeof row !== 'object')
88
+ continue;
89
+ const rate = {};
90
+ for (const [canonical, legacy] of RATE_FIELDS) {
91
+ const v = row[canonical] !== undefined ? row[canonical] : row[legacy];
92
+ rate[canonical] = Number(v) || 0;
86
93
  }
94
+ out[key] = rate;
87
95
  }
88
- return merged;
96
+ return out;
89
97
  }
90
- export function createPriceStore(defaultPrices, defaultCurrency) {
91
- const prices = Object.assign({}, defaultPrices);
98
+ /**
99
+ * The inverse of `normalizePrices`, for the config form.
100
+ *
101
+ * Only needed because the persisted schema still spells `inputPerM`; keeping the
102
+ * mapper here means the rest of the codebase never sees that spelling.
103
+ *
104
+ * @param {Record<string, object>} prices - canonical rows.
105
+ * @returns {Record<string, object>} rows the Config schema accepts.
106
+ */
107
+ export function configPrices(prices) {
108
+ const out = {};
109
+ for (const [key, row] of Object.entries(prices || {})) {
110
+ const rate = {};
111
+ for (const [canonical, legacy] of RATE_FIELDS)
112
+ rate[legacy] = Number(row[canonical]) || 0;
113
+ out[key] = rate;
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * The in-memory pricing view the aggregation code reads.
119
+ *
120
+ * It owns no policy: every answer comes from `resolvePrice` / `costOf`, so the
121
+ * dashboard, the dialog and the settings form cannot disagree about what a model
122
+ * costs.
123
+ *
124
+ * @param {unknown} defaultPrices - configured price rows (any spelling).
125
+ * @param {string} [defaultCurrency] - display currency.
126
+ * @param {{ matches?: Record<string,string>|(() => Record<string,string>) }} [options]
127
+ * confirmed matches, or a getter for the live map (a commit replaces the map,
128
+ * so a store closing over a snapshot would price from the pre-commit state).
129
+ */
130
+ export function createPriceStore(defaultPrices, defaultCurrency, options = {}) {
131
+ const prices = normalizePrices(defaultPrices);
92
132
  let currency = defaultCurrency || DEFAULT_CURRENCY;
133
+ const matchesOf = typeof options.matches === 'function'
134
+ ? options.matches
135
+ : () => (options.matches && typeof options.matches === 'object' ? options.matches : null);
136
+ const isRecord = (r) => r !== null && typeof r === 'object';
93
137
  const store = {
94
138
  prices,
95
139
  get currency() { return currency; },
96
- /** Merge incoming per-model prices (each field coerced to a number). */
140
+ /** Merge incoming rows over the current ones (both normalized). */
97
141
  setPrices(incoming) {
98
- if (!incoming || typeof incoming !== 'object')
99
- return;
100
- for (const [model, p] of Object.entries(incoming)) {
101
- if (p && typeof p === 'object') {
102
- prices[model] = {
103
- inputPerM: Number(p.inputPerM) || 0,
104
- outputPerM: Number(p.outputPerM) || 0,
105
- cacheReadPerM: Number(p.cacheReadPerM) || 0,
106
- cacheWritePerM: Number(p.cacheWritePerM) || 0,
107
- };
108
- }
109
- }
142
+ Object.assign(prices, normalizePrices(incoming));
110
143
  },
111
144
  setCurrency(v) {
112
145
  if (typeof v === 'string' && v.length > 0)
113
146
  currency = v;
114
147
  },
115
- /** Look up a price by `provider/model` composite first, then bare model. */
148
+ /**
149
+ * Resolve one record to a price.
150
+ *
151
+ * @param {{ provider?: string, model?: string }} r - a usage record.
152
+ * @returns {{ price: object|null, how: string, state: string, target: string|null, why: string }}
153
+ */
154
+ lookup(r) {
155
+ if (!isRecord(r))
156
+ return { price: null, how: 'unmatched', state: 'none', target: null, why: '' };
157
+ return resolvePrice({
158
+ model: typeof r.model === 'string' ? r.model : '',
159
+ provider: typeof r.provider === 'string' ? r.provider : '',
160
+ custom: prices,
161
+ matches: matchesOf(),
162
+ });
163
+ },
164
+ /** The first hit of the ladder, keeping the old `undefined when absent` contract. */
116
165
  priceOf(r) {
117
- const composite = r.provider ? r.provider + '/' + r.model : '';
118
- return prices[composite] || prices[r.model];
166
+ const hit = store.lookup(r);
167
+ return hit.price === null ? undefined : hit.price;
119
168
  },
120
- /** Whether an explicit price entry exists (composite first, then bare model). */
169
+ /** Whether the ladder found a price at all. */
121
170
  hasPrice(r) {
122
- const composite = r.provider ? r.provider + '/' + r.model : '';
123
- return composite in prices || r.model in prices;
171
+ return store.lookup(r).price !== null;
124
172
  },
125
173
  costOf(r) {
126
- const p = store.priceOf(r);
127
- if (!p)
128
- return 0;
129
- return (r.inputTokens * p.inputPerM +
130
- r.outputTokens * p.outputPerM +
131
- r.cacheReadTokens * (p.cacheReadPerM || 0) +
132
- r.cacheWriteTokens * (p.cacheWritePerM || 0)) / 1e6;
174
+ const hit = store.lookup(r);
175
+ return hit.price === null ? 0 : costOf(r, hit.price);
133
176
  },
134
177
  snapshot() {
135
178
  return { prices: Object.assign({}, prices), currency };
package/lib/host/state.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * Mutable runtime state capsule for the usage host half.
3
3
  *
4
4
  * - records: folded usage records, one per billed assistant settlement
5
- * (`assistant/message` or `assistant/attempt`) that reported usage.
5
+ * (`assistant/message` or `assistant/attempt`) that reported usage. A record
6
+ * may also carry `tokens: { peak?, off? }` — the same usage split by the price
7
+ * tier its own timestamp fell into (see fold.js).
6
8
  * - watermarks: sessionId -> last folded event seq.
7
9
  * - revisions: sessionId -> last persisted revision folded (opaque equality
8
10
  * token from `sessionPersistence.list()`); lets the slow scan skip sessions
@@ -17,16 +19,30 @@
17
19
  * (`assistant/attempt` never does).
18
20
  * - index: incremental aggregation index (aggregate.js), kept in sync by
19
21
  * fold.js and consumed by query.js.
22
+ * - peakOf: `(atMs) => boolean` price-tier test, injected by apply(). Absent
23
+ * means "record flat token fields only", which is what the tests rely on.
24
+ * - matchMap: model key -> reference model id, the user's confirmed matches.
25
+ * A key that is present overrides the automatic ladder in pricing.js.
20
26
  */
21
27
  export function createState(priceStore) {
22
28
  return {
23
29
  records: [],
24
30
  watermarks: new Map(),
25
31
  revisions: new Map(),
26
- backfill: { status: 'idle', total: 0, done: 0 },
32
+ // 启动回填是**按需**触发的:`idle` 一直保持到第一次查询(面板真正要被看)。
33
+ // 这样"缓存加载"这件事发生在用户眼前,而不是在进程启动的头一秒里悄悄做完。
34
+ // `started` 与 `status` 分开:调用方会先把 status 置 running 好让这一帧就带上
35
+ // 加载态,所以不能用 status 当"只跑一次"的守卫。
36
+ backfill: { status: 'idle', total: 0, done: 0, started: false },
37
+ // Last scan pass summary: how long listing took, how much came from the fold
38
+ // cache, how long the whole pass took. Surfaced in the dashboard response —
39
+ // the plugin logger does not reliably reach the host log file on this host.
40
+ lastPass: null,
27
41
  prices: priceStore,
28
42
  lastSample: new Map(),
29
43
  route: new Map(),
30
44
  index: null, // wired by apply() after createUsageIndex(state)
45
+ peakOf: null, // wired by apply() once the holiday calendar is known
46
+ matchMap: {},
31
47
  };
32
48
  }