dsh-plugin-cost-meter 0.5.1 → 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,34 @@
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
+
23
+ ## 0.5.2
24
+
25
+ - The remote catalog's `peakSchedule` now overrides the bundled snapshot, so a
26
+ schedule change pushed to `catalog/prices.json` reaches installed copies
27
+ (previously the bundled schedule always won).
28
+ - Header badge sizing fixed: `box-sizing: border-box`, `height: 24px`,
29
+ `border-radius: var(--dsw-radius-sm)` — the badge previously rendered
30
+ ~46px tall and no longer matched the header controls (e.g. the 24px
31
+ `.OMoRSG_split` control).
32
+
5
33
  ## 0.5.1
6
34
 
7
35
  - Manual `config.models` entries now outrank every automatic source even at
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/client.js CHANGED
@@ -16,18 +16,20 @@ const TAG_ID = "dsh-plugin-cost-meter/client.css";
16
16
 
17
17
  const css = [
18
18
  ".dsh-cost-meter-badge{",
19
- "border:1px solid var(--dsw-alias-border-l2);",
19
+ "box-sizing:border-box;",
20
+ "border:0.5px solid var(--dsw-alias-border-l2);",
20
21
  "color:var(--dsw-alias-label-primary);",
21
22
  "font-family:var(--dsw-font-family);",
22
23
  "background:transparent;",
23
- "border-radius:18px;",
24
- "height:32px;",
25
- "padding:6px 12px;",
26
- "font-size:13px;",
24
+ "border-radius:var(--dsw-radius-sm);",
25
+ "height:24px;",
26
+ "padding:0 8px;",
27
+ "font-size:12px;",
27
28
  "font-weight:400;",
28
- "line-height:20px;",
29
+ "line-height:16px;",
29
30
  "display:inline-flex;",
30
31
  "align-items:center;",
32
+ "justify-content:center;",
31
33
  "white-space:nowrap;",
32
34
  "font-variant-numeric:tabular-nums;",
33
35
  "cursor:default;",
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
  * ---------------
@@ -381,14 +386,20 @@ async function loadSources(config, warn, previous) {
381
386
  }
382
387
  }
383
388
 
384
- const schedule = resolveSchedule(config.peak, firstSchedule(BUNDLED_CATALOG) ?? firstSchedule(remote));
389
+ // The remote catalog is the live update channel, so its peak schedule must
390
+ // win over the bundled snapshot; the bundled one is only the offline fallback.
391
+ const schedule = resolveSchedule(config.peak, firstSchedule(remote) ?? firstSchedule(BUNDLED_CATALOG));
385
392
  const table = buildPriceTable({ bundled: BUNDLED_CATALOG, remote, legacyDocs: legacy === undefined ? [] : [legacy], config, schedule });
386
393
  return { table, schedule, remote, legacy };
387
394
  }
388
395
 
389
396
  /**
390
397
  * Build the `costUsage` projection unit over a live source ref.
391
- * 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.
392
403
  */
393
404
  function buildProjection(live) {
394
405
  const bucketsSchema = zod.object({
@@ -402,12 +413,18 @@ function buildProjection(live) {
402
413
  provider: zod.string().nullable(),
403
414
  model: zod.string().nullable(),
404
415
  off: bucketsSchema,
405
- 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()
406
421
  }).strict();
407
422
 
408
423
  const stateSchema = zod.object({
409
424
  provider: zod.string().nullable(),
410
425
  model: zod.string().nullable(),
426
+ /** Currency the frozen amounts are expressed in (null before the first priced sample). */
427
+ currency: zod.string().nullable(),
411
428
  perRoute: zod.record(zod.string(), routeSchema),
412
429
  last: zod.union([
413
430
  zod.null(),
@@ -416,7 +433,10 @@ function buildProjection(live) {
416
433
  step: zod.number().int(),
417
434
  route: zod.string(),
418
435
  tier: zod.enum(["off", "peak"]),
419
- 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()
420
440
  }).strict()
421
441
  ])
422
442
  }).strict();
@@ -439,7 +459,7 @@ function buildProjection(live) {
439
459
  return {
440
460
  key: "costUsage",
441
461
  stateSchema,
442
- init: () => ({ provider: null, model: null, perRoute: {}, last: null }),
462
+ init: () => ({ provider: null, model: null, currency: null, perRoute: {}, last: null }),
443
463
  apply(state, event) {
444
464
  const liveNow = live.current;
445
465
  let provider = state.provider;
@@ -469,47 +489,57 @@ function buildProjection(live) {
469
489
  const route = `${provider ?? ""}\u0000${model ?? ""}`;
470
490
  const buckets = bucketsFrom(usage);
471
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
+
472
499
  const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : undefined;
473
500
  if (previous !== undefined && previous.route === route && previous.tier === tier && bucketsEqual(previous.buckets, buckets)) {
474
- 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 };
475
504
  }
476
505
 
477
506
  const perRoute = { ...state.perRoute };
478
507
  if (previous !== undefined) {
479
- 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 }) };
480
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);
481
512
  perRoute[previous.route] = replaced;
482
513
  }
483
- 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 }) };
484
515
  entry.provider = provider;
485
516
  entry.model = model;
486
517
  entry[tier] = addBuckets(entry[tier], buckets);
518
+ entry.cost += contribution;
519
+ entry.unpriced += unpricedContribution;
487
520
  perRoute[route] = entry;
488
521
 
489
- 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 } };
490
523
  },
491
524
  wire: {
492
525
  viewSchema,
493
526
  view(state) {
494
527
  const liveNow = live.current;
495
- const table = liveNow.table;
496
528
  const totals = ZERO_BUCKETS();
497
529
  const unpriced = [];
498
530
  let cost = 0;
499
531
  for (const entry of Object.values(state.perRoute)) {
500
- const matched = lookupPrice(table, entry.provider, entry.model);
501
- if (matched === undefined) {
502
- unpriced.push(`${entry.provider ?? "?"}/${entry.model ?? "?"}`);
503
- } else {
504
- cost += costOf(entry.off, matched.rates.off) + costOf(entry.peak, matched.rates.peak);
505
- }
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 ?? "?"}`);
506
536
  totals.uncachedInputTokens += entry.off.uncachedInputTokens + entry.peak.uncachedInputTokens;
507
537
  totals.outputTokens += entry.off.outputTokens + entry.peak.outputTokens;
508
538
  totals.cacheReadTokens += entry.off.cacheReadTokens + entry.peak.cacheReadTokens;
509
539
  totals.cacheWriteTokens += entry.off.cacheWriteTokens + entry.peak.cacheWriteTokens;
510
540
  }
511
541
  return {
512
- currency: table.currency,
542
+ currency: state.currency ?? liveNow.table.currency,
513
543
  cost,
514
544
  priced: unpriced.length === 0,
515
545
  provider: state.provider,
@@ -521,7 +551,7 @@ function buildProjection(live) {
521
551
  };
522
552
  }
523
553
  },
524
- stateVersion: 3
554
+ stateVersion: 4
525
555
  };
526
556
  }
527
557
 
@@ -569,6 +599,6 @@ async function apply(ctx, config) {
569
599
  }
570
600
 
571
601
  /** Internal helpers exported for the contract tests. */
572
- const internals = { buildPriceTable, catalogEntries, isPeakAt, lookupPrice, parseRates, resolveSchedule };
602
+ const internals = { buildPriceTable, buildProjection, catalogEntries, isPeakAt, lookupPrice, parseRates, resolveSchedule };
573
603
 
574
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.1",
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",