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/README.md +101 -9
- package/image-1.png +0 -0
- package/image.png +0 -0
- package/lib/client/index.js +1566 -99
- package/lib/host/fold-cache.js +117 -0
- package/lib/host/fold.js +11 -0
- package/lib/host/holidays.js +104 -0
- package/lib/host/index.js +335 -30
- package/lib/host/peak.js +107 -0
- package/lib/host/prices.js +876 -0
- package/lib/host/pricing.js +349 -0
- package/lib/host/query.js +60 -2
- package/lib/host/rpc.js +69 -36
- package/lib/host/scan.js +231 -12
- package/lib/host/settings.js +119 -76
- package/lib/host/state.js +18 -2
- package/lib/host/typert.js +221 -0
- package/package.json +11 -5
- package/screenshots.json +4 -0
- package/lib/host/client-source.js +0 -34
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),
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
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({
|
package/lib/host/settings.js
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Currency + the user's own price table + the in-memory price store.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
|
|
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
|
-
*
|
|
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 -
|
|
72
|
-
* @returns {Record<string,
|
|
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
|
|
75
|
-
const
|
|
76
|
-
if (incoming
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
96
|
+
return out;
|
|
89
97
|
}
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
140
|
+
/** Merge incoming rows over the current ones (both normalized). */
|
|
97
141
|
setPrices(incoming) {
|
|
98
|
-
|
|
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
|
-
/**
|
|
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
|
|
118
|
-
return
|
|
166
|
+
const hit = store.lookup(r);
|
|
167
|
+
return hit.price === null ? undefined : hit.price;
|
|
119
168
|
},
|
|
120
|
-
/** Whether
|
|
169
|
+
/** Whether the ladder found a price at all. */
|
|
121
170
|
hasPrice(r) {
|
|
122
|
-
|
|
123
|
-
return composite in prices || r.model in prices;
|
|
171
|
+
return store.lookup(r).price !== null;
|
|
124
172
|
},
|
|
125
173
|
costOf(r) {
|
|
126
|
-
const
|
|
127
|
-
|
|
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
|
-
|
|
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
|
}
|