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/buckets.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * dsh-usage-badge — the token bucket model and its stored form.
3
+ *
4
+ * A bucket is one (provider, model) route on one day: token totals, a 24-slot hourly
5
+ * breakdown for the day chart, and the raw per-request records that let the pricing layer
6
+ * apply a different multiplier to each request rather than pricing a day flat.
7
+ *
8
+ * This module owns that shape and its JSON representation, because two very different
9
+ * places need to agree on it: the folder builds buckets while reading session logs, and the
10
+ * cache store reads them back years later. Keeping the codec here means a store never has to
11
+ * know what a bucket is — it moves `{ date: { routeKey: bucket } }` slices in and out.
12
+ *
13
+ * @module dsh-usage-badge/buckets
14
+ */
15
+
16
+ import { initHourly } from './pricing.js'
17
+
18
+ /** Bucket key and display name used when a log records no provider/model. */
19
+ export const UNKNOWN = 'unknown'
20
+
21
+ /** A fresh, empty bucket for one route. */
22
+ export function emptyBucket(provider, model) {
23
+ return {
24
+ provider,
25
+ model,
26
+ input: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ output: 0,
30
+ requests: 0,
31
+ hourly: initHourly(),
32
+ usageRecords: [],
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Add `source` into `target`.
38
+ *
39
+ * A missing per-request list marks a legacy aggregate row: mixing one into a
40
+ * bucket that has a list would silently drop the aggregate's share from the
41
+ * context-tier price, so the list is dropped to `null` for the whole bucket
42
+ * instead, and that bucket falls back to flat pricing. Appending is looped rather
43
+ * than spread because one day bucket can hold hundreds of thousands of requests,
44
+ * which would overflow the argument list long before it exhausted memory.
45
+ */
46
+ export function addBucket(target, source) {
47
+ target.input += Number(source?.input) || 0
48
+ target.cacheRead += Number(source?.cacheRead) || 0
49
+ target.cacheWrite += Number(source?.cacheWrite) || 0
50
+ target.output += Number(source?.output) || 0
51
+ target.requests += Number(source?.requests) || 0
52
+ if (!Array.isArray(target.hourly)) target.hourly = initHourly()
53
+ if (Array.isArray(source?.hourly)) {
54
+ for (let hour = 0; hour < 24; hour++) {
55
+ const from = source.hourly[hour]
56
+ const to = target.hourly[hour]
57
+ if (!from || !to) continue
58
+ to.input += Number(from.input) || 0
59
+ to.cacheRead += Number(from.cacheRead) || 0
60
+ to.cacheWrite += Number(from.cacheWrite) || 0
61
+ to.output += Number(from.output) || 0
62
+ to.requests += Number(from.requests) || 0
63
+ }
64
+ }
65
+ if (!Array.isArray(target.usageRecords) || !Array.isArray(source?.usageRecords)) {
66
+ target.usageRecords = null
67
+ } else {
68
+ for (const usage of source.usageRecords) {
69
+ if (usage && typeof usage === 'object') target.usageRecords.push(usage)
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * One day, as storable JSON: `{ routeKey: bucket }`.
76
+ *
77
+ * The bucket objects are handed over by reference; every store either stringifies the slice
78
+ * immediately or replaces the whole entry, and the folder never mutates a bucket it has
79
+ * already handed over (a re-fold builds fresh ones), so aliasing is safe and avoids copying
80
+ * a day's worth of per-request records on every save.
81
+ */
82
+ export function serializeDay(dayObj) {
83
+ const routes = {}
84
+ for (const [key, bucket] of dayObj ?? []) routes[key] = bucket
85
+ return routes
86
+ }
87
+
88
+ /** The inverse of {@link serializeDay}, rebuilding the buckets' invariants as it goes. */
89
+ export function deserializeDay(routes) {
90
+ const dayObj = new Map()
91
+ for (const [key, source] of Object.entries(routes ?? {})) {
92
+ if (!source || typeof source !== 'object') continue
93
+ const bucket = emptyBucket(String(source.provider ?? UNKNOWN), String(source.model ?? UNKNOWN))
94
+ addBucket(bucket, source)
95
+ dayObj.set(key, bucket)
96
+ }
97
+ return dayObj
98
+ }
99
+
100
+ /** A whole session's days, as storable JSON: `{ date: { routeKey: bucket } }`. */
101
+ export function serializeDays(days) {
102
+ const plain = {}
103
+ for (const [date, dayObj] of days ?? []) plain[date] = serializeDay(dayObj)
104
+ return plain
105
+ }
106
+
107
+ /** The inverse of {@link serializeDays}. */
108
+ export function deserializeDays(plain) {
109
+ const days = new Map()
110
+ for (const [date, routes] of Object.entries(plain ?? {})) {
111
+ if (!routes || typeof routes !== 'object') continue
112
+ days.set(date, deserializeDay(routes))
113
+ }
114
+ return days
115
+ }