@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/README.md +7 -0
- package/docs/README.ru.md +6 -0
- package/docs/design/DESIGN.md +15 -2
- package/docs/plans/findings.md +14 -19
- package/docs/plans/progress.md +11 -16
- package/docs/plans/task_plan.md +29 -30
- package/lib/client.js +693 -349
- package/lib/index.js +612 -522
- package/package.json +1 -1
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 {
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
route,
|
|
529
|
-
source: own.source,
|
|
530
|
-
matchedId: own.matchedId,
|
|
531
|
-
schedules,
|
|
532
|
-
fetchedAt: catalog.fetchedAt || null,
|
|
533
|
-
catalogError: catalog.error,
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
}
|