dsh-usage-badge 0.1.2 → 0.1.4
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 +31 -12
- package/assets/usage-dialog.png +0 -0
- package/docs/design-notes.md +172 -39
- package/lib/buckets.js +115 -0
- package/lib/cache-store.js +633 -0
- package/lib/client.js +1413 -169
- package/lib/fold.js +82 -178
- package/lib/holidays.js +5 -4
- package/lib/index.js +246 -30
- package/lib/official-pricing.js +14 -3
- package/lib/pricing.js +188 -20
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { createCacheStore } from './cache-store.js'
|
|
22
|
+
import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
|
|
22
23
|
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
23
24
|
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
24
25
|
import {
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
bucketHourlyCosts,
|
|
28
29
|
dayContext,
|
|
29
30
|
dayKey,
|
|
31
|
+
isUnpriced,
|
|
30
32
|
normalizePricing,
|
|
31
33
|
timeOfUseState,
|
|
32
34
|
totalCurrencyOf,
|
|
@@ -41,13 +43,33 @@ export const name = 'usage-badge'
|
|
|
41
43
|
/** Route prefix owned by this plugin. */
|
|
42
44
|
const ROUTE_PREFIX = '/usage-badge'
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* What the browser half requires of the host half, and what this build provides.
|
|
48
|
+
*
|
|
49
|
+
* Bump it whenever the panel starts depending on something only a newer host does — an
|
|
50
|
+
* accepted patch key, a field in a response. The host half is loaded when the DSH process
|
|
51
|
+
* starts while the browser half is re-read on every page load, so a refreshed page against
|
|
52
|
+
* an unrestarted host is the normal way to get a UI whose buttons silently do nothing
|
|
53
|
+
* (an unknown patch key is accepted with a 200 and ignored). The panel compares this number
|
|
54
|
+
* and says so instead. `test/client.render.mjs` asserts the two halves agree.
|
|
55
|
+
*/
|
|
56
|
+
export const HOST_API_VERSION = 6
|
|
57
|
+
|
|
44
58
|
/**
|
|
45
59
|
* Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
|
|
46
60
|
* cache and price table are never mistaken for harness data in the shared root.
|
|
47
61
|
*/
|
|
48
62
|
const DATA_DIR_NAME = 'usage-badge'
|
|
49
63
|
|
|
50
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* How many of the newest days **that have usage** travel in the payload; covers the 12-month view
|
|
66
|
+
* plus slack.
|
|
67
|
+
*
|
|
68
|
+
* Not a calendar-day window: the snapshot takes the newest 400 entries of the in-memory day map, so
|
|
69
|
+
* a sparse history reaches further back than 400 days — and a dense one stops inside them. The
|
|
70
|
+
* store's loading window (`CACHE_WINDOW_DAYS`) is a separate, calendar-day bound on what is read
|
|
71
|
+
* back from disk; it is deliberately wider so it never truncates what this can ask for.
|
|
72
|
+
*/
|
|
51
73
|
const DAYS_KEPT = 400
|
|
52
74
|
|
|
53
75
|
/** A snapshot younger than this is reused instead of rebuilt on every poll. */
|
|
@@ -157,11 +179,94 @@ function summarizeDay(dayObj, pricing, date, withHourly, rules) {
|
|
|
157
179
|
},
|
|
158
180
|
providers: [...providers.values()]
|
|
159
181
|
.sort((a, b) => b.amount - a.amount || a.provider.localeCompare(b.provider))
|
|
160
|
-
.map((row) =>
|
|
182
|
+
.map((row) => {
|
|
183
|
+
const base = { ...row, amount: round(row.amount, 4) }
|
|
184
|
+
// Only the 24-hour view asks for a per-hour series, and a row without one omits the key
|
|
185
|
+
// rather than carrying `hourly: null`, so a month row and a day row have the same shape.
|
|
186
|
+
if (row.hourly) base.hourly = roundSeries(row.hourly)
|
|
187
|
+
return base
|
|
188
|
+
}),
|
|
161
189
|
hourly: roundSeries(dayHours),
|
|
162
190
|
}
|
|
163
191
|
}
|
|
164
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Fold one calendar year's day rows into twelve natural months.
|
|
195
|
+
*
|
|
196
|
+
* All twelve are always present, in order, empty ones included: a month bar has to line up
|
|
197
|
+
* across years, and "no usage in March" is a fact about March rather than a month to drop.
|
|
198
|
+
* Costing is the day summariser's, day by day and request by request, so a month total is the
|
|
199
|
+
* sum of its days and nothing is priced at a coarser granularity than the daily charts use.
|
|
200
|
+
*
|
|
201
|
+
* @param {Map<string, Map<string, object>>} dayRows - merged day buckets, as `mergeRecords`
|
|
202
|
+
* produces them, already restricted to the year.
|
|
203
|
+
*/
|
|
204
|
+
function summarizeYear(dayRows, pricing, rules, year) {
|
|
205
|
+
const months = Array.from({ length: 12 }, (_, index) => ({
|
|
206
|
+
month: `${year}-${String(index + 1).padStart(2, '0')}`,
|
|
207
|
+
label: `${index + 1}月`,
|
|
208
|
+
amount: 0,
|
|
209
|
+
requests: 0,
|
|
210
|
+
input: 0,
|
|
211
|
+
cacheRead: 0,
|
|
212
|
+
cacheWrite: 0,
|
|
213
|
+
output: 0,
|
|
214
|
+
providers: new Map(),
|
|
215
|
+
}))
|
|
216
|
+
// The year's days travel with the months: the heatmap next to the monthly chart is a calendar
|
|
217
|
+
// grid, and days older than the loading window are not in the snapshot at all.
|
|
218
|
+
const days = []
|
|
219
|
+
|
|
220
|
+
for (const [date, dayObj] of dayRows) {
|
|
221
|
+
const month = months[Number(date.slice(5, 7)) - 1]
|
|
222
|
+
if (!month) continue
|
|
223
|
+
const summary = summarizeDay(dayObj, pricing, date, false, rules)
|
|
224
|
+
days.push(dayRow(date, summary, rules))
|
|
225
|
+
addTokens(month, summary.totals, summary.totals.amount)
|
|
226
|
+
for (const row of summary.providers) {
|
|
227
|
+
const target = month.providers.get(row.provider) ?? {
|
|
228
|
+
provider: row.provider,
|
|
229
|
+
amount: 0,
|
|
230
|
+
requests: 0,
|
|
231
|
+
input: 0,
|
|
232
|
+
cacheRead: 0,
|
|
233
|
+
cacheWrite: 0,
|
|
234
|
+
output: 0,
|
|
235
|
+
}
|
|
236
|
+
addTokens(target, row, row.amount)
|
|
237
|
+
month.providers.set(row.provider, target)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const total = { amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
242
|
+
for (const month of months) {
|
|
243
|
+
total.amount += month.amount
|
|
244
|
+
total.requests += month.requests
|
|
245
|
+
total.input += month.input
|
|
246
|
+
total.cacheRead += month.cacheRead
|
|
247
|
+
total.cacheWrite += month.cacheWrite
|
|
248
|
+
total.output += month.output
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
year: Number(year),
|
|
253
|
+
// How many days of that year this is based on, so an empty year is legible. Counted from the
|
|
254
|
+
// rows actually reported, not from the input map — a row whose date is unparseable is skipped
|
|
255
|
+
// and must not be counted as a day the caller can see.
|
|
256
|
+
days: days.length,
|
|
257
|
+
months: months.map((month) => ({
|
|
258
|
+
...month,
|
|
259
|
+
amount: round(month.amount, 4),
|
|
260
|
+
providers: [...month.providers.values()]
|
|
261
|
+
.sort((a, b) => b.amount - a.amount || a.provider.localeCompare(b.provider))
|
|
262
|
+
.map((row) => ({ ...row, amount: round(row.amount, 4) })),
|
|
263
|
+
})),
|
|
264
|
+
// The days that had usage, oldest first — the calendar grid fills in the quiet ones itself.
|
|
265
|
+
daily: days,
|
|
266
|
+
total: { ...total, amount: round(total.amount, 4) },
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
165
270
|
/**
|
|
166
271
|
* Recover the `provider|model` route behind a provider row, so a per-model
|
|
167
272
|
* pricing rule can be evaluated for the period indicator. A provider row is
|
|
@@ -178,8 +283,50 @@ function dominantRouteOf(dayObj, providerName) {
|
|
|
178
283
|
return best?.model ?? null
|
|
179
284
|
}
|
|
180
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Routes inside the snapshot window that have usage but no price at all.
|
|
288
|
+
*
|
|
289
|
+
* Only reachable with `priceUnmatchedModels` off. The snapshot reports them because a
|
|
290
|
+
* route that contributes tokens and ¥0 is indistinguishable from a free one — the panel
|
|
291
|
+
* has to be able to say which models the amount is missing, or the total would be a
|
|
292
|
+
* silently wrong number of exactly the kind this plugin avoids everywhere else.
|
|
293
|
+
*/
|
|
294
|
+
function collectUnpriced(pricing, dayObjs, dates) {
|
|
295
|
+
// The common case by far: every model is priced, so there is nothing to walk. Answering
|
|
296
|
+
// before the loop keeps this off the snapshot-building path for tables that never ask.
|
|
297
|
+
if (pricing.priceUnmatchedModels !== false) return []
|
|
298
|
+
const window = new Set(dates)
|
|
299
|
+
const routes = new Map()
|
|
300
|
+
for (const [date, dayObj] of dayObjs) {
|
|
301
|
+
if (!window.has(date)) continue
|
|
302
|
+
for (const bucket of dayObj.values()) {
|
|
303
|
+
if (!isUnpriced(pricing, bucket.provider, bucket.model)) continue
|
|
304
|
+
const provider = String(bucket.provider ?? '').trim() || 'unknown'
|
|
305
|
+
const model = String(bucket.model ?? '').trim() || 'unknown'
|
|
306
|
+
const key = `${provider}|${model}`
|
|
307
|
+
const row = routes.get(key) ?? { provider, model, tokens: 0, requests: 0 }
|
|
308
|
+
row.tokens += (bucket.input || 0) + (bucket.cacheRead || 0) + (bucket.cacheWrite || 0) + (bucket.output || 0)
|
|
309
|
+
row.requests += bucket.requests || 0
|
|
310
|
+
routes.set(key, row)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return [...routes.values()].sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One day, as a row: totals, per-provider splits, and the day's own classification.
|
|
318
|
+
*
|
|
319
|
+
* Shared by the snapshot and the year view, because both feed the same chart and the same
|
|
320
|
+
* heatmap, and a day that read differently in the two would be a bug with no upside. The marks
|
|
321
|
+
* travel with the row because it is the only way to read a past day's holiday status back.
|
|
322
|
+
*/
|
|
323
|
+
function dayRow(date, summary, rules) {
|
|
324
|
+
const context = dayContext(date, rules)
|
|
325
|
+
return { date, ...summary.totals, providers: summary.providers, dayClass: context.class, dayName: context.name }
|
|
326
|
+
}
|
|
327
|
+
|
|
181
328
|
/** Build the payload the browser half renders. */
|
|
182
|
-
function buildSnapshot(aggregator, pricing, rules, status) {
|
|
329
|
+
function buildSnapshot(aggregator, pricing, rules, status, years = []) {
|
|
183
330
|
const now = Date.now()
|
|
184
331
|
const today = dayKey(now)
|
|
185
332
|
const dayObjs = aggregator.days
|
|
@@ -188,16 +335,13 @@ function buildSnapshot(aggregator, pricing, rules, status) {
|
|
|
188
335
|
const todayObj = dayObjs.get(today)
|
|
189
336
|
const todaySummary = summarizeDay(todayObj, pricing, today, true, rules)
|
|
190
337
|
|
|
191
|
-
const days = dates.map((date) =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const summary = summarizeDay(dayObjs.get(date), pricing, date, false, rules)
|
|
199
|
-
return { date, ...summary.totals, providers: summary.providers, ...marks }
|
|
200
|
-
})
|
|
338
|
+
const days = dates.map((date) =>
|
|
339
|
+
dayRow(
|
|
340
|
+
date,
|
|
341
|
+
date === today ? todaySummary : summarizeDay(dayObjs.get(date), pricing, date, false, rules),
|
|
342
|
+
rules,
|
|
343
|
+
),
|
|
344
|
+
)
|
|
201
345
|
|
|
202
346
|
// The badge is always CNY: a CNY total needs no rate, a USD total is converted
|
|
203
347
|
// with the live exchange rate.
|
|
@@ -224,6 +368,12 @@ function buildSnapshot(aggregator, pricing, rules, status) {
|
|
|
224
368
|
badge: { date: today, amount: round(badgeCny, 2), currency: 'cny' },
|
|
225
369
|
today: { date: today, ...todaySummary.totals, hourly: todaySummary.hourly, providers: todaySummary.providers },
|
|
226
370
|
days,
|
|
371
|
+
// Which calendar years have data, newest first. Answered by the store, not by the days
|
|
372
|
+
// above: the snapshot only carries a window, so a year older than that would be invisible
|
|
373
|
+
// even though its rows are still on disk.
|
|
374
|
+
years,
|
|
375
|
+
// Empty unless unmatched models are deliberately left unpriced.
|
|
376
|
+
unpriced: collectUnpriced(pricing, dayObjs, dates),
|
|
227
377
|
band,
|
|
228
378
|
calendar: describeCalendar(rules, now),
|
|
229
379
|
status: { ...status, sessions: aggregator.sessionCount },
|
|
@@ -258,7 +408,7 @@ function readPricing(paths) {
|
|
|
258
408
|
return normalizePricing(readRawPricing(paths).raw)
|
|
259
409
|
}
|
|
260
410
|
|
|
261
|
-
/** Read a request body, bounded. */
|
|
411
|
+
/** Read a request body, bounded. An oversized one is a 413, not a server error. */
|
|
262
412
|
function readBody(req) {
|
|
263
413
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
264
414
|
const chunks = []
|
|
@@ -266,7 +416,9 @@ function readBody(req) {
|
|
|
266
416
|
req.on('data', (chunk) => {
|
|
267
417
|
size += chunk.length
|
|
268
418
|
if (size > MAX_BODY_BYTES) {
|
|
269
|
-
|
|
419
|
+
const error = new Error(`request body larger than ${MAX_BODY_BYTES} bytes`)
|
|
420
|
+
error.statusCode = 413
|
|
421
|
+
rejectPromise(error)
|
|
270
422
|
req.destroy()
|
|
271
423
|
return
|
|
272
424
|
}
|
|
@@ -321,7 +473,19 @@ function mergeConfig(raw, patch) {
|
|
|
321
473
|
if (patch.exchangeRate !== undefined) next.exchangeRate = Number(patch.exchangeRate) || next.exchangeRate
|
|
322
474
|
if (patch.totalCurrency !== undefined) next.totalCurrency = patch.totalCurrency === 'usd' ? 'usd' : 'cny'
|
|
323
475
|
if (patch.multiplier !== undefined) next.multiplier = Number(patch.multiplier) || 1
|
|
324
|
-
|
|
476
|
+
// Accepted here even though the panel offers no field for it: only an explicit
|
|
477
|
+
// `false` turns the model-prefix fallback off.
|
|
478
|
+
if (patch.modelPrefixFallback !== undefined) next.modelPrefixFallback = patch.modelPrefixFallback !== false
|
|
479
|
+
// Same shape: only an explicit `false` stops the `default` row pricing unmatched models.
|
|
480
|
+
if (patch.priceUnmatchedModels !== undefined) next.priceUnmatchedModels = patch.priceUnmatchedModels !== false
|
|
481
|
+
// The catch-all row is replaced **wholesale**, exactly like `overrides` below — the panel sends
|
|
482
|
+
// the whole row it was showing, so a field the user cleared has to disappear. Merging instead
|
|
483
|
+
// (the older behaviour) made clearing a field a silent no-op that still reported success, and
|
|
484
|
+
// it meant an unwanted `currency` or rate could never be removed from the panel.
|
|
485
|
+
// `default: null` removes the row; the table then falls back to the built-in template until an
|
|
486
|
+
// official apply fills it again.
|
|
487
|
+
if (patch.default === null) delete next.default
|
|
488
|
+
else if (patch.default && typeof patch.default === 'object') next.default = { ...patch.default }
|
|
325
489
|
if (patch.overrides && typeof patch.overrides === 'object') next.overrides = patch.overrides
|
|
326
490
|
if (patch.timeOfUse !== undefined) next.timeOfUse = patch.timeOfUse
|
|
327
491
|
if (patch.contextMultiplier !== undefined) next.contextMultiplier = patch.contextMultiplier
|
|
@@ -342,12 +506,15 @@ export function apply(ctx) {
|
|
|
342
506
|
// already holds harness caches and, on some installs, other tools' settings.
|
|
343
507
|
const dataDir = join(home, 'storages', DATA_DIR_NAME)
|
|
344
508
|
const pricingPath = join(dataDir, 'pricing.json')
|
|
345
|
-
const cachePath = join(dataDir, 'cache.json')
|
|
346
509
|
/** Pre-isolation location of the price table, honored as a migration fallback. */
|
|
347
510
|
const legacyPricingPath = join(home, 'storages', 'usage-pricing.json')
|
|
348
511
|
const pricingPaths = [pricingPath, legacyPricingPath]
|
|
349
512
|
|
|
350
|
-
|
|
513
|
+
// The folded history lives in whichever store this runtime can offer — SQLite when
|
|
514
|
+
// `node:sqlite` is there, the original single-document JSON otherwise. Either way it is one
|
|
515
|
+
// file inside the plugin's own directory.
|
|
516
|
+
const store = createCacheStore({ dir: dataDir, log })
|
|
517
|
+
const aggregator = createAggregator({ sessionsRoot, store, log })
|
|
351
518
|
aggregator.loadCache()
|
|
352
519
|
|
|
353
520
|
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
@@ -381,7 +548,16 @@ export function apply(ctx) {
|
|
|
381
548
|
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
382
549
|
// than re-costing every day on each poll.
|
|
383
550
|
if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
|
|
384
|
-
|
|
551
|
+
// The year list is the one thing here that comes from the store rather than from memory, so a
|
|
552
|
+
// store that cannot answer must not take the badge down with it: every number in the payload is
|
|
553
|
+
// already in hand, and the picker simply has nothing to offer.
|
|
554
|
+
let years = []
|
|
555
|
+
try {
|
|
556
|
+
years = store.years()
|
|
557
|
+
} catch (error) {
|
|
558
|
+
log(`cache year list failed: ${error?.message ?? error}`)
|
|
559
|
+
}
|
|
560
|
+
snapshot = buildSnapshot(aggregator, pricing, rules, status, years)
|
|
385
561
|
return snapshot
|
|
386
562
|
}
|
|
387
563
|
|
|
@@ -406,12 +582,12 @@ export function apply(ctx) {
|
|
|
406
582
|
async function warm() {
|
|
407
583
|
try {
|
|
408
584
|
const status = await refreshOnce()
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
if (status.folded > 0) aggregator.saveCache()
|
|
585
|
+
// The SQLite store has already committed every session it folded; the JSON fallback
|
|
586
|
+
// batches, and a short-lived host would otherwise pay the full cold scan on every start.
|
|
587
|
+
aggregator.flush()
|
|
413
588
|
log(
|
|
414
589
|
`ready: ${aggregator.sessionCount} session(s) over ${status.files} log(s), folded ${status.folded} in ${status.ms}ms` +
|
|
590
|
+
`; cache: ${store.kind}` +
|
|
415
591
|
(rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
|
|
416
592
|
)
|
|
417
593
|
if (rules.missingYears?.length) {
|
|
@@ -432,15 +608,50 @@ export function apply(ctx) {
|
|
|
432
608
|
return
|
|
433
609
|
}
|
|
434
610
|
|
|
611
|
+
// One calendar year, by month. Read straight from the store rather than from the
|
|
612
|
+
// snapshot, because a year older than the loading window is not in memory at all — and
|
|
613
|
+
// it is the store that knows which years exist in the first place.
|
|
614
|
+
if (route === '/year' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
615
|
+
const year = String(url.searchParams.get('year') ?? '')
|
|
616
|
+
if (!/^\d{4}$/.test(year)) {
|
|
617
|
+
sendJson(res, 400, { error: `year must be a four-digit calendar year, got ${JSON.stringify(year)}` })
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
const records = store.load({ since: `${year}-01-01`, until: `${year}-12-31` })
|
|
621
|
+
const dayRows = mergeRecords(records)
|
|
622
|
+
sendJson(res, 200, summarizeYear(dayRows, pricing, rules, year))
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
|
|
435
626
|
if (route === '/config' && req.method === 'GET') {
|
|
436
627
|
const calendar = describeCalendar(rules)
|
|
437
628
|
// The bundled table is reported separately from the resolved rules: a
|
|
438
629
|
// deployment with the calendar off still needs to see what data is
|
|
439
630
|
// available before deciding to switch it on.
|
|
440
631
|
const bundled = loadBundledTable()
|
|
632
|
+
// Read once: both the path the table came from and whether it carries a usable
|
|
633
|
+
// `default` row, which decides whether that row is the user's or the template's.
|
|
634
|
+
const raw = readRawPricing(pricingPaths)
|
|
635
|
+
const rawDefault = raw.raw?.default
|
|
636
|
+
// A row only counts when it actually says something: `{}` (or a non-object, which a hand
|
|
637
|
+
// edit can leave) still takes every rate from the template, and reporting that as the
|
|
638
|
+
// user's row is the silent-template problem this field exists to avoid. The panel clears
|
|
639
|
+
// all four inputs to `{}` if a save is allowed to, so this is reachable from the UI too.
|
|
640
|
+
const hasDefaultRow = Boolean(rawDefault) && typeof rawDefault === 'object' && Object.keys(rawDefault).length > 0
|
|
441
641
|
sendJson(res, 200, {
|
|
442
642
|
effective: pricing,
|
|
443
643
|
template: DEFAULT_TEMPLATE,
|
|
644
|
+
// The handshake the panel checks before trusting a button: see HOST_API_VERSION.
|
|
645
|
+
apiVersion: HOST_API_VERSION,
|
|
646
|
+
// Where the `default` row comes from. A table with no `default` row of its own
|
|
647
|
+
// is priced by the built-in template, and the panel has to say so rather than
|
|
648
|
+
// present those rates as the user's.
|
|
649
|
+
defaultSource: hasDefaultRow ? 'file' : 'template',
|
|
650
|
+
// The row exactly as the file has it, which is what the editor shows and seeds from.
|
|
651
|
+
// `effective.default` is the template-merged view, and writing *that* back would
|
|
652
|
+
// materialise template fields nobody wrote — a field the user just cleared would
|
|
653
|
+
// look like it had come back on the next save.
|
|
654
|
+
defaultRow: hasDefaultRow ? rawDefault : null,
|
|
444
655
|
calendar,
|
|
445
656
|
bundled: {
|
|
446
657
|
years: [...bundled.years].sort(),
|
|
@@ -460,11 +671,13 @@ export function apply(ctx) {
|
|
|
460
671
|
paths: {
|
|
461
672
|
dataDir,
|
|
462
673
|
pricing: pricingPath,
|
|
463
|
-
cache
|
|
674
|
+
// The cache file this runtime is using, whichever backend that turned out to be.
|
|
675
|
+
cache: store.path,
|
|
676
|
+
cacheKind: store.kind,
|
|
464
677
|
legacyPricing: legacyPricingPath,
|
|
465
678
|
// Which file the effective table was actually read from; null means
|
|
466
679
|
// neither exists, so the built-in template is in force.
|
|
467
|
-
pricingSource:
|
|
680
|
+
pricingSource: raw.path,
|
|
468
681
|
},
|
|
469
682
|
})
|
|
470
683
|
return
|
|
@@ -516,6 +729,7 @@ export function apply(ctx) {
|
|
|
516
729
|
log(
|
|
517
730
|
`official prices applied: ${applied.models.join(', ')}${applied.aliases.length ? ` (+ ${applied.aliases.join(', ')})` : ''};` +
|
|
518
731
|
` override rows ${before} -> ${after}` +
|
|
732
|
+
(applied.defaultFrom ? `; default row filled from the first published line, ${applied.defaultFrom}` : '') +
|
|
519
733
|
warning,
|
|
520
734
|
)
|
|
521
735
|
sendJson(res, 200, { ok: true, applied, effective: pricing, calendar: describeCalendar(rules) })
|
|
@@ -524,8 +738,10 @@ export function apply(ctx) {
|
|
|
524
738
|
|
|
525
739
|
sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
|
|
526
740
|
} catch (error) {
|
|
527
|
-
|
|
528
|
-
|
|
741
|
+
// A caller's mistake is reported as one; only genuinely unexpected failures are 500s.
|
|
742
|
+
const status = Number(error?.statusCode) >= 400 && Number(error?.statusCode) < 500 ? Number(error.statusCode) : 500
|
|
743
|
+
if (status === 500) log(`request failed: ${error?.message ?? error}`)
|
|
744
|
+
sendJson(res, status, { error: String(error?.message ?? error) })
|
|
529
745
|
}
|
|
530
746
|
}
|
|
531
747
|
|
|
@@ -536,8 +752,8 @@ export function apply(ctx) {
|
|
|
536
752
|
|
|
537
753
|
ctx.effect(() => {
|
|
538
754
|
void warm()
|
|
539
|
-
return () => aggregator.
|
|
540
|
-
}, 'usage-badge: warm the fold and
|
|
755
|
+
return () => aggregator.close()
|
|
756
|
+
}, 'usage-badge: warm the fold and close the cache on unload')
|
|
541
757
|
|
|
542
758
|
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
543
759
|
}
|
package/lib/official-pricing.js
CHANGED
|
@@ -275,11 +275,12 @@ export async function fetchOfficialPricing(options = {}) {
|
|
|
275
275
|
* holidays — without it a weekday holiday would be billed at peak.
|
|
276
276
|
*
|
|
277
277
|
* Unknown keys in the existing document survive, so hand-written rows elsewhere
|
|
278
|
-
* are not lost
|
|
278
|
+
* are not lost — including a `default` row, which is only ever *filled in* when the
|
|
279
|
+
* document has none (see below).
|
|
279
280
|
*
|
|
280
281
|
* @param {object} current - the raw user pricing document.
|
|
281
282
|
* @param {object} parsed - a {@link parseOfficialPricing} result.
|
|
282
|
-
* @returns {{document:object, applied:{models:string[], aliases:string[], peakRanges:number[][]}}}
|
|
283
|
+
* @returns {{document:object, applied:{models:string[], aliases:string[], peakRanges:number[][], defaultFrom:string|null}}}
|
|
283
284
|
*/
|
|
284
285
|
export function applyOfficialPricing(current, parsed) {
|
|
285
286
|
const next = current && typeof current === 'object' ? { ...current } : {}
|
|
@@ -309,6 +310,16 @@ export function applyOfficialPricing(current, parsed) {
|
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
next.overrides = overrides
|
|
313
|
+
// A table with no `default` row of its own is priced by the built-in template, and those
|
|
314
|
+
// rates are nobody's choice. Fill it from the published list's **first** line instead: the
|
|
315
|
+
// page leads with its cheapest model, so a model the table does not name is guessed low
|
|
316
|
+
// rather than invented. A `default` row the user already wrote is still left alone — this
|
|
317
|
+
// only replaces the absence of one.
|
|
318
|
+
let defaultFrom = null
|
|
319
|
+
if (!next.default && parsed.models.length > 0) {
|
|
320
|
+
next.default = rowFor(parsed.models[0])
|
|
321
|
+
defaultFrom = parsed.models[0].model
|
|
322
|
+
}
|
|
312
323
|
next.timeOfUse = {
|
|
313
324
|
...(next.timeOfUse ?? {}),
|
|
314
325
|
enabled: true,
|
|
@@ -323,6 +334,6 @@ export function applyOfficialPricing(current, parsed) {
|
|
|
323
334
|
|
|
324
335
|
return {
|
|
325
336
|
document: next,
|
|
326
|
-
applied: { models: applied, aliases: aliasNames, peakRanges: parsed.policy.peakRangesLocal },
|
|
337
|
+
applied: { models: applied, aliases: aliasNames, peakRanges: parsed.policy.peakRangesLocal, defaultFrom },
|
|
327
338
|
}
|
|
328
339
|
}
|