@goodandready/dsh-cost-meter 0.7.8 → 0.8.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/lib/index.js CHANGED
@@ -1,378 +1,388 @@
1
- // dsh-cost-meter — host half.
2
- //
3
- // The harness prices nothing on purpose: pi-ai carries a `cost` field but
4
- // zeroes it and no consumer reports spend. So this half owns the rate card and
5
- // serves it to the browser over GET /dsh-cost-meter/state.
6
- //
7
- // Rates come from OpenRouter's public catalog (https://openrouter.ai/api/v1/models,
8
- // no auth). Besides the four token buckets it also publishes `pricing.overrides`
9
- // — time-of-day discount windows keyed by UTC HHMM — which is exactly the
10
- // peak/off-peak schedule DeepSeek runs. So the whole tariff, windows included,
11
- // is derived rather than typed in; `prices` in config stays as a manual
12
- // override for routes the catalog does not know (self-hosted, subscriptions).
13
- //
14
- // No agent hooks: the widget only reads, so it cannot corrupt a session.
15
-
16
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1
+ // dsh-cost-meter — host half.
2
+ //
3
+ // The harness prices nothing on purpose: pi-ai carries a `cost` field but
4
+ // zeroes it and no consumer reports spend. So this half owns the rate card and
5
+ // serves it to the browser over GET /dsh-cost-meter/state.
6
+ //
7
+ // Rates come from OpenRouter's public catalog (https://openrouter.ai/api/v1/models,
8
+ // no auth). Besides the four token buckets it also publishes `pricing.overrides`
9
+ // — time-of-day discount windows keyed by UTC HHMM — which is exactly the
10
+ // peak/off-peak schedule DeepSeek runs. So the whole tariff, windows included,
11
+ // is derived rather than typed in; `prices` in config stays as a manual
12
+ // override for routes the catalog does not know (self-hosted, subscriptions).
13
+ //
14
+ // No agent hooks: the widget only reads, so it cannot corrupt a session.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
- import z from '@deepseek-ai/schemastery'
20
- import { z as zod } from 'zod'
21
-
22
- export const name = 'dsh-cost-meter'
23
- export const inject = ['webServer']
24
-
25
- const NS = 'dsh-cost-meter'
26
- const STATE_PATH = '/dsh-cost-meter/state'
27
- const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import { z as zod } from 'zod'
21
+
22
+ export const name = 'dsh-cost-meter'
23
+ export const inject = ['webServer']
24
+
25
+ const NS = 'dsh-cost-meter'
26
+ const STATE_PATH = '/dsh-cost-meter/state'
27
+ const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
28
28
  const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
