dsh-usage-badge 0.1.2 → 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/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 { createAggregator, resolveDshHome } from './fold.js'
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
- /** Calendar days retained in the payload; covers the 12-month view plus slack. */
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) => ({ ...row, amount: round(row.amount, 4), hourly: roundSeries(row.hourly) })),
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
- // The day's own classification travels with its row: the panel marks holidays
193
- // on the chart, and it is the only way to read a past day's holiday status
194
- // back from a snapshot.
195
- const context = dayContext(date, rules)
196
- const marks = { dayClass: context.class, dayName: context.name }
197
- if (date === today) return { date, ...todaySummary.totals, providers: todaySummary.providers, ...marks }
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
- rejectPromise(new Error('request body too large'))
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
- if (patch.default && typeof patch.default === 'object') next.default = { ...(next.default ?? {}), ...patch.default }
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
- const aggregator = createAggregator({ sessionsRoot, cachePath, log })
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
- snapshot = buildSnapshot(aggregator, pricing, rules, status)
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
- // Persist straight away rather than waiting for the debounce: the initial
410
- // fold is the expensive one, and its timer is unref'd, so a short-lived
411
- // host would otherwise pay the full cold scan on every start.
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: cachePath,
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: readRawPricing(pricingPaths).path,
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
- log(`request failed: ${error?.message ?? error}`)
528
- sendJson(res, 500, { error: String(error?.message ?? error) })
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.saveCache()
540
- }, 'usage-badge: warm the fold and persist the cache on unload')
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
  }
@@ -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
  }