@goodandready/dsh-cost-meter 0.7.9 → 0.8.1

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,540 +1,630 @@
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 { createHash } from 'node:crypto'
17
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
18
  import { homedir } from 'node:os'
18
19
  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'
20
+ import z from '@deepseek-ai/schemastery'
21
+ import { z as zod } from 'zod'
22
+
23
+ export const name = 'dsh-cost-meter'
24
+ export const inject = ['webServer']
25
+
26
+ const NS = 'dsh-cost-meter'
27
+ const STATE_PATH = '/dsh-cost-meter/state'
28
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
29
+ const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
28
30
  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. */
31
+
32
+ /** One route's rates, in USD per 1M tokens. */
33
+ const PriceRow = z.object({
34
+ cacheHit: z.number().description('Input tokens served from the provider cache.').default(0),
35
+ input: z.number().description('Input tokens that missed the cache.').default(0),
36
+ cacheWrite: z.number().description('Input tokens written into the cache.').default(0),
37
+ output: z.number().description('Output tokens (reasoning included).').default(0),
38
+ })
39
+
40
+ /**
41
+ * DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
42
+ * Their API publishes no prices (/models lists ids, there is no pricing route),
43
+ * and OpenRouter's `deepseek/*` entries quote different numbers, so this table
44
+ * is the authoritative source for the official route. Values are PEAK, USD per
45
+ * 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
46
+ */
47
+ const DEEPSEEK_PEAK_RATES = {
48
+ 'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
49
+ 'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
50
+ }
51
+ const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
52
+
53
+ /** Common provider / vendor aliases for seamless catalog matching. */
54
+ export const VENDOR_ALIASES = {
55
+ 'z-ai': 'zai',
56
+ 'zai': 'zai',
57
+ 'google': 'google',
58
+ 'gemini': 'google',
59
+ 'deepseek-ai': 'deepseek',
60
+ 'deepseek-official': 'deepseek',
61
+ 'deepseek': 'deepseek',
62
+ 'anthropic-ai': 'anthropic',
63
+ 'anthropic': 'anthropic',
64
+ 'openai': 'openai',
65
+ 'meta-llama': 'meta-llama',
66
+ 'meta': 'meta-llama',
67
+ }
68
+
69
+ export const Config = z.object({
70
+ currency: z.string().description('Currency symbol shown in the widget.').default('$'),
71
+ usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
72
+ displayTimeZone: z
73
+ .string()
74
+ .description('IANA zone the widget shows tariff hours in.')
75
+ .default('Europe/Moscow'),
76
+ budgetThreshold: z
77
+ .number()
78
+ .description('Optional session budget threshold in USD. 0 disables threshold warnings.')
79
+ .default(0),
80
+ deepseekPeakPrices: z
81
+ .dict(PriceRow)
82
+ .description('Override the built-in DeepSeek peak rates, by model id.')
83
+ .default({}),
84
+ useOpenRouter: z
85
+ .boolean()
86
+ .description('Pull rates and discount windows from the public OpenRouter catalog.')
87
+ .default(true),
88
+ refreshHours: z.number().description('How often to refetch the catalog.').default(24),
89
+ modelMap: z
90
+ .dict(z.string())
91
+ .description('Route override: "provider/model" -> OpenRouter model id.')
92
+ .default({}),
93
+ prices: z
94
+ .dict(PriceRow)
95
+ .description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
96
+ .default({}),
97
+ manualPeakWindowsUtc: z
98
+ .array(z.string())
99
+ .description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
100
+ .default([]),
101
+ manualOffPeakMultiplier: z
102
+ .number()
103
+ .description('Multiplier outside the manual peak windows.')
104
+ .default(1),
105
+ })
106
+
107
+ /** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
108
+ function perMillion(value) {
109
+ const n = Number(value)
110
+ return Number.isFinite(n) ? n * 1e6 : 0
111
+ }
112
+
113
+ /** Pull the four buckets out of one OpenRouter pricing object. */
114
+ function bucketsOf(pricing) {
115
+ return {
116
+ cacheHit: perMillion(pricing.input_cache_read),
117
+ input: perMillion(pricing.prompt),
118
+ cacheWrite: perMillion(pricing.input_cache_write),
119
+ output: perMillion(pricing.completion),
120
+ }
121
+ }
122
+
123
+ /** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
124
+ export function hhmmToMinutes(value) {
125
+ const n = Number(value)
126
+ if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
127
+ return Math.floor(n / 100) * 60 + (n % 100)
128
+ }
129
+
130
+ /** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
131
+ export function parseWindow(spec) {
132
+ const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
133
+ if (!m) return undefined
134
+ return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
135
+ }
136
+
137
+ /**
138
+ * Normalize one catalog entry into the schedule the browser consumes:
139
+ * a base rate card plus the discount windows that shadow it.
140
+ */
141
+ function scheduleOf(entry) {
142
+ const pricing = entry.pricing || {}
143
+ const base = bucketsOf(pricing)
144
+ const windows = []
145
+ for (const override of pricing.overrides || []) {
146
+ const start = hhmmToMinutes(override.utc_start)
147
+ const end = hhmmToMinutes(override.utc_end)
148
+ if (start === undefined || end === undefined) continue
149
+ // An override omits buckets it does not change; fall back to the base.
150
+ const rates = bucketsOf(override)
151
+ windows.push({
152
+ start,
153
+ end,
154
+ rates: {
155
+ cacheHit: rates.cacheHit || base.cacheHit,
156
+ input: rates.input || base.input,
157
+ cacheWrite: rates.cacheWrite || base.cacheWrite,
158
+ output: rates.output || base.output,
159
+ },
160
+ })
161
+ }
162
+ return { base, windows }
163
+ }
164
+
165
+ /**
166
+ * Map a harness route onto an OpenRouter id. Exact id first, then normalized
167
+ * vendor alias + model, then vendor suffix.
168
+ */
169
+ export function matchModel(index, route, modelMap) {
170
+ if (!route || !index) return undefined
171
+ const key = route.provider ? route.provider + '/' + route.model : route.model
172
+ const mapped = modelMap && (modelMap[key] || modelMap[route.model])
173
+ if (mapped && index.byId && index.byId[mapped]) return mapped
174
+ if (index.byId && index.byId[key]) return key
175
+ if (index.byId && index.byId[route.model]) return route.model
176
+
177
+ // Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
178
+ const prov = String(route.provider || '').toLowerCase()
179
+ const aliasProv = VENDOR_ALIASES[prov]
180
+ if (aliasProv && route.model) {
181
+ const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
182
+ if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
183
+ }
184
+
185
+ // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
186
+ // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
187
+ const lower = String(route.model).toLowerCase()
188
+ const byLowerId = index.byLowerId || {}
189
+ if (byLowerId[lower]) return byLowerId[lower]
190
+ if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
191
+ const bySuffix = index.bySuffix || {}
192
+ const list = bySuffix[lower]
193
+ return list && list.length ? list[0] : undefined
194
+ }
195
+
196
+ /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
170
197
  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
