@aryacode/dsh-aryacode-polish 0.0.0-stage → 0.3.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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Estimated session cost, priced from recorded provider usage.
3
+ *
4
+ * The Host half registers one durable projection: the fold replays
5
+ * model-attributed token usage from the session log, and the wire view prices
6
+ * it at the published DeepSeek rates. The browser half only formats numbers,
7
+ * so no rate table and no money arithmetic live in the bundle.
8
+ */
9
+ import { z } from 'zod'
10
+ import { applyModelUsage, initialModelUsage } from './usage.js'
11
+
12
+ /**
13
+ * Off-peak price per million tokens. Peak is twice this. `usd` is the English
14
+ * price page and `cny` the Chinese one; cache writes are billed as misses.
15
+ * Verified 2026-09-27 against https://api-docs.deepseek.com/quick_start/pricing/
16
+ */
17
+ const OFF_PEAK = {
18
+ flash: {
19
+ usd: { cache: '0.003', miss: '0.15', output: '0.6' },
20
+ cny: { cache: '0.02', miss: '1', output: '4' },
21
+ },
22
+ pro: {
23
+ usd: { cache: '0.022', miss: '0.66', output: '1.98' },
24
+ cny: { cache: '0.15', miss: '4.5', output: '13.5' },
25
+ },
26
+ }
27
+
28
+ /**
29
+ * Days the table above describes. Append a new period when prices change; the
30
+ * fold stamps each row with the hour its usage landed in.
31
+ */
32
+ const RATE_PERIODS = [{ from: Date.parse('2026-09-10T04:00:00Z'), until: Infinity, rates: OFF_PEAK }]
33
+
34
+ /** Providers whose prices this table describes. */
35
+ const PRICED_PROVIDERS = ['deepseek-official', 'deepseek-account']
36
+
37
+ /** Models that bill at the flash rate. */
38
+ const FLASH_MODELS = ['deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp']
39
+
40
+ /** Models that bill at the pro rate. */
41
+ const PRO_MODELS = ['deepseek-v4-pro']
42
+
43
+ const bucketsSchema = z.object({
44
+ uncachedInputTokens: z.number().int().nonnegative(),
45
+ outputTokens: z.number().int().nonnegative(),
46
+ cacheReadTokens: z.number().int().nonnegative(),
47
+ cacheWriteTokens: z.number().int().nonnegative(),
48
+ }).strict()
49
+
50
+ const rowSchema = z.object({
51
+ provider: z.string(),
52
+ model: z.string(),
53
+ requests: z.number().int().nonnegative(),
54
+ hour: z.number().int().nonnegative(),
55
+ peak: bucketsSchema,
56
+ offPeak: bucketsSchema,
57
+ }).strict()
58
+
59
+ const viewSchema = z.object({ models: z.array(rowSchema) }).strict()
60
+
61
+ const lastSchema = z.object({
62
+ turn: z.number().int().nonnegative(),
63
+ step: z.number().int().nonnegative(),
64
+ key: z.string(),
65
+ peak: z.boolean(),
66
+ buckets: bucketsSchema,
67
+ }).strict()
68
+
69
+ const stateSchema = z.object({
70
+ rows: z.record(z.string(), rowSchema),
71
+ pending: z.object({ provider: z.string(), model: z.string() }).strict().nullable(),
72
+ last: lastSchema.nullable(),
73
+ view: viewSchema,
74
+ }).strict()
75
+
76
+ /** One priced model row: tokens, requests, and money when a rate is known. */
77
+ const pricedModelSchema = z.object({
78
+ provider: z.string(),
79
+ model: z.string(),
80
+ requests: z.number().int().nonnegative(),
81
+ cacheRead: z.number().int().nonnegative(),
82
+ uncached: z.number().int().nonnegative(),
83
+ cacheWrite: z.number().int().nonnegative(),
84
+ output: z.number().int().nonnegative(),
85
+ incomplete: z.boolean(),
86
+ costCny: z.string().optional(),
87
+ costUsd: z.string().optional(),
88
+ }).strict()
89
+
90
+ /** The wire view: session totals plus one row per model. */
91
+ const pricedSessionSchema = z.object({
92
+ tokens: z.number().int().nonnegative(),
93
+ cacheRead: z.number().int().nonnegative(),
94
+ uncached: z.number().int().nonnegative(),
95
+ cacheWrite: z.number().int().nonnegative(),
96
+ output: z.number().int().nonnegative(),
97
+ requests: z.number().int().nonnegative(),
98
+ incomplete: z.boolean(),
99
+ models: z.array(pricedModelSchema),
100
+ costCny: z.string().optional(),
101
+ costUsd: z.string().optional(),
102
+ }).strict()
103
+
104
+ /**
105
+ * @param {string} provider - routed provider id.
106
+ * @param {string} model - routed model id.
107
+ * @param {number} time - event time in milliseconds.
108
+ * @returns {object | undefined} the rate table for that route, when one is known.
109
+ */
110
+ export function rateTable(provider, model, time) {
111
+ if (!PRICED_PROVIDERS.includes(provider)) return undefined
112
+ const kind = PRO_MODELS.includes(model) ? 'pro' : FLASH_MODELS.includes(model) ? 'flash' : undefined
113
+ if (kind === undefined) return undefined
114
+ const period = RATE_PERIODS.find(candidate => time >= candidate.from && time < candidate.until)
115
+ return period === undefined ? undefined : period.rates[kind]
116
+ }
117
+
118
+ /**
119
+ * @param {number} tokens - tokens billed at that rate.
120
+ * @param {string} perMillion - published price per million tokens.
121
+ * @param {boolean} peak - whether to double the price.
122
+ * @returns {number} money for those tokens.
123
+ */
124
+ function price(tokens, perMillion, peak) {
125
+ const rate = Number(perMillion)
126
+ return (peak ? rate * 2 : rate) * tokens / 1_000_000
127
+ }
128
+
129
+ /**
130
+ * @param {object} band - one band's four counts.
131
+ * @returns {object} the same counts under the names the surface shows.
132
+ */
133
+ function bandTokens(band) {
134
+ return {
135
+ cacheRead: band.cacheReadTokens,
136
+ uncached: band.uncachedInputTokens,
137
+ cacheWrite: band.cacheWriteTokens,
138
+ output: band.outputTokens,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * @param {object} row - one usage row.
144
+ * @returns {object} both bands added up.
145
+ */
146
+ function addCounts(row) {
147
+ const peak = bandTokens(row.peak)
148
+ const off = bandTokens(row.offPeak)
149
+ return {
150
+ cacheRead: peak.cacheRead + off.cacheRead,
151
+ uncached: peak.uncached + off.uncached,
152
+ cacheWrite: peak.cacheWrite + off.cacheWrite,
153
+ output: peak.output + off.output,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {object} band - one band's four counts.
159
+ * @param {object} table - rate table for the route.
160
+ * @param {boolean} peak - whether that band is billed as peak.
161
+ * @returns {{ cny: number, usd: number }} money for that band.
162
+ */
163
+ function priceBand(band, table, peak) {
164
+ // Cache writes are a disjoint input bucket; DeepSeek bills them with misses.
165
+ const input = band.uncachedInputTokens + band.cacheWriteTokens
166
+ return {
167
+ cny: price(band.cacheReadTokens, table.cny.cache, peak)
168
+ + price(input, table.cny.miss, peak)
169
+ + price(band.outputTokens, table.cny.output, peak),
170
+ usd: price(band.cacheReadTokens, table.usd.cache, peak)
171
+ + price(input, table.usd.miss, peak)
172
+ + price(band.outputTokens, table.usd.output, peak),
173
+ }
174
+ }
175
+
176
+ /** The priced view of a session that has recorded nothing yet. */
177
+ const EMPTY_PRICED = {
178
+ tokens: 0, cacheRead: 0, uncached: 0, cacheWrite: 0, output: 0, requests: 0, incomplete: false, models: [],
179
+ }
180
+
181
+ /**
182
+ * Price the durable model projection at the published DeepSeek rates.
183
+ * Unknown providers, models, or periods keep their tokens and mark the subtotal
184
+ * incomplete, so the surface never reports money it cannot stand behind.
185
+ * @param {object} usage - the projection's raw view.
186
+ * @returns {object} token totals, money for priced models, and one row per model.
187
+ */
188
+ export function priceModelUsage(usage) {
189
+ if (usage === undefined || !Array.isArray(usage.models) || usage.models.length === 0) return EMPTY_PRICED
190
+ let cacheRead = 0
191
+ let uncached = 0
192
+ let cacheWrite = 0
193
+ let output = 0
194
+ let requests = 0
195
+ let cny = 0
196
+ let usd = 0
197
+ let priced = false
198
+ let incomplete = false
199
+ const grouped = new Map()
200
+ for (const row of usage.models) {
201
+ const counts = addCounts(row)
202
+ cacheRead += counts.cacheRead
203
+ uncached += counts.uncached
204
+ cacheWrite += counts.cacheWrite
205
+ output += counts.output
206
+ requests += row.requests
207
+ const table = rateTable(row.provider, row.model, row.hour * 3_600_000)
208
+ let costs
209
+ if (table !== undefined) {
210
+ const peakCost = priceBand(row.peak, table, true)
211
+ const offCost = priceBand(row.offPeak, table, false)
212
+ costs = { cny: peakCost.cny + offCost.cny, usd: peakCost.usd + offCost.usd }
213
+ }
214
+ if (costs === undefined) incomplete = true
215
+ else {
216
+ cny += costs.cny
217
+ usd += costs.usd
218
+ priced = true
219
+ }
220
+ const key = JSON.stringify([row.provider, row.model])
221
+ const previous = grouped.get(key)
222
+ const merged = {
223
+ provider: row.provider,
224
+ model: row.model,
225
+ requests: row.requests + (previous?.requests ?? 0),
226
+ cacheRead: counts.cacheRead + (previous?.cacheRead ?? 0),
227
+ uncached: counts.uncached + (previous?.uncached ?? 0),
228
+ cacheWrite: counts.cacheWrite + (previous?.cacheWrite ?? 0),
229
+ output: counts.output + (previous?.output ?? 0),
230
+ incomplete: costs === undefined || previous?.incomplete === true,
231
+ }
232
+ if (costs !== undefined || previous?.costUsd !== undefined) {
233
+ merged.costCny = ((costs?.cny ?? 0) + Number(previous?.costCny ?? 0)).toFixed(8)
234
+ merged.costUsd = ((costs?.usd ?? 0) + Number(previous?.costUsd ?? 0)).toFixed(8)
235
+ }
236
+ grouped.set(key, merged)
237
+ }
238
+ return {
239
+ tokens: cacheRead + uncached + cacheWrite + output,
240
+ cacheRead,
241
+ uncached,
242
+ cacheWrite,
243
+ output,
244
+ requests,
245
+ incomplete,
246
+ models: [...grouped.values()],
247
+ ...priced ? { costCny: cny.toFixed(8), costUsd: usd.toFixed(8) } : {},
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The durable unit this feature registers. The fold is `entries/usage.js`; the
253
+ * wire view is its priced shape, so the browser half receives money, not rates.
254
+ */
255
+ export const costProjection = {
256
+ key: 'polishCost',
257
+ stateVersion: 1,
258
+ stateSchema,
259
+ init: initialModelUsage,
260
+ apply: applyModelUsage,
261
+ wire: { viewSchema: pricedSessionSchema, view: state => priceModelUsage(state.view) },
262
+ }
263
+
264
+ /**
265
+ * Mount the host half of the cost surface.
266
+ * @param {object} ctx - the feature's own cordis context; its effects release the registration.
267
+ * @returns {Promise<void>} resolves once the projection is registered.
268
+ */
269
+ export async function mountCost(ctx) {
270
+ const projections = ctx.get('sessionProjections')
271
+ if (projections === undefined) return
272
+ await projections.register(costProjection)
273
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `polish` row: every feature of the bundle behind one switch each.
3
+ *
4
+ * Mounting this row mounts the host half of each enabled feature. A feature that
5
+ * is switched off is disposed, not just ignored, so its listeners and routes go
6
+ * away — a switch that only hid behaviour would keep costing the app.
7
+ *
8
+ * @module dsh-aryacode-polish
9
+ */
10
+ import z from '@deepseek-ai/schemastery'
11
+ import { flag } from './config.js'
12
+ import { configSchema, features } from './registry.js'
13
+
14
+ /** The plugin name inside a composition. */
15
+ export const name = 'aryacode-polish'
16
+
17
+ /** No services are required; features that need one declare it in their own module. */
18
+ export const inject = []
19
+
20
+ export { features }
21
+
22
+ /**
23
+ * The row's config: one volatile boolean per registered feature, derived from
24
+ * `entries/registry.js` so a new feature gains its switch automatically.
25
+ */
26
+ export const Config = z.object(configSchema(z))
27
+
28
+ /**
29
+ * Mount the enabled features and follow the switches at runtime.
30
+ *
31
+ * @param ctx - plugin context.
32
+ * @param config - the row config; every field is a volatile reference, not a value.
33
+ */
34
+ export function apply(ctx, config = {}) {
35
+ /** @type {Map<string, { dispose: () => unknown }>} */
36
+ const mounted = new Map()
37
+
38
+ // The loader commits a volatile-only change into the live references and emits
39
+ // `loader/volatile-update` instead of re-running apply(), so the switches have
40
+ // to mount and dispose their feature in place — otherwise the page would claim
41
+ // a feature is on while nothing is running.
42
+ const sync = () => {
43
+ for (const feature of features) {
44
+ const wanted = flag(config[feature.id], feature.defaultOn ?? true)
45
+ const running = mounted.get(feature.id)
46
+ if (wanted && running === undefined) {
47
+ if (typeof feature.mount === 'function') mounted.set(feature.id, ctx.plugin(feature.mount))
48
+ } else if (!wanted && running !== undefined) {
49
+ void running.dispose()
50
+ mounted.delete(feature.id)
51
+ }
52
+ }
53
+ }
54
+
55
+ sync()
56
+ ctx.on('loader/volatile-update', sync)
57
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The feature list — the one place a quality-of-life feature is declared.
3
+ *
4
+ * One entry is one feature, and one feature is one switch: the row's `Config`
5
+ * schema is derived from this array (see `configSchema`), so adding an entry
6
+ * here is what makes the switch appear in the Configure page. There is no second
7
+ * registration to forget.
8
+ *
9
+ * Entry shape:
10
+ *
11
+ * {
12
+ * id: 'spellcheck', // the config field name and the switch name
13
+ * label: 'Spelling corrections', // the words the Configure page shows
14
+ * description: 'Right-click a misspelled word to pick a correction.',
15
+ * defaultOn: true, // the switch defaults to on
16
+ * mount: spellcheck, // optional host half, a Cordis plugin
17
+ * }
18
+ *
19
+ * `mount` is the host half: a Cordis plugin object (or function) that this row
20
+ * plugs in when the switch is on and disposes when it is off. A feature with no
21
+ * host work omits `mount` and lives only in the client face.
22
+ *
23
+ * @module dsh-aryacode-polish-registry
24
+ */
25
+
26
+ import { mountCost } from './cost.js'
27
+
28
+ /**
29
+ * Every feature this bundle ships. Spelling corrections are investigated and
30
+ * documented in `docs/parked-spellcheck.md`; they are parked because a
31
+ * right-click correction menu needs a shell change no plugin can reach.
32
+ *
33
+ * @type {ReadonlyArray<{ id: string, label: string, description: string, defaultOn: boolean, mount?: unknown }>}
34
+ */
35
+ export const features = [
36
+ {
37
+ id: 'sessionCost',
38
+ label: 'Estimated session cost',
39
+ description: 'Show what the open session has cost so far, priced from recorded usage at the published DeepSeek rates.',
40
+ defaultOn: true,
41
+ mount: mountCost,
42
+ },
43
+ {
44
+ id: 'accountBalance',
45
+ label: 'Account balance',
46
+ description: 'Show the remaining account balance in the sidebar foot, above the account row.',
47
+ defaultOn: true,
48
+ },
49
+ ]
50
+
51
+ /**
52
+ * Derive the row's config shape from the feature list.
53
+ *
54
+ * Every field is boolean, defaults to the feature's own default, and is
55
+ * `.volatile()`: a volatile field is committed into the running fiber without a
56
+ * remount, so flipping a switch does not restart the plugin — `apply()` listens
57
+ * for `loader/volatile-update` and mounts or disposes the feature in place.
58
+ *
59
+ * @param z - the schemastery factory (`import z from '@deepseek-ai/schemastery'`).
60
+ * @param list - features to derive from; the real list unless a test passes one.
61
+ * @returns a schema shape: one field per feature.
62
+ */
63
+ export function configSchema(z, list = features) {
64
+ const shape = {}
65
+ for (const feature of list) {
66
+ shape[feature.id] = z
67
+ .boolean()
68
+ .default(feature.defaultOn ?? true)
69
+ .volatile()
70
+ .description(feature.description)
71
+ }
72
+ return shape
73
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Model-attributed provider usage, replayed from the durable session log.
3
+ *
4
+ * The Host keeps this fold so the browser half never has to read the event
5
+ * window itself: the projection's wire view carries everything the cost
6
+ * surface shows.
7
+ */
8
+
9
+ /** The empty wire view handed to a session with no usage yet. */
10
+ const EMPTY_VIEW = { models: [] }
11
+
12
+ /** One empty token band. */
13
+ function zero() {
14
+ return { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
15
+ }
16
+
17
+ /**
18
+ * Beijing peak is weekdays 09:00-12:00 and 14:00-18:00; weekends are off-peak
19
+ * all day. DeepSeek bills peak hours at twice the off-peak rate.
20
+ * @param {number} time - event time in milliseconds.
21
+ * @returns {boolean} whether that instant is a billed peak instant.
22
+ */
23
+ export function isBeijingPeak(time) {
24
+ const parts = new Intl.DateTimeFormat('en-US', {
25
+ timeZone: 'Asia/Shanghai', weekday: 'short', hour: 'numeric', hourCycle: 'h23',
26
+ }).formatToParts(new Date(time))
27
+ const weekday = parts.find(part => part.type === 'weekday')?.value
28
+ const hour = Number(parts.find(part => part.type === 'hour')?.value)
29
+ if (weekday === 'Sat' || weekday === 'Sun' || !Number.isInteger(hour)) return false
30
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
31
+ }
32
+
33
+ /** @returns {boolean} whether the value is a usable non-negative integer count. */
34
+ function isCount(value) {
35
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
36
+ }
37
+
38
+ /**
39
+ * @param {object} usage - provider-reported usage for one call.
40
+ * @returns {object | undefined} the four disjoint buckets, or undefined when the sample is unusable.
41
+ */
42
+ function bucketsFrom(usage) {
43
+ if (!isCount(usage.inputTokens) || !isCount(usage.outputTokens)) return undefined
44
+ if (usage.cacheReadTokens !== undefined && !isCount(usage.cacheReadTokens)) return undefined
45
+ if (usage.cacheWriteTokens !== undefined && !isCount(usage.cacheWriteTokens)) return undefined
46
+ return {
47
+ uncachedInputTokens: usage.inputTokens,
48
+ outputTokens: usage.outputTokens,
49
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
50
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {object} left - first band.
56
+ * @param {object} right - second band.
57
+ * @returns {boolean} whether the two bands hold the same four counts.
58
+ */
59
+ function bucketsEqual(left, right) {
60
+ return left.uncachedInputTokens === right.uncachedInputTokens
61
+ && left.outputTokens === right.outputTokens
62
+ && left.cacheReadTokens === right.cacheReadTokens
63
+ && left.cacheWriteTokens === right.cacheWriteTokens
64
+ }
65
+
66
+ /**
67
+ * The last raw stream chunk of one type, scanning backwards.
68
+ * @param {readonly object[]} stream - compact records from one durable settlement.
69
+ * @param {string} type - chunk type that only appears as a raw record.
70
+ * @returns {object | undefined} the stream's final chunk of that type.
71
+ */
72
+ function lastChunk(stream, type) {
73
+ if (!Array.isArray(stream)) return undefined
74
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
75
+ const record = stream[index]
76
+ if (record?.type === 'chunk' && record.chunk?.type === type) return record.chunk
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /**
82
+ * @param {object} event - one durable session event.
83
+ * @returns {object | undefined} the provider usage reported by that event.
84
+ */
85
+ function usageOf(event) {
86
+ if (event.type === 'assistant/message' && event.data?.usage !== undefined) return event.data.usage
87
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
88
+ return lastChunk(event.data?.stream, 'usage')?.usage
89
+ }
90
+
91
+ /**
92
+ * @param {object} route - provider and model.
93
+ * @param {number} hour - UTC hour bucket.
94
+ * @returns {string} the row key for one route in one hour.
95
+ */
96
+ function routeKey(route, hour) {
97
+ return JSON.stringify([route.provider, route.model, hour])
98
+ }
99
+
100
+ /**
101
+ * @param {object} event - one durable session event.
102
+ * @param {object | null} pending - route carried over from the request header.
103
+ * @returns {object} the route that produced this sample.
104
+ */
105
+ function routeOf(event, pending) {
106
+ if (event.type === 'assistant/message') {
107
+ const source = event.data?.message?.source
108
+ if (typeof source?.provider === 'string' && source.provider.length > 0
109
+ && typeof source?.model === 'string' && source.model.length > 0) {
110
+ return { provider: source.provider, model: source.model }
111
+ }
112
+ }
113
+ return pending ?? { provider: '', model: '' }
114
+ }
115
+
116
+ /**
117
+ * @param {object} event - one `request/header` event.
118
+ * @returns {object | undefined} the routed provider and model, when both are named.
119
+ */
120
+ function headerRoute(event) {
121
+ const config = event.data?.header?.config
122
+ const provider = config?.provider
123
+ const model = config?.model
124
+ if (typeof provider !== 'string' || typeof model !== 'string') return undefined
125
+ if (provider.length === 0 || model.length === 0) return undefined
126
+ return { provider, model }
127
+ }
128
+
129
+ /**
130
+ * @param {object} row - one usage row.
131
+ * @returns {number} every bucket of both bands added up.
132
+ */
133
+ function totalTokens(row) {
134
+ const bands = [row.peak, row.offPeak]
135
+ return bands.reduce((sum, band) => sum
136
+ + band.uncachedInputTokens + band.cacheReadTokens + band.cacheWriteTokens + band.outputTokens, 0)
137
+ }
138
+
139
+ /**
140
+ * @param {Record<string, object>} rows - usage rows by key.
141
+ * @returns {object} the wire view: the non-empty rows, heaviest first.
142
+ */
143
+ function viewOf(rows) {
144
+ const models = Object.values(rows).filter(row => totalTokens(row) > 0).sort((left, right) => {
145
+ const delta = totalTokens(right) - totalTokens(left)
146
+ if (delta !== 0) return delta
147
+ if (left.model === right.model && left.provider === right.provider) return left.hour - right.hour
148
+ if (left.model === right.model) return left.provider < right.provider ? -1 : 1
149
+ return left.model < right.model ? -1 : 1
150
+ })
151
+ return models.length === 0 ? EMPTY_VIEW : { models }
152
+ }
153
+
154
+ /**
155
+ * @param {object} band - band to change.
156
+ * @param {object} delta - counts to add or remove.
157
+ * @param {1 | -1} sign - direction.
158
+ * @returns {object} the changed band.
159
+ */
160
+ function addBand(band, delta, sign) {
161
+ return {
162
+ uncachedInputTokens: band.uncachedInputTokens + sign * delta.uncachedInputTokens,
163
+ outputTokens: band.outputTokens + sign * delta.outputTokens,
164
+ cacheReadTokens: band.cacheReadTokens + sign * delta.cacheReadTokens,
165
+ cacheWriteTokens: band.cacheWriteTokens + sign * delta.cacheWriteTokens,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Record one sample, replacing the sample this turn and step reported before.
171
+ * @param {object} state - previous fold.
172
+ * @param {object} route - provider and model that produced the sample.
173
+ * @param {boolean} peak - whether the sample fell in a billed peak hour.
174
+ * @param {object} buckets - the sample's four disjoint counts.
175
+ * @param {number} turn - turn index.
176
+ * @param {number} step - step index within the turn.
177
+ * @param {number} hour - UTC hour bucket.
178
+ * @returns {object} the updated fold.
179
+ */
180
+ function withSample(state, route, peak, buckets, turn, step, hour) {
181
+ const key = routeKey(route, hour)
182
+ const previous = state.last !== null && state.last.turn === turn && state.last.step === step
183
+ ? state.last
184
+ : undefined
185
+ if (previous !== undefined
186
+ && previous.key === key
187
+ && previous.peak === peak
188
+ && bucketsEqual(previous.buckets, buckets)) return state
189
+
190
+ const rows = { ...state.rows }
191
+ const touch = (modelKey, bandPeak, delta, sign, requests) => {
192
+ const current = rows[modelKey] ?? {
193
+ provider: route.provider,
194
+ model: route.model,
195
+ hour,
196
+ requests: 0,
197
+ peak: zero(),
198
+ offPeak: zero(),
199
+ }
200
+ rows[modelKey] = {
201
+ ...current,
202
+ requests: current.requests + requests,
203
+ peak: bandPeak ? addBand(current.peak, delta, sign) : current.peak,
204
+ offPeak: bandPeak ? current.offPeak : addBand(current.offPeak, delta, sign),
205
+ }
206
+ }
207
+
208
+ if (previous === undefined) touch(key, peak, buckets, 1, 1)
209
+ else {
210
+ touch(previous.key, previous.peak, previous.buckets, -1, previous.key === key ? 0 : -1)
211
+ touch(key, peak, buckets, 1, previous.key === key ? 0 : 1)
212
+ }
213
+ return {
214
+ ...state,
215
+ rows,
216
+ last: { turn, step, key, peak, buckets },
217
+ view: viewOf(rows),
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Fold one durable event.
223
+ * @param {object} state - previous fold.
224
+ * @param {object} event - next durable event.
225
+ * @returns {object} the updated fold.
226
+ */
227
+ export function applyModelUsage(state, event) {
228
+ if (event.type === 'request/header') {
229
+ const route = headerRoute(event)
230
+ if (route === undefined) return { ...state, pending: null }
231
+ if (state.pending?.provider === route.provider && state.pending.model === route.model) return state
232
+ return { ...state, pending: route }
233
+ }
234
+ if (event.type === 'llm/retry-started') {
235
+ return state.last?.turn === event.data?.turn && state.last?.step === event.data?.step
236
+ ? { ...state, last: null }
237
+ : state
238
+ }
239
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return state
240
+ const sample = usageOf(event)
241
+ if (sample === undefined) return state
242
+ const buckets = bucketsFrom(sample)
243
+ if (buckets === undefined) return state
244
+ return withSample(
245
+ state, routeOf(event, state.pending), isBeijingPeak(event.time), buckets,
246
+ event.data?.turn ?? 0, event.data?.step ?? 0, Math.floor(event.time / 3_600_000),
247
+ )
248
+ }
249
+
250
+ /**
251
+ * @returns {object} an empty accumulator.
252
+ */
253
+ export function initialModelUsage() {
254
+ return { rows: {}, pending: null, last: null, view: EMPTY_VIEW }
255
+ }
256
+
257
+ /**
258
+ * @param {readonly object[]} events - durable events in session order.
259
+ * @returns {object} usage grouped by provider, model, and hour.
260
+ */
261
+ export function deriveModelUsage(events) {
262
+ let state = initialModelUsage()
263
+ for (const event of events) state = applyModelUsage(state, event)
264
+ return state.view
265
+ }