dsh-plugin-cost-meter 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.6.0
6
+
7
+ - **Historical costs are now frozen.** An amount is computed and stored when
8
+ its usage sample is folded, and `wire.view` only sums the stored amounts.
9
+ Previously the view re-priced the whole token history with the live table,
10
+ so a price update (or a catalog refresh) rewrote past sessions' totals.
11
+ Price updates now apply to samples folded after them only.
12
+ - Replacing a duplicated sample (`chunk` → `message`) subtracts the frozen
13
+ amount of the sample it replaces, so dedup stays exact.
14
+ - The currency a session is counted in is stored with the state, so a later
15
+ `currency` change does not silently reinterpret old amounts.
16
+ - Projection state bumped to `stateVersion 4`. The persisted projection cache
17
+ is invalidated once on upgrade, so every session is re-folded against the
18
+ current table at that moment (the same table the badge already displayed),
19
+ then frozen from there on.
20
+ - Two regression tests: a price swap leaves counted samples untouched, and a
21
+ sample replacement subtracts exactly.
22
+
5
23
  ## 0.5.2
6
24
 
7
25
  - The remote catalog's `peakSchedule` now overrides the bundled snapshot, so a
package/README.md CHANGED
@@ -99,7 +99,8 @@ A timer re-reads the remote catalog and `pricingUrl` every `refreshMinutes` (def
99
99
 
100
100
  - a failed refresh **keeps the last successful data**;
101
101
  - a source that never loaded does not clear anything;
102
- - a price change applies to the whole session on the **next event**, and immediately on a page reload.
102
+ - **historical amounts are frozen**: a sample is priced when it is folded and the amount is stored, so a price change applies only to samples folded after it — a past session's total is never rewritten;
103
+ - the frozen amounts ride the persisted projection cache, so they survive restarts; only a cold replay without a checkpoint prices the log at the then-current table.
103
104
 
104
105
  This makes the catalog the single place to maintain rates: **edit and push `catalog/prices.json`, and installed copies update themselves** — no new plugin release. Forks get their own update channel automatically because the default URL is derived from `package.json.repository`.
105
106
 
package/README.zh.md CHANGED
@@ -100,7 +100,8 @@ dsh web
100
100
 
101
101
  - 刷新失败**保留上一次成功的数据**;
102
102
  - 从未成功过的源不会清空任何已有数据;
103
- - 价格变更会在**下一次会话事件**时对整个会话重新计价;刷新页面则立即生效。
103
+ - **历史金额已冻结**:每条用量在折叠时按当时的价格表计价并存入状态,因此改价只影响其后折叠的样本——过去会话的总额不会被重写;
104
+ - 冻结的金额随持久化投影缓存保存,重启后仍在;只有完全没有检查点的冷回放才会按当时价格表重新计价。
104
105
 
105
106
  因此价格只需维护**一处**:**改并推送 `catalog/prices.json`,已安装的插件自动更新**,不用发版。默认地址由 `package.json.repository` 推导,fork 出来的仓库自动拥有自己的更新通道。
106
107
 
package/lib/index.js CHANGED
@@ -16,8 +16,13 @@
16
16
  * Silent updates: a timer re-reads the remote catalog / pricingUrl on an
17
17
  * interval (`config.refreshMinutes`, default 360) and swaps the live table.
18
18
  * A failed refresh keeps the last successful data; a failed source never
19
- * clears data it did not create. The badge reflects a price change on the
20
- * next session event (the whole session is re-priced) or a page reload.
19
+ * clears data it did not create.
20
+ *
21
+ * Historical costs are immutable: an amount is frozen when its sample is
22
+ * folded, so a price change only affects samples folded after it. A past
23
+ * session is never re-priced (the projection cache persists the frozen
24
+ * amounts; a cold replay without a checkpoint necessarily prices the log at
25
+ * the then-current table).
21
26
  *
22
27
  * Peak / off-peak
23
28
  * ---------------
@@ -390,7 +395,11 @@ async function loadSources(config, warn, previous) {
390
395
 
391
396
  /**
392
397
  * Build the `costUsage` projection unit over a live source ref.
393
- * State is pure token accounting per (route × tier); `wire.view` prices it.
398
+ *
399
+ * Money is **frozen at fold time**: a usage sample is priced with the table in
400
+ * effect the moment it enters the fold, and the amount is stored in the state.
401
+ * `wire.view` only sums those stored amounts, so a later price update changes
402
+ * future samples only and never rewrites an already-counted session.
394
403
  */
395
404
  function buildProjection(live) {
396
405
  const bucketsSchema = zod.object({
@@ -404,12 +413,18 @@ function buildProjection(live) {
404
413
  provider: zod.string().nullable(),
405
414
  model: zod.string().nullable(),
406
415
  off: bucketsSchema,
407
- peak: bucketsSchema
416
+ peak: bucketsSchema,
417
+ /** Frozen amount contributed by this route, in `state.currency`. */
418
+ cost: zod.number().nonnegative(),
419
+ /** Count of folded samples this route had no price for. */
420
+ unpriced: zod.number().int().nonnegative()
408
421
  }).strict();
409
422
 
410
423
  const stateSchema = zod.object({
411
424
  provider: zod.string().nullable(),
412
425
  model: zod.string().nullable(),
426
+ /** Currency the frozen amounts are expressed in (null before the first priced sample). */
427
+ currency: zod.string().nullable(),
413
428
  perRoute: zod.record(zod.string(), routeSchema),
414
429
  last: zod.union([
415
430
  zod.null(),
@@ -418,7 +433,10 @@ function buildProjection(live) {
418
433
  step: zod.number().int(),
419
434
  route: zod.string(),
420
435
  tier: zod.enum(["off", "peak"]),
421
- buckets: bucketsSchema
436
+ buckets: bucketsSchema,
437
+ /** Frozen amount of this sample, so a replacement can subtract it exactly. */
438
+ cost: zod.number().nonnegative(),
439
+ unpriced: zod.number().int().nonnegative()
422
440
  }).strict()
423
441
  ])
424
442
  }).strict();
@@ -441,7 +459,7 @@ function buildProjection(live) {
441
459
  return {
442
460
  key: "costUsage",
443
461
  stateSchema,
444
- init: () => ({ provider: null, model: null, perRoute: {}, last: null }),
462
+ init: () => ({ provider: null, model: null, currency: null, perRoute: {}, last: null }),
445
463
  apply(state, event) {
446
464
  const liveNow = live.current;
447
465
  let provider = state.provider;
@@ -471,47 +489,57 @@ function buildProjection(live) {
471
489
  const route = `${provider ?? ""}\u0000${model ?? ""}`;
472
490
  const buckets = bucketsFrom(usage);
473
491
 
492
+ // Freeze the amount now, with the table in effect when this sample is
493
+ // folded. Later price updates must never rewrite this contribution.
494
+ const matched = lookupPrice(liveNow.table, provider, model);
495
+ const contribution = matched === undefined ? 0 : costOf(buckets, matched.rates[tier]);
496
+ const unpricedContribution = matched === undefined ? 1 : 0;
497
+ const currency = state.currency ?? (matched === undefined ? null : liveNow.table.currency);
498
+
474
499
  const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : undefined;
475
500
  if (previous !== undefined && previous.route === route && previous.tier === tier && bucketsEqual(previous.buckets, buckets)) {
476
- return provider === state.provider && model === state.model ? state : { ...state, provider, model };
501
+ return provider === state.provider && model === state.model && currency === state.currency
502
+ ? state
503
+ : { ...state, provider, model, currency };
477
504
  }
478
505
 
479
506
  const perRoute = { ...state.perRoute };
480
507
  if (previous !== undefined) {
481
- const replaced = { ...(perRoute[previous.route] ?? { provider: null, model: null, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS() }) };
508
+ const replaced = { ...(perRoute[previous.route] ?? { provider: null, model: null, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS(), cost: 0, unpriced: 0 }) };
482
509
  replaced[previous.tier] = subBuckets(replaced[previous.tier], previous.buckets);
510
+ replaced.cost = Math.max(0, replaced.cost - previous.cost);
511
+ replaced.unpriced = Math.max(0, replaced.unpriced - previous.unpriced);
483
512
  perRoute[previous.route] = replaced;
484
513
  }
485
- const entry = { ...(perRoute[route] ?? { provider, model, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS() }) };
514
+ const entry = { ...(perRoute[route] ?? { provider, model, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS(), cost: 0, unpriced: 0 }) };
486
515
  entry.provider = provider;
487
516
  entry.model = model;
488
517
  entry[tier] = addBuckets(entry[tier], buckets);
518
+ entry.cost += contribution;
519
+ entry.unpriced += unpricedContribution;
489
520
  perRoute[route] = entry;
490
521
 
491
- return { provider, model, perRoute, last: { turn, step, route, tier, buckets } };
522
+ return { provider, model, currency, perRoute, last: { turn, step, route, tier, buckets, cost: contribution, unpriced: unpricedContribution } };
492
523
  },
493
524
  wire: {
494
525
  viewSchema,
495
526
  view(state) {
496
527
  const liveNow = live.current;
497
- const table = liveNow.table;
498
528
  const totals = ZERO_BUCKETS();
499
529
  const unpriced = [];
500
530
  let cost = 0;
501
531
  for (const entry of Object.values(state.perRoute)) {
502
- const matched = lookupPrice(table, entry.provider, entry.model);
503
- if (matched === undefined) {
504
- unpriced.push(`${entry.provider ?? "?"}/${entry.model ?? "?"}`);
505
- } else {
506
- cost += costOf(entry.off, matched.rates.off) + costOf(entry.peak, matched.rates.peak);
507
- }
532
+ // Sum frozen amounts only. Re-pricing here would let a later price
533
+ // update rewrite a past session's total, which this plugin forbids.
534
+ cost += entry.cost;
535
+ if (entry.unpriced > 0) unpriced.push(`${entry.provider ?? "?"}/${entry.model ?? "?"}`);
508
536
  totals.uncachedInputTokens += entry.off.uncachedInputTokens + entry.peak.uncachedInputTokens;
509
537
  totals.outputTokens += entry.off.outputTokens + entry.peak.outputTokens;
510
538
  totals.cacheReadTokens += entry.off.cacheReadTokens + entry.peak.cacheReadTokens;
511
539
  totals.cacheWriteTokens += entry.off.cacheWriteTokens + entry.peak.cacheWriteTokens;
512
540
  }
513
541
  return {
514
- currency: table.currency,
542
+ currency: state.currency ?? liveNow.table.currency,
515
543
  cost,
516
544
  priced: unpriced.length === 0,
517
545
  provider: state.provider,
@@ -523,7 +551,7 @@ function buildProjection(live) {
523
551
  };
524
552
  }
525
553
  },
526
- stateVersion: 3
554
+ stateVersion: 4
527
555
  };
528
556
  }
529
557
 
@@ -571,6 +599,6 @@ async function apply(ctx, config) {
571
599
  }
572
600
 
573
601
  /** Internal helpers exported for the contract tests. */
574
- const internals = { buildPriceTable, catalogEntries, isPeakAt, lookupPrice, parseRates, resolveSchedule };
602
+ const internals = { buildPriceTable, buildProjection, catalogEntries, isPeakAt, lookupPrice, parseRates, resolveSchedule };
575
603
 
576
604
  export { apply, inject, internals, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-cost-meter",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Live per-session cost badge in the DeepSeek Harness conversation header (top-right), with peak/off-peak (峰谷分时) pricing.",
5
5
  "keywords": [
6
6
  "dsh-plugin",