- */
198
+ const byId = {}
199
+ const byLowerId = {}
200
+ const bySuffix = {}
201
+ for (const entry of models) {
202
+ if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
203
+ // Variants like ":batch" or ":free" are different products; skip them so a
204
+ // suffix match cannot silently price against a cheaper batch tier.
205
+ if (entry.id.includes(':')) continue
206
+ byId[entry.id] = scheduleOf(entry)
207
+ byLowerId[entry.id.toLowerCase()] = entry.id
208
+ const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
209
+ ;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
210
+ }
211
+ return { byId, byLowerId, bySuffix }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Per-model usage projection
216
+ // ---------------------------------------------------------------------------
217
+
218
+ const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
219
+
220
+ const bucketSchema = zod.object({
221
+ uncachedInputTokens: zod.number().nonnegative(),
222
+ outputTokens: zod.number().nonnegative(),
223
+ cacheReadTokens: zod.number().nonnegative(),
224
+ cacheWriteTokens: zod.number().nonnegative(),
225
+ }).strict()
226
+
227
+ /** Pull the four buckets out of one provider usage record. */
228
+ function bucketsFromUsage(usage) {
229
+ return {
230
+ uncachedInputTokens: Number(usage.inputTokens) || 0,
231
+ outputTokens: Number(usage.outputTokens) || 0,
232
+ cacheReadTokens: Number(usage.cacheReadTokens) || 0,
233
+ cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
234
+ }
235
+ }
236
+
237
+ function addBuckets(base, remove, add) {
238
+ const out = {}
239
+ for (const k of Object.keys(ZERO)) {
240
+ out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
241
+ }
242
+ return out
243
+ }
244
+
245
+ function sameBuckets(a, b) {
246
+ return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
247
+ }
248
+
249
+ /** The usage a streamed chunk or a finalized assistant message reports. */
250
+ function usageOf(event) {
251
+ if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
252
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
253
+ }
254
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) {
255
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
256
+ }
257
+ return undefined
258
+ }
259
+
260
+ /**
261
+ * Half-hour slot of the UTC day an event falls into (0..47).
262
+ */
263
+ export function slotOf(timeMs) {
264
+ let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
265
+ if (Number.isNaN(ms)) {
266
+ if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
267
+ ms = timeMs.getTime()
268
+ } else {
269
+ ms = Date.now()
270
+ }
271
+ }
272
+ const d = new Date(ms)
273
+ return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
274
+ }
275
+
276
+ const usageByModelProjection = {
277
+ key: 'costByModel',
278
+ schema: zod.object({
279
+ models: zod.array(zod.object({
280
+ provider: zod.string(),
281
+ model: zod.string(),
282
+ slots: zod.record(zod.string(), bucketSchema),
283
+ })),
284
+ }).strict(),
285
+ init: () => ({ route: null, byKey: {}, last: null }),
286
+ apply: (state, event) => {
287
+ if (event.type === 'request/header') {
288
+ const config = event.data && event.data.header && event.data.header.config
289
+ const provider = config && typeof config.provider === 'string' ? config.provider : undefined
290
+ const model = config && typeof config.model === 'string' ? config.model : undefined
291
+ if (provider === undefined || model === undefined) return state
292
+ if (state.route && state.route.provider === provider && state.route.model === model) return state
293
+ return { ...state, route: { provider, model } }
294
+ }
295
+ const sample = usageOf(event)
296
+ if (sample === undefined) return state
297
+ const route = state.route || { provider: '', model: 'unknown' }
298
+ const key = route.provider + '/' + route.model
299
+ const slot = String(slotOf(event.time))
300
+ const buckets = bucketsFromUsage(sample.usage)
301
+ const previous =
302
+ state.last !== null &&
303
+ state.last.turn === sample.turn &&
304
+ state.last.step === sample.step &&
305
+ state.last.key === key &&
306
+ state.last.slot === slot
307
+ ? state.last.buckets
308
+ : undefined
309
+ if (previous !== undefined && sameBuckets(previous, buckets)) return state
310
+ const slots = state.byKey[key] || {}
311
+ return {
312
+ route: state.route,
313
+ byKey: {
314
+ ...state.byKey,
315
+ [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
316
+ },
317
+ last: { turn: sample.turn, step: sample.step, key, slot, buckets },
318
+ }
319
+ },
320
+ view: (state) => ({
321
+ models: Object.keys(state.byKey).map((key) => {
322
+ const slash = key.indexOf('/')
323
+ return {
324
+ provider: key.slice(0, slash),
325
+ model: key.slice(slash + 1),
326
+ slots: state.byKey[key],
327
+ }
328
+ }),
329
+ }),
330
+ stateVersion: 2,
331
+ }
332
+
333
+ /** The agent's default route, read off settings descriptors when available. */
334
+ function defaultRoute(ctx) {
335
+ const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
336
+ if (settings === undefined) return undefined
337
+ try {
338
+ const found = settings
339
+ .describe({ redactSecrets: true })
340
+ .find((descriptor) => descriptor.ns === 'agent-default-model')
341
+ const value = found && found.value
342
+ if (!value || typeof value.provider !== 'string' || typeof value.model !== 'string') return undefined
343
+ return { provider: value.provider, model: value.model }
344
+ } catch {
345
+ return undefined
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
351
+ * then the OpenRouter catalog.
352
+ */
340
353
  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.
376
- const providerLower = String(route.provider || "").toLowerCase();
377
- // #3: Provider-aware routing: if routed explicitly through OpenRouter,
378
- // consult OpenRouter catalog first to honor actual provider billing.
379
- if (providerLower === "openrouter" && cfg.useOpenRouter !== false) {
380
- const matchedId = matchModel(index, route, cfg.modelMap);
381
- if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
354
+ if (!route) return { schedule: null, source: 'none', matchedId: null }
355
+ const key = route.provider ? route.provider + '/' + route.model : route.model
356
+ const prices = cfg.prices || {}
357
+ const manual = prices[key] || prices[route.model] || prices['*']
358
+
359
+ if (manual) {
360
+ const base = {
361
+ cacheHit: Number(manual.cacheHit) || 0,
362
+ input: Number(manual.input) || 0,
363
+ cacheWrite: Number(manual.cacheWrite) || 0,
364
+ output: Number(manual.output) || 0,
365
+ }
366
+ const factor = Number(cfg.manualOffPeakMultiplier) || 1
367
+ const windows = []
368
+ for (const spec of cfg.manualPeakWindowsUtc || []) {
369
+ const parsed = parseWindow(spec)
370
+ if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
371
+ }
372
+ const schedule = windows.length
373
+ ? {
374
+ base: {
375
+ cacheHit: base.cacheHit * factor,
376
+ input: base.input * factor,
377
+ cacheWrite: base.cacheWrite * factor,
378
+ output: base.output * factor,
379
+ },
380
+ windows,
381
+ }
382
+ : { base, windows: [] }
383
+ return { schedule, source: 'manual', matchedId: key }
384
+ }
385
+
386
+ const providerLower = String(route.provider || '').toLowerCase()
387
+ if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
388
+ const matchedId = matchModel(index, route, cfg.modelMap)
389
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
390
+ }
391
+
392
+ const base = String(route.model).toLowerCase().split(':')[0]
393
+ const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
394
+ if (peak) {
395
+ const full = {
396
+ cacheHit: Number(peak.cacheHit) || 0,
397
+ input: Number(peak.input) || 0,
398
+ cacheWrite: Number(peak.cacheWrite) || 0,
399
+ output: Number(peak.output) || 0,
400
+ }
401
+ return {
402
+ schedule: {
403
+ base: {
404
+ cacheHit: full.cacheHit / 2,
405
+ input: full.input / 2,
406
+ cacheWrite: full.cacheWrite / 2,
407
+ output: full.output / 2,
408
+ },
409
+ windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
410
+ },
411
+ source: 'deepseek',
412
+ matchedId: base,
413
+ }
414
+ }
415
+
416
+ if (cfg.useOpenRouter !== false) {
417
+ const matchedId = matchModel(index, route, cfg.modelMap)
418
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
419
+ }
420
+ return { schedule: null, source: 'none', matchedId: null }
421
+ }
422
+
423
+ /** "provider/model" -> {provider, model}; the model half may contain slashes. */
424
+ export function splitRoute(key) {
425
+ const str = String(key || '').trim()
426
+ if (!str) return undefined
427
+ const slash = str.indexOf('/')
428
+ if (slash < 0) return { provider: '', model: str }
429
+ return { provider: str.slice(0, slash), model: str.slice(slash + 1) }
430
+ }
431
+
432
+ export function apply(ctx, config) {
433
+ let current = config
434
+ let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
435
+ let lastManualRefreshAt = 0
436
+
437
+ // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
438
+ const resolveCache = new Map()
439
+
440
+ function getCachedTariff(cfg, route) {
441
+ if (!route) return resolveTariff(cfg, catalog.index, route)
442
+ const cacheKey = (route.provider || '') + '/' + (route.model || '')
443
+ if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
444
+ const res = resolveTariff(cfg, catalog.index, route)
445
+ // Limit cache size to prevent runaway memory
446
+ if (resolveCache.size >= 500) resolveCache.clear()
447
+ resolveCache.set(cacheKey, res)
448
+ return res
449
+ }
450
+
451
+ function clearResolveCache() {
452
+ resolveCache.clear()
453
+ }
454
+
455
+ // Disk cache survives restarts, so a boot without network still prices.
456
+ try {
457
+ const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
458
+ if (cached && Array.isArray(cached.models)) {
459
+ catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
460
+ }
461
+ } catch {
462
+ // absent or unreadable cache is normal on first run
463
+ }
464
+
465
+ async function refresh(signal, force = false) {
466
+ const cfg = current ?? config
467
+ if (cfg.useOpenRouter === false) return
468
+ const ageMs = Date.now() - catalog.fetchedAt
469
+ if (!force && catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
470
+ try {
471
+ const timeoutSignal = AbortSignal.timeout(15000)
472
+ const combinedSignal = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal) : timeoutSignal
473
+ const response = await fetch(CATALOG_URL, { signal: combinedSignal, headers: { accept: 'application/json' } })
474
+ if (!response.ok) throw new Error('HTTP ' + response.status)
475
+ const body = await response.json()
476
+ const models = Array.isArray(body && body.data) ? body.data : []
477
+ if (models.length === 0) throw new Error('empty catalog')
478
+ const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
479
+ catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
480
+ clearResolveCache()
481
+ try {
482
+ mkdirSync(dirname(CACHE_FILE), { recursive: true })
483
+ writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
484
+ } catch {
485
+ // a read-only storages dir only costs us the restart-time cache
486
+ }
487
+ } catch (error) {
488
+ catalog = { ...catalog, error: String((error && error.message) || error) }
489
+ }
382
490
  }
383
491
 
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')
492
+ // Live config from the GUI, with the composition entry as the base layer.
493
+ ctx.inject(['settings'], (sctx) => {
494
+ const scope = sctx.settings.register(NS, Config, { base: config })
495
+ current = scope.get() ?? config
496
+ const stop = scope.watch(() => {
497
+ current = scope.get() ?? config
498
+ clearResolveCache()
499
+ void refresh()
500
+ })
501
+ sctx.effect(() => () => {
502
+ stop()
503
+ current = config
504
+ clearResolveCache()
505
+ })
506
+ })
507
+
508
+ // Optional capability: headless assemblies without the registry keep working
509
+ ctx.inject(['sessionProjections'], (pctx) => {
510
+ pctx.sessionProjections.register(usageByModelProjection)
511
+ })
512
+
513
+ ctx.effect(() => {
514
+ const controller = new AbortController()
515
+ void refresh(controller.signal)
516
+ const timer = setInterval(() => void refresh(controller.signal), 3600e3)
517
+ return () => {
518
+ clearInterval(timer)
519
+ controller.abort()
520
+ }
521
+ }, 'dsh-cost-meter: catalog refresh')
522
+
523
+ // Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
524
+ ctx.effect(
525
+ () =>
526
+ ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: REFRESH_PATH,
529
+ handler: async (req, res) => {
530
+ if (req.method !== 'POST') {
531
+ res.writeHead(405, { 'Content-Type': 'application/json' })
532
+ res.end(JSON.stringify({ error: 'method not allowed' }))
533
+ return
534
+ }
535
+ const now = Date.now()
536
+ const COOLDOWN_MS = 30000
537
+ if (now - lastManualRefreshAt < COOLDOWN_MS) {
538
+ const retryAfter = Math.ceil((COOLDOWN_MS - (now - lastManualRefreshAt)) / 1000)
539
+ res.writeHead(429, {
540
+ 'Content-Type': 'application/json',
541
+ 'Retry-After': String(retryAfter),
542
+ })
543
+ res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
544
+ return
545
+ }
546
+ lastManualRefreshAt = now
547
+ const controller = new AbortController()
548
+ await refresh(controller.signal, true)
549
+ const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
550
+ res.writeHead(200, { 'Content-Type': 'application/json' })
551
+ res.end(JSON.stringify({
552
+ ok: !catalog.error,
553
+ fetchedAt: catalog.fetchedAt,
554
+ modelsCount,
555
+ error: catalog.error,
556
+ }))
557
+ },
558
+ }),
559
+ 'dsh-cost-meter: refresh route',
560
+ )
561
+
562
+ // Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
563
+ ctx.effect(
564
+ () =>
565
+ ctx.webServer.register({
566
+ kind: 'exact',
567
+ path: STATE_PATH,
568
+ handler: (req, res) => {
569
+ if (req.method !== 'GET') {
570
+ res.writeHead(405, { 'Content-Type': 'application/json' })
571
+ res.end(JSON.stringify({ error: 'method not allowed' }))
572
+ return
573
+ }
574
+ const cfg = current ?? config
575
+ const route = defaultRoute(ctx) ?? null
576
+ const own = getCachedTariff(cfg, route)
577
+
578
+ const schedules = {}
579
+ const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
510
580
  for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
511
581
  const trimmed = String(key || '').trim()
512
582
  if (!trimmed || trimmed.length > 128) continue
513
583
  const parsed = splitRoute(trimmed)
514
584
  if (!parsed) continue
515
- const resolved = resolveTariff(cfg, catalog.index, parsed)
585
+ const resolved = getCachedTariff(cfg, parsed)
516
586
  schedules[trimmed] = {
517
587
  schedule: resolved.schedule,
518
588
  source: resolved.source,
519
589
  matchedId: resolved.matchedId,
520
590
  }
521
591
  }
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
- }
592
+
593
+ const payloadObj = {
594
+ currency: cfg.currency,
595
+ usdRate: cfg.usdRate,
596
+ displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
597
+ budgetThreshold: Number(cfg.budgetThreshold) || 0,
598
+ route,
599
+ source: own.source,
600
+ matchedId: own.matchedId,
601
+ schedules,
602
+ fetchedAt: catalog.fetchedAt || null,
603
+ catalogError: catalog.error,
604
+ catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
605
+ schedule: own.schedule,
606
+ }
607
+
608
+ const serialized = JSON.stringify(payloadObj)
609
+ const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
610
+
611
+ if (req.headers['if-none-match'] === etag) {
612
+ res.writeHead(304, {
613
+ 'ETag': etag,
614
+ 'Cache-Control': 'no-cache',
615
+ })
616
+ res.end()
617
+ return
618
+ }
619
+
620
+ res.writeHead(200, {
621
+ 'Content-Type': 'application/json',
622
+ 'Cache-Control': 'no-cache',
623
+ 'ETag': etag,
624
+ })
625
+ res.end(serialized)
626
+ },
627
+ }),
628
+ 'dsh-cost-meter: state route',
629
+ )
630
+ }