@goodandready/dsh-cost-meter 0.7.9 → 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/docs/design/DESIGN.md +6 -2
- package/docs/plans/findings.md +11 -20
- package/docs/plans/progress.md +24 -16
- package/docs/plans/task_plan.md +23 -30
- package/lib/client.js +480 -338
- package/lib/index.js +530 -515
- package/package.json +1 -1
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
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
ctx.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
}
|