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 +28 -0
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +8 -6
- package/lib/index.js +51 -21
- package/package.json +1 -1
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
|
|
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
|
-
"
|
|
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:
|
|
24
|
-
"height:
|
|
25
|
-
"padding:
|
|
26
|
-
"font-size:
|
|
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:
|
|
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.
|
|
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
|
* ---------------
|
|
@@ -381,14 +386,20 @@ async function loadSources(config, warn, previous) {
|
|
|
381
386
|
}
|
|
382
387
|
}
|
|
383
388
|
|
|
384
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
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:
|
|
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