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 +18 -0
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/index.js +48 -20
- package/package.json +1 -1
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
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
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:
|
|
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