dsh-plugin-usage 0.3.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 +104 -9
- package/image-1.png +0 -0
- package/image.png +0 -0
- package/lib/client/index.js +1710 -111
- 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 +13 -6
- package/screenshots.json +4 -0
- package/lib/host/client-source.js +0 -34
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold cache: the folded records of one session, kept on disk between runs.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the plugin keeps its index in memory, so every restart folded
|
|
5
|
+
* the whole persisted history again. Measured on this machine (2026-10-29):
|
|
6
|
+
* 258 sessions, 404 MB compressed, 1.35 GB / 574k lines after inflation — 12.5 s
|
|
7
|
+
* just to read and inflate, before parsing and folding. A cache turns an
|
|
8
|
+
* unchanged session into a single JSON read.
|
|
9
|
+
*
|
|
10
|
+
* Layout: one file per session under `<dsh home>/cache/dsh-plugin-usage/fold/`.
|
|
11
|
+
* One file per session, not one big file, because the slow scan rewrites only
|
|
12
|
+
* the sessions that changed.
|
|
13
|
+
*
|
|
14
|
+
* Safety, in order of importance:
|
|
15
|
+
* - a cache entry is keyed by the backend's own change token (`revision`), so an
|
|
16
|
+
* appended log always misses and is re-folded;
|
|
17
|
+
* - the signature carries FOLD_CACHE_VERSION and the holiday calendar's
|
|
18
|
+
* `capturedAt` — a cached `tokens.peak`/`tokens.off` split is only valid for the
|
|
19
|
+
* calendar that produced it, and a fold-rule change must not be papered over;
|
|
20
|
+
* - every failure is a miss, never a throw: a broken cache degrades to the
|
|
21
|
+
* full scan it replaces.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { PUBLIC_HOLIDAYS } from './holidays.js';
|
|
27
|
+
/**
|
|
28
|
+
* Bump when the shape of a folded record changes (fields added/removed, tokens
|
|
29
|
+
* split differently). Not a nice-to-have: a stale entry would be served as if it
|
|
30
|
+
* were freshly folded.
|
|
31
|
+
*/
|
|
32
|
+
export const FOLD_CACHE_VERSION = 1;
|
|
33
|
+
/** Everything a cached record depends on beyond the session log itself. */
|
|
34
|
+
export function foldSignature() {
|
|
35
|
+
return FOLD_CACHE_VERSION + ':' + (PUBLIC_HOLIDAYS.capturedAt || 'no-calendar');
|
|
36
|
+
}
|
|
37
|
+
/** Where the per-session files live; `DSH_HOME` wins so tests can redirect it. */
|
|
38
|
+
export function cacheDir(home) {
|
|
39
|
+
const root = home || process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
40
|
+
return join(root, 'cache', 'dsh-plugin-usage', 'fold');
|
|
41
|
+
}
|
|
42
|
+
/** File name for one session. Ids are opaque; encode so no separator can escape. */
|
|
43
|
+
function entryPath(dir, id) {
|
|
44
|
+
return join(dir, encodeURIComponent(id) + '.json');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read one session's entry.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} dir - cache directory.
|
|
50
|
+
* @param {string} id - session id.
|
|
51
|
+
* @returns {{ revision?: string, watermark: number, records: unknown[] } | null}
|
|
52
|
+
* the entry, or null when absent/unreadable/stale-shaped.
|
|
53
|
+
*/
|
|
54
|
+
export function readEntry(dir, id) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = readFileSync(entryPath(dir, id), 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
let payload;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!payload || typeof payload !== 'object')
|
|
70
|
+
return null;
|
|
71
|
+
if (payload.sig !== foldSignature())
|
|
72
|
+
return null;
|
|
73
|
+
if (!Array.isArray(payload.records))
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof payload.watermark !== 'number' || !Number.isFinite(payload.watermark))
|
|
76
|
+
return null;
|
|
77
|
+
return { revision: payload.revision, watermark: payload.watermark, records: payload.records };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Write one session's entry (atomic: temp file then rename).
|
|
81
|
+
*
|
|
82
|
+
* @param {string} dir - cache directory.
|
|
83
|
+
* @param {string} id - session id.
|
|
84
|
+
* @param {{ revision?: string, watermark: number, records: unknown[] }} entry
|
|
85
|
+
* @returns {boolean} whether the file was written.
|
|
86
|
+
*/
|
|
87
|
+
export function writeEntry(dir, id, entry) {
|
|
88
|
+
if (!Array.isArray(entry && entry.records))
|
|
89
|
+
return false;
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
const path = entryPath(dir, id);
|
|
93
|
+
const temp = path + '.tmp';
|
|
94
|
+
writeFileSync(temp, JSON.stringify({
|
|
95
|
+
sig: foldSignature(),
|
|
96
|
+
revision: entry.revision,
|
|
97
|
+
watermark: entry.watermark,
|
|
98
|
+
records: entry.records,
|
|
99
|
+
}));
|
|
100
|
+
renameSync(temp, path);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
/*
|
|
109
|
+
* There is deliberately no prune().
|
|
110
|
+
*
|
|
111
|
+
* `sp.list()` returns a PARTIAL list while the session store warms up after a
|
|
112
|
+
* start, so "absent from this listing" does not mean "gone". Pruning on it
|
|
113
|
+
* deleted 257 of 258 valid entries on a live instance (2026-10-30 00:18) and
|
|
114
|
+
* every following start had to fold the whole history again — the exact symptom
|
|
115
|
+
* the cache exists to remove. A stale entry costs a few kilobytes and nothing
|
|
116
|
+
* else; it is only ever read by id from a listing.
|
|
117
|
+
*/
|
package/lib/host/fold.js
CHANGED
|
@@ -125,6 +125,17 @@ export function foldEvent(state, sessionId, e) {
|
|
|
125
125
|
? u.totalTokens
|
|
126
126
|
: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
127
127
|
};
|
|
128
|
+
// Peak / off-peak buckets. This is the only place an event's own timestamp is
|
|
129
|
+
// available, and DeepSeek bills by that timestamp: one model can mix both
|
|
130
|
+
// tiers inside a single day, so an aggregate priced after the fact is always
|
|
131
|
+
// either too high or too low. `state.peakOf` is injected by apply(), which
|
|
132
|
+
// keeps this function a pure function of its arguments in tests.
|
|
133
|
+
if (typeof state.peakOf === 'function') {
|
|
134
|
+
const peak = state.peakOf(e.time) === true;
|
|
135
|
+
rec.tokens = {
|
|
136
|
+
[peak ? 'peak' : 'off']: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
128
139
|
const previous = state.lastSample.get(sessionId);
|
|
129
140
|
if (previous !== undefined
|
|
130
141
|
&& data.turn !== undefined && data.step !== undefined
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-prices.mjs — do not edit by hand.
|
|
2
|
+
export const PUBLIC_HOLIDAYS = {
|
|
3
|
+
"capturedAt": "2026-10-29T15:02:16.737Z",
|
|
4
|
+
"source": "https://github.com/NateScarlet/holiday-cn",
|
|
5
|
+
"note": "中国法定节假日(isOffDay=true 的日期)。峰谷判定中这些日期全天按空闲时段计。",
|
|
6
|
+
"coverage": {
|
|
7
|
+
"fromYear": 2025,
|
|
8
|
+
"toYear": 2027,
|
|
9
|
+
"basis": "session store",
|
|
10
|
+
"years": [
|
|
11
|
+
2025,
|
|
12
|
+
2026,
|
|
13
|
+
2027
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"years": {
|
|
17
|
+
"2025": {
|
|
18
|
+
"offDays": 28,
|
|
19
|
+
"first": "2025-01-01",
|
|
20
|
+
"last": "2025-10-08",
|
|
21
|
+
"papers": [
|
|
22
|
+
"https://www.gov.cn/zhengce/zhengceku/202411/content_6986383.htm"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"2026": {
|
|
26
|
+
"offDays": 33,
|
|
27
|
+
"first": "2026-01-01",
|
|
28
|
+
"last": "2026-10-07",
|
|
29
|
+
"papers": [
|
|
30
|
+
"https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"2027": {
|
|
34
|
+
"unpublished": true,
|
|
35
|
+
"offDays": 0,
|
|
36
|
+
"first": null,
|
|
37
|
+
"last": null,
|
|
38
|
+
"papers": []
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"dates": {
|
|
42
|
+
"2025-01-01": "元旦",
|
|
43
|
+
"2025-01-28": "春节",
|
|
44
|
+
"2025-01-29": "春节",
|
|
45
|
+
"2025-01-30": "春节",
|
|
46
|
+
"2025-01-31": "春节",
|
|
47
|
+
"2025-02-01": "春节",
|
|
48
|
+
"2025-02-02": "春节",
|
|
49
|
+
"2025-02-03": "春节",
|
|
50
|
+
"2025-02-04": "春节",
|
|
51
|
+
"2025-04-04": "清明节",
|
|
52
|
+
"2025-04-05": "清明节",
|
|
53
|
+
"2025-04-06": "清明节",
|
|
54
|
+
"2025-05-01": "劳动节",
|
|
55
|
+
"2025-05-02": "劳动节",
|
|
56
|
+
"2025-05-03": "劳动节",
|
|
57
|
+
"2025-05-04": "劳动节",
|
|
58
|
+
"2025-05-05": "劳动节",
|
|
59
|
+
"2025-05-31": "端午节",
|
|
60
|
+
"2025-06-01": "端午节",
|
|
61
|
+
"2025-06-02": "端午节",
|
|
62
|
+
"2025-10-01": "国庆节、中秋节",
|
|
63
|
+
"2025-10-02": "国庆节、中秋节",
|
|
64
|
+
"2025-10-03": "国庆节、中秋节",
|
|
65
|
+
"2025-10-04": "国庆节、中秋节",
|
|
66
|
+
"2025-10-05": "国庆节、中秋节",
|
|
67
|
+
"2025-10-06": "国庆节、中秋节",
|
|
68
|
+
"2025-10-07": "国庆节、中秋节",
|
|
69
|
+
"2025-10-08": "国庆节、中秋节",
|
|
70
|
+
"2026-01-01": "元旦",
|
|
71
|
+
"2026-01-02": "元旦",
|
|
72
|
+
"2026-01-03": "元旦",
|
|
73
|
+
"2026-02-15": "春节",
|
|
74
|
+
"2026-02-16": "春节",
|
|
75
|
+
"2026-02-17": "春节",
|
|
76
|
+
"2026-02-18": "春节",
|
|
77
|
+
"2026-02-19": "春节",
|
|
78
|
+
"2026-02-20": "春节",
|
|
79
|
+
"2026-02-21": "春节",
|
|
80
|
+
"2026-02-22": "春节",
|
|
81
|
+
"2026-02-23": "春节",
|
|
82
|
+
"2026-04-04": "清明节",
|
|
83
|
+
"2026-04-05": "清明节",
|
|
84
|
+
"2026-04-06": "清明节",
|
|
85
|
+
"2026-05-01": "劳动节",
|
|
86
|
+
"2026-05-02": "劳动节",
|
|
87
|
+
"2026-05-03": "劳动节",
|
|
88
|
+
"2026-05-04": "劳动节",
|
|
89
|
+
"2026-05-05": "劳动节",
|
|
90
|
+
"2026-06-19": "端午节",
|
|
91
|
+
"2026-06-20": "端午节",
|
|
92
|
+
"2026-06-21": "端午节",
|
|
93
|
+
"2026-09-25": "中秋节",
|
|
94
|
+
"2026-09-26": "中秋节",
|
|
95
|
+
"2026-09-27": "中秋节",
|
|
96
|
+
"2026-10-01": "国庆节",
|
|
97
|
+
"2026-10-02": "国庆节",
|
|
98
|
+
"2026-10-03": "国庆节",
|
|
99
|
+
"2026-10-04": "国庆节",
|
|
100
|
+
"2026-10-05": "国庆节",
|
|
101
|
+
"2026-10-06": "国庆节",
|
|
102
|
+
"2026-10-07": "国庆节"
|
|
103
|
+
}
|
|
104
|
+
};
|
package/lib/host/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* usage — Token usage statistics host half (entry module).
|
|
3
3
|
*
|
|
4
|
-
* Wires the submodules (fold / scan / query / settings /
|
|
5
|
-
*
|
|
4
|
+
* Wires the submodules (fold / scan / query / settings / pricing / aggregate /
|
|
5
|
+
* rpc / typert) into the cordis plugin lifecycle. Strategy notes:
|
|
6
6
|
* - DSH persists every loop model call's token usage in each session log as
|
|
7
7
|
* an `assistant/message` (or `assistant/attempt`) settlement carrying
|
|
8
8
|
* `usage`; this plugin folds those events with the same replacement /
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
* - Live sessions are scanned from the in-memory store every poll tick;
|
|
12
12
|
* persisted sessions are backfilled at startup and re-scanned incrementally
|
|
13
13
|
* (watermark + revision per session).
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
14
|
+
* - The fold also splits each settlement by the price tier its own timestamp
|
|
15
|
+
* fell into, because DeepSeek bills peak and off-peak separately and one
|
|
16
|
+
* model can mix both inside a day.
|
|
17
|
+
* - Aggregation and the model list are served through src/host/rpc.js; prices
|
|
18
|
+
* come from the generated reference table (prices.js) unless the user has
|
|
19
|
+
* entered their own.
|
|
16
20
|
*
|
|
17
21
|
* The price table is the plugin's own config: the exported `Config` schema is
|
|
18
22
|
* the official, durable form surface (`Plugins` settings tab -> profile
|
|
@@ -24,58 +28,359 @@
|
|
|
24
28
|
* plugin.
|
|
25
29
|
*/
|
|
26
30
|
import Schema from '@deepseek-ai/schemastery';
|
|
27
|
-
import {
|
|
31
|
+
import { DEFAULT_CURRENCY, configPrices, createPriceStore, currencyOf, normalizePrices } from './settings.js';
|
|
28
32
|
import { createState } from './state.js';
|
|
29
33
|
import { createUsageIndex } from './aggregate.js';
|
|
30
|
-
import { scanLive, scanPersisted,
|
|
34
|
+
import { scanLive, scanPersisted, startBackfillOnDemand } from './scan.js';
|
|
31
35
|
import { query } from './query.js';
|
|
32
|
-
import { registerRPC } from './rpc.js';
|
|
33
|
-
import {
|
|
36
|
+
import { registerRPC, logSink } from './rpc.js';
|
|
37
|
+
import { USAGE_SERVICE } from './typert.js';
|
|
38
|
+
import { priceRow, referenceGroups, referenceTable } from './pricing.js';
|
|
39
|
+
import { isPeak } from './peak.js';
|
|
34
40
|
export const name = 'usage-host';
|
|
35
41
|
export const inject = ['timer'];
|
|
36
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Durable overrides + currency + the user's confirmed model matches,
|
|
44
|
+
* materialized as the plugin's config form.
|
|
45
|
+
*
|
|
46
|
+
* `prices` defaults to EMPTY on purpose: every price the reference table can
|
|
47
|
+
* answer must come from the table (see settings.js). A row here is an override
|
|
48
|
+
* for an id only the user knows, and the ladder consults it first.
|
|
49
|
+
*
|
|
50
|
+
* `matchMap` is deliberately a free-form `dict(string)`: it maps a model key
|
|
51
|
+
* exactly as a session log recorded it (`provider/model`, including a `/` inside
|
|
52
|
+
* the model id) to a reference-table id. A plain dict rather than a structured
|
|
53
|
+
* object because the keys are user data, and a schema that rejected an
|
|
54
|
+
* unexpected shape would silently drop confirmed matches on save.
|
|
55
|
+
*/
|
|
37
56
|
export const Config = Schema.object({
|
|
38
|
-
|
|
57
|
+
// ⚠️ Every field this plugin writes back must be `.volatile()`. The official
|
|
58
|
+
// settings service refuses anything else:
|
|
59
|
+
// dsh-settings/lib/index.js `write()`: `volatileForm(schema)` is undefined
|
|
60
|
+
// when no field is volatile, and it throws `has no volatile fields`;
|
|
61
|
+
// `validatePaths` then rejects any non-volatile key in the patch.
|
|
62
|
+
// Without these markers `settings.update('usage', …)` threw on every write,
|
|
63
|
+
// and the old `.catch(() => {})` hid it — so confirmed matches, custom prices
|
|
64
|
+
// and the currency were lost at every restart.
|
|
65
|
+
currency: Schema.string().default(DEFAULT_CURRENCY).volatile().description({
|
|
66
|
+
zh: '参考成本卡片与单价使用的币种(固定汇率 1 USD = 7 CNY)',
|
|
67
|
+
en: 'Currency for the reference-cost card and rates (fixed rate: 1 USD = 7 CNY)',
|
|
68
|
+
}),
|
|
39
69
|
prices: Schema.dict(Schema.object({
|
|
40
70
|
inputPerM: Schema.number().default(0),
|
|
41
71
|
outputPerM: Schema.number().default(0),
|
|
42
72
|
cacheReadPerM: Schema.number().default(0),
|
|
43
73
|
cacheWritePerM: Schema.number().default(0),
|
|
44
|
-
})).default(
|
|
74
|
+
})).default({}).volatile().description({
|
|
75
|
+
zh: '自定义参考定价:模型 id → 每百万 token 的输入/输出/缓存读/缓存写价,单位美元(界面里按币种换算显示)',
|
|
76
|
+
en: 'Custom reference prices: model id → input/output/cache-read/cache-write per million tokens, in USD (the UI converts for display)',
|
|
77
|
+
}),
|
|
78
|
+
matchMap: Schema.dict(Schema.string()).default({}).volatile().description({
|
|
79
|
+
zh: '已确认的模型匹配:模型 id → 参考模型 id(在「定价匹配」里点确定时写入)',
|
|
80
|
+
en: 'Confirmed model matches: model id → reference model id (written when you confirm in the pricing dialog)',
|
|
81
|
+
}),
|
|
45
82
|
});
|
|
46
83
|
const LIVE_POLL_MS = 2000;
|
|
47
84
|
const PERSISTED_POLL_MS = 30000;
|
|
85
|
+
/**
|
|
86
|
+
* Whether an instant bills at DeepSeek's peak rate.
|
|
87
|
+
*
|
|
88
|
+
* Wired into the state capsule rather than imported by fold.js so the fold stays
|
|
89
|
+
* a pure function of its arguments: tests pass their own predicate and never
|
|
90
|
+
* depend on today's calendar. Exported so a test can pin the wiring itself —
|
|
91
|
+
* the fold used to call `isPeak()` bare, which billed every public holiday at
|
|
92
|
+
* the peak rate while the price display showed it as off-peak.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} atMs - epoch milliseconds.
|
|
95
|
+
* @returns {boolean} true inside a Beijing peak window on a non-holiday weekday.
|
|
96
|
+
*/
|
|
97
|
+
export function peakAt(atMs) {
|
|
98
|
+
return isPeak(atMs);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate one `pricing` commit row.
|
|
102
|
+
*
|
|
103
|
+
* Nothing reaches the persisted config unless it passes: a bad id here becomes a
|
|
104
|
+
* silent zero cost later, which is the failure mode this whole rebuild exists to
|
|
105
|
+
* remove.
|
|
106
|
+
*
|
|
107
|
+
* @param {unknown} decision - `{ key, target }` from the dialog.
|
|
108
|
+
* @returns {{ ok: true, key: string, target: string } | { ok: false, key: string, why: string }} verdict.
|
|
109
|
+
*/
|
|
110
|
+
function checkDecision(decision) {
|
|
111
|
+
const key = decision && typeof decision.key === 'string' ? decision.key : '';
|
|
112
|
+
if (key === '')
|
|
113
|
+
return { ok: false, key, why: '缺少模型 key' };
|
|
114
|
+
const target = decision.target;
|
|
115
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
116
|
+
return { ok: false, key, why: '目标必须是一个参考模型 id' };
|
|
117
|
+
}
|
|
118
|
+
if (referenceTable().models[target] === undefined) {
|
|
119
|
+
return { ok: false, key, why: '参考模型不在当前价目表中:' + target };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, key, target };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate one hand-typed reference price.
|
|
125
|
+
*
|
|
126
|
+
* This is the dialog's 自定义 option: not a reference model but three rates the
|
|
127
|
+
* user enters, stored as a `prices` row for that model key. Values arrive in the
|
|
128
|
+
* DISPLAY currency the dialog printed (¥ by default), so they are converted to
|
|
129
|
+
* the table's USD here — the one place that knows the exchange rate.
|
|
130
|
+
*
|
|
131
|
+
* @param {unknown} entry - `{ key, input, output, cacheRead }`.
|
|
132
|
+
* @param {number} perUsd - display units per USD.
|
|
133
|
+
* @returns {{ ok: true, key: string, rate: object } | { ok: false, key: string, why: string }} verdict.
|
|
134
|
+
*/
|
|
135
|
+
function checkCustom(entry, perUsd) {
|
|
136
|
+
const key = entry && typeof entry.key === 'string' ? entry.key : '';
|
|
137
|
+
if (key === '')
|
|
138
|
+
return { ok: false, key, why: '缺少模型 key' };
|
|
139
|
+
const rate = {};
|
|
140
|
+
for (const field of ['input', 'output', 'cacheRead']) {
|
|
141
|
+
const v = Number(entry[field]);
|
|
142
|
+
if (!Number.isFinite(v) || v < 0)
|
|
143
|
+
return { ok: false, key, why: '单价必须是非负数字:' + field };
|
|
144
|
+
// Six decimals: a price per million tokens never needs more, and dividing by
|
|
145
|
+
// 7 otherwise stores 0.09999999999999999 where the user typed 0.7.
|
|
146
|
+
rate[field] = Math.round((v / (perUsd > 0 ? perUsd : 1)) * 1e6) / 1e6;
|
|
147
|
+
}
|
|
148
|
+
rate.cacheWrite = 0;
|
|
149
|
+
return { ok: true, key, rate };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Unwrap a cordis "volatile" config value.
|
|
153
|
+
*
|
|
154
|
+
* `Schema.prototype.volatile()` marks a field writable at runtime, and cordis
|
|
155
|
+
* then keeps the raw value in a wrapper (so a `!!js` expression stays unevaluated
|
|
156
|
+
* until someone asks). Neighbouring official code unwraps it — `dsh-settings`
|
|
157
|
+
* does `if (isVolatile(value)) value = value.get()` — and so must we: without
|
|
158
|
+
* this, `currency`/`prices`/`matchMap` arrive as wrapper objects.
|
|
159
|
+
*/
|
|
160
|
+
function plainConfigValue(value) {
|
|
161
|
+
if (value === null || typeof value !== 'object')
|
|
162
|
+
return value;
|
|
163
|
+
// The wrapper is a plain object carrying `get` plus `Symbol(cosmokit.volatile.write)`,
|
|
164
|
+
// so detect it by that symbol rather than by its prototype.
|
|
165
|
+
const volatileOfValue = Object.getOwnPropertySymbols(value).some((symbol) => String(symbol).includes('cosmokit.volatile'));
|
|
166
|
+
if (volatileOfValue && typeof value.get === 'function')
|
|
167
|
+
return value.get();
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Resolved configuration: defaults applied, volatile wrappers unwrapped.
|
|
172
|
+
*
|
|
173
|
+
* ⚠️ Unwrap the INPUT too. The loader hands us the value the settings line
|
|
174
|
+
* stored, which for a volatile field is already the wrapper; calling `Config()`
|
|
175
|
+
* on it throws `ValidationError: $.currency expected string but got [object
|
|
176
|
+
* Object]` and the whole plugin fails to activate — no Remote face, no Fetch
|
|
177
|
+
* routes, every dashboard call 404/405. `dsh-settings` unwraps before validating
|
|
178
|
+
* for exactly this reason (`projectForm(…, plainConfig(raw) …)`).
|
|
179
|
+
*
|
|
180
|
+
* Exported because the tests must see exactly what `apply` sees.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveConfig(raw) {
|
|
183
|
+
const source = raw && typeof raw === 'object' ? raw : {};
|
|
184
|
+
// Deep-clone into mutable plain JSON: the values inside a volatile wrapper are
|
|
185
|
+
// frozen (`schemastery` freezes dict defaults), and re-validating a frozen
|
|
186
|
+
// object throws `Cannot assign to read only property …` inside the schema.
|
|
187
|
+
const clone = (value) => {
|
|
188
|
+
if (value === null || typeof value !== 'object')
|
|
189
|
+
return value;
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(JSON.stringify(value));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const plain = {};
|
|
198
|
+
for (const key of ['currency', 'prices', 'matchMap']) {
|
|
199
|
+
const value = plainConfigValue(source[key]);
|
|
200
|
+
if (value !== undefined)
|
|
201
|
+
plain[key] = clone(value);
|
|
202
|
+
}
|
|
203
|
+
let resolved;
|
|
204
|
+
try {
|
|
205
|
+
resolved = Config(plain);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// A hand-edited patch with a wrong type must not take the plugin down: fall
|
|
209
|
+
// back to the shipped defaults (the dashboard still works, the row shows 0).
|
|
210
|
+
resolved = Config({});
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
currency: plainConfigValue(resolved.currency),
|
|
214
|
+
prices: plainConfigValue(resolved.prices),
|
|
215
|
+
matchMap: plainConfigValue(resolved.matchMap),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
48
218
|
export function apply(ctx, config) {
|
|
49
|
-
const resolved = config
|
|
50
|
-
const priceStore = createPriceStore(
|
|
219
|
+
const resolved = resolveConfig(config);
|
|
220
|
+
const priceStore = createPriceStore(resolved.prices, resolved.currency || DEFAULT_CURRENCY,
|
|
221
|
+
// A getter, not the map: a commit replaces `state.matchMap`, and a store
|
|
222
|
+
// closed over the old object would keep pricing from the pre-commit map.
|
|
223
|
+
{ matches: () => state.matchMap });
|
|
51
224
|
const state = createState(priceStore);
|
|
52
225
|
state.index = createUsageIndex(state);
|
|
226
|
+
state.peakOf = peakAt;
|
|
227
|
+
state.matchMap = resolved.matchMap && typeof resolved.matchMap === 'object' ? { ...resolved.matchMap } : {};
|
|
228
|
+
/**
|
|
229
|
+
* Write the durable half of the pricing config.
|
|
230
|
+
*
|
|
231
|
+
* Failures are LOGGED, not swallowed: a silent `.catch(() => {})` here hid a
|
|
232
|
+
* real defect for weeks — the settings service refused every patch (no field
|
|
233
|
+
* was volatile), so confirmed matches and custom prices vanished on restart
|
|
234
|
+
* with nothing in any log to say why.
|
|
235
|
+
*/
|
|
236
|
+
const persist = (patch) => {
|
|
237
|
+
// `logSink` returns a **function** `(message, level?)`, not a logger object.
|
|
238
|
+
// Calling `.warn` on it threw `TypeError: … warn is not a function`, which is
|
|
239
|
+
// how a "settings service unavailable" branch turned into a crashed host.
|
|
240
|
+
const log = logSink(ctx);
|
|
241
|
+
let settings;
|
|
242
|
+
try {
|
|
243
|
+
settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
settings = undefined;
|
|
247
|
+
}
|
|
248
|
+
if (!settings || typeof settings.update !== 'function') {
|
|
249
|
+
log('[usage] settings service unavailable: this session keeps pricing in memory only');
|
|
250
|
+
return patch;
|
|
251
|
+
}
|
|
252
|
+
Promise.resolve()
|
|
253
|
+
.then(() => settings.update('usage', patch))
|
|
254
|
+
.catch((error) => {
|
|
255
|
+
log('[usage] could not persist pricing config: ' + (error && error.message ? error.message : String(error)));
|
|
256
|
+
});
|
|
257
|
+
return patch;
|
|
258
|
+
};
|
|
53
259
|
const handlers = new Map();
|
|
54
|
-
handlers.set('query', (args) =>
|
|
260
|
+
handlers.set('query', (args) => {
|
|
261
|
+
// 「缓存加载也是加载」:全量统计由第一次查询拉起,所以面板打开的这一刻就是
|
|
262
|
+
// 统计开始的那一刻(暖缓存 ~0.8 s、冷缓存 ~12 s 的骨架与进度条都看得见)。
|
|
263
|
+
// 幂等:只有第一次真正启动,后续查询只是读状态。
|
|
264
|
+
startBackfillOnDemand(ctx, state, { log: logSink(ctx) });
|
|
265
|
+
return query(state, args || {});
|
|
266
|
+
});
|
|
267
|
+
/**
|
|
268
|
+
* The face this plugin registers on the native Remote channel.
|
|
269
|
+
*
|
|
270
|
+
* The browser half cannot enumerate a face that was registered at runtime, and
|
|
271
|
+
* the endpoint names must match what the host registered (a duplicated list
|
|
272
|
+
* drifts). So it asks for them here — over the Fetch fallback — and mounts its
|
|
273
|
+
* own descriptors: the gateway's client side wants a src-json result, while the
|
|
274
|
+
* host contribution carries a strict codec, so the two are not identical.
|
|
275
|
+
*/
|
|
276
|
+
handlers.set('contract', () => ({
|
|
277
|
+
package: USAGE_SERVICE,
|
|
278
|
+
names: [...handlers.keys()].filter((name) => name !== 'contract'),
|
|
279
|
+
}));
|
|
55
280
|
handlers.set('prices', (args) => {
|
|
56
281
|
const store = state.prices;
|
|
57
282
|
store.setPrices(args && args.prices);
|
|
58
283
|
if (args && typeof args.currency === 'string')
|
|
59
284
|
store.setCurrency(args.currency);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
285
|
+
persist({ prices: configPrices(store.prices), currency: store.currency });
|
|
286
|
+
return store.snapshot();
|
|
287
|
+
});
|
|
288
|
+
/**
|
|
289
|
+
* Model-id matching: propose, then commit.
|
|
290
|
+
*
|
|
291
|
+
* Two actions on one endpoint because they are two halves of one decision:
|
|
292
|
+
* `propose` answers "what would each of my models match to?" and writes
|
|
293
|
+
* nothing; `commit` applies the rows the user confirmed. Keeping them apart is
|
|
294
|
+
* what makes "确定才保存" true — opening the dialog cannot change a price.
|
|
295
|
+
*/
|
|
296
|
+
handlers.set('pricing', (args) => {
|
|
297
|
+
const action = args && args.action === 'commit' ? 'commit' : 'propose';
|
|
298
|
+
if (action === 'commit') {
|
|
299
|
+
const decisions = Array.isArray(args?.decisions) ? args.decisions : [];
|
|
300
|
+
const customs = Array.isArray(args?.customs) ? args.customs : [];
|
|
301
|
+
const applied = {};
|
|
302
|
+
const typed = {};
|
|
303
|
+
const rejected = [];
|
|
304
|
+
for (const decision of decisions) {
|
|
305
|
+
const verdict = checkDecision(decision);
|
|
306
|
+
if (verdict.ok)
|
|
307
|
+
applied[verdict.key] = verdict.target;
|
|
308
|
+
else
|
|
309
|
+
rejected.push({ key: verdict.key, why: verdict.why });
|
|
68
310
|
}
|
|
311
|
+
// 自定义:三档手填单价,写进 `prices`(用户覆盖),不写 matchMap。
|
|
312
|
+
const perUsd = currencyOf(args?.currency, state.prices.currency).perUsd;
|
|
313
|
+
for (const entry of customs) {
|
|
314
|
+
const verdict = checkCustom(entry, perUsd);
|
|
315
|
+
if (verdict.ok)
|
|
316
|
+
typed[verdict.key] = verdict.rate;
|
|
317
|
+
else
|
|
318
|
+
rejected.push({ key: verdict.key, why: verdict.why });
|
|
319
|
+
}
|
|
320
|
+
// Clearing is expressed by listing the key with an empty target, and an
|
|
321
|
+
// empty change set is a no-op rather than a wipe.
|
|
322
|
+
const cleared = Array.isArray(args?.cleared) ? args.cleared.filter((k) => typeof k === 'string' && k !== '') : [];
|
|
323
|
+
if (Object.keys(applied).length > 0 || cleared.length > 0 || Object.keys(typed).length > 0) {
|
|
324
|
+
const next = { ...state.matchMap };
|
|
325
|
+
for (const key of cleared)
|
|
326
|
+
delete next[key];
|
|
327
|
+
Object.assign(next, applied);
|
|
328
|
+
state.matchMap = next;
|
|
329
|
+
// One key, one answer — whichever the user just gave wins:
|
|
330
|
+
// · a hand-typed price replaces the confirmed match (the ladder checks
|
|
331
|
+
// `prices` first, so leaving it would silently outrank the match);
|
|
332
|
+
// · a confirmed table target drops a stale hand-typed price;
|
|
333
|
+
// · clearing drops both.
|
|
334
|
+
if (Object.keys(typed).length > 0)
|
|
335
|
+
state.prices.setPrices(typed);
|
|
336
|
+
for (const key of [...Object.keys(applied), ...cleared, ...Object.keys(typed)]) {
|
|
337
|
+
delete state.matchMap[key];
|
|
338
|
+
delete state.prices.prices[key];
|
|
339
|
+
}
|
|
340
|
+
Object.assign(state.matchMap, applied);
|
|
341
|
+
if (Object.keys(typed).length > 0)
|
|
342
|
+
Object.assign(state.prices.prices, typed);
|
|
343
|
+
persist({ matchMap: { ...state.matchMap }, prices: configPrices(state.prices.prices) });
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
applied: Object.keys(applied).length,
|
|
348
|
+
custom: Object.keys(typed).length,
|
|
349
|
+
cleared: cleared.length,
|
|
350
|
+
rejected,
|
|
351
|
+
matchMap: { ...state.matchMap },
|
|
352
|
+
prices: configPrices(state.prices.prices),
|
|
353
|
+
};
|
|
69
354
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
355
|
+
const models = Array.isArray(args?.models) ? args.models : [];
|
|
356
|
+
const atMs = Date.now();
|
|
357
|
+
const rows = models.map((entry) => {
|
|
358
|
+
const model = typeof entry === 'string' ? entry : String(entry?.model ?? '');
|
|
359
|
+
const provider = typeof entry === 'string' ? '' : String(entry?.provider ?? '');
|
|
360
|
+
const key = typeof entry?.key === 'string' ? entry.key : (provider === '' ? model : provider + '/' + model);
|
|
361
|
+
// `priceRow` owns the wire shape for both this endpoint and `query`, so the
|
|
362
|
+
// dialog can never classify a model differently from the bill it shows.
|
|
363
|
+
return { key, provider, model, ...priceRow(priceStore.lookup({ model, provider }), atMs) };
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
table: {
|
|
367
|
+
source: 'opencode Zen + DeepSeek',
|
|
368
|
+
capturedAt: referenceTable().capturedAt,
|
|
369
|
+
unit: referenceTable().unit,
|
|
370
|
+
// Three groups, not one flat list: the reference ids come from DeepSeek,
|
|
371
|
+
// from Zen, or from the user's own price rows, and the dialog shows them
|
|
372
|
+
// apart. `custom` used to be unreachable from the picker entirely.
|
|
373
|
+
groups: referenceGroups(),
|
|
374
|
+
},
|
|
375
|
+
rows,
|
|
376
|
+
matchMap: { ...state.matchMap },
|
|
377
|
+
};
|
|
74
378
|
});
|
|
75
|
-
|
|
76
|
-
registerRPC(ctx, handlers);
|
|
77
|
-
//
|
|
78
|
-
|
|
379
|
+
// The chosen client→host channel travels with every dashboard response.
|
|
380
|
+
state.channel = registerRPC(ctx, handlers);
|
|
381
|
+
// 全量回填**不在激活时跑**:它按需触发(见 `startBackfillOnDemand`),由第一次
|
|
382
|
+
// 查询拉起。缓存加载(暖 0.8 s / 冷 12 s)因此发生在用户打开面板的那一刻,
|
|
383
|
+
// 而不是进程启动的头一秒 —— 否则加载态永远看不见。
|
|
79
384
|
// Live sessions every tick; persisted sessions on a slow cadence to keep
|
|
80
385
|
// per-session log reads cheap (unchanged revisions are skipped).
|
|
81
386
|
ctx.interval(() => {
|