dsh-usage-badge 0.1.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/LICENSE +21 -0
- package/README.md +89 -0
- package/assets/usage-dialog.png +0 -0
- package/cordis.patch.yml +7 -0
- package/docs/design-notes.md +293 -0
- package/lib/client.js +1007 -0
- package/lib/fold.js +512 -0
- package/lib/holidays-cn.json +41 -0
- package/lib/holidays.js +141 -0
- package/lib/index.js +543 -0
- package/lib/official-pricing.js +316 -0
- package/lib/pricing.js +485 -0
- package/package.json +63 -0
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — pricing model and cost math.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the DeepSeek-Harness desktop shell's `usage-sidecar.mjs` so that an
|
|
5
|
+
* existing `$DSH_HOME/storages/usage-pricing.json` keeps behaving identically:
|
|
6
|
+
* the same four override-key shapes (model / provider|model / provider|* / *|model),
|
|
7
|
+
* the same case-insensitive key matching, and the same per-request application of
|
|
8
|
+
* the model multiplier, the context-length tier and the peak/valley rule.
|
|
9
|
+
*
|
|
10
|
+
* Naming note: every cost function here returns an amount in the *total currency*
|
|
11
|
+
* (`totalCurrency`, CNY by default), not necessarily USD — the sidecar inherited
|
|
12
|
+
* the misleading `usageCostUsd` name, which this port drops.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-usage-badge/pricing
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The price fields resolved per (provider, model). */
|
|
18
|
+
export const PRICE_KEYS = ['inputPerMillion', 'cacheReadPerMillion', 'cacheWritePerMillion', 'outputPerMillion']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Written to `usage-pricing.json` on first run (only when the file is missing) so
|
|
22
|
+
* a fresh install starts from a working table instead of zero prices. Mirrors the
|
|
23
|
+
* shell's template.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_TEMPLATE = {
|
|
26
|
+
exchangeRate: 6.74,
|
|
27
|
+
default: {
|
|
28
|
+
inputPerMillion: 1.5,
|
|
29
|
+
cacheReadPerMillion: 0.05,
|
|
30
|
+
cacheWritePerMillion: 0,
|
|
31
|
+
outputPerMillion: 4.5,
|
|
32
|
+
currency: 'cny',
|
|
33
|
+
},
|
|
34
|
+
totalCurrency: 'cny',
|
|
35
|
+
multiplier: 1,
|
|
36
|
+
overrides: {
|
|
37
|
+
'deepseek-v4-flash': {
|
|
38
|
+
inputPerMillion: 1.5,
|
|
39
|
+
cacheReadPerMillion: 0.05,
|
|
40
|
+
cacheWritePerMillion: 0,
|
|
41
|
+
outputPerMillion: 4.5,
|
|
42
|
+
currency: 'cny',
|
|
43
|
+
multiplier: 1,
|
|
44
|
+
timeOfUse: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
peakMultiplier: 2,
|
|
47
|
+
valleyMultiplier: 1,
|
|
48
|
+
peakRanges: [[9, 12], [14, 18]],
|
|
49
|
+
days: 'weekday',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
'deepseek-v4-pro': {
|
|
53
|
+
inputPerMillion: 4.5,
|
|
54
|
+
cacheReadPerMillion: 0.15,
|
|
55
|
+
cacheWritePerMillion: 0,
|
|
56
|
+
outputPerMillion: 13.5,
|
|
57
|
+
currency: 'cny',
|
|
58
|
+
multiplier: 1,
|
|
59
|
+
timeOfUse: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
peakMultiplier: 2,
|
|
62
|
+
valleyMultiplier: 1,
|
|
63
|
+
peakRanges: [[9, 12], [14, 18]],
|
|
64
|
+
days: 'weekday',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a parsed pricing document, applying the template only as a *default
|
|
72
|
+
* row* fallback. Override rows are the user's explicit set and are never merged
|
|
73
|
+
* back from the template — otherwise a model the user deleted would silently
|
|
74
|
+
* reappear.
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} raw - parsed JSON, or anything when the file is unreadable.
|
|
77
|
+
* @returns {object} a pricing document safe for the cost functions.
|
|
78
|
+
*/
|
|
79
|
+
export function normalizePricing(raw) {
|
|
80
|
+
const user = raw && typeof raw === 'object' ? raw : {}
|
|
81
|
+
return {
|
|
82
|
+
exchangeRate: Number(user.exchangeRate) || DEFAULT_TEMPLATE.exchangeRate,
|
|
83
|
+
default: { ...DEFAULT_TEMPLATE.default, ...(user.default ?? {}) },
|
|
84
|
+
overrides: user.overrides && typeof user.overrides === 'object' ? user.overrides : {},
|
|
85
|
+
timeOfUse: user.timeOfUse,
|
|
86
|
+
multiplier: user.multiplier ?? DEFAULT_TEMPLATE.multiplier,
|
|
87
|
+
contextMultiplier: user.contextMultiplier,
|
|
88
|
+
totalCurrency: user.totalCurrency ?? DEFAULT_TEMPLATE.totalCurrency,
|
|
89
|
+
// The legal-holiday calendar the peak/valley rule consults; a separate field
|
|
90
|
+
// from `timeOfUse` because it says which days peak windows may apply to at all.
|
|
91
|
+
holidays: user.holidays,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Match keys for one request, in resolution priority order: a pure model name
|
|
97
|
+
* (no provider) wins, then the exact `provider|model`, then the `provider|*` and
|
|
98
|
+
* `*|model` wildcards. This lets a single row like `"deepseek-v4-flash"` apply to
|
|
99
|
+
* every provider.
|
|
100
|
+
*
|
|
101
|
+
* @param {string|null} provider - provider id as recorded in the session log.
|
|
102
|
+
* @param {string|null} model - model id as recorded in the session log.
|
|
103
|
+
* @returns {string[]} candidate override keys, highest priority first.
|
|
104
|
+
*/
|
|
105
|
+
export function matchKeys(provider, model) {
|
|
106
|
+
const keys = []
|
|
107
|
+
if (model) keys.push(model)
|
|
108
|
+
if (provider && model) keys.push(`${provider}|${model}`)
|
|
109
|
+
if (provider) keys.push(`${provider}|*`)
|
|
110
|
+
if (model) keys.push(`*|${model}`)
|
|
111
|
+
return keys
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hasOwnKey = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lowercase → authored-key index, memoized per pricing object.
|
|
118
|
+
*
|
|
119
|
+
* Override rows are matched case-insensitively because a model id reaches us
|
|
120
|
+
* spelled exactly as the session log records it, while the price row is typed by
|
|
121
|
+
* hand: a row named `deepSeek-flash` must still price the model `deepseek-flash`,
|
|
122
|
+
* and the same holds for both sides of `provider|model`. Precedence stays
|
|
123
|
+
* predictable: an exact key always wins, otherwise the first row whose lowercased
|
|
124
|
+
* key matches is used.
|
|
125
|
+
*/
|
|
126
|
+
const lowerOverrideKeys = new WeakMap()
|
|
127
|
+
|
|
128
|
+
function overrideKeysByLower(pricing) {
|
|
129
|
+
const rows = pricing.overrides
|
|
130
|
+
if (!rows) return new Map()
|
|
131
|
+
let index = lowerOverrideKeys.get(rows)
|
|
132
|
+
if (!index) {
|
|
133
|
+
index = new Map()
|
|
134
|
+
for (const key of Object.keys(rows)) {
|
|
135
|
+
const lower = key.toLowerCase()
|
|
136
|
+
if (!index.has(lower)) index.set(lower, key)
|
|
137
|
+
}
|
|
138
|
+
lowerOverrideKeys.set(rows, index)
|
|
139
|
+
}
|
|
140
|
+
return index
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The override row for one match key, or undefined when no row applies. */
|
|
144
|
+
function overrideRow(pricing, key) {
|
|
145
|
+
const rows = pricing.overrides
|
|
146
|
+
if (!rows) return undefined
|
|
147
|
+
if (hasOwnKey(rows, key)) return rows[key]
|
|
148
|
+
const actual = overrideKeysByLower(pricing).get(String(key).toLowerCase())
|
|
149
|
+
return actual === undefined ? undefined : rows[actual]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the four price fields for one route.
|
|
154
|
+
*
|
|
155
|
+
* Each field is resolved independently: the highest-priority row that *defines*
|
|
156
|
+
* that field wins, so a `provider|model` row may override just the output price
|
|
157
|
+
* while inheriting the rest from a broader row or the default row.
|
|
158
|
+
*/
|
|
159
|
+
export function resolvePrice(pricing, provider, model) {
|
|
160
|
+
const out = {}
|
|
161
|
+
for (const k of PRICE_KEYS) {
|
|
162
|
+
out[k] = Number(pricing.default?.[k]) || 0
|
|
163
|
+
for (const key of matchKeys(provider, model)) {
|
|
164
|
+
const row = overrideRow(pricing, key)
|
|
165
|
+
if (row && row[k] != null) {
|
|
166
|
+
out[k] = Number(row[k]) || 0
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Per-model peak/valley rule: the first matching row's own rule, else the global one. */
|
|
175
|
+
export function modelTimeOfUse(pricing, provider, model) {
|
|
176
|
+
for (const key of matchKeys(provider, model)) {
|
|
177
|
+
const row = overrideRow(pricing, key)
|
|
178
|
+
if (row && row.timeOfUse) return row.timeOfUse
|
|
179
|
+
}
|
|
180
|
+
return pricing.timeOfUse
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Per-model multiplier applied to the whole request cost (row wins, else global, default 1). */
|
|
184
|
+
export function modelMultiplier(pricing, provider, model) {
|
|
185
|
+
for (const key of matchKeys(provider, model)) {
|
|
186
|
+
const row = overrideRow(pricing, key)
|
|
187
|
+
if (row && row.multiplier != null) return Number(row.multiplier) || 1
|
|
188
|
+
}
|
|
189
|
+
return Number(pricing.multiplier) || 1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Per-model context-length tier rule (row wins, else the default-row rule). */
|
|
193
|
+
export function modelContextMultiplier(pricing, provider, model) {
|
|
194
|
+
for (const key of matchKeys(provider, model)) {
|
|
195
|
+
const row = overrideRow(pricing, key)
|
|
196
|
+
if (row && row.contextMultiplier != null) return row.contextMultiplier
|
|
197
|
+
}
|
|
198
|
+
return pricing.contextMultiplier
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The currency a route's prices are quoted in (`cny` unless the row says otherwise). */
|
|
202
|
+
export function resolveCurrency(pricing, provider, model) {
|
|
203
|
+
for (const key of matchKeys(provider, model)) {
|
|
204
|
+
const row = overrideRow(pricing, key)
|
|
205
|
+
if (row && row.currency) return row.currency
|
|
206
|
+
}
|
|
207
|
+
return pricing.default?.currency || 'cny'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The currency every summed total is expressed in. */
|
|
211
|
+
export function totalCurrencyOf(pricing) {
|
|
212
|
+
return pricing.totalCurrency === 'usd' ? 'usd' : 'cny'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Convert a cost quoted in `from` into the configured total currency. Same-currency
|
|
217
|
+
* conversion is a no-op, so an all-CNY table needs no exchange rate at all.
|
|
218
|
+
*/
|
|
219
|
+
export function convertCost(cost, from, pricing) {
|
|
220
|
+
const total = totalCurrencyOf(pricing)
|
|
221
|
+
if (from === total) return cost
|
|
222
|
+
const rate = Number(pricing.exchangeRate) || 1
|
|
223
|
+
return from === 'cny' ? cost / rate : cost * rate
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Cost of one token bucket at one resolved price, before any multiplier. */
|
|
227
|
+
export function tokenCost(tokens, price) {
|
|
228
|
+
return (
|
|
229
|
+
((Number(tokens.input) || 0) / 1e6) * price.inputPerMillion +
|
|
230
|
+
((Number(tokens.cacheRead) || 0) / 1e6) * price.cacheReadPerMillion +
|
|
231
|
+
((Number(tokens.cacheWrite) || 0) / 1e6) * price.cacheWritePerMillion +
|
|
232
|
+
((Number(tokens.output) || 0) / 1e6) * price.outputPerMillion
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The request's input context size used by the tiered-price rule. DSH records
|
|
238
|
+
* uncached input and cache usage as disjoint values; their sum is the input
|
|
239
|
+
* context its own tiered calculation uses.
|
|
240
|
+
*/
|
|
241
|
+
export function contextTokens(tokens) {
|
|
242
|
+
return (Number(tokens.input) || 0) + (Number(tokens.cacheRead) || 0) + (Number(tokens.cacheWrite) || 0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Parse a token count written either as a number or as a compact string with a
|
|
247
|
+
* K / M / B suffix (case-insensitive, optional whitespace): `128000`, `"128K"`,
|
|
248
|
+
* `"1.5M"`, `"2b"`. Returns NaN for anything unparseable.
|
|
249
|
+
*/
|
|
250
|
+
export function parseTokenCount(value) {
|
|
251
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
|
|
252
|
+
const text = String(value ?? '').trim()
|
|
253
|
+
if (!text) return NaN
|
|
254
|
+
const m = /^(\d+(?:\.\d+)?)\s*([kKmMbB]?)$/.exec(text)
|
|
255
|
+
if (!m) return NaN
|
|
256
|
+
const suffix = m[2] ? m[2].toLowerCase() : ''
|
|
257
|
+
const scale = suffix === 'k' ? 1e3 : suffix === 'm' ? 1e6 : suffix === 'b' ? 1e9 : 1
|
|
258
|
+
return parseFloat(m[1]) * scale
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The context-tier multiplier for one request: strictly above the threshold, else 1. */
|
|
262
|
+
export function contextMultiplierFor(pricing, provider, model, tokens) {
|
|
263
|
+
const rule = modelContextMultiplier(pricing, provider, model)
|
|
264
|
+
const threshold = parseTokenCount(rule?.threshold)
|
|
265
|
+
const multiplier = Number(rule?.multiplier)
|
|
266
|
+
if (!Number.isFinite(threshold) || threshold <= 0 || !Number.isFinite(multiplier) || multiplier <= 0) return 1
|
|
267
|
+
return contextTokens(tokens) > threshold ? multiplier : 1
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Cost of one per-request usage record, in the total currency. The peak/valley
|
|
272
|
+
* multiplier, the model multiplier and the context tier all apply per request,
|
|
273
|
+
* which is why the raw per-request records are kept rather than only aggregates.
|
|
274
|
+
*
|
|
275
|
+
* @param {{input:number,cacheRead:number,cacheWrite:number,output:number}} tokens - this request's usage.
|
|
276
|
+
* @param {number} hour - local hour (0–23) the request was sent.
|
|
277
|
+
* @param {object} day - `{ date, weekday, class, name }` of the request's day; a bare
|
|
278
|
+
* weekday number is accepted for callers that have no calendar.
|
|
279
|
+
*/
|
|
280
|
+
export function requestCost(tokens, provider, model, hour, pricing, day) {
|
|
281
|
+
const context = normalizeDay(day)
|
|
282
|
+
const price = resolvePrice(pricing, provider, model)
|
|
283
|
+
const timeMultiplier = multiplierFor(context.weekday, hour, modelTimeOfUse(pricing, provider, model), context.class)
|
|
284
|
+
const modelMultiplierValue = modelMultiplier(pricing, provider, model)
|
|
285
|
+
const contextMultiplierValue = contextMultiplierFor(pricing, provider, model, tokens)
|
|
286
|
+
const cost = tokenCost(tokens, price) * timeMultiplier * modelMultiplierValue * contextMultiplierValue
|
|
287
|
+
return convertCost(cost, resolveCurrency(pricing, provider, model), pricing)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Cost for an aggregate-shaped bucket whose per-request records are unavailable
|
|
292
|
+
* (a v1 cache row). It prices the whole bucket flat at the current hour and skips
|
|
293
|
+
* the context tier, matching the shell's pre-context-tier behavior.
|
|
294
|
+
*/
|
|
295
|
+
export function legacyCost(tokens, provider, model, hour, pricing, day) {
|
|
296
|
+
const context = normalizeDay(day)
|
|
297
|
+
const price = resolvePrice(pricing, provider, model)
|
|
298
|
+
const timeMultiplier = multiplierFor(context.weekday, hour, modelTimeOfUse(pricing, provider, model), context.class)
|
|
299
|
+
const cost = tokenCost(tokens, price) * timeMultiplier * modelMultiplier(pricing, provider, model)
|
|
300
|
+
return convertCost(cost, resolveCurrency(pricing, provider, model), pricing)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Per-hour cost array (0…23) for one bucket, in a single pass over its request
|
|
305
|
+
* records so a day chart never rescans the list once per hour.
|
|
306
|
+
*/
|
|
307
|
+
export function bucketHourlyCosts(bucket, pricing, day) {
|
|
308
|
+
const context = normalizeDay(day)
|
|
309
|
+
const out = new Array(24).fill(0)
|
|
310
|
+
if (Array.isArray(bucket.usageRecords)) {
|
|
311
|
+
for (const usage of bucket.usageRecords) {
|
|
312
|
+
let hour = Number.isInteger(usage?.hour) ? usage.hour : new Date().getHours()
|
|
313
|
+
if (hour < 0 || hour > 23) hour = new Date().getHours()
|
|
314
|
+
out[hour] += requestCost(usage, bucket.provider, bucket.model, hour, pricing, context)
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
319
|
+
const tokens = bucket.hourly?.[hour]
|
|
320
|
+
out[hour] = tokens ? legacyCost(tokens, bucket.provider, bucket.model, hour, pricing, context) : 0
|
|
321
|
+
}
|
|
322
|
+
return out
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Total cost of one bucket in the total currency. */
|
|
326
|
+
export function bucketCost(bucket, pricing, day) {
|
|
327
|
+
const context = normalizeDay(day)
|
|
328
|
+
if (!Array.isArray(bucket.hourly) && !Array.isArray(bucket.usageRecords)) {
|
|
329
|
+
return legacyCost(bucket, bucket.provider, bucket.model, new Date().getHours(), pricing, context)
|
|
330
|
+
}
|
|
331
|
+
let total = 0
|
|
332
|
+
for (const hourCost of bucketHourlyCosts(bucket, pricing, context)) total += hourCost
|
|
333
|
+
return total
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── local calendar helpers ───────────────────────────────────────────────────
|
|
337
|
+
// Day and hour bucketing follows the host's local calendar, which is what a
|
|
338
|
+
// "today" figure has to mean. `offsetMinutes()` is re-read per call rather than
|
|
339
|
+
// cached in a module constant so a host that changes zone (or crosses DST) is
|
|
340
|
+
// bucketed correctly on the next pass.
|
|
341
|
+
|
|
342
|
+
/** Minutes east of UTC for the host's current local time. */
|
|
343
|
+
export function offsetMinutes(now = Date.now()) {
|
|
344
|
+
return -new Date(now).getTimezoneOffset()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Local calendar day key (`YYYY-MM-DD`) for an epoch millisecond timestamp. */
|
|
348
|
+
export function dayKey(ms) {
|
|
349
|
+
const d = new Date(ms + offsetMinutes(ms) * 60000)
|
|
350
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Local hour (0–23) for an epoch millisecond timestamp. */
|
|
354
|
+
export function localHour(ms) {
|
|
355
|
+
return new Date(ms + offsetMinutes(ms) * 60000).getUTCHours()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** ISO weekday (1=Monday … 7=Sunday) from a JS Date. */
|
|
359
|
+
export function weekdayOf(d) {
|
|
360
|
+
return ((d.getDay() + 6) % 7) + 1
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** ISO weekday (1=Mon … 7=Sun) of a local `YYYY-MM-DD` day key. */
|
|
364
|
+
export function weekdayFromDayKey(dk) {
|
|
365
|
+
const [y, m, d] = String(dk).split('-').map(Number)
|
|
366
|
+
return weekdayOf(new Date(y, m - 1, d))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The day context every cost function prices against: the local date key, its ISO
|
|
371
|
+
* weekday, and whether the holiday calendar marks it a public holiday.
|
|
372
|
+
*
|
|
373
|
+
* Only two classes exist, because that is all the published policy distinguishes:
|
|
374
|
+
* peak hours are 周一至周五 excluding public holidays, and *every* other hour —
|
|
375
|
+
* "包括周末及中国法定节假日全天" — is off-peak. A weekend the State Council turns
|
|
376
|
+
* into a working day (调休) is still a 周末, so it is deliberately NOT treated as a
|
|
377
|
+
* working day here; doing so would charge peak rates on a day the page calls
|
|
378
|
+
* off-peak in full.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} dateKey - local `YYYY-MM-DD` day key.
|
|
381
|
+
* @param {object} [rules] - resolved holiday rules; absent means "no holiday handling".
|
|
382
|
+
* @returns {{date:string, weekday:number, class:'normal'|'holiday', name:string|null}}
|
|
383
|
+
*/
|
|
384
|
+
export function dayContext(dateKey, rules) {
|
|
385
|
+
const weekday = weekdayFromDayKey(dateKey)
|
|
386
|
+
let cls = 'normal'
|
|
387
|
+
let name = null
|
|
388
|
+
if (rules?.enabled) {
|
|
389
|
+
const holidayName = rules.holidays?.get?.(dateKey)
|
|
390
|
+
if (holidayName !== undefined) {
|
|
391
|
+
cls = 'holiday'
|
|
392
|
+
name = holidayName || null
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { date: dateKey, weekday, class: cls, name }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** A fresh 24-slot hourly token/request series. */
|
|
399
|
+
export function initHourly() {
|
|
400
|
+
return Array.from({ length: 24 }, () => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0, requests: 0 }))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether a weekday matches a `days` rule: `"all"` / `"weekday"` / `"weekend"` /
|
|
405
|
+
* an array like `[1,2,3,4,5]`. A missing or unknown rule is lenient and matches
|
|
406
|
+
* every day, which keeps older configs behaving exactly as they did.
|
|
407
|
+
*
|
|
408
|
+
* Matching is on the calendar weekday, which is what the published policy names
|
|
409
|
+
* ("周一至周五"). A public holiday is handled separately, by {@link multiplierFor},
|
|
410
|
+
* because it is off-peak whatever weekday it falls on.
|
|
411
|
+
*
|
|
412
|
+
* @param {number} weekday - ISO weekday (1=Mon … 7=Sun).
|
|
413
|
+
* @param {unknown} days - the rule's `days` field.
|
|
414
|
+
*/
|
|
415
|
+
export function dayMatches(weekday, days) {
|
|
416
|
+
if (days == null || days === '') return true
|
|
417
|
+
if (typeof days === 'string') {
|
|
418
|
+
const s = days.trim().toLowerCase()
|
|
419
|
+
if (s === 'all') return true
|
|
420
|
+
if (s === 'weekday' || s === 'workday') return weekday >= 1 && weekday <= 5
|
|
421
|
+
if (s === 'weekend') return weekday >= 6
|
|
422
|
+
return true
|
|
423
|
+
}
|
|
424
|
+
if (Array.isArray(days)) return days.some((d) => Number(d) === weekday)
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Tolerate a bare weekday number as well as a `{ date, weekday, class }` context. */
|
|
429
|
+
function normalizeDay(day) {
|
|
430
|
+
if (typeof day === 'number') return { date: null, weekday: day, class: 'normal', name: null }
|
|
431
|
+
if (day && typeof day === 'object') return { class: 'normal', name: null, ...day }
|
|
432
|
+
return { date: null, weekday: 1, class: 'normal', name: null }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Peak/valley multiplier for one local hour on one day. Flat (1) when time-of-use
|
|
437
|
+
* is off or the day does not match the `days` rule; the peak multiplier inside
|
|
438
|
+
* `peakRanges`; the valley multiplier otherwise. The peak multiplier is a
|
|
439
|
+
* surcharge and is floored at 1.
|
|
440
|
+
*
|
|
441
|
+
* A public holiday is off-peak for the whole day, which is DeepSeek's published
|
|
442
|
+
* rule — so the holiday check comes first and overrides every `days` form.
|
|
443
|
+
*
|
|
444
|
+
* @param {number} weekday - ISO weekday (1=Mon … 7=Sun).
|
|
445
|
+
* @param {number} hour - local hour (0–23).
|
|
446
|
+
* @param {object} tou - the resolved time-of-use rule.
|
|
447
|
+
* @param {'normal'|'holiday'} [dayClass] - holiday classification of the day.
|
|
448
|
+
*/
|
|
449
|
+
export function multiplierFor(weekday, hour, tou, dayClass = 'normal') {
|
|
450
|
+
if (!tou || !tou.enabled) return 1
|
|
451
|
+
if (dayClass === 'holiday') return 1
|
|
452
|
+
if (!dayMatches(weekday, tou.days)) return 1
|
|
453
|
+
for (const range of tou.peakRanges || []) {
|
|
454
|
+
if (!Array.isArray(range) || range.length < 2) continue
|
|
455
|
+
const [s, e] = range
|
|
456
|
+
if (typeof s === 'number' && typeof e === 'number' && hour >= s && hour < e) {
|
|
457
|
+
return Math.max(1, Number(tou.peakMultiplier) || 1)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return Number(tou.valleyMultiplier ?? 1) || 0
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The peak/valley state of one day at one hour, for the panel's period indicator.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} pricing - the normalized pricing document.
|
|
467
|
+
* @param {string|null} provider - route provider, for a per-model rule lookup.
|
|
468
|
+
* @param {string|null} model - route model.
|
|
469
|
+
* @param {object|number} day - a `{ date, weekday, class, name }` context.
|
|
470
|
+
* @param {number} [hour] - local hour to evaluate; defaults to the current hour.
|
|
471
|
+
*/
|
|
472
|
+
export function timeOfUseState(pricing, provider, model, day, hour = new Date().getHours()) {
|
|
473
|
+
const context = normalizeDay(day)
|
|
474
|
+
const tou = modelTimeOfUse(pricing, provider, model)
|
|
475
|
+
const multiplier = multiplierFor(context.weekday, hour, tou, context.class)
|
|
476
|
+
const peak = multiplier > 1
|
|
477
|
+
return {
|
|
478
|
+
enabled: Boolean(tou && tou.enabled),
|
|
479
|
+
peak,
|
|
480
|
+
label: peak ? 'peak' : 'valley',
|
|
481
|
+
multiplier,
|
|
482
|
+
dayClass: context.class,
|
|
483
|
+
dayName: context.name ?? null,
|
|
484
|
+
}
|
|
485
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-usage-badge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month charts, official DeepSeek pricing and Chinese public holidays.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ai-written/dsh-usage-badge.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ai-written/dsh-usage-badge#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ai-written/dsh-usage-badge/issues"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./lib/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"default": "./lib/client.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"dsh": {
|
|
25
|
+
"manifestVersion": 1,
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"assets",
|
|
39
|
+
"docs"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node test/manifest.verify.mjs && node test/pricing.verify.mjs && node test/holidays.verify.mjs && node test/official-pricing.verify.mjs && node test/client.render.mjs",
|
|
43
|
+
"test:smoke": "node test/smoke.mjs",
|
|
44
|
+
"prepublishOnly": "npm test"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.15"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"dsh",
|
|
51
|
+
"dsh-plugin",
|
|
52
|
+
"deepseek-harness",
|
|
53
|
+
"usage",
|
|
54
|
+
"cost",
|
|
55
|
+
"badge",
|
|
56
|
+
"billing"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"react": "^18.3.1",
|
|
61
|
+
"react-dom": "^18.3.1"
|
|
62
|
+
}
|
|
63
|
+
}
|