dsh-plugin-usage 0.4.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.
@@ -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 / aggregate / rpc /
5
- * client-source) into the cordis plugin lifecycle. Strategy notes:
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
- * - Aggregation, cost (user-configurable price table) and model list are
15
- * served through src/host/rpc.js.
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 { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, mergePrices } from './settings.js';
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, runBackfill } from './scan.js';
34
+ import { scanLive, scanPersisted, startBackfillOnDemand } from './scan.js';
31
35
  import { query } from './query.js';
32
- import { registerRPC } from './rpc.js';
33
- import { getClientSource } from './client-source.js';
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
- /** Durable price table + currency, materialized as the plugin's config form. */
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
- currency: Schema.string().default(DEFAULT_CURRENCY),
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(DEFAULT_PRICES),
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 && typeof config === 'object' ? config : {};
50
- const priceStore = createPriceStore(mergePrices(resolved.prices), resolved.currency || DEFAULT_CURRENCY);
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) => query(state, 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
- // Secondary durability for hosts where the client cannot reach its config
61
- // form: write the same values through the official SettingsForms API.
62
- // The entry id is the bundle patch row id (`usage`).
63
- try {
64
- const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
65
- if (settings && typeof settings.update === 'function') {
66
- settings.update('usage', { prices: Object.assign({}, store.prices), currency: store.currency })
67
- .catch(() => { });
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
- catch {
71
- // settings is optional; the in-memory store still answers this session.
72
- }
73
- return store.snapshot();
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
- handlers.set('client-source', () => getClientSource(ctx));
76
- registerRPC(ctx, handlers);
77
- // Full history scan once at startup.
78
- runBackfill(ctx, state);
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(() => {