dsh-usage-badge 0.1.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/LICENSE +21 -0
- package/README.md +89 -0
- package/assets/usage-dialog.png +0 -0
- package/cordis.patch.yml +7 -0
- package/docs/design-notes.md +293 -0
- package/lib/client.js +1007 -0
- package/lib/fold.js +512 -0
- package/lib/holidays-cn.json +41 -0
- package/lib/holidays.js +141 -0
- package/lib/index.js +543 -0
- package/lib/official-pricing.js +316 -0
- package/lib/pricing.js +485 -0
- package/package.json +63 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — host half.
|
|
3
|
+
*
|
|
4
|
+
* Folds every DSH session log into per-day cost totals and serves them to the
|
|
5
|
+
* browser half over a small JSON API. It owns no model-visible surface: nothing
|
|
6
|
+
* here adds a prompt section, a tool, or a session event.
|
|
7
|
+
*
|
|
8
|
+
* Why an HTTP route instead of a Typert remote: a third-party plugin cannot
|
|
9
|
+
* easily ship the generated invocation descriptors strict mode wants, and the
|
|
10
|
+
* Typert source-plane fallback is documented as a development path. Named
|
|
11
|
+
* `ctx.webServer` routes need no codegen and no gateway wiring, which is what
|
|
12
|
+
* keeps this plugin dependency-free. The routes are unauthenticated like the rest
|
|
13
|
+
* of the loopback UI, so the two write paths additionally require a loopback peer.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-usage-badge
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import { createAggregator, resolveDshHome } from './fold.js'
|
|
22
|
+
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
23
|
+
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_TEMPLATE,
|
|
26
|
+
bucketCost,
|
|
27
|
+
bucketHourlyCosts,
|
|
28
|
+
dayContext,
|
|
29
|
+
dayKey,
|
|
30
|
+
normalizePricing,
|
|
31
|
+
timeOfUseState,
|
|
32
|
+
totalCurrencyOf,
|
|
33
|
+
} from './pricing.js'
|
|
34
|
+
|
|
35
|
+
/** Cordis service this plugin requires; the host half is meaningless without it. */
|
|
36
|
+
export const inject = ['webServer']
|
|
37
|
+
|
|
38
|
+
/** Plugin name as it appears in the loader tree. */
|
|
39
|
+
export const name = 'usage-badge'
|
|
40
|
+
|
|
41
|
+
/** Route prefix owned by this plugin. */
|
|
42
|
+
const ROUTE_PREFIX = '/usage-badge'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
|
|
46
|
+
* cache and price table are never mistaken for harness data in the shared root.
|
|
47
|
+
*/
|
|
48
|
+
const DATA_DIR_NAME = 'usage-badge'
|
|
49
|
+
|
|
50
|
+
/** Calendar days retained in the payload; covers the 12-month view plus slack. */
|
|
51
|
+
const DAYS_KEPT = 400
|
|
52
|
+
|
|
53
|
+
/** A snapshot younger than this is reused instead of rebuilt on every poll. */
|
|
54
|
+
const SNAPSHOT_TTL_MS = 2000
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How long a fetched price list is reused. Opening the dialog asks for a fresh
|
|
58
|
+
* copy every time, and this window only coalesces the requests that arrive within
|
|
59
|
+
* a few seconds of each other so a reopened panel cannot hammer the docs site.
|
|
60
|
+
*/
|
|
61
|
+
const OFFICIAL_TTL_MS = 20000
|
|
62
|
+
|
|
63
|
+
/** Largest accepted request body, so a bad client cannot buffer unbounded memory. */
|
|
64
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
65
|
+
|
|
66
|
+
const log = (message) => console.log(`[dsh-usage-badge] ${message}`)
|
|
67
|
+
|
|
68
|
+
/** Round to a fixed number of decimals. */
|
|
69
|
+
const round = (value, decimals) => {
|
|
70
|
+
const factor = 10 ** decimals
|
|
71
|
+
return Math.round((Number(value) || 0) * factor) / factor
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A fresh 24-slot cost/token series. */
|
|
75
|
+
const newHourSeries = () =>
|
|
76
|
+
Array.from({ length: 24 }, (_, hour) => ({
|
|
77
|
+
hour,
|
|
78
|
+
amount: 0,
|
|
79
|
+
requests: 0,
|
|
80
|
+
input: 0,
|
|
81
|
+
cacheRead: 0,
|
|
82
|
+
cacheWrite: 0,
|
|
83
|
+
output: 0,
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
/** Add one bucket's token/request totals (and cost) into a row. */
|
|
87
|
+
function addTokens(target, source, amount) {
|
|
88
|
+
target.amount += amount
|
|
89
|
+
target.requests += source.requests || 0
|
|
90
|
+
target.input += source.input || 0
|
|
91
|
+
target.cacheRead += source.cacheRead || 0
|
|
92
|
+
target.cacheWrite += source.cacheWrite || 0
|
|
93
|
+
target.output += source.output || 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fold one day's route buckets into day totals and provider rows.
|
|
98
|
+
*
|
|
99
|
+
* `date` is passed in rather than read off the bucket because the day's cost
|
|
100
|
+
* depends on facts the bucket does not carry: its weekday, and whether the holiday
|
|
101
|
+
* calendar marks it a holiday or a 调休 working day.
|
|
102
|
+
*
|
|
103
|
+
* `withHourly` materializes the 24-hour series for the day *and* for every
|
|
104
|
+
* provider row, which costs one pass over the day's per-request records — so it
|
|
105
|
+
* is requested only for the day being charted, and that same pass supplies the
|
|
106
|
+
* day total instead of costing the buckets twice.
|
|
107
|
+
*/
|
|
108
|
+
function summarizeDay(dayObj, pricing, date, withHourly, rules) {
|
|
109
|
+
const day = dayContext(date, rules)
|
|
110
|
+
const providers = new Map()
|
|
111
|
+
const totals = { amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
112
|
+
const dayHours = withHourly ? newHourSeries() : null
|
|
113
|
+
|
|
114
|
+
for (const bucket of dayObj?.values() ?? []) {
|
|
115
|
+
const providerName = String(bucket.provider ?? '').trim() || 'unknown'
|
|
116
|
+
let row = providers.get(providerName)
|
|
117
|
+
if (!row) {
|
|
118
|
+
row = { provider: providerName, amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
119
|
+
if (withHourly) row.hourly = newHourSeries()
|
|
120
|
+
providers.set(providerName, row)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const costs = dayHours ? bucketHourlyCosts(bucket, pricing, day) : null
|
|
124
|
+
let amount = 0
|
|
125
|
+
if (costs) for (let hour = 0; hour < 24; hour++) amount += costs[hour]
|
|
126
|
+
else amount = bucketCost(bucket, pricing, day)
|
|
127
|
+
|
|
128
|
+
addTokens(row, bucket, amount)
|
|
129
|
+
addTokens(totals, bucket, amount)
|
|
130
|
+
|
|
131
|
+
if (!costs) continue
|
|
132
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
133
|
+
row.hourly[hour].amount += costs[hour]
|
|
134
|
+
dayHours[hour].amount += costs[hour]
|
|
135
|
+
const source = bucket.hourly?.[hour]
|
|
136
|
+
if (!source) continue
|
|
137
|
+
for (const target of [row.hourly[hour], dayHours[hour]]) {
|
|
138
|
+
target.requests += source.requests || 0
|
|
139
|
+
target.input += source.input || 0
|
|
140
|
+
target.cacheRead += source.cacheRead || 0
|
|
141
|
+
target.cacheWrite += source.cacheWrite || 0
|
|
142
|
+
target.output += source.output || 0
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const roundSeries = (series) => series?.map((slot) => ({ ...slot, amount: round(slot.amount, 4) })) ?? null
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
totals: {
|
|
151
|
+
amount: round(totals.amount, 4),
|
|
152
|
+
requests: totals.requests,
|
|
153
|
+
input: totals.input,
|
|
154
|
+
cacheRead: totals.cacheRead,
|
|
155
|
+
cacheWrite: totals.cacheWrite,
|
|
156
|
+
output: totals.output,
|
|
157
|
+
},
|
|
158
|
+
providers: [...providers.values()]
|
|
159
|
+
.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) })),
|
|
161
|
+
hourly: roundSeries(dayHours),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Recover the `provider|model` route behind a provider row, so a per-model
|
|
167
|
+
* pricing rule can be evaluated for the period indicator. A provider row is
|
|
168
|
+
* already collapsed across models, so that provider's heaviest model stands in
|
|
169
|
+
* for the provider as a whole.
|
|
170
|
+
*/
|
|
171
|
+
function dominantRouteOf(dayObj, providerName) {
|
|
172
|
+
let best = null
|
|
173
|
+
for (const bucket of dayObj?.values() ?? []) {
|
|
174
|
+
if ((String(bucket.provider ?? '').trim() || 'unknown') !== providerName) continue
|
|
175
|
+
const weight = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
|
|
176
|
+
if (!best || weight > best.weight) best = { weight, model: bucket.model ?? null }
|
|
177
|
+
}
|
|
178
|
+
return best?.model ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Build the payload the browser half renders. */
|
|
182
|
+
function buildSnapshot(aggregator, pricing, rules, status) {
|
|
183
|
+
const now = Date.now()
|
|
184
|
+
const today = dayKey(now)
|
|
185
|
+
const dayObjs = aggregator.days
|
|
186
|
+
const dates = [...dayObjs.keys()].sort().reverse().slice(0, DAYS_KEPT)
|
|
187
|
+
|
|
188
|
+
const todayObj = dayObjs.get(today)
|
|
189
|
+
const todaySummary = summarizeDay(todayObj, pricing, today, true, rules)
|
|
190
|
+
|
|
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
|
+
})
|
|
201
|
+
|
|
202
|
+
// The badge is always CNY: a CNY total needs no rate, a USD total is converted
|
|
203
|
+
// with the live exchange rate.
|
|
204
|
+
const totalCurrency = totalCurrencyOf(pricing)
|
|
205
|
+
const badgeCny =
|
|
206
|
+
totalCurrency === 'cny'
|
|
207
|
+
? todaySummary.totals.amount
|
|
208
|
+
: todaySummary.totals.amount * (Number(pricing.exchangeRate) || 1)
|
|
209
|
+
|
|
210
|
+
// Period indicator: today's dominant route's rule, so a per-model override is
|
|
211
|
+
// reflected rather than only the global default row.
|
|
212
|
+
const dominant = todaySummary.providers[0]
|
|
213
|
+
const band = timeOfUseState(
|
|
214
|
+
pricing,
|
|
215
|
+
dominant ? dominant.provider : null,
|
|
216
|
+
dominant ? dominantRouteOf(todayObj, dominant.provider) : null,
|
|
217
|
+
dayContext(today, rules),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
generatedAt: now,
|
|
222
|
+
currency: totalCurrency,
|
|
223
|
+
exchangeRate: pricing.exchangeRate,
|
|
224
|
+
badge: { date: today, amount: round(badgeCny, 2), currency: 'cny' },
|
|
225
|
+
today: { date: today, ...todaySummary.totals, hourly: todaySummary.hourly, providers: todaySummary.providers },
|
|
226
|
+
days,
|
|
227
|
+
band,
|
|
228
|
+
calendar: describeCalendar(rules, now),
|
|
229
|
+
status: { ...status, sessions: aggregator.sessionCount },
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read a pricing document, preferring the plugin's own directory.
|
|
235
|
+
*
|
|
236
|
+
* `paths` is ordered: the canonical file first, then the pre-isolation location in
|
|
237
|
+
* the shared storages root. The fallback exists so an install that predates the
|
|
238
|
+
* move keeps working, and so a harness-side `usage-pricing.json` is still honored
|
|
239
|
+
* if one reappears — the first write migrates it into the plugin's directory.
|
|
240
|
+
*
|
|
241
|
+
* @returns {{raw:object, path:string|null}} the parsed document and where it came
|
|
242
|
+
* from; `path` is null when neither file exists or parses.
|
|
243
|
+
*/
|
|
244
|
+
function readRawPricing(paths) {
|
|
245
|
+
for (const path of paths) {
|
|
246
|
+
try {
|
|
247
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
248
|
+
if (raw && typeof raw === 'object') return { raw, path }
|
|
249
|
+
} catch {
|
|
250
|
+
// Missing or corrupt: fall through to the next candidate.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { raw: {}, path: null }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Normalize a read result, falling back to the template when nothing was found. */
|
|
257
|
+
function readPricing(paths) {
|
|
258
|
+
return normalizePricing(readRawPricing(paths).raw)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Read a request body, bounded. */
|
|
262
|
+
function readBody(req) {
|
|
263
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
264
|
+
const chunks = []
|
|
265
|
+
let size = 0
|
|
266
|
+
req.on('data', (chunk) => {
|
|
267
|
+
size += chunk.length
|
|
268
|
+
if (size > MAX_BODY_BYTES) {
|
|
269
|
+
rejectPromise(new Error('request body too large'))
|
|
270
|
+
req.destroy()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
chunks.push(chunk)
|
|
274
|
+
})
|
|
275
|
+
req.on('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8')))
|
|
276
|
+
req.on('error', rejectPromise)
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sendJson(res, status, payload) {
|
|
281
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
|
282
|
+
res.writeHead(status, {
|
|
283
|
+
'content-type': 'application/json; charset=utf-8',
|
|
284
|
+
'content-length': String(body.length),
|
|
285
|
+
'cache-control': 'no-store',
|
|
286
|
+
})
|
|
287
|
+
res.end(body)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Whether a socket belongs to this machine, so a LAN peer cannot rewrite config. */
|
|
291
|
+
function isLoopback(req) {
|
|
292
|
+
const address = req.socket?.remoteAddress ?? ''
|
|
293
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Atomically replace a file with `text`. */
|
|
297
|
+
function writeFileAtomic(path, text) {
|
|
298
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
299
|
+
writeFileSync(temp, text)
|
|
300
|
+
try {
|
|
301
|
+
renameSync(temp, path)
|
|
302
|
+
} catch {
|
|
303
|
+
// Windows cannot rename over an existing file; the complete temp file is
|
|
304
|
+
// already on disk, so removing the old one first loses nothing.
|
|
305
|
+
try {
|
|
306
|
+
unlinkSync(path)
|
|
307
|
+
} catch {
|
|
308
|
+
// absent is fine
|
|
309
|
+
}
|
|
310
|
+
renameSync(temp, path)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Merge an accepted config patch into the raw user document. Only the fields this
|
|
316
|
+
* plugin prices with are touched, and only when the client sent them, so keys it
|
|
317
|
+
* does not understand survive the round trip.
|
|
318
|
+
*/
|
|
319
|
+
function mergeConfig(raw, patch) {
|
|
320
|
+
const next = raw && typeof raw === 'object' ? { ...raw } : {}
|
|
321
|
+
if (patch.exchangeRate !== undefined) next.exchangeRate = Number(patch.exchangeRate) || next.exchangeRate
|
|
322
|
+
if (patch.totalCurrency !== undefined) next.totalCurrency = patch.totalCurrency === 'usd' ? 'usd' : 'cny'
|
|
323
|
+
if (patch.multiplier !== undefined) next.multiplier = Number(patch.multiplier) || 1
|
|
324
|
+
if (patch.default && typeof patch.default === 'object') next.default = { ...(next.default ?? {}), ...patch.default }
|
|
325
|
+
if (patch.overrides && typeof patch.overrides === 'object') next.overrides = patch.overrides
|
|
326
|
+
if (patch.timeOfUse !== undefined) next.timeOfUse = patch.timeOfUse
|
|
327
|
+
if (patch.contextMultiplier !== undefined) next.contextMultiplier = patch.contextMultiplier
|
|
328
|
+
if (patch.holidays !== undefined) next.holidays = patch.holidays
|
|
329
|
+
return next
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Host plugin body.
|
|
334
|
+
*
|
|
335
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context carrying `webServer`.
|
|
336
|
+
*/
|
|
337
|
+
export function apply(ctx) {
|
|
338
|
+
const home = resolveDshHome()
|
|
339
|
+
const sessionsRoot = join(home, 'sessions')
|
|
340
|
+
// Everything this plugin owns lives in one directory of its own. Writing into
|
|
341
|
+
// the shared storages root would leave files a user cannot attribute: the root
|
|
342
|
+
// already holds harness caches and, on some installs, other tools' settings.
|
|
343
|
+
const dataDir = join(home, 'storages', DATA_DIR_NAME)
|
|
344
|
+
const pricingPath = join(dataDir, 'pricing.json')
|
|
345
|
+
const cachePath = join(dataDir, 'cache.json')
|
|
346
|
+
/** Pre-isolation location of the price table, honored as a migration fallback. */
|
|
347
|
+
const legacyPricingPath = join(home, 'storages', 'usage-pricing.json')
|
|
348
|
+
const pricingPaths = [pricingPath, legacyPricingPath]
|
|
349
|
+
|
|
350
|
+
const aggregator = createAggregator({ sessionsRoot, cachePath, log })
|
|
351
|
+
aggregator.loadCache()
|
|
352
|
+
|
|
353
|
+
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
354
|
+
if (pricingSourcePath === legacyPricingPath) {
|
|
355
|
+
log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
|
|
356
|
+
}
|
|
357
|
+
let pricing = readPricing(pricingPaths)
|
|
358
|
+
let rules = resolveHolidayRules(pricing.holidays)
|
|
359
|
+
let snapshot = null
|
|
360
|
+
let inFlight = null
|
|
361
|
+
let official = null
|
|
362
|
+
|
|
363
|
+
/** Serialize concurrent refreshes so a burst of polls folds the logs once. */
|
|
364
|
+
function refreshOnce() {
|
|
365
|
+
if (!inFlight) {
|
|
366
|
+
inFlight = (async () => {
|
|
367
|
+
const status = await aggregator.refresh()
|
|
368
|
+
// Re-read pricing every pass so an edit applies without a restart.
|
|
369
|
+
pricing = readPricing(pricingPaths)
|
|
370
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
371
|
+
return status
|
|
372
|
+
})().finally(() => {
|
|
373
|
+
inFlight = null
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
return inFlight
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function getSnapshot() {
|
|
380
|
+
const status = await refreshOnce()
|
|
381
|
+
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
382
|
+
// than re-costing every day on each poll.
|
|
383
|
+
if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
|
|
384
|
+
snapshot = buildSnapshot(aggregator, pricing, rules, status)
|
|
385
|
+
return snapshot
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Fetch a price list, reusing a very recent one so a reopen cannot hammer the site. */
|
|
389
|
+
async function officialPricing(source, { refresh = false } = {}) {
|
|
390
|
+
const key = OFFICIAL_SOURCES[source] ? source : 'zh-cn'
|
|
391
|
+
if (!refresh && official && official.key === key && Date.now() - official.at < OFFICIAL_TTL_MS) return official.value
|
|
392
|
+
const value = await fetchOfficialPricing({ source: key })
|
|
393
|
+
official = { key, at: Date.now(), value }
|
|
394
|
+
return value
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Write a pricing document through and refresh everything derived from it. */
|
|
398
|
+
function commitPricing(next) {
|
|
399
|
+
mkdirSync(dataDir, { recursive: true })
|
|
400
|
+
writeFileAtomic(pricingPath, JSON.stringify(next, null, 2) + '\n')
|
|
401
|
+
pricing = normalizePricing(next)
|
|
402
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
403
|
+
snapshot = null // force a rebuild on the next read
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function warm() {
|
|
407
|
+
try {
|
|
408
|
+
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()
|
|
413
|
+
log(
|
|
414
|
+
`ready: ${aggregator.sessionCount} session(s) over ${status.files} log(s), folded ${status.folded} in ${status.ms}ms` +
|
|
415
|
+
(rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
|
|
416
|
+
)
|
|
417
|
+
if (rules.missingYears?.length) {
|
|
418
|
+
log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
log(`initial fold failed: ${error?.message ?? error}`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function handle(req, res) {
|
|
426
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
427
|
+
const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
if (route === '/summary' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
431
|
+
sendJson(res, 200, await getSnapshot())
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (route === '/config' && req.method === 'GET') {
|
|
436
|
+
const calendar = describeCalendar(rules)
|
|
437
|
+
// The bundled table is reported separately from the resolved rules: a
|
|
438
|
+
// deployment with the calendar off still needs to see what data is
|
|
439
|
+
// available before deciding to switch it on.
|
|
440
|
+
const bundled = loadBundledTable()
|
|
441
|
+
sendJson(res, 200, {
|
|
442
|
+
effective: pricing,
|
|
443
|
+
template: DEFAULT_TEMPLATE,
|
|
444
|
+
calendar,
|
|
445
|
+
bundled: {
|
|
446
|
+
years: [...bundled.years].sort(),
|
|
447
|
+
holidayCount: bundled.holidays.size,
|
|
448
|
+
workdayCount: bundled.workdays.size,
|
|
449
|
+
source: bundled.source,
|
|
450
|
+
note: bundled.note,
|
|
451
|
+
},
|
|
452
|
+
holidayTable: {
|
|
453
|
+
years: [...bundled.years].sort(),
|
|
454
|
+
holidays: [...rules.holidays.keys()].sort(),
|
|
455
|
+
// Recorded for audit only: the published policy makes every weekend
|
|
456
|
+
// off-peak, so a 调休 working day does not change any price.
|
|
457
|
+
workdays: [...bundled.workdays].sort(),
|
|
458
|
+
source: rules.source,
|
|
459
|
+
},
|
|
460
|
+
paths: {
|
|
461
|
+
dataDir,
|
|
462
|
+
pricing: pricingPath,
|
|
463
|
+
cache: cachePath,
|
|
464
|
+
legacyPricing: legacyPricingPath,
|
|
465
|
+
// Which file the effective table was actually read from; null means
|
|
466
|
+
// neither exists, so the built-in template is in force.
|
|
467
|
+
pricingSource: readRawPricing(pricingPaths).path,
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (route === '/config' && (req.method === 'PUT' || req.method === 'POST')) {
|
|
474
|
+
if (!isLoopback(req)) {
|
|
475
|
+
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
const patch = JSON.parse((await readBody(req)) || '{}')
|
|
479
|
+
commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
|
|
480
|
+
log('pricing config updated')
|
|
481
|
+
sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// The published price list, parsed. `refresh=1` is what "fetch it live when
|
|
486
|
+
// the panel opens" sends; without it a very recent result is reused.
|
|
487
|
+
if (route === '/official-pricing' && req.method === 'GET') {
|
|
488
|
+
const source = url.searchParams.get('source') ?? 'zh-cn'
|
|
489
|
+
const parsed = await officialPricing(source, { refresh: url.searchParams.get('refresh') === '1' })
|
|
490
|
+
sendJson(res, 200, { ...parsed, sources: OFFICIAL_SOURCES })
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (route === '/official-pricing/apply' && (req.method === 'PUT' || req.method === 'POST')) {
|
|
495
|
+
if (!isLoopback(req)) {
|
|
496
|
+
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
500
|
+
// Apply the copy the user was looking at, reusing the recent fetch rather
|
|
501
|
+
// than pulling a possibly different page under them.
|
|
502
|
+
const parsed = await officialPricing(body.source ?? 'zh-cn')
|
|
503
|
+
const base = readRawPricing(pricingPaths)
|
|
504
|
+
const { document, applied } = applyOfficialPricing(base.raw, parsed)
|
|
505
|
+
// Report the shape change: applying the official list must only touch the
|
|
506
|
+
// rows it names, so a shrinking override set means the base document was not
|
|
507
|
+
// read and the user's own rows are about to be lost. That is worth a loud
|
|
508
|
+
// line rather than a silent overwrite.
|
|
509
|
+
const before = Object.keys(base.raw?.overrides ?? {}).length
|
|
510
|
+
const after = Object.keys(document.overrides ?? {}).length
|
|
511
|
+
const warning =
|
|
512
|
+
after < before
|
|
513
|
+
? ` WARNING: override rows dropped ${before} -> ${after}; base read from ${base.path ?? 'no file (defaults)'}`
|
|
514
|
+
: ''
|
|
515
|
+
commitPricing(document)
|
|
516
|
+
log(
|
|
517
|
+
`official prices applied: ${applied.models.join(', ')}${applied.aliases.length ? ` (+ ${applied.aliases.join(', ')})` : ''};` +
|
|
518
|
+
` override rows ${before} -> ${after}` +
|
|
519
|
+
warning,
|
|
520
|
+
)
|
|
521
|
+
sendJson(res, 200, { ok: true, applied, effective: pricing, calendar: describeCalendar(rules) })
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
|
|
526
|
+
} catch (error) {
|
|
527
|
+
log(`request failed: ${error?.message ?? error}`)
|
|
528
|
+
sendJson(res, 500, { error: String(error?.message ?? error) })
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
ctx.effect(
|
|
533
|
+
() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }),
|
|
534
|
+
'usage-badge: summary, config and official-pricing routes',
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
ctx.effect(() => {
|
|
538
|
+
void warm()
|
|
539
|
+
return () => aggregator.saveCache()
|
|
540
|
+
}, 'usage-badge: warm the fold and persist the cache on unload')
|
|
541
|
+
|
|
542
|
+
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
543
|
+
}
|