dsh-token-bill 0.0.0-stage → 2.0.2
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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.en.md +169 -0
- package/README.md +168 -2
- package/client.js +522 -0
- package/cordis.patch.yml +16 -0
- package/docs/panel-dark-en.png +0 -0
- package/docs/panel-dark.png +0 -0
- package/docs/panel-light-en.png +0 -0
- package/docs/panel-light.png +0 -0
- package/docs/sidebar-en.png +0 -0
- package/docs/sidebar.png +0 -0
- package/icon.svg +6 -0
- package/index.js +25 -0
- package/lib/fold.js +281 -0
- package/lib/pricing.js +330 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +73 -4
package/lib/fold.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dshTokenBill` session projection: a replay-stable fold of every billed
|
|
3
|
+
* model call in one Session log into token buckets, kept per route
|
|
4
|
+
* (provider + model) and per DeepSeek billing tier (peak / off-peak).
|
|
5
|
+
*
|
|
6
|
+
* The state holds tokens only. Prices are applied by the view (see
|
|
7
|
+
* `lib/pricing.js`), so a price edit re-prices every Session without
|
|
8
|
+
* re-folding its log, and a checkpoint stays valid across price changes.
|
|
9
|
+
*/
|
|
10
|
+
import { buildView, tierAt } from './pricing.js'
|
|
11
|
+
|
|
12
|
+
/** Projection key; distinct from v1's `tokenCost` so both versions can coexist during an upgrade. */
|
|
13
|
+
export const PROJECTION_KEY = 'dshTokenBill'
|
|
14
|
+
|
|
15
|
+
/** Bump whenever the state shape or fold semantics change: stale checkpoints are then discarded. */
|
|
16
|
+
export const STATE_VERSION = 2
|
|
17
|
+
|
|
18
|
+
const FIELDS = Object.freeze(['input', 'cacheRead', 'cacheWrite', 'output', 'reasoning'])
|
|
19
|
+
|
|
20
|
+
const UNKNOWN_ROUTE = Object.freeze({ provider: '', model: '' })
|
|
21
|
+
|
|
22
|
+
/** @returns {object} an all-zero bucket set. */
|
|
23
|
+
export function emptyBuckets() {
|
|
24
|
+
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function count(value) {
|
|
28
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bucket one provider usage sample. `inputTokens` is the uncached (cache-miss)
|
|
33
|
+
* prompt part, as the built-in token meter reads it; reasoning tokens are a
|
|
34
|
+
* subset of output and only reported for display.
|
|
35
|
+
* @param {object} usage - an LLM usage record.
|
|
36
|
+
* @returns {object} bucket set.
|
|
37
|
+
*/
|
|
38
|
+
export function bucketsFromUsage(usage) {
|
|
39
|
+
const output = count(usage.outputTokens)
|
|
40
|
+
return {
|
|
41
|
+
input: count(usage.inputTokens),
|
|
42
|
+
cacheRead: count(usage.cacheReadTokens),
|
|
43
|
+
cacheWrite: count(usage.cacheWriteTokens),
|
|
44
|
+
output,
|
|
45
|
+
reasoning: Math.min(count(usage.reasoningTokens), output),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bucketsEqual(a, b) {
|
|
50
|
+
return FIELDS.every(field => a[field] === b[field])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bucketsEmpty(buckets) {
|
|
54
|
+
return FIELDS.every(field => buckets[field] === 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function routeOf(provider, model) {
|
|
58
|
+
if (typeof model !== 'string' || model.length === 0) return undefined
|
|
59
|
+
return { provider: typeof provider === 'string' ? provider : '', model }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sameRoute(a, b) {
|
|
63
|
+
return a != null && b != null && a.provider === b.provider && a.model === b.model
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add (`sign` = 1) or remove (`sign` = -1) one bucket set from a ledger without
|
|
68
|
+
* mutating it. An entry that drops to zero is removed.
|
|
69
|
+
* @param {ReadonlyArray<object>} ledger - route entries.
|
|
70
|
+
* @param {{ provider: string, model: string }} route - the call's route.
|
|
71
|
+
* @param {'peak' | 'offPeak'} tier - the call's billing tier.
|
|
72
|
+
* @param {object} buckets - the call's buckets.
|
|
73
|
+
* @param {1 | -1} sign - direction.
|
|
74
|
+
* @returns {ReadonlyArray<object>} the next ledger.
|
|
75
|
+
*/
|
|
76
|
+
export function ledgerAdd(ledger, route, tier, buckets, sign) {
|
|
77
|
+
if (bucketsEmpty(buckets)) return ledger
|
|
78
|
+
const index = ledger.findIndex(entry => sameRoute(entry, route))
|
|
79
|
+
if (index < 0 && sign < 0) return ledger
|
|
80
|
+
const base = index >= 0
|
|
81
|
+
? ledger[index]
|
|
82
|
+
: { provider: route.provider, model: route.model, peak: emptyBuckets(), offPeak: emptyBuckets() }
|
|
83
|
+
const next = {}
|
|
84
|
+
for (const field of FIELDS) next[field] = Math.max(0, base[tier][field] + sign * buckets[field])
|
|
85
|
+
const entry = { ...base, [tier]: next }
|
|
86
|
+
if (index < 0) return [...ledger, entry]
|
|
87
|
+
if (bucketsEmpty(entry.peak) && bucketsEmpty(entry.offPeak)) return ledger.filter((_, i) => i !== index)
|
|
88
|
+
const copy = ledger.slice()
|
|
89
|
+
copy[index] = entry
|
|
90
|
+
return copy
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The usage sample of one Assistant settlement, mirroring the built-in token meter. */
|
|
94
|
+
function usageOf(event) {
|
|
95
|
+
const data = event.data
|
|
96
|
+
if (event.type === 'assistant/message' && data.usage != null) return data.usage
|
|
97
|
+
const stream = data.stream
|
|
98
|
+
if (!Array.isArray(stream)) return undefined
|
|
99
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
100
|
+
const record = stream[index]
|
|
101
|
+
if (record != null && record.type === 'chunk' && record.chunk != null && record.chunk.type === 'usage') {
|
|
102
|
+
return record.chunk.usage
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initial state. Events below `inheritedEventCount` were copied from the parent
|
|
110
|
+
* when this Session was forked; they were billed there, so they are not billed
|
|
111
|
+
* again here.
|
|
112
|
+
* @param {number} [inheritedEventCount] - fork-inherited cut.
|
|
113
|
+
* @returns {object} state.
|
|
114
|
+
*/
|
|
115
|
+
export function initialState(inheritedEventCount) {
|
|
116
|
+
return {
|
|
117
|
+
inherited: typeof inheritedEventCount === 'number' && inheritedEventCount > 0 ? inheritedEventCount : 0,
|
|
118
|
+
total: [],
|
|
119
|
+
compaction: [],
|
|
120
|
+
calls: 0,
|
|
121
|
+
compactions: 0,
|
|
122
|
+
turn: null,
|
|
123
|
+
turnNo: null,
|
|
124
|
+
last: null,
|
|
125
|
+
header: null,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function inherited(state, event) {
|
|
130
|
+
return typeof event.seq === 'number' && event.seq < state.inherited
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One `assistant/attempt` or `assistant/message`: a billed model call, or the final sample of one. */
|
|
134
|
+
function foldSettlement(state, event, tierOf) {
|
|
135
|
+
if (inherited(state, event)) return state
|
|
136
|
+
const data = event.data
|
|
137
|
+
const sample = usageOf(event)
|
|
138
|
+
if (typeof sample !== 'object' || sample === null) return state
|
|
139
|
+
const { turn, step } = data
|
|
140
|
+
if (typeof turn !== 'number' || typeof step !== 'number') return state
|
|
141
|
+
|
|
142
|
+
const source = event.type === 'assistant/message' && data.message != null ? data.message.source : undefined
|
|
143
|
+
const route = (source != null ? routeOf(source.provider, source.model) : undefined) ?? state.header ?? UNKNOWN_ROUTE
|
|
144
|
+
const tier = tierOf(event.time)
|
|
145
|
+
const buckets = bucketsFromUsage(sample)
|
|
146
|
+
|
|
147
|
+
// A final message sample replaces the streaming sample of the same attempt;
|
|
148
|
+
// `llm/retry-started` closes that slot, so a retried attempt adds instead.
|
|
149
|
+
const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : null
|
|
150
|
+
if (previous !== null && previous.tier === tier && sameRoute(previous.route, route) && bucketsEqual(previous.buckets, buckets)) {
|
|
151
|
+
return state
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let total = state.total
|
|
155
|
+
let current = state.turn !== null && state.turn.no === turn ? state.turn.ledger : []
|
|
156
|
+
if (previous !== null) {
|
|
157
|
+
total = ledgerAdd(total, previous.route, previous.tier, previous.buckets, -1)
|
|
158
|
+
current = ledgerAdd(current, previous.route, previous.tier, previous.buckets, -1)
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
...state,
|
|
162
|
+
total: ledgerAdd(total, route, tier, buckets, 1),
|
|
163
|
+
turn: { no: turn, ledger: ledgerAdd(current, route, tier, buckets, 1) },
|
|
164
|
+
calls: previous === null ? state.calls + 1 : state.calls,
|
|
165
|
+
last: { turn, step, route: { provider: route.provider, model: route.model }, tier, buckets },
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One `compaction/summary`: the summarizer's own model call, which the built-in meter does not count. */
|
|
170
|
+
function foldCompaction(state, event, tierOf) {
|
|
171
|
+
if (inherited(state, event)) return state
|
|
172
|
+
const data = event.data
|
|
173
|
+
if (typeof data.usage !== 'object' || data.usage === null) return state
|
|
174
|
+
const buckets = bucketsFromUsage(data.usage)
|
|
175
|
+
if (bucketsEmpty(buckets)) return state
|
|
176
|
+
const route = routeOf(data.provider, data.model) ?? state.header ?? UNKNOWN_ROUTE
|
|
177
|
+
const tier = tierOf(event.time)
|
|
178
|
+
const no = typeof state.turnNo === 'number' ? state.turnNo : state.turn !== null ? state.turn.no : 0
|
|
179
|
+
const current = state.turn !== null && state.turn.no === no ? state.turn.ledger : []
|
|
180
|
+
return {
|
|
181
|
+
...state,
|
|
182
|
+
total: ledgerAdd(state.total, route, tier, buckets, 1),
|
|
183
|
+
compaction: ledgerAdd(state.compaction, route, tier, buckets, 1),
|
|
184
|
+
compactions: state.compactions + 1,
|
|
185
|
+
turn: { no, ledger: ledgerAdd(current, route, tier, buckets, 1) },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fold one committed Session event. Returns the same reference for events
|
|
191
|
+
* that do not concern this unit, as the registry requires.
|
|
192
|
+
* @param {object} state - current state.
|
|
193
|
+
* @param {object} event - committed Session event.
|
|
194
|
+
* @param {(time: number) => 'peak' | 'offPeak'} tierOf - billing tier classifier.
|
|
195
|
+
* @returns {object} next state.
|
|
196
|
+
*/
|
|
197
|
+
export function foldEvent(state, event, tierOf) {
|
|
198
|
+
const data = event.data
|
|
199
|
+
if (typeof data !== 'object' || data === null) return state
|
|
200
|
+
switch (event.type) {
|
|
201
|
+
case 'request/header': {
|
|
202
|
+
const config = data.header != null ? data.header.config : undefined
|
|
203
|
+
const route = config != null ? routeOf(config.provider, config.model) : undefined
|
|
204
|
+
return route === undefined || sameRoute(route, state.header) ? state : { ...state, header: route }
|
|
205
|
+
}
|
|
206
|
+
case 'turn/start':
|
|
207
|
+
return typeof data.turn !== 'number' || data.turn === state.turnNo ? state : { ...state, turnNo: data.turn }
|
|
208
|
+
case 'llm/retry-started':
|
|
209
|
+
return state.last !== null && state.last.turn === data.turn && state.last.step === data.step
|
|
210
|
+
? { ...state, last: null }
|
|
211
|
+
: state
|
|
212
|
+
case 'assistant/attempt':
|
|
213
|
+
case 'assistant/message':
|
|
214
|
+
return foldSettlement(state, event, tierOf)
|
|
215
|
+
case 'compaction/summary':
|
|
216
|
+
return foldCompaction(state, event, tierOf)
|
|
217
|
+
default:
|
|
218
|
+
return state
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isCount(value) {
|
|
223
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isBuckets(value) {
|
|
227
|
+
return typeof value === 'object' && value !== null && FIELDS.every(field => isCount(value[field]))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isLedger(value) {
|
|
231
|
+
return Array.isArray(value) && value.every(entry => typeof entry === 'object' && entry !== null
|
|
232
|
+
&& typeof entry.provider === 'string' && typeof entry.model === 'string'
|
|
233
|
+
&& isBuckets(entry.peak) && isBuckets(entry.offPeak))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validate a state restored from a persisted checkpoint. The registry discards
|
|
238
|
+
* rows whose version differs; this guards against a corrupted row of the right
|
|
239
|
+
* version reaching the fold.
|
|
240
|
+
* @param {unknown} value - candidate state.
|
|
241
|
+
* @returns {object} the same value.
|
|
242
|
+
* @throws {Error} when the value is not a state of this version.
|
|
243
|
+
*/
|
|
244
|
+
export function parseState(value) {
|
|
245
|
+
const ok = typeof value === 'object' && value !== null
|
|
246
|
+
&& isCount(value.inherited) && isLedger(value.total) && isLedger(value.compaction)
|
|
247
|
+
&& isCount(value.calls) && isCount(value.compactions)
|
|
248
|
+
&& (value.turn === null || (typeof value.turn === 'object' && isCount(value.turn.no) && isLedger(value.turn.ledger)))
|
|
249
|
+
&& (value.turnNo === null || isCount(value.turnNo))
|
|
250
|
+
&& (value.header === null || (typeof value.header === 'object' && typeof value.header.model === 'string'))
|
|
251
|
+
&& (value.last === null || (typeof value.last === 'object' && isBuckets(value.last.buckets)))
|
|
252
|
+
if (!ok) throw new Error('dsh-token-bill: invalid projection state')
|
|
253
|
+
return value
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The projection definition registered with `ctx.sessionProjections`.
|
|
258
|
+
* @param {object} pricing - result of `resolvePricing(config)`.
|
|
259
|
+
* @returns {object} projection definition.
|
|
260
|
+
*/
|
|
261
|
+
export function createTokenCostProjection(pricing) {
|
|
262
|
+
const tierOf = time => tierAt(time, pricing.holidays)
|
|
263
|
+
return {
|
|
264
|
+
key: PROJECTION_KEY,
|
|
265
|
+
stateVersion: STATE_VERSION,
|
|
266
|
+
stateSchema: { parse: parseState },
|
|
267
|
+
init: (_header, inheritedEventCount) => initialState(inheritedEventCount),
|
|
268
|
+
apply: (state, event) => foldEvent(state, event, tierOf),
|
|
269
|
+
wire: {
|
|
270
|
+
viewSchema: {
|
|
271
|
+
parse: (value) => {
|
|
272
|
+
if (typeof value !== 'object' || value === null || typeof value.then === 'function') {
|
|
273
|
+
throw new Error('dsh-token-bill: view must be a plain object')
|
|
274
|
+
}
|
|
275
|
+
return value
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
view: state => buildView(state, pricing),
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price tables, DeepSeek peak/off-peak tiers, and the priced client view.
|
|
3
|
+
*
|
|
4
|
+
* Pure module with no Harness imports, so the same code runs inside the Host
|
|
5
|
+
* and under plain `node --test`. Every price is per one million tokens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Date the built-in DeepSeek table was last checked against the official page. */
|
|
9
|
+
export const PRICES_AS_OF = '2026-11-07'
|
|
10
|
+
|
|
11
|
+
/** Official price page the built-in table mirrors. */
|
|
12
|
+
export const PRICE_SOURCE = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
|
|
13
|
+
|
|
14
|
+
/** One rate card: per-1M-token prices for each billed bucket. */
|
|
15
|
+
function rate(input, cacheHit, output) {
|
|
16
|
+
return Object.freeze({ input, cacheHit, cacheWrite: input, output })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Official DeepSeek API prices. Off-peak is half of peak; see {@link tierAt}.
|
|
21
|
+
* Cache writes are billed as ordinary (cache-miss) input.
|
|
22
|
+
*/
|
|
23
|
+
export const BUILTIN_PRICES = Object.freeze({
|
|
24
|
+
'deepseek-flash': Object.freeze({
|
|
25
|
+
CNY: Object.freeze({ peak: rate(2, 0.04, 8), offPeak: rate(1, 0.02, 4) }),
|
|
26
|
+
USD: Object.freeze({ peak: rate(0.3, 0.006, 1.2), offPeak: rate(0.15, 0.003, 0.6) }),
|
|
27
|
+
}),
|
|
28
|
+
'deepseek-v4-pro': Object.freeze({
|
|
29
|
+
CNY: Object.freeze({ peak: rate(9, 0.3, 27), offPeak: rate(4.5, 0.15, 13.5) }),
|
|
30
|
+
USD: Object.freeze({ peak: rate(1.32, 0.044, 3.96), offPeak: rate(0.66, 0.022, 1.98) }),
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Retired model ids the API still accepts and bills at another model's price. */
|
|
35
|
+
export const MODEL_ALIASES = Object.freeze({
|
|
36
|
+
'deepseek-v4-flash': 'deepseek-flash',
|
|
37
|
+
'deepseek-v4-flash-vision-exp': 'deepseek-flash',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Days off under the State Council's 2026 holiday notice (2025-11-04), as
|
|
42
|
+
* Beijing dates. Weekends are off-peak anyway; listing whole breaks keeps the
|
|
43
|
+
* table easy to check against the notice. Make-up workdays on weekends stay
|
|
44
|
+
* off-peak, because DeepSeek's rule counts every weekend as off-peak.
|
|
45
|
+
*/
|
|
46
|
+
export const CN_HOLIDAYS = Object.freeze([
|
|
47
|
+
// New Year
|
|
48
|
+
'2026-01-01', '2026-01-02', '2026-01-03',
|
|
49
|
+
// Spring Festival
|
|
50
|
+
'2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
|
|
51
|
+
'2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
|
|
52
|
+
// Qingming
|
|
53
|
+
'2026-04-04', '2026-04-05', '2026-04-06',
|
|
54
|
+
// Labour Day
|
|
55
|
+
'2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
|
|
56
|
+
// Dragon Boat
|
|
57
|
+
'2026-06-19', '2026-06-20', '2026-06-21',
|
|
58
|
+
// Mid-Autumn
|
|
59
|
+
'2026-09-25', '2026-09-26', '2026-09-27',
|
|
60
|
+
// National Day
|
|
61
|
+
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04', '2026-10-05',
|
|
62
|
+
'2026-10-06', '2026-10-07',
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/** Peak windows in Beijing minutes after midnight, as [start, end). */
|
|
66
|
+
export const PEAK_WINDOWS = Object.freeze([[9 * 60, 12 * 60], [14 * 60, 18 * 60]])
|
|
67
|
+
|
|
68
|
+
/** Beijing has no daylight saving time, so a fixed offset is exact. */
|
|
69
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000
|
|
70
|
+
|
|
71
|
+
/** The two billing tiers every ledger entry keeps apart. */
|
|
72
|
+
export const TIERS = Object.freeze(['peak', 'offPeak'])
|
|
73
|
+
|
|
74
|
+
const CURRENCY_SYMBOLS = Object.freeze({ CNY: '¥', USD: '$', EUR: '€', GBP: '£', JPY: '¥', HKD: 'HK$' })
|
|
75
|
+
|
|
76
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Beijing calendar facts of one instant.
|
|
80
|
+
* @param {number} time - epoch milliseconds.
|
|
81
|
+
* @returns {{ date: string, weekday: number, minute: number }} ISO date, 0 = Sunday, minutes after midnight.
|
|
82
|
+
*/
|
|
83
|
+
export function beijingParts(time) {
|
|
84
|
+
const shifted = new Date(time + BEIJING_OFFSET_MS)
|
|
85
|
+
return {
|
|
86
|
+
date: shifted.toISOString().slice(0, 10),
|
|
87
|
+
weekday: shifted.getUTCDay(),
|
|
88
|
+
minute: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* DeepSeek billing tier of one instant: peak is Beijing time Monday to Friday,
|
|
94
|
+
* 09:00-12:00 and 14:00-18:00, except Chinese public holidays; everything else
|
|
95
|
+
* is off-peak. An instant that cannot be read counts as peak, the higher price.
|
|
96
|
+
* @param {number} time - epoch milliseconds of the billed event.
|
|
97
|
+
* @param {ReadonlySet<string>} holidays - Beijing dates (YYYY-MM-DD) that are off-peak all day.
|
|
98
|
+
* @returns {'peak' | 'offPeak'} the tier.
|
|
99
|
+
*/
|
|
100
|
+
export function tierAt(time, holidays) {
|
|
101
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) return 'peak'
|
|
102
|
+
const { date, weekday, minute } = beijingParts(time)
|
|
103
|
+
if (weekday === 0 || weekday === 6 || holidays.has(date)) return 'offPeak'
|
|
104
|
+
for (const [start, end] of PEAK_WINDOWS) {
|
|
105
|
+
if (minute >= start && minute < end) return 'peak'
|
|
106
|
+
}
|
|
107
|
+
return 'offPeak'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isObject(value) {
|
|
111
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A non-negative finite price; numeric strings are accepted because YAML authors quote numbers. */
|
|
115
|
+
function price(value) {
|
|
116
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
|
|
117
|
+
return typeof number === 'number' && Number.isFinite(number) && number >= 0 ? number : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalize one user rate card. `input` and `output` are required unless the
|
|
122
|
+
* base supplies them; `cacheHit` and `cacheWrite` default to the input price.
|
|
123
|
+
* The v1 names `inputPrice`, `cacheHitPrice`, and `outputPrice` are accepted.
|
|
124
|
+
* @param {unknown} raw - user value.
|
|
125
|
+
* @param {object} [base] - rate card the missing fields inherit from.
|
|
126
|
+
* @returns {object | undefined} a complete rate card, or undefined when unusable.
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeRate(raw, base) {
|
|
129
|
+
if (!isObject(raw)) return undefined
|
|
130
|
+
const input = price(raw.input ?? raw.inputPrice) ?? base?.input
|
|
131
|
+
const output = price(raw.output ?? raw.outputPrice) ?? base?.output
|
|
132
|
+
if (input === undefined || output === undefined) return undefined
|
|
133
|
+
const cacheHit = price(raw.cacheHit ?? raw.cacheRead ?? raw.cacheHitPrice) ?? base?.cacheHit ?? input
|
|
134
|
+
const cacheWrite = price(raw.cacheWrite ?? raw.cacheWritePrice) ?? base?.cacheWrite ?? input
|
|
135
|
+
return Object.freeze({ input, cacheHit, cacheWrite, output })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Normalize one user price entry: a rate card plus an optional `offPeak` card
|
|
140
|
+
* whose missing fields inherit from the main one.
|
|
141
|
+
* @param {unknown} raw - user value.
|
|
142
|
+
* @returns {{ peak: object, offPeak?: object } | undefined} the entry, or undefined when unusable.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeEntry(raw) {
|
|
145
|
+
const peak = normalizeRate(raw)
|
|
146
|
+
if (peak === undefined) return undefined
|
|
147
|
+
const offPeak = isObject(raw.offPeak) ? normalizeRate(raw.offPeak, peak) : undefined
|
|
148
|
+
return Object.freeze(offPeak === undefined ? { peak } : { peak, offPeak })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the plugin's raw config into the pricing used by the fold and view.
|
|
153
|
+
* Unknown or malformed fields fall back to defaults instead of failing, so a
|
|
154
|
+
* typo never takes the badge down.
|
|
155
|
+
* @param {unknown} config - the Loader row's config object.
|
|
156
|
+
* @returns {object} frozen pricing facts plus `priceFor(provider, model)`.
|
|
157
|
+
*/
|
|
158
|
+
export function resolvePricing(config) {
|
|
159
|
+
const cfg = isObject(config) ? config : {}
|
|
160
|
+
const currency = typeof cfg.currency === 'string' && cfg.currency.trim() !== ''
|
|
161
|
+
? cfg.currency.trim().toUpperCase()
|
|
162
|
+
: 'CNY'
|
|
163
|
+
const symbol = typeof cfg.symbol === 'string' ? cfg.symbol : CURRENCY_SYMBOLS[currency] ?? `${currency} `
|
|
164
|
+
const timeOfDay = cfg.timeOfDayPricing !== false
|
|
165
|
+
const extraHolidays = Array.isArray(cfg.holidays)
|
|
166
|
+
? cfg.holidays.filter(day => typeof day === 'string' && DATE_PATTERN.test(day.trim())).map(day => day.trim())
|
|
167
|
+
: []
|
|
168
|
+
const holidays = new Set([...CN_HOLIDAYS, ...extraHolidays])
|
|
169
|
+
|
|
170
|
+
const models = new Map()
|
|
171
|
+
if (isObject(cfg.models)) {
|
|
172
|
+
for (const [key, raw] of Object.entries(cfg.models)) {
|
|
173
|
+
const entry = normalizeEntry(raw)
|
|
174
|
+
if (entry !== undefined && key.trim() !== '') models.set(key.trim().toLowerCase(), entry)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `fallback` prices every model without its own entry. The v1 top-level
|
|
179
|
+
// fields keep working as that fallback.
|
|
180
|
+
const legacy = cfg.inputPrice !== undefined || cfg.outputPrice !== undefined
|
|
181
|
+
? normalizeEntry({ input: cfg.inputPrice, cacheHit: cfg.cacheHitPrice, output: cfg.outputPrice })
|
|
182
|
+
: undefined
|
|
183
|
+
const fallback = (isObject(cfg.fallback) ? normalizeEntry(cfg.fallback) : undefined) ?? legacy
|
|
184
|
+
|
|
185
|
+
const builtin = (model) => {
|
|
186
|
+
const table = BUILTIN_PRICES[MODEL_ALIASES[model] ?? model]
|
|
187
|
+
return table === undefined ? undefined : table[currency]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Price entry of one route: `provider/model` override, then `model`
|
|
192
|
+
* override, then the built-in table, then the fallback.
|
|
193
|
+
* @param {string} provider - provider id the message was routed through.
|
|
194
|
+
* @param {string} model - model id.
|
|
195
|
+
* @returns {{ peak: object, offPeak?: object } | undefined} the entry, or undefined when unpriced.
|
|
196
|
+
*/
|
|
197
|
+
const priceFor = (provider, model) => {
|
|
198
|
+
const m = String(model ?? '').toLowerCase()
|
|
199
|
+
const p = String(provider ?? '').toLowerCase()
|
|
200
|
+
return models.get(`${p}/${m}`) ?? models.get(m) ?? builtin(m) ?? fallback
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return Object.freeze({
|
|
204
|
+
currency,
|
|
205
|
+
symbol,
|
|
206
|
+
timeOfDay,
|
|
207
|
+
holidays,
|
|
208
|
+
showInSessionList: cfg.showInSessionList !== false,
|
|
209
|
+
priceFor,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const PER_TOKENS = 1_000_000
|
|
214
|
+
|
|
215
|
+
/** Cost of one bucket set under one rate card, per bucket. */
|
|
216
|
+
function bucketCosts(buckets, card) {
|
|
217
|
+
return {
|
|
218
|
+
input: buckets.input * card.input / PER_TOKENS,
|
|
219
|
+
cacheRead: buckets.cacheRead * card.cacheHit / PER_TOKENS,
|
|
220
|
+
cacheWrite: buckets.cacheWrite * card.cacheWrite / PER_TOKENS,
|
|
221
|
+
output: buckets.output * card.output / PER_TOKENS,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sumCosts(costs) {
|
|
226
|
+
return costs.input + costs.cacheRead + costs.cacheWrite + costs.output
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function hasTokens(buckets) {
|
|
230
|
+
return buckets.input + buckets.cacheRead + buckets.cacheWrite + buckets.output > 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Drop float noise so equal ledgers produce equal views. */
|
|
234
|
+
function money(value) {
|
|
235
|
+
return Number(value.toFixed(8))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Price one ledger (route entries split into peak and off-peak buckets).
|
|
240
|
+
* @param {ReadonlyArray<object>} ledger - fold ledger.
|
|
241
|
+
* @param {object} pricing - result of {@link resolvePricing}.
|
|
242
|
+
* @returns {object} totals, per-bucket costs, per-model rows, and off-peak savings.
|
|
243
|
+
*/
|
|
244
|
+
export function priceLedger(ledger, pricing) {
|
|
245
|
+
const tokens = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 }
|
|
246
|
+
const costs = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
247
|
+
let saved = 0
|
|
248
|
+
let complete = true
|
|
249
|
+
const models = []
|
|
250
|
+
for (const entry of ledger) {
|
|
251
|
+
const prices = pricing.priceFor(entry.provider, entry.model)
|
|
252
|
+
let modelCost = prices === undefined ? null : 0
|
|
253
|
+
let modelTokens = 0
|
|
254
|
+
for (const tier of TIERS) {
|
|
255
|
+
const buckets = entry[tier]
|
|
256
|
+
tokens.input += buckets.input
|
|
257
|
+
tokens.cacheRead += buckets.cacheRead
|
|
258
|
+
tokens.cacheWrite += buckets.cacheWrite
|
|
259
|
+
tokens.output += buckets.output
|
|
260
|
+
tokens.reasoning += buckets.reasoning
|
|
261
|
+
modelTokens += buckets.input + buckets.cacheRead + buckets.cacheWrite + buckets.output
|
|
262
|
+
if (prices === undefined) {
|
|
263
|
+
if (hasTokens(buckets)) complete = false
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const discounted = tier === 'offPeak' && pricing.timeOfDay && prices.offPeak !== undefined
|
|
267
|
+
const tierCosts = bucketCosts(buckets, discounted ? prices.offPeak : prices.peak)
|
|
268
|
+
costs.input += tierCosts.input
|
|
269
|
+
costs.cacheRead += tierCosts.cacheRead
|
|
270
|
+
costs.cacheWrite += tierCosts.cacheWrite
|
|
271
|
+
costs.output += tierCosts.output
|
|
272
|
+
modelCost += sumCosts(tierCosts)
|
|
273
|
+
if (discounted) saved += sumCosts(bucketCosts(buckets, prices.peak)) - sumCosts(tierCosts)
|
|
274
|
+
}
|
|
275
|
+
models.push({
|
|
276
|
+
provider: entry.provider,
|
|
277
|
+
model: entry.model,
|
|
278
|
+
tokens: modelTokens,
|
|
279
|
+
cost: modelCost === null ? null : money(modelCost),
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
models.sort((a, b) => (b.cost ?? -1) - (a.cost ?? -1) || b.tokens - a.tokens)
|
|
283
|
+
return {
|
|
284
|
+
cost: money(sumCosts(costs)),
|
|
285
|
+
complete,
|
|
286
|
+
tokens,
|
|
287
|
+
costs: {
|
|
288
|
+
input: money(costs.input),
|
|
289
|
+
cacheRead: money(costs.cacheRead),
|
|
290
|
+
cacheWrite: money(costs.cacheWrite),
|
|
291
|
+
output: money(costs.output),
|
|
292
|
+
},
|
|
293
|
+
saved: money(saved),
|
|
294
|
+
models,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The client view of one fold state: everything the badge, the detail panel,
|
|
300
|
+
* and the session-list label render. Pricing is applied here, at read time,
|
|
301
|
+
* so editing prices re-prices every session without re-folding its log.
|
|
302
|
+
* @param {object} state - fold state from `lib/fold.js`.
|
|
303
|
+
* @param {object} pricing - result of {@link resolvePricing}.
|
|
304
|
+
* @returns {object} JSON view.
|
|
305
|
+
*/
|
|
306
|
+
export function buildView(state, pricing) {
|
|
307
|
+
const total = priceLedger(state.total, pricing)
|
|
308
|
+
const prompt = total.tokens.input + total.tokens.cacheRead + total.tokens.cacheWrite
|
|
309
|
+
const turn = state.turn === null ? null : priceLedger(state.turn.ledger, pricing)
|
|
310
|
+
const compaction = state.compactions > 0 ? priceLedger(state.compaction, pricing) : null
|
|
311
|
+
return {
|
|
312
|
+
v: 2,
|
|
313
|
+
currency: pricing.currency,
|
|
314
|
+
symbol: pricing.symbol,
|
|
315
|
+
cost: total.cost,
|
|
316
|
+
complete: total.complete,
|
|
317
|
+
calls: state.calls,
|
|
318
|
+
tokens: total.tokens,
|
|
319
|
+
costs: total.costs,
|
|
320
|
+
cacheHitRate: prompt > 0 ? Number((total.tokens.cacheRead / prompt).toFixed(4)) : null,
|
|
321
|
+
tiered: pricing.timeOfDay,
|
|
322
|
+
saved: total.saved,
|
|
323
|
+
turn: turn === null ? null : { no: state.turn.no, cost: turn.cost, complete: turn.complete },
|
|
324
|
+
compaction: compaction === null
|
|
325
|
+
? null
|
|
326
|
+
: { calls: state.compactions, cost: compaction.cost, complete: compaction.complete },
|
|
327
|
+
models: total.models,
|
|
328
|
+
showInList: pricing.showInSessionList,
|
|
329
|
+
}
|
|
330
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Conversation cost",
|
|
4
|
+
"description": "Estimated cost of every conversation: a badge with a breakdown under the composer and a cost beside each conversation in the sidebar, priced per model and by DeepSeek peak / off-peak hours.",
|
|
5
|
+
"detail": "README.en.md"
|
|
6
|
+
}
|
|
7
|
+
}
|