29
-
30
- /** One route's rates, in USD per 1M tokens. */
31
- const PriceRow = z.object({
32
- cacheHit: z.number().description('Input tokens served from the provider cache.').default(0),
33
- input: z.number().description('Input tokens that missed the cache.').default(0),
34
- cacheWrite: z.number().description('Input tokens written into the cache.').default(0),
35
- output: z.number().description('Output tokens (reasoning included).').default(0),
36
- })
37
-
38
- /**
39
- * DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
40
- * Their API publishes no prices (/models lists ids, there is no pricing route),
41
- * and OpenRouter's `deepseek/*` entries quote different numbers, so this table
42
- * is the authoritative source for the official route. Values are PEAK, USD per
43
- * 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
44
- */
45
- const DEEPSEEK_PEAK_RATES = {
46
- 'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
47
- 'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
48
- }
49
- const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
50
-
51
- // ponytail: a DeepSeek model reached THROUGH OpenRouter is still priced from
52
- // this table, though the bill comes from OpenRouter. Fix by classifying the
53
- // route (provider id / baseURL) before the model, and for a `:floor` slug take
54
- // the minimum over /models/{id}/endpoints rather than the base listing.
55
-
56
- export const Config = z.object({
57
- currency: z.string().description('Currency symbol shown in the widget.').default('$'),
58
- usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
59
- displayTimeZone: z
60
- .string()
61
- .description('IANA zone the widget shows tariff hours in.')
62
- .default('Europe/Moscow'),
63
- deepseekPeakPrices: z
64
- .dict(PriceRow)
65
- .description('Override the built-in DeepSeek peak rates, by model id.')
66
- .default({}),
67
- useOpenRouter: z
68
- .boolean()
69
- .description('Pull rates and discount windows from the public OpenRouter catalog.')
70
- .default(true),
71
- refreshHours: z.number().description('How often to refetch the catalog.').default(24),
72
- modelMap: z
73
- .dict(z.string())
74
- .description('Route override: "provider/model" -> OpenRouter model id.')
75
- .default({}),
76
- prices: z
77
- .dict(PriceRow)
78
- .description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
79
- .default({}),
80
- manualPeakWindowsUtc: z
81
- .array(z.string())
82
- .description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
83
- .default([]),
84
- manualOffPeakMultiplier: z
85
- .number()
86
- .description('Multiplier outside the manual peak windows.')
87
- .default(1),
88
- })
89
-
90
- /** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
91
- function perMillion(value) {
92
- const n = Number(value)
93
- return Number.isFinite(n) ? n * 1e6 : 0
94
- }
95
-
96
- /** Pull the four buckets out of one OpenRouter pricing object. */
97
- function bucketsOf(pricing) {
98
- return {
99
- cacheHit: perMillion(pricing.input_cache_read),
100
- input: perMillion(pricing.prompt),
101
- cacheWrite: perMillion(pricing.input_cache_write),
102
- output: perMillion(pricing.completion),
103
- }
104
- }
105
-
106
- /** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
107
- export function hhmmToMinutes(value) {
108
- const n = Number(value)
109
- if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
110
- return Math.floor(n / 100) * 60 + (n % 100)
111
- }
112
-
113
- /** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
114
- export function parseWindow(spec) {
115
- const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
116
- if (!m) return undefined
117
- return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
118
- }
119
-
120
- /**
121
- * Normalize one catalog entry into the schedule the browser consumes:
122
- * a base rate card plus the discount windows that shadow it.
123
- */
124
- function scheduleOf(entry) {
125
- const pricing = entry.pricing || {}
126
- const base = bucketsOf(pricing)
127
- const windows = []
128
- for (const override of pricing.overrides || []) {
129
- const start = hhmmToMinutes(override.utc_start)
130
- const end = hhmmToMinutes(override.utc_end)
131
- if (start === undefined || end === undefined) continue
132
- // An override omits buckets it does not change; fall back to the base.
133
- const rates = bucketsOf(override)
134
- windows.push({
135
- start,
136
- end,
137
- rates: {
138
- cacheHit: rates.cacheHit || base.cacheHit,
139
- input: rates.input || base.input,
140
- cacheWrite: rates.cacheWrite || base.cacheWrite,
141
- output: rates.output || base.output,
142
- },
143
- })
144
- }
145
- return { base, windows }
146
- }
147
-
148
- /**
149
- * Map a harness route onto an OpenRouter id. Exact id first, then the vendor
150
- * suffix (`deepseek-official/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`),
151
- * which is what the shared model names make possible.
152
- */
153
- export function matchModel(index, route, modelMap) {
154
- if (!route) return undefined
155
- const key = route.provider + '/' + route.model
156
- const mapped = modelMap && modelMap[key]
157
- if (mapped && index.byId[mapped]) return mapped
158
- if (index.byId[key]) return key
159
- if (index.byId[route.model]) return route.model
160
- // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
161
- // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
162
- const lower = String(route.model).toLowerCase()
163
- if (index.byLowerId[lower]) return index.byLowerId[lower]
164
- if (index.byLowerId[key.toLowerCase()]) return index.byLowerId[key.toLowerCase()]
165
- const bySuffix = index.bySuffix[lower]
166
- return bySuffix && bySuffix.length ? bySuffix[0] : undefined
167
- }
168
-
169
- /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
29
+
30
+ /** One route's rates, in USD per 1M tokens. */
31
+ const PriceRow = z.object({
32
+ cacheHit: z.number().description('Input tokens served from the provider cache.').default(0),
33
+ input: z.number().description('Input tokens that missed the cache.').default(0),
34
+ cacheWrite: z.number().description('Input tokens written into the cache.').default(0),
35
+ output: z.number().description('Output tokens (reasoning included).').default(0),
36
+ })
37
+
38
+ /**
39
+ * DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
40
+ * Their API publishes no prices (/models lists ids, there is no pricing route),
41
+ * and OpenRouter's `deepseek/*` entries quote different numbers, so this table
42
+ * is the authoritative source for the official route. Values are PEAK, USD per
43
+ * 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
44
+ */
45
+ const DEEPSEEK_PEAK_RATES = {
46
+ 'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
47
+ 'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
48
+ }
49
+ const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
50
+
51
+ // ponytail: a DeepSeek model reached THROUGH OpenRouter is still priced from
52
+ // this table, though the bill comes from OpenRouter. Fix by classifying the
53
+ // route (provider id / baseURL) before the model, and for a `:floor` slug take
54
+ // the minimum over /models/{id}/endpoints rather than the base listing.
55
+
56
+ export const Config = z.object({
57
+ currency: z.string().description('Currency symbol shown in the widget.').default('$'),
58
+ usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
59
+ displayTimeZone: z
60
+ .string()
61
+ .description('IANA zone the widget shows tariff hours in.')
62
+ .default('Europe/Moscow'),
63
+ deepseekPeakPrices: z
64
+ .dict(PriceRow)
65
+ .description('Override the built-in DeepSeek peak rates, by model id.')
66
+ .default({}),
67
+ useOpenRouter: z
68
+ .boolean()
69
+ .description('Pull rates and discount windows from the public OpenRouter catalog.')
70
+ .default(true),
71
+ refreshHours: z.number().description('How often to refetch the catalog.').default(24),
72
+ modelMap: z
73
+ .dict(z.string())
74
+ .description('Route override: "provider/model" -> OpenRouter model id.')
75
+ .default({}),
76
+ prices: z
77
+ .dict(PriceRow)
78
+ .description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
79
+ .default({}),
80
+ manualPeakWindowsUtc: z
81
+ .array(z.string())
82
+ .description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
83
+ .default([]),
84
+ manualOffPeakMultiplier: z
85
+ .number()
86
+ .description('Multiplier outside the manual peak windows.')
87
+ .default(1),
88
+ })
89
+
90
+ /** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
91
+ function perMillion(value) {
92
+ const n = Number(value)
93
+ return Number.isFinite(n) ? n * 1e6 : 0
94
+ }
95
+
96
+ /** Pull the four buckets out of one OpenRouter pricing object. */
97
+ function bucketsOf(pricing) {
98
+ return {
99
+ cacheHit: perMillion(pricing.input_cache_read),
100
+ input: perMillion(pricing.prompt),
101
+ cacheWrite: perMillion(pricing.input_cache_write),
102
+ output: perMillion(pricing.completion),
103
+ }
104
+ }
105
+
106
+ /** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
107
+ export function hhmmToMinutes(value) {
108
+ const n = Number(value)
109
+ if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
110
+ return Math.floor(n / 100) * 60 + (n % 100)
111
+ }
112
+
113
+ /** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
114
+ export function parseWindow(spec) {
115
+ const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
116
+ if (!m) return undefined
117
+ return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
118
+ }
119
+
120
+ /**
121
+ * Normalize one catalog entry into the schedule the browser consumes:
122
+ * a base rate card plus the discount windows that shadow it.
123
+ */
124
+ function scheduleOf(entry) {
125
+ const pricing = entry.pricing || {}
126
+ const base = bucketsOf(pricing)
127
+ const windows = []
128
+ for (const override of pricing.overrides || []) {
129
+ const start = hhmmToMinutes(override.utc_start)
130
+ const end = hhmmToMinutes(override.utc_end)
131
+ if (start === undefined || end === undefined) continue
132
+ // An override omits buckets it does not change; fall back to the base.
133
+ const rates = bucketsOf(override)
134
+ windows.push({
135
+ start,
136
+ end,
137
+ rates: {
138
+ cacheHit: rates.cacheHit || base.cacheHit,
139
+ input: rates.input || base.input,
140
+ cacheWrite: rates.cacheWrite || base.cacheWrite,
141
+ output: rates.output || base.output,
142
+ },
143
+ })
144
+ }
145
+ return { base, windows }
146
+ }
147
+
148
+ /**
149
+ * Map a harness route onto an OpenRouter id. Exact id first, then the vendor
150
+ * suffix (`deepseek-official/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`),
151
+ * which is what the shared model names make possible.
152
+ */
153
+ export function matchModel(index, route, modelMap) {
154
+ if (!route || !index) return undefined
155
+ const key = route.provider ? route.provider + '/' + route.model : route.model
156
+ const mapped = modelMap && (modelMap[key] || modelMap[route.model])
157
+ if (mapped && index.byId && index.byId[mapped]) return mapped
158
+ if (index.byId && index.byId[key]) return key
159
+ if (index.byId && index.byId[route.model]) return route.model
160
+ // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
161
+ // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
162
+ const lower = String(route.model).toLowerCase()
163
+ const byLowerId = index.byLowerId || {}
164
+ if (byLowerId[lower]) return byLowerId[lower]
165
+ if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
166
+ const bySuffix = index.bySuffix || {}
167
+ const list = bySuffix[lower]
168
+ return list && list.length ? list[0] : undefined
169
+ }
170
+
171
+ /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
170
172
  export function indexCatalog(models) {
171
- const byId = {}
172
- const byLowerId = {}
173
- const bySuffix = {}
174
- for (const entry of models) {
175
- if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
176
- // Variants like ":batch" or ":free" are different products; skip them so a
177
- // suffix match cannot silently price against a cheaper batch tier.
178
- if (entry.id.includes(':')) continue
179
- byId[entry.id] = scheduleOf(entry)
180
- byLowerId[entry.id.toLowerCase()] = entry.id
181
- const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
182
- ;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
183
- }
184
- return { byId, byLowerId, bySuffix }
185
- }
186
-
187
- // ---------------------------------------------------------------------------
188
- // Per-model usage projection
189
- //
190
- // `tokenUsage` from dsh-token-meter totals the whole session, which prices
191
- // wrongly the moment the model changes mid-session. The log has what is
192
- // needed: `request/header` carries `header.config = {provider, model}`, and
193
- // usage arrives afterwards for that same step. So this unit keeps the last
194
- // header's route and files every usage sample under it.
195
- //
196
- // The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
197
- // and the finalized assistant message report the same numbers for one step.
198
- // ---------------------------------------------------------------------------
199
-
200
- const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
201
-
202
- // Projection schemas are validated with ZOD, not schemastery: the registry
203
- // calls `schema.parse(view)` at the boundary, and a schemastery schema has no
204
- // `.parse`, which fails every history read with
205
- // "registration.def.schema.parse is not a function".
206
- const bucketSchema = zod.object({
207
- uncachedInputTokens: zod.number().nonnegative(),
208
- outputTokens: zod.number().nonnegative(),
209
- cacheReadTokens: zod.number().nonnegative(),
210
- cacheWriteTokens: zod.number().nonnegative(),
211
- }).strict()
212
-
213
- /** Pull the four buckets out of one provider usage record. */
214
- function bucketsFromUsage(usage) {
215
- return {
216
- uncachedInputTokens: Number(usage.inputTokens) || 0,
217
- outputTokens: Number(usage.outputTokens) || 0,
218
- cacheReadTokens: Number(usage.cacheReadTokens) || 0,
219
- cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
220
- }
221
- }
222
-
223
- function addBuckets(base, remove, add) {
224
- const out = {}
225
- for (const k of Object.keys(ZERO)) {
226
- out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
227
- }
228
- return out
229
- }
230
-
231
- function sameBuckets(a, b) {
232
- return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
233
- }
234
-
235
- /** The usage a streamed chunk or a finalized assistant message reports. */
236
- function usageOf(event) {
237
- if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
238
- return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
239
- }
240
- if (event.type === 'assistant/message' && event.data.usage !== undefined) {
241
- return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
242
- }
243
- return undefined
244
- }
245
-
246
- /**
247
- * Half-hour slot of the UTC day an event falls into (0..47). Tariff windows are
248
- * expressed in UTC clock time, and every real schedule so far lands on a whole
249
- * or half hour, so 48 slots reproduce the rate exactly while keeping the state
250
- * small enough to ship on every projection frame.
251
- */
252
- export function slotOf(timeMs) {
253
- const d = new Date(timeMs)
254
- return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
255
- }
256
-
257
- const usageByModelProjection = {
258
- key: 'costByModel',
259
- schema: zod.object({
260
- models: zod.array(zod.object({
261
- provider: zod.string(),
262
- model: zod.string(),
263
- // Usage split by the half-hour of the UTC day it was spent in. Spend is
264
- // priced at the rate in force THEN; a later tariff switch must not
265
- // reprice tokens that are already paid for.
266
- slots: zod.record(zod.string(), bucketSchema),
267
- })),
268
- }).strict(),
269
- init: () => ({ route: null, byKey: {}, last: null }),
270
- apply: (state, event) => {
271
- if (event.type === 'request/header') {
272
- const config = event.data && event.data.header && event.data.header.config
273
- const provider = config && typeof config.provider === 'string' ? config.provider : undefined
274
- const model = config && typeof config.model === 'string' ? config.model : undefined
275
- if (provider === undefined || model === undefined) return state
276
- if (state.route && state.route.provider === provider && state.route.model === model) return state
277
- return { ...state, route: { provider, model } }
278
- }
279
- const sample = usageOf(event)
280
- if (sample === undefined) return state
281
- const route = state.route || { provider: '', model: 'unknown' }
282
- const key = route.provider + '/' + route.model
283
- const slot = String(slotOf(event.time))
284
- const buckets = bucketsFromUsage(sample.usage)
285
- // A streamed usage chunk and the finalized assistant message report the
286
- // same step twice; the second one replaces the first instead of adding.
287
- const previous =
288
- state.last !== null &&
289
- state.last.turn === sample.turn &&
290
- state.last.step === sample.step &&
291
- state.last.key === key &&
292
- state.last.slot === slot
293
- ? state.last.buckets
294
- : undefined
295
- if (previous !== undefined && sameBuckets(previous, buckets)) return state
296
- const slots = state.byKey[key] || {}
297
- return {
298
- route: state.route,
299
- byKey: {
300
- ...state.byKey,
301
- [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
302
- },
303
- last: { turn: sample.turn, step: sample.step, key, slot, buckets },
304
- }
305
- },
306
- view: (state) => ({
307
- models: Object.keys(state.byKey).map((key) => {
308
- const slash = key.indexOf('/')
309
- return {
310
- provider: key.slice(0, slash),
311
- model: key.slice(slash + 1),
312
- slots: state.byKey[key],
313
- }
314
- }),
315
- }),
316
- stateVersion: 2,
317
- }
318
-
319
- /** The agent's default route, read off settings descriptors when available. */
320
- function defaultRoute(ctx) {
321
- const settings = ctx.get('settings')
322
- if (settings === undefined) return undefined
323
- try {
324
- const found = settings
325
- .describe({ redactSecrets: true })
326
- .find((descriptor) => descriptor.ns === 'agent-default-model')
327
- const value = found && found.value
328
- if (!value || typeof value.provider !== 'string' || typeof value.model !== 'string') return undefined
329
- return { provider: value.provider, model: value.model }
330
- } catch {
331
- return undefined
332
- }
333
- }
334
-
335
- /**
336
- * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
337
- * then the OpenRouter catalog. Returns `schedule: null` when nothing knows it,
338
- * so the widget can say so instead of pricing at zero.
339
- */
173
+ const byId = {}
174
+ const byLowerId = {}
175
+ const bySuffix = {}
176
+ for (const entry of models) {
177
+ if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
178
+ // Variants like ":batch" or ":free" are different products; skip them so a
179
+ // suffix match cannot silently price against a cheaper batch tier.
180
+ if (entry.id.includes(':')) continue
181
+ byId[entry.id] = scheduleOf(entry)
182
+ byLowerId[entry.id.toLowerCase()] = entry.id
183
+ const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
184
+ ;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
185
+ }
186
+ return { byId, byLowerId, bySuffix }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Per-model usage projection
191
+ //
192
+ // `tokenUsage` from dsh-token-meter totals the whole session, which prices
193
+ // wrongly the moment the model changes mid-session. The log has what is
194
+ // needed: `request/header` carries `header.config = {provider, model}`, and
195
+ // usage arrives afterwards for that same step. So this unit keeps the last
196
+ // header's route and files every usage sample under it.
197
+ //
198
+ // The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
199
+ // and the finalized assistant message report the same numbers for one step.
200
+ // ---------------------------------------------------------------------------
201
+
202
+ const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
203
+
204
+ // Projection schemas are validated with ZOD, not schemastery: the registry
205
+ // calls `schema.parse(view)` at the boundary, and a schemastery schema has no
206
+ // `.parse`, which fails every history read with
207
+ // "registration.def.schema.parse is not a function".
208
+ const bucketSchema = zod.object({
209
+ uncachedInputTokens: zod.number().nonnegative(),
210
+ outputTokens: zod.number().nonnegative(),
211
+ cacheReadTokens: zod.number().nonnegative(),
212
+ cacheWriteTokens: zod.number().nonnegative(),
213
+ }).strict()
214
+
215
+ /** Pull the four buckets out of one provider usage record. */
216
+ function bucketsFromUsage(usage) {
217
+ return {
218
+ uncachedInputTokens: Number(usage.inputTokens) || 0,
219
+ outputTokens: Number(usage.outputTokens) || 0,
220
+ cacheReadTokens: Number(usage.cacheReadTokens) || 0,
221
+ cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
222
+ }
223
+ }
224
+
225
+ function addBuckets(base, remove, add) {
226
+ const out = {}
227
+ for (const k of Object.keys(ZERO)) {
228
+ out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
229
+ }
230
+ return out
231
+ }
232
+
233
+ function sameBuckets(a, b) {
234
+ return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
235
+ }
236
+
237
+ /** The usage a streamed chunk or a finalized assistant message reports. */
238
+ function usageOf(event) {
239
+ if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
240
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
241
+ }
242
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) {
243
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
244
+ }
245
+ return undefined
246
+ }
247
+
248
+ /**
249
+ * Half-hour slot of the UTC day an event falls into (0..47). Tariff windows are
250
+ * expressed in UTC clock time, and every real schedule so far lands on a whole
251
+ * or half hour, so 48 slots reproduce the rate exactly while keeping the state
252
+ * small enough to ship on every projection frame.
253
+ */
254
+ export function slotOf(timeMs) {
255
+ let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
256
+ if (Number.isNaN(ms)) {
257
+ if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
258
+ ms = timeMs.getTime()
259
+ } else {
260
+ ms = Date.now()
261
+ }
262
+ }
263
+ const d = new Date(ms)
264
+ return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
265
+ }
266
+
267
+ const usageByModelProjection = {
268
+ key: 'costByModel',
269
+ schema: zod.object({
270
+ models: zod.array(zod.object({
271
+ provider: zod.string(),
272
+ model: zod.string(),
273
+ // Usage split by the half-hour of the UTC day it was spent in. Spend is
274
+ // priced at the rate in force THEN; a later tariff switch must not
275
+ // reprice tokens that are already paid for.
276
+ slots: zod.record(zod.string(), bucketSchema),
277
+ })),
278
+ }).strict(),
279
+ init: () => ({ route: null, byKey: {}, last: null }),
280
+ apply: (state, event) => {
281
+ if (event.type === 'request/header') {
282
+ const config = event.data && event.data.header && event.data.header.config
283
+ const provider = config && typeof config.provider === 'string' ? config.provider : undefined
284
+ const model = config && typeof config.model === 'string' ? config.model : undefined
285
+ if (provider === undefined || model === undefined) return state
286
+ if (state.route && state.route.provider === provider && state.route.model === model) return state
287
+ return { ...state, route: { provider, model } }
288
+ }
289
+ const sample = usageOf(event)
290
+ if (sample === undefined) return state
291
+ const route = state.route || { provider: '', model: 'unknown' }
292
+ const key = route.provider + '/' + route.model
293
+ const slot = String(slotOf(event.time))
294
+ const buckets = bucketsFromUsage(sample.usage)
295
+ // A streamed usage chunk and the finalized assistant message report the
296
+ // same step twice; the second one replaces the first instead of adding.
297
+ const previous =
298
+ state.last !== null &&
299
+ state.last.turn === sample.turn &&
300
+ state.last.step === sample.step &&
301
+ state.last.key === key &&
302
+ state.last.slot === slot
303
+ ? state.last.buckets
304
+ : undefined
305
+ if (previous !== undefined && sameBuckets(previous, buckets)) return state
306
+ const slots = state.byKey[key] || {}
307
+ return {
308
+ route: state.route,
309
+ byKey: {
310
+ ...state.byKey,
311
+ [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
312
+ },
313
+ last: { turn: sample.turn, step: sample.step, key, slot, buckets },
314
+ }
315
+ },
316
+ view: (state) => ({
317
+ models: Object.keys(state.byKey).map((key) => {
318
+ const slash = key.indexOf('/')
319
+ return {
320
+ provider: key.slice(0, slash),
321
+ model: key.slice(slash + 1),
322
+ slots: state.byKey[key],
323
+ }
324
+ }),
325
+ }),
326
+ stateVersion: 2,
327
+ }
328
+
329
+ /** The agent's default route, read off settings descriptors when available. */
330
+ function defaultRoute(ctx) {
331
+ const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
332
+ if (settings === undefined) return undefined
333
+ try {
334
+ const found = settings
335
+ .describe({ redactSecrets: true })
336
+ .find((descriptor) => descriptor.ns === 'agent-default-model')
337
+ const value = found && found.value
338
+ if (!value || typeof value.provider !== 'string' || typeof value.model !== 'string') return undefined
339
+ return { provider: value.provider, model: value.model }
340
+ } catch {
341
+ return undefined
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
347
+ * then the OpenRouter catalog. Returns `schedule: null` when nothing knows it,
348
+ * so the widget can say so instead of pricing at zero.
349
+ */
340
350
  export function resolveTariff(cfg, index, route) {
341
- if (!route) return { schedule: null, source: 'none', matchedId: null }
342
- const key = route.provider + '/' + route.model
343
- const prices = cfg.prices || {}
344
- const manual = prices[key] || prices[route.model] || prices['*']
345
-
346
- if (manual) {
347
- const base = {
348
- cacheHit: Number(manual.cacheHit) || 0,
349
- input: Number(manual.input) || 0,
350
- cacheWrite: Number(manual.cacheWrite) || 0,
351
- output: Number(manual.output) || 0,
352
- }
353
- const factor = Number(cfg.manualOffPeakMultiplier) || 1
354
- const windows = []
355
- for (const spec of cfg.manualPeakWindowsUtc || []) {
356
- const parsed = parseWindow(spec)
357
- if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
358
- }
359
- const schedule = windows.length
360
- ? {
361
- base: {
362
- cacheHit: base.cacheHit * factor,
363
- input: base.input * factor,
364
- cacheWrite: base.cacheWrite * factor,
365
- output: base.output * factor,
366
- },
367
- windows,
368
- }
369
- : { base, windows: [] }
370
- return { schedule, source: 'manual', matchedId: key }
371
- }
372
-
373
- // Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
374
- // published rate whoever routes it (deepseek-official, opencode-go, ollama).
375
- // Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
351
+ if (!route) return { schedule: null, source: 'none', matchedId: null }
352
+ const key = route.provider + '/' + route.model
353
+ const prices = cfg.prices || {}
354
+ const manual = prices[key] || prices[route.model] || prices['*']
355
+
356
+ if (manual) {
357
+ const base = {
358
+ cacheHit: Number(manual.cacheHit) || 0,
359
+ input: Number(manual.input) || 0,
360
+ cacheWrite: Number(manual.cacheWrite) || 0,
361
+ output: Number(manual.output) || 0,
362
+ }
363
+ const factor = Number(cfg.manualOffPeakMultiplier) || 1
364
+ const windows = []
365
+ for (const spec of cfg.manualPeakWindowsUtc || []) {
366
+ const parsed = parseWindow(spec)
367
+ if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
368
+ }
369
+ const schedule = windows.length
370
+ ? {
371
+ base: {
372
+ cacheHit: base.cacheHit * factor,
373
+ input: base.input * factor,
374
+ cacheWrite: base.cacheWrite * factor,
375
+ output: base.output * factor,
376
+ },
377
+ windows,
378
+ }
379
+ : { base, windows: [] }
380
+ return { schedule, source: 'manual', matchedId: key }
381
+ }
382
+
383
+ // Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
384
+ // published rate whoever routes it (deepseek-official, opencode-go, ollama).
385
+ // Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
376
386
  const providerLower = String(route.provider || "").toLowerCase();
377
387
  // #3: Provider-aware routing: if routed explicitly through OpenRouter,
378
388
  // consult OpenRouter catalog first to honor actual provider billing.
@@ -381,132 +391,136 @@ export function resolveTariff(cfg, index, route) {
381
391
  if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
382
392
  }
383
393
 
384
- const base = String(route.model).toLowerCase().split(':')[0]
385
- const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
386
- if (peak) {
387
- const full = {
388
- cacheHit: Number(peak.cacheHit) || 0,
389
- input: Number(peak.input) || 0,
390
- cacheWrite: Number(peak.cacheWrite) || 0,
391
- output: Number(peak.output) || 0,
392
- }
393
- return {
394
- schedule: {
395
- base: {
396
- cacheHit: full.cacheHit / 2,
397
- input: full.input / 2,
398
- cacheWrite: full.cacheWrite / 2,
399
- output: full.output / 2,
400
- },
401
- windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
402
- },
403
- source: 'deepseek',
404
- matchedId: base,
405
- }
406
- }
407
-
408
- if (cfg.useOpenRouter !== false) {
409
- const matchedId = matchModel(index, route, cfg.modelMap)
410
- if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
411
- }
412
- return { schedule: null, source: 'none', matchedId: null }
413
- }
414
-
415
- /** "provider/model" -> {provider, model}; the model half may contain slashes. */
416
- function splitRoute(key) {
417
- const slash = String(key).indexOf('/')
418
- if (slash < 0) return undefined
419
- return { provider: key.slice(0, slash), model: key.slice(slash + 1) }
420
- }
421
-
422
- export function apply(ctx, config) {
423
- let current = config
424
- let catalog = { index: { byId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
425
-
426
- // Disk cache survives restarts, so a boot without network still prices.
427
- try {
428
- const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
429
- if (cached && Array.isArray(cached.models)) {
430
- catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
431
- }
432
- } catch {
433
- // absent or unreadable cache is normal on first run
434
- }
435
-
436
- async function refresh(signal) {
437
- const cfg = current ?? config
438
- if (cfg.useOpenRouter === false) return
439
- const ageMs = Date.now() - catalog.fetchedAt
440
- if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
441
- try {
442
- const response = await fetch(CATALOG_URL, { signal, headers: { accept: 'application/json' } })
443
- if (!response.ok) throw new Error('HTTP ' + response.status)
444
- const body = await response.json()
445
- const models = Array.isArray(body && body.data) ? body.data : []
446
- if (models.length === 0) throw new Error('empty catalog')
447
- // Keep only what pricing needs — the raw payload is mostly prose.
448
- const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
449
- catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
450
- try {
451
- mkdirSync(dirname(CACHE_FILE), { recursive: true })
452
- writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
453
- } catch {
454
- // a read-only storages dir only costs us the restart-time cache
455
- }
456
- } catch (error) {
457
- catalog = { ...catalog, error: String((error && error.message) || error) }
458
- }
459
- }
460
-
461
- // Live config from the GUI, with the composition entry as the base layer.
462
- ctx.inject(['settings'], (sctx) => {
463
- const scope = sctx.settings.register(NS, Config, { base: config })
464
- current = scope.get() ?? config
465
- const stop = scope.watch(() => {
466
- current = scope.get() ?? config
467
- void refresh()
468
- })
469
- sctx.effect(() => () => {
470
- stop()
471
- current = config
472
- })
473
- })
474
-
475
- // Optional capability: headless assemblies without the registry keep working,
476
- // the widget just falls back to the session-wide tokenUsage projection.
477
- ctx.inject(['sessionProjections'], (pctx) => {
478
- pctx.sessionProjections.register(usageByModelProjection)
479
- })
480
-
481
- ctx.effect(() => {
482
- const controller = new AbortController()
483
- void refresh(controller.signal)
484
- const timer = setInterval(() => void refresh(controller.signal), 3600e3)
485
- return () => {
486
- clearInterval(timer)
487
- controller.abort()
488
- }
489
- }, 'dsh-cost-meter: catalog refresh')
490
-
491
- ctx.effect(
492
- () =>
493
- ctx.webServer.register({
494
- kind: 'exact',
495
- path: STATE_PATH,
496
- handler: (req, res) => {
497
- if (req.method !== 'GET') {
498
- res.writeHead(405, { 'Content-Type': 'application/json' })
499
- res.end(JSON.stringify({ error: 'method not allowed' }))
500
- return
501
- }
502
- const cfg = current ?? config
503
- const route = defaultRoute(ctx) ?? null
504
- const own = resolveTariff(cfg, catalog.index, route)
505
-
506
- // The client asks for every model the session actually used, so a
507
- // session that switched models can price each part on its own tariff.
508
- const schedules = {}
509
- const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
394
+ const base = String(route.model).toLowerCase().split(':')[0]
395
+ const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
396
+ if (peak) {
397
+ const full = {
398
+ cacheHit: Number(peak.cacheHit) || 0,
399
+ input: Number(peak.input) || 0,
400
+ cacheWrite: Number(peak.cacheWrite) || 0,
401
+ output: Number(peak.output) || 0,
402
+ }
403
+ return {
404
+ schedule: {
405
+ base: {
406
+ cacheHit: full.cacheHit / 2,
407
+ input: full.input / 2,
408
+ cacheWrite: full.cacheWrite / 2,
409
+ output: full.output / 2,
410
+ },
411
+ windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
412
+ },
413
+ source: 'deepseek',
414
+ matchedId: base,
415
+ }
416
+ }
417
+
418
+ if (cfg.useOpenRouter !== false) {
419
+ const matchedId = matchModel(index, route, cfg.modelMap)
420
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
421
+ }
422
+ return { schedule: null, source: 'none', matchedId: null }
423
+ }
424
+
425
+ /** "provider/model" -> {provider, model}; the model half may contain slashes. */
426
+ export function splitRoute(key) {
427
+ const str = String(key || '').trim()
428
+ if (!str) return undefined
429
+ const slash = str.indexOf('/')
430
+ if (slash < 0) return { provider: '', model: str }
431
+ return { provider: str.slice(0, slash), model: str.slice(slash + 1) }
432
+ }
433
+
434
+ export function apply(ctx, config) {
435
+ let current = config
436
+ let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
437
+
438
+ // Disk cache survives restarts, so a boot without network still prices.
439
+ try {
440
+ const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
441
+ if (cached && Array.isArray(cached.models)) {
442
+ catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
443
+ }
444
+ } catch {
445
+ // absent or unreadable cache is normal on first run
446
+ }
447
+
448
+ async function refresh(signal) {
449
+ const cfg = current ?? config
450
+ if (cfg.useOpenRouter === false) return
451
+ const ageMs = Date.now() - catalog.fetchedAt
452
+ if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
453
+ try {
454
+ const timeoutSignal = AbortSignal.timeout(15000)
455
+ const combinedSignal = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal) : timeoutSignal
456
+ const response = await fetch(CATALOG_URL, { signal: combinedSignal, headers: { accept: 'application/json' } })
457
+ if (!response.ok) throw new Error('HTTP ' + response.status)
458
+ const body = await response.json()
459
+ const models = Array.isArray(body && body.data) ? body.data : []
460
+ if (models.length === 0) throw new Error('empty catalog')
461
+ // Keep only what pricing needs — the raw payload is mostly prose.
462
+ const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
463
+ catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
464
+ try {
465
+ mkdirSync(dirname(CACHE_FILE), { recursive: true })
466
+ writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
467
+ } catch {
468
+ // a read-only storages dir only costs us the restart-time cache
469
+ }
470
+ } catch (error) {
471
+ catalog = { ...catalog, error: String((error && error.message) || error) }
472
+ }
473
+ }
474
+
475
+ // Live config from the GUI, with the composition entry as the base layer.
476
+ ctx.inject(['settings'], (sctx) => {
477
+ const scope = sctx.settings.register(NS, Config, { base: config })
478
+ current = scope.get() ?? config
479
+ const stop = scope.watch(() => {
480
+ current = scope.get() ?? config
481
+ void refresh()
482
+ })
483
+ sctx.effect(() => () => {
484
+ stop()
485
+ current = config
486
+ })
487
+ })
488
+
489
+ // Optional capability: headless assemblies without the registry keep working,
490
+ // the widget just falls back to the session-wide tokenUsage projection.
491
+ ctx.inject(['sessionProjections'], (pctx) => {
492
+ pctx.sessionProjections.register(usageByModelProjection)
493
+ })
494
+
495
+ ctx.effect(() => {
496
+ const controller = new AbortController()
497
+ void refresh(controller.signal)
498
+ const timer = setInterval(() => void refresh(controller.signal), 3600e3)
499
+ return () => {
500
+ clearInterval(timer)
501
+ controller.abort()
502
+ }
503
+ }, 'dsh-cost-meter: catalog refresh')
504
+
505
+ ctx.effect(
506
+ () =>
507
+ ctx.webServer.register({
508
+ kind: 'exact',
509
+ path: STATE_PATH,
510
+ handler: (req, res) => {
511
+ if (req.method !== 'GET') {
512
+ res.writeHead(405, { 'Content-Type': 'application/json' })
513
+ res.end(JSON.stringify({ error: 'method not allowed' }))
514
+ return
515
+ }
516
+ const cfg = current ?? config
517
+ const route = defaultRoute(ctx) ?? null
518
+ const own = resolveTariff(cfg, catalog.index, route)
519
+
520
+ // The client asks for every model the session actually used, so a
521
+ // session that switched models can price each part on its own tariff.
522
+ const schedules = {}
523
+ const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
510
524
  for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
511
525
  const trimmed = String(key || '').trim()
512
526
  if (!trimmed || trimmed.length > 128) continue
@@ -519,22 +533,23 @@ export function apply(ctx, config) {
519
533
  matchedId: resolved.matchedId,
520
534
  }
521
535
  }
522
-
523
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
524
- res.end(JSON.stringify({
525
- currency: cfg.currency,
526
- usdRate: cfg.usdRate,
527
- displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
528
- route,
529
- source: own.source,
530
- matchedId: own.matchedId,
531
- schedules,
532
- fetchedAt: catalog.fetchedAt || null,
533
- catalogError: catalog.error,
534
- schedule: own.schedule,
535
- }))
536
- },
537
- }),
538
- 'dsh-cost-meter: state route',
539
- )
540
- }
536
+
537
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
538
+ res.end(JSON.stringify({
539
+ currency: cfg.currency,
540
+ usdRate: cfg.usdRate,
541
+ displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
542
+ route,
543
+ source: own.source,
544
+ matchedId: own.matchedId,
545
+ schedules,
546
+ fetchedAt: catalog.fetchedAt || null,
547
+ catalogError: catalog.error,
548
+ catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
549
+ schedule: own.schedule,
550
+ }))
551
+ },
552
+ }),
553
+ 'dsh-cost-meter: state route',
554
+ )
555
+ }