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/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
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "对话费用",
4
+ "description": "估算每个对话的费用:输入框下方显示本会话费用并可展开明细,侧边栏显示每个对话的费用;按模型、按 DeepSeek 高峰/空闲时段分别计价。",
5
+ "detail": "README.md"
6
+ }
7
+ }