dsh-plugin-usage 0.2.0 → 0.3.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/lib/host/scan.js CHANGED
@@ -1,9 +1,22 @@
1
1
  /**
2
2
  * Live/persisted session scanning + startup backfill.
3
3
  *
4
- * P1-6: optimization lives here — bounded-concurrency backfill, per-session
5
- * failure backoff, progress notifications, and an optional onProgress/
6
- * onDone callback so the host can push a usage/updated event to clients.
4
+ * DSH 0.2.x session accessors (verified against @deepseek-ai/dsh-session and
5
+ * @deepseek-ai/dsh-session-persistence 0.2.0-rc.2 / 0.2.1-rc.2):
6
+ *
7
+ * - live: `ctx.sessions.list()` -> `Session`, events via
8
+ * `session.snapshotEvents(fromSeq)`.
9
+ * - persisted: `ctx.sessionPersistence.list()` -> `{ header, revision,
10
+ * eventCount?, sizeBytes? }`, then `open(id, 'read')` and
11
+ * `handle.read(offset)`; the handle carries
12
+ * `inheritedEventCount` and must be closed.
13
+ *
14
+ * A forked session replays its parent's prefix; `inheritedEventCount` is the
15
+ * cut, so folding starts there and the parent is never billed twice.
16
+ *
17
+ * Optimization lives here: bounded-concurrency backfill, per-session failure
18
+ * backoff, revision-based skip (unchanged logs are not re-read), progress
19
+ * callbacks, and watermark repair when a log shrank.
7
20
  */
8
21
  import { foldNew } from './fold.js';
9
22
  /** How many persisted headers are read concurrently per batch. */
@@ -17,28 +30,94 @@ const BATCH_SIZE = 8;
17
30
  const backoffBySession = new Map();
18
31
  /** True while a scanPersisted pass is in flight; overlapping passes are dropped. */
19
32
  let persistedInFlight = false;
33
+ /**
34
+ * Events of one live session at or after `fromSeq`.
35
+ *
36
+ * @param {any} session - live Session.
37
+ * @param {number} fromSeq - first seq to return.
38
+ * @returns {ReadonlyArray<object>} events in log order.
39
+ */
40
+ export function liveEvents(session, fromSeq) {
41
+ if (session === null || typeof session !== 'object')
42
+ return [];
43
+ if (typeof session.snapshotEvents === 'function')
44
+ return session.snapshotEvents(fromSeq);
45
+ // Legacy pre-0.2 sessions exposed the whole log as `events`.
46
+ if (Array.isArray(session.events)) {
47
+ return session.events.filter((event) => !event || typeof event.seq !== 'number' || event.seq >= fromSeq);
48
+ }
49
+ return [];
50
+ }
20
51
  /** Scan live in-memory sessions (cheap, every tick). */
21
52
  export function scanLive(ctx, state) {
22
53
  const sessions = ctx.get('sessions');
23
- if (sessions === undefined)
54
+ if (sessions === undefined || sessions === null)
24
55
  return;
25
56
  for (const s of sessions.list()) {
26
57
  if (!s || !s.id)
27
58
  continue;
28
59
  const wm = state.watermarks.get(s.id) ?? -1;
29
- const next = foldNew(state, s.id, s.events || [], wm);
60
+ // Never bill the fork-inherited prefix to the child.
61
+ const inherited = typeof s.inheritedEventCount === 'number' ? s.inheritedEventCount : 0;
62
+ const from = Math.max(wm + 1, inherited);
63
+ const next = foldNew(state, s.id, liveEvents(s, from), wm);
30
64
  if (next > wm)
31
65
  state.watermarks.set(s.id, next);
32
66
  }
33
67
  }
