dsh-usage-badge 0.1.1 → 0.1.3

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/pricing.js CHANGED
@@ -7,6 +7,13 @@
7
7
  * the same case-insensitive key matching, and the same per-request application of
8
8
  * the model multiplier, the context-length tier and the peak/valley rule.
9
9
  *
10
+ * Two rules were added on top of that port, both opt-out and both documented in
11
+ * `docs/design-notes.md`: a trailing `*` on a model part makes a family row, and — unless
12
+ * `modelPrefixFallback` is off — an ordinary row also prices its own separator-suffixed
13
+ * variants (`deepseek-flash` covers `deepseek-flash-preview`). `priceUnmatchedModels` can
14
+ * also be switched off, which makes a model no row matches cost nothing rather than being
15
+ * priced by the `default` row.
16
+ *
10
17
  * Naming note: every cost function here returns an amount in the *total currency*
11
18
  * (`totalCurrency`, CNY by default), not necessarily USD — the sidecar inherited
12
19
  * the misleading `usageCostUsd` name, which this port drops.
@@ -18,9 +25,11 @@
18
25
  export const PRICE_KEYS = ['inputPerMillion', 'cacheReadPerMillion', 'cacheWritePerMillion', 'outputPerMillion']
19
26
 
20
27
  /**
21
- * Written to `usage-pricing.json` on first run (only when the file is missing) so
22
- * a fresh install starts from a working table instead of zero prices. Mirrors the
23
- * shell's template.
28
+ * The rates a table with no `default` row of its own falls back to, plus the exchange rate the
29
+ * shell used. **Never written to disk** — nothing in this module (or the plugin) persists it, it
30
+ * is only the in-memory fallback so a fresh install shows an estimate instead of zeroes. It is
31
+ * served to the panel as `template` so those rates can be labelled as what they are, and
32
+ * 「应用官方价格」 replaces them with the published list's first line.
24
33
  */