68
+ /** Id of one persistence listing snapshot (`{header:{id}}` in 0.2.x). */
69
+ function snapshotId(snap) {
70
+ if (!snap || typeof snap !== 'object')
71
+ return undefined;
72
+ if (snap.header && typeof snap.header.id === 'string')
73
+ return snap.header.id;
74
+ if (typeof snap.id === 'string')
75
+ return snap.id;
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Read events for one persisted session at or after `fromSeq`, preferring the
80
+ * 0.2.x handle API and falling back to a legacy `readFrom` backend.
81
+ *
82
+ * @param {any} persistence - sessionPersistence service.
83
+ * @param {string} id - session id.
84
+ * @param {number} fromSeq - first seq to return.
85
+ * @returns {Promise<{ events: ReadonlyArray<object>, inherited: number }>} events + fork cut.
86
+ */
87
+ export async function readSessionEvents(persistence, id, fromSeq) {
88
+ if (typeof persistence.open === 'function') {
89
+ const handle = await persistence.open(id, 'read');
90
+ try {
91
+ const inherited = typeof handle.inheritedEventCount === 'number' ? handle.inheritedEventCount : 0;
92
+ const page = await handle.read(Math.max(fromSeq, inherited));
93
+ return { events: (page && page.events) || [], inherited };
94
+ }
95
+ finally {
96
+ if (handle && typeof handle.close === 'function') {
97
+ try {
98
+ await handle.close();
99
+ }
100
+ catch {
101
+ // A close failure must not turn a successful read into a failed scan.
102
+ }
103
+ }
104
+ }
105
+ }
106
+ if (typeof persistence.readFrom === 'function') {
107
+ const page = await persistence.readFrom(id, fromSeq);
108
+ return { events: (page && page.events) || [], inherited: 0 };
109
+ }
110
+ return { events: [], inherited: 0 };
111
+ }
34
112
  /**
35
113
  * Incremental scan of persisted sessions (slow cadence). Headers are read in
36
- * small concurrent batches; a failing session is backed off for an
37
- * exponentially growing number of passes instead of aborting the pass.
114
+ * small concurrent batches; unchanged revisions are skipped; a failing session
115
+ * is backed off for an exponentially growing number of passes instead of
116
+ * aborting the pass.
38
117
  */
39
118
  export async function scanPersisted(ctx, state, options) {
40
119
  const sp = ctx.get('sessionPersistence');
41
- if (sp === undefined)
120
+ if (sp === undefined || sp === null)
42
121
  return;
43
122
  if (persistedInFlight)
44
123
  return; // previous pass still running: skip this tick
@@ -65,22 +144,36 @@ export async function scanPersisted(ctx, state, options) {
65
144
  }
66
145
  }
67
146
  /** Read + fold one persisted session header; bounded by the batch caller. */
68
- async function processHeader(sp, state, h, options) {
69
- if (!h || !h.id)
147
+ async function processHeader(sp, state, snap, options) {
148
+ const id = snapshotId(snap);
149
+ if (id === undefined)
70
150
  return;
71
- const id = h.id;
72
151
  const backoff = backoffBySession.get(id);
73
152
  if (backoff && backoff.skipLeft > 0) {
74
153
  backoff.skipLeft -= 1; // decay the skip count one pass at a time
75
154
  return;
76
155
  }
77
- const wm = state.watermarks.get(id) ?? -1;
78
156
  try {
79
- const page = await sp.readFrom(id, wm + 1);
80
- const events = (page && page.events) || [];
157
+ const revision = snap && snap.revision;
158
+ if (revision !== undefined && Object.is(state.revisions.get(id), revision)) {
159
+ // The backend reports the same change token: the log is unchanged.
160
+ backoffBySession.delete(id);
161
+ return;
162
+ }
163
+ let wm = state.watermarks.get(id) ?? -1;
164
+ // A crash-repaired or migrated log can shrink below our watermark; the
165
+ // backend reports the current event count, so repair instead of stalling.
166
+ if (typeof snap.eventCount === 'number' && wm >= snap.eventCount) {
167
+ wm = -1;
168
+ state.watermarks.set(id, -1);
169
+ state.lastSample.delete(id);
170
+ }
171
+ const { events } = await readSessionEvents(sp, id, wm + 1);
81
172
  const next = foldNew(state, id, events, wm);
82
173
  if (next > wm)
83
174
  state.watermarks.set(id, next);
175
+ if (revision !== undefined)
176
+ state.revisions.set(id, revision);
84
177
  backoffBySession.delete(id); // success resets the consecutive-failure chain
85
178
  }
86
179
  catch (error) {
@@ -107,7 +200,7 @@ async function processHeader(sp, state, h, options) {
107
200
  /** Full history scan, once at startup. */
108
201
  export async function runBackfill(ctx, state, options) {
109
202
  const sp = ctx.get('sessionPersistence');
110
- if (sp === undefined) {
203
+ if (sp === undefined || sp === null) {
111
204
  state.backfill.status = 'done';
112
205
  fireBackfillDone(options, state);
113
206
  return;
@@ -1,29 +1,98 @@
1
1
  /**
2
2
  * Price table + cost computation.
3
3
  *
4
- * P0-1: createPriceStore() keeps the process-memory table. The optimization
5
- * adds a durable settings-namespace backend via `ctx.settings` (official
6
- * dsh-settings) with the in-memory store as fallback — implemented by the
7
- * settings workstream inside this file only.
4
+ * Persistence is the official plugin-config path: `index.js` exports a
5
+ * schemastery `Config`, so DSH materializes a form under the plugin entry's
6
+ * id (`usage`) in the Plugins settings tab and stores values in the profile
7
+ * patch. There is deliberately no private `settings.yaml` / settings-service
8
+ * registration here — DSH 0.2.x `ctx.settings` (SettingsForms) only offers
9
+ * `describe/update/replace/mutate/configure`, and hand-rolled namespaces are
10
+ * not reachable from the client config surface.
11
+ *
12
+ * The store below stays the in-memory view the aggregation code reads.
8
13
  */
9
14
  export const DEFAULT_CURRENCY = '¥';
15
+ /**
16
+ * Currency the price table is denominated in.
17
+ *
18
+ * DEFAULT_PRICES carries DeepSeek's USD list price per 1M tokens (0.27 input /
19
+ * 1.10 output / 0.07 cache-read for `deepseek-chat`, which is exactly 2 / 8 /
20
+ * 0.5 CNY at the fixed 7:1). The table is never rewritten at runtime: a display
21
+ * switch converts on the way out, so a query can never pollute the next one.
22
+ */
23
+ export const PRICE_UNIT_CURRENCY = 'USD';
24
+ /** Display currencies; `perUsd` is how many units of it one USD buys. */
25
+ export const CURRENCIES = {
26
+ USD: { code: 'USD', symbol: '$', perUsd: 1 },
27
+ CNY: { code: 'CNY', symbol: '¥', perUsd: 7 },
28
+ };
29
+ function currencyCodeOf(value) {
30
+ const raw = typeof value === 'string' ? value.trim().toUpperCase() : '';
31
+ if (raw === 'USD' || raw === '$' || raw === 'US$')
32
+ return 'USD';
33
+ if (raw === 'CNY' || raw === '¥' || raw === 'RMB' || raw === 'CN¥')
34
+ return 'CNY';
35
+ return '';
36
+ }
37
+ /**
38
+ * Resolve a configured currency (ISO code or the bare symbol the store has
39
+ * always held) to a CURRENCIES entry. Unknown values fall back to `fallback`,
40
+ * then to the default display currency — never throw on user input.
41
+ *
42
+ * @param {unknown} value
43
+ * @param {unknown} [fallback]
44
+ */
45
+ export function currencyOf(value, fallback) {
46
+ const hit = CURRENCIES[currencyCodeOf(value)];
47
+ if (hit)
48
+ return hit;
49
+ const fb = CURRENCIES[currencyCodeOf(fallback)];
50
+ if (fb)
51
+ return fb;
52
+ return CURRENCIES[currencyCodeOf(DEFAULT_CURRENCY)] || CURRENCIES.CNY;
53
+ }
10
54
  export const DEFAULT_PRICES = {
11
55
  'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
12
56
  'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
13
57
  'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
14
58
  'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
15
59
  'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
60
+ 'deepseek-v4.1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
61
+ 'deepseek-v4.1-flash-1017': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
62
+ // DSH's routed default model id (`magpie` provider) aliases the same route;
63
+ // without this entry every session bills as "unpriced" on a stock install.
64
+ 'group/auto-deepseek-v4-1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
16
65
  'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
17
66
  'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
18
67
  };
68
+ /**
69
+ * Shallow-merge configured prices over the defaults.
70
+ *
71
+ * @param {unknown} incoming - configured per-model prices.
72
+ * @returns {Record<string, object>} complete price table.
73
+ */
74
+ export function mergePrices(incoming) {
75
+ const merged = Object.assign({}, DEFAULT_PRICES);
76
+ if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
77
+ for (const [model, price] of Object.entries(incoming)) {
78
+ if (price && typeof price === 'object') {
79
+ merged[model] = {
80
+ inputPerM: Number(price.inputPerM) || 0,
81
+ outputPerM: Number(price.outputPerM) || 0,
82
+ cacheReadPerM: Number(price.cacheReadPerM) || 0,
83
+ cacheWritePerM: Number(price.cacheWritePerM) || 0,
84
+ };
85
+ }
86
+ }
87
+ }
88
+ return merged;
89
+ }
19
90
  export function createPriceStore(defaultPrices, defaultCurrency) {
20
91
  const prices = Object.assign({}, defaultPrices);
21
92
  let currency = defaultCurrency || DEFAULT_CURRENCY;
22
93
  const store = {
23
94
  prices,
24
95
  get currency() { return currency; },
25
- /** Write-through slot wired by initSettings(); null without a settings service. */
26
- persist: null,
27
96
  /** Merge incoming per-model prices (each field coerced to a number). */
28
97
  setPrices(incoming) {
29
98
  if (!incoming || typeof incoming !== 'object')
@@ -38,15 +107,10 @@ export function createPriceStore(defaultPrices, defaultCurrency) {
38
107
  };
39
108
  }
40
109
  }
41
- if (typeof store.persist === 'function')
42
- store.persist({ prices: Object.assign({}, prices) });
43
110
  },
44
111
  setCurrency(v) {
45
- if (typeof v === 'string' && v.length > 0) {
112
+ if (typeof v === 'string' && v.length > 0)
46
113
  currency = v;
47
- if (typeof store.persist === 'function')
48
- store.persist({ currency });
49
- }
50
114
  },
51
115
  /** Look up a price by `provider/model` composite first, then bare model. */
52
116
  priceOf(r) {
@@ -73,90 +137,3 @@ export function createPriceStore(defaultPrices, defaultCurrency) {
73
137
  };
74
138
  return store;
75
139
  }
76
- const USAGE_PRICES_NS = 'usage.prices';
77
- /** JSON-schema-shaped descriptor for the usage.prices settings section. */
78
- const PRICE_SECTION_DESCRIPTOR = {
79
- type: 'object',
80
- properties: {
81
- prices: {
82
- type: 'object',
83
- description: 'Per-model prices (cost per 1M tokens, in `currency`).',
84
- additionalProperties: {
85
- type: 'object',
86
- properties: {
87
- inputPerM: { type: 'number' },
88
- outputPerM: { type: 'number' },
89
- cacheReadPerM: { type: 'number' },
90
- cacheWritePerM: { type: 'number' },
91
- },
92
- required: ['inputPerM', 'outputPerM', 'cacheReadPerM', 'cacheWritePerM'],
93
- },
94
- },
95
- currency: { type: 'string' },
96
- },
97
- required: ['prices', 'currency'],
98
- };
99
- /** Apply a resolved settings section onto the in-memory price store. */
100
- function applySection(store, section) {
101
- if (!section || typeof section !== 'object')
102
- return;
103
- if (typeof store.setPrices === 'function' && section.prices && typeof section.prices === 'object' && !Array.isArray(section.prices)) {
104
- store.setPrices(section.prices);
105
- }
106
- if (typeof store.setCurrency === 'function' && typeof section.currency === 'string')
107
- store.setCurrency(section.currency);
108
- }
109
- /**
110
- * P0-1: wire the price store to the official `ctx.settings` namespace
111
- * (durable, shown in the official Plugins config tab) when available.
112
- * Registers `usage.prices`, seeds the store from the resolved section, and
113
- * writes every setPrices/setCurrency mutation through. Falls back to the
114
- * in-memory store whenever the settings service is missing or unavailable.
115
- */
116
- export function initSettings(ctx, store) {
117
- if (!ctx || typeof ctx.get !== 'function' || !store || typeof store !== 'object')
118
- return;
119
- let settings;
120
- try {
121
- settings = ctx.get('settings');
122
- }
123
- catch {
124
- return;
125
- }
126
- if (!settings || typeof settings.register !== 'function')
127
- return;
128
- let scope;
129
- try {
130
- scope = settings.register(USAGE_PRICES_NS, PRICE_SECTION_DESCRIPTOR, { base: { prices: DEFAULT_PRICES, currency: DEFAULT_CURRENCY }, applies: 'live' });
131
- }
132
- catch {
133
- return; // registration failed — keep the current in-memory behavior
134
- }
135
- store.persist = (patch) => {
136
- try {
137
- if (scope && typeof scope.update === 'function')
138
- return scope.update(USAGE_PRICES_NS, patch);
139
- if (settings && typeof settings.update === 'function')
140
- return settings.update(USAGE_PRICES_NS, patch);
141
- }
142
- catch {
143
- // Never throw when the settings scope is unavailable.
144
- }
145
- };
146
- let section;
147
- try {
148
- if (scope && typeof scope.get === 'function')
149
- section = scope.get();
150
- else if (settings && typeof settings.get === 'function')
151
- section = settings.get(USAGE_PRICES_NS);
152
- }
153
- catch {
154
- section = undefined;
155
- }
156
- if (section && typeof section.then === 'function') {
157
- section.then((resolved) => applySection(store, resolved)).catch(() => { });
158
- }
159
- else {
160
- applySection(store, section);
161
- }
162
- }
package/lib/host/state.js CHANGED
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * Mutable runtime state capsule for the usage host half.
3
3
  *
4
- * - records: folded usage records, one per assistant/message with usage.
4
+ * - records: folded usage records, one per billed assistant settlement
5
+ * (`assistant/message` or `assistant/attempt`) that reported usage.
5
6
  * - watermarks: sessionId -> last folded event seq.
7
+ * - revisions: sessionId -> last persisted revision folded (opaque equality
8
+ * token from `sessionPersistence.list()`); lets the slow scan skip sessions
9
+ * whose log did not change.
6
10
  * - backfill: progress counters surfaced through query results.
7
11
  * - prices: the price store from settings.js.
8
- * - dedup: (sessionId, turn, step) -> index in records; owned by fold.js.
12
+ * - lastSample: sessionId -> { turn, step, record } — the replacement slot the
13
+ * official tokenUsage projection keeps, so a later sample for the same
14
+ * (turn, step) replaces in place and `llm/retry-started` clears it.
15
+ * - route: sessionId -> { provider, model } from the latest request route,
16
+ * used to attribute settlements that carry no `message.source`
17
+ * (`assistant/attempt` never does).
9
18
  * - index: incremental aggregation index (aggregate.js), kept in sync by
10
19
  * fold.js and consumed by query.js.
11
20
  */
@@ -13,9 +22,11 @@ export function createState(priceStore) {
13
22
  return {
14
23
  records: [],
15
24
  watermarks: new Map(),
25
+ revisions: new Map(),
16
26
  backfill: { status: 'idle', total: 0, done: 0 },
17
27
  prices: priceStore,
18
- dedup: new Map(),
28
+ lastSample: new Map(),
29
+ route: new Map(),
19
30
  index: null, // wired by apply() after createUsageIndex(state)
20
31
  };
21
32
  }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Usage Statistics",
4
+ "description": "Token, call and reference-cost statistics by day and by model, with model filters and a yearly heatmap."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "用量统计",
4
+ "description": "按天、按模型查看 Token、调用次数与参考成本,支持模型筛选与年度热力图。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "dsh-plugin-usage",
3
3
  "description": "Token usage statistics for DeepSeek Harness — daily/custom-range views, model filters, call counts and cost, inspired by CC Switch statistics.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/bennett-dsh/dsh-plugin-usage.git"
9
9
  },
10
10
  "type": "module",
11
+ "icon": "./icon.svg",
11
12
  "main": "lib/host/index.js",
12
13
  "exports": {
13
14
  ".": {
@@ -17,11 +18,16 @@
17
18
  "default": "./lib/client/index.js"
18
19
  },
19
20
  "./cordis.patch.yml": "./cordis.patch.yml",
20
- "./package.json": "./package.json"
21
+ "./package.json": "./package.json",
22
+ "./locale/*.json": "./locale/*.json"
21
23
  },
22
24
  "files": [
23
25
  "lib",
24
- "cordis.patch.yml"
26
+ "cordis.patch.yml",
27
+ "icon.svg",
28
+ "locale",
29
+ "image.png",
30
+ "image-1.png"
25
31
  ],
26
32
  "keywords": [
27
33
  "dsh",
@@ -32,7 +38,12 @@
32
38
  "statistics",
33
39
  "cost"
34
40
  ],
41
+ "engines": {
42
+ "node": ">=22",
43
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
44
+ },
35
45
  "dsh": {
46
+ "manifestVersion": 1,
36
47
  "bundle": {
37
48
  "patch": "./cordis.patch.yml"
38
49
  },
@@ -45,12 +56,14 @@
45
56
  "platform": "web"
46
57
  }
47
58
  },
59
+ "dependencies": {
60
+ "@deepseek-ai/schemastery": "~3.18.5"
61
+ },
48
62
  "peerDependencies": {
49
- "@deepseek-ai/cordis": "^4.0.1",
50
- "react": "^18.2.0"
63
+ "@deepseek-ai/cordis": "~4.0.5"
51
64
  },
52
65
  "devDependencies": {
53
- "@deepseek-ai/cordis": "^4.0.1",
66
+ "@deepseek-ai/cordis": "~4.0.5",
54
67
  "@types/react": "~18.3.1",
55
68
  "react": "^18.2.0",
56
69
  "typescript": "^5.5.0"
@@ -59,6 +72,6 @@
59
72
  "build": "tsc -p tsconfig.json && node scripts/build-client.js",
60
73
  "test": "node --test tests/*.test.js",
61
74
  "watch": "tsc -p tsconfig.json --watch",
62
- "prepublishOnly": "npm run build"
75
+ "prepublishOnly": "npm run build && npm test"
63
76
  }
64
- }
77
+ }