25
34
  export const DEFAULT_TEMPLATE = {
26
35
  exchangeRate: 6.74,
@@ -82,6 +91,16 @@ export function normalizePricing(raw) {
82
91
  exchangeRate: Number(user.exchangeRate) || DEFAULT_TEMPLATE.exchangeRate,
83
92
  default: { ...DEFAULT_TEMPLATE.default, ...(user.default ?? {}) },
84
93
  overrides: user.overrides && typeof user.overrides === 'object' ? user.overrides : {},
94
+ // Implicit prefix fallback: an authored row also prices the suffixed variants of
95
+ // its own name, so the `deepseek-flash` row the official list writes covers
96
+ // `deepseek-flash-preview` with nothing hand-written. On unless switched off, and
97
+ // only an explicit `false` switches it off.
98
+ modelPrefixFallback: user.modelPrefixFallback !== false,
99
+ // Whether a model that no row matches is priced by the `default` row at all. On by
100
+ // default — pricing everything is what that row is for — and switched off it means
101
+ // "we do not know what this cost": the request contributes tokens and requests but no
102
+ // amount, and the snapshot reports which routes those are rather than a silent ¥0.
103
+ priceUnmatchedModels: user.priceUnmatchedModels !== false,
85
104
  timeOfUse: user.timeOfUse,
86
105
  multiplier: user.multiplier ?? DEFAULT_TEMPLATE.multiplier,
87
106
  contextMultiplier: user.contextMultiplier,
@@ -98,6 +117,10 @@ export function normalizePricing(raw) {
98
117
  * `*|model` wildcards. This lets a single row like `"deepseek-v4-flash"` apply to
99
118
  * every provider.
100
119
  *
120
+ * These are the *exact* keys a route can resolve to. Rows that end a model part with
121
+ * `*` are family rules and are appended after these by {@link rowsForRoute}, because
122
+ * an exact row always outranks a family one.
123
+ *
101
124
  * @param {string|null} provider - provider id as recorded in the session log.
102
125
  * @param {string|null} model - model id as recorded in the session log.
103
126
  * @returns {string[]} candidate override keys, highest priority first.
@@ -149,6 +172,132 @@ function overrideRow(pricing, key) {
149
172
  return actual === undefined ? undefined : rows[actual]
150
173
  }
151
174
 
175
+ /**
176
+ * The prefix a family row matches on, or null when the key is not a family row.
177
+ *
178
+ * A trailing `*` on the model part means "every model whose id starts with this", so
179
+ * the row `deepseek-flash-*` prices `deepseek-flash-preview` and any dated or
180
+ * experimental variant of the family without a row per name.
181
+ *
182
+ * The prefix must be non-empty, and that is exactly what keeps a bare `*` inert:
183
+ * `provider|*` already means "any model" through the exact candidate list, and a lone
184
+ * `*` key has never matched anything. The star is not a separator either, so
185
+ * `deepseek-flash-*` does not cover the model `deepseek-flash` itself — write
186
+ * `deepseek-flash*` when the family includes the unsuffixed name.
187
+ */
188
+ function familyPrefix(pattern) {
189
+ const text = String(pattern)
190
+ return text.endsWith('*') && text.length > 1 ? text.slice(0, -1) : null
191
+ }
192
+
193
+ /**
194
+ * What must follow a prefix for the *implicit* fallback to accept it: a separator.
195
+ *
196
+ * Without this, `gpt-4` would swallow `gpt-4o` — a different model with a different
197
+ * price — and `deepseek-flash` would swallow `deepseek-flashlight`. With it, the
198
+ * fallback only ever reaches `deepseek-flash-preview`, `deepseek.v3`, `qwen:72b`
199
+ * shapes. An explicit `deepseek-flash*` row skips this test: writing the star is how
200
+ * you say "yes, the glued-together names too".
201
+ */
202
+ const PREFIX_BOUNDARY = /^[-_.:/@]/
203
+
204
+ /**
205
+ * The prefix rows that apply to one route, most specific first.
206
+ *
207
+ * Two kinds end up here, and they share one ranking:
208
+ *
209
+ * - explicit family rows, a model part ending in `*` (`deepseek-flash-*`);
210
+ * - implicit ones, when `modelPrefixFallback` is on: an ordinary row also prices the
211
+ * suffixed variants of its own name, which is what lets the row the official price
212
+ * list writes — `deepseek-flash` — cover `deepseek-flash-preview` with nothing
213
+ * hand-written at all.
214
+ *
215
+ * Ranked by the shape they take — pure model, `provider|model`, `*|model`, the same
216
+ * order the exact keys resolve in — then by prefix length (the narrow `deepseek-flash-*`
217
+ * outranks the broad `deepseek-*`), and explicit wins a tie against implicit. A key that
218
+ * names a different provider, or has more than one `|`, belongs to no shape and is
219
+ * ignored, exactly as it was when it could never match.
220
+ */
221
+ function familyRows(rows, provider, model, fallback) {
222
+ if (!model) return []
223
+ const routeProvider = String(provider ?? '').toLowerCase()
224
+ const routeModel = String(model).toLowerCase()
225
+ const found = []
226
+ for (const key of Object.keys(rows)) {
227
+ const parts = String(key).split('|')
228
+ if (parts.length > 2) continue
229
+ const modelPart = String(parts.length === 2 ? parts[1] : parts[0])
230
+ const prefix = familyPrefix(modelPart)
231
+ let length
232
+ if (prefix !== null) {
233
+ if (!routeModel.startsWith(prefix.toLowerCase())) continue
234
+ length = prefix.length
235
+ } else if (fallback) {
236
+ const name = modelPart.toLowerCase()
237
+ // The exact key has already had its turn by the time this runs, so an implicit
238
+ // row is always a *strict* prefix plus a separator.
239
+ if (!name || !routeModel.startsWith(name) || !PREFIX_BOUNDARY.test(routeModel.slice(name.length))) continue
240
+ length = name.length
241
+ } else {
242
+ continue
243
+ }
244
+ let shape
245
+ if (parts.length === 1) shape = 0
246
+ else if (parts[0] === '*') shape = 2
247
+ else if (parts[0].toLowerCase() === routeProvider) shape = 1
248
+ else continue
249
+ // A row can be `null` in the file — a hand edit, or a delete that left the key behind. The
250
+ // exact-key path has always skipped those; skipping them here too is what keeps a single such
251
+ // row from throwing out of every price lookup and turning `/summary` into a permanent 500.
252
+ const row = rows[key]
253
+ if (!row) continue
254
+ found.push({ shape, length, explicit: prefix === null ? 0 : 1, row })
255
+ }
256
+ return found
257
+ .sort((a, b) => a.shape - b.shape || b.length - a.length || b.explicit - a.explicit)
258
+ .map((entry) => entry.row)
259
+ }
260
+
261
+ /**
262
+ * The override rows that price one route, highest priority first.
263
+ *
264
+ * Exact keys come first, in the order {@link matchKeys} establishes, so a table that
265
+ * writes no family row resolves exactly as it did before families existed. Prefix rows
266
+ * follow — same shape order, longest prefix first — which is what lets a family rule
267
+ * cover the models nobody wrote a row for without ever outranking one that somebody did.
268
+ *
269
+ * Memoized per override set and route: this is consulted once per price field of every
270
+ * request record while a day is folded. The entry is only reusable while the override *map*
271
+ * is the same object — adding or removing a key in place would go unnoticed, so callers must
272
+ * treat a document as immutable (every path here re-parses the file instead of editing it).
273
+ * The fallback switch is part of the cache key because two normalized documents can share one
274
+ * override map and differ only by that flag — which is exactly what toggling the switch does
275
+ * when a caller re-normalizes without re-reading.
276
+ */
277
+ const routeRows = new WeakMap()
278
+
279
+ function rowsForRoute(pricing, provider, model) {
280
+ const rows = pricing.overrides
281
+ if (!rows) return []
282
+ const fallback = pricing.modelPrefixFallback !== false
283
+ let byRoute = routeRows.get(rows)
284
+ if (!byRoute) {
285
+ byRoute = new Map()
286
+ routeRows.set(rows, byRoute)
287
+ }
288
+ const routeKey = `${fallback ? 'p' : 'e'}\u0000${provider ?? ''}\u0000${model ?? ''}`
289
+ const cached = byRoute.get(routeKey)
290
+ if (cached) return cached
291
+ const resolved = []
292
+ for (const key of matchKeys(provider, model)) {
293
+ const row = overrideRow(pricing, key)
294
+ if (row) resolved.push(row)
295
+ }
296
+ const all = resolved.concat(familyRows(rows, provider, model, fallback))
297
+ byRoute.set(routeKey, all)
298
+ return all
299
+ }
300
+
152
301
  /**
153
302
  * Resolve the four price fields for one route.
154
303
  *
@@ -158,11 +307,11 @@ function overrideRow(pricing, key) {
158
307
  */
159
308
  export function resolvePrice(pricing, provider, model) {
160
309
  const out = {}
310
+ const rows = rowsForRoute(pricing, provider, model)
161
311
  for (const k of PRICE_KEYS) {
162
312
  out[k] = Number(pricing.default?.[k]) || 0
163
- for (const key of matchKeys(provider, model)) {
164
- const row = overrideRow(pricing, key)
165
- if (row && row[k] != null) {
313
+ for (const row of rows) {
314
+ if (row[k] != null) {
166
315
  out[k] = Number(row[k]) || 0
167
316
  break
168
317
  }
@@ -173,36 +322,32 @@ export function resolvePrice(pricing, provider, model) {
173
322
 
174
323
  /** Per-model peak/valley rule: the first matching row's own rule, else the global one. */
175
324
  export function modelTimeOfUse(pricing, provider, model) {
176
- for (const key of matchKeys(provider, model)) {
177
- const row = overrideRow(pricing, key)
178
- if (row && row.timeOfUse) return row.timeOfUse
325
+ for (const row of rowsForRoute(pricing, provider, model)) {
326
+ if (row.timeOfUse) return row.timeOfUse
179
327
  }
180
328
  return pricing.timeOfUse
181
329
  }
182
330
 
183
331
  /** Per-model multiplier applied to the whole request cost (row wins, else global, default 1). */
184
332
  export function modelMultiplier(pricing, provider, model) {
185
- for (const key of matchKeys(provider, model)) {
186
- const row = overrideRow(pricing, key)
187
- if (row && row.multiplier != null) return Number(row.multiplier) || 1
333
+ for (const row of rowsForRoute(pricing, provider, model)) {
334
+ if (row.multiplier != null) return Number(row.multiplier) || 1
188
335
  }
189
336
  return Number(pricing.multiplier) || 1
190
337
  }
191
338
 
192
339
  /** Per-model context-length tier rule (row wins, else the default-row rule). */
193
340
  export function modelContextMultiplier(pricing, provider, model) {
194
- for (const key of matchKeys(provider, model)) {
195
- const row = overrideRow(pricing, key)
196
- if (row && row.contextMultiplier != null) return row.contextMultiplier
341
+ for (const row of rowsForRoute(pricing, provider, model)) {
342
+ if (row.contextMultiplier != null) return row.contextMultiplier
197
343
  }
198
344
  return pricing.contextMultiplier
199
345
  }
200
346
 
201
347
  /** The currency a route's prices are quoted in (`cny` unless the row says otherwise). */
202
348
  export function resolveCurrency(pricing, provider, model) {
203
- for (const key of matchKeys(provider, model)) {
204
- const row = overrideRow(pricing, key)
205
- if (row && row.currency) return row.currency
349
+ for (const row of rowsForRoute(pricing, provider, model)) {
350
+ if (row.currency) return row.currency
206
351
  }
207
352
  return pricing.default?.currency || 'cny'
208
353
  }
@@ -212,6 +357,23 @@ export function totalCurrencyOf(pricing) {
212
357
  return pricing.totalCurrency === 'usd' ? 'usd' : 'cny'
213
358
  }
214
359
 
360
+ /**
361
+ * Whether a route has no price at all.
362
+ *
363
+ * True only when `priceUnmatchedModels` is switched off *and* no override row applies to
364
+ * the route. A row that matches counts even when it defines nothing but a multiplier:
365
+ * "matched" means the table has something to say about this model, and the `default` row
366
+ * supplies whatever the row leaves out.
367
+ *
368
+ * @param {object} pricing - the normalized pricing document.
369
+ * @param {string|null} provider - route provider.
370
+ * @param {string|null} model - route model.
371
+ */
372
+ export function isUnpriced(pricing, provider, model) {
373
+ if (pricing.priceUnmatchedModels !== false) return false
374
+ return rowsForRoute(pricing, provider, model).length === 0
375
+ }
376
+
215
377
  /**
216
378
  * Convert a cost quoted in `from` into the configured total currency. Same-currency
217
379
  * conversion is a no-op, so an all-CNY table needs no exchange rate at all.
@@ -279,6 +441,7 @@ export function contextMultiplierFor(pricing, provider, model, tokens) {
279
441
  */
280
442
  export function requestCost(tokens, provider, model, hour, pricing, day) {
281
443
  const context = normalizeDay(day)
444
+ if (isUnpriced(pricing, provider, model)) return 0
282
445
  const price = resolvePrice(pricing, provider, model)
283
446
  const timeMultiplier = multiplierFor(context.weekday, hour, modelTimeOfUse(pricing, provider, model), context.class)
284
447
  const modelMultiplierValue = modelMultiplier(pricing, provider, model)
@@ -294,6 +457,7 @@ export function requestCost(tokens, provider, model, hour, pricing, day) {
294
457
  */
295
458
  export function legacyCost(tokens, provider, model, hour, pricing, day) {
296
459
  const context = normalizeDay(day)
460
+ if (isUnpriced(pricing, provider, model)) return 0
297
461
  const price = resolvePrice(pricing, provider, model)
298
462
  const timeMultiplier = multiplierFor(context.weekday, hour, modelTimeOfUse(pricing, provider, model), context.class)
299
463
  const cost = tokenCost(tokens, price) * timeMultiplier * modelMultiplier(pricing, provider, model)
@@ -439,7 +603,11 @@ function normalizeDay(day) {
439
603
  * surcharge and is floored at 1.
440
604
  *
441
605
  * A public holiday is off-peak for the whole day, which is DeepSeek's published
442
- * rule — so the holiday check comes first and overrides every `days` form.
606
+ * rule — so the holiday check comes before every `days` form. A rule can decline that
607
+ * calendar with `honorHolidays: false`, which is what a gateway that keeps charging its
608
+ * own peak windows on a Chinese public holiday needs; the day is then judged by `days`
609
+ * like any other. Weekends need no such flag: they are already per rule through `days`
610
+ * (`"all"`, `"weekend"`, `[6,7]`).
443
611
  *
444
612
  * @param {number} weekday - ISO weekday (1=Mon … 7=Sun).
445
613
  * @param {number} hour - local hour (0–23).
@@ -448,7 +616,7 @@ function normalizeDay(day) {
448
616
  */
449
617
  export function multiplierFor(weekday, hour, tou, dayClass = 'normal') {
450
618
  if (!tou || !tou.enabled) return 1
451
- if (dayClass === 'holiday') return 1
619
+ if (dayClass === 'holiday' && tou.honorHolidays !== false) return 1
452
620
  if (!dayMatches(weekday, tou.days)) return 1
453
621
  for (const range of tou.peakRanges || []) {
454
622
  if (!Array.isArray(range) || range.length < 2) continue
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.1",
4
- "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month charts, official DeepSeek pricing and Chinese public holidays.",
3
+ "version": "0.1.3",
4
+ "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "repository": {