@aryacode/dsh-aryacode-polish 0.0.0-stage → 0.3.4
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 +165 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/client/account.js +96 -0
- package/client/face.js +403 -0
- package/client/money.js +31 -0
- package/client/switches.json +17 -0
- package/cordis.patch.yml +17 -0
- package/docs/parked-spellcheck.md +105 -0
- package/entries/config.js +61 -0
- package/entries/cost.js +273 -0
- package/entries/polish.js +57 -0
- package/entries/registry.js +73 -0
- package/entries/usage.js +265 -0
- package/lib/client.js +561 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
package/entries/cost.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimated session cost, priced from recorded provider usage.
|
|
3
|
+
*
|
|
4
|
+
* The Host half registers one durable projection: the fold replays
|
|
5
|
+
* model-attributed token usage from the session log, and the wire view prices
|
|
6
|
+
* it at the published DeepSeek rates. The browser half only formats numbers,
|
|
7
|
+
* so no rate table and no money arithmetic live in the bundle.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { applyModelUsage, initialModelUsage } from './usage.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Off-peak price per million tokens. Peak is twice this. `usd` is the English
|
|
14
|
+
* price page and `cny` the Chinese one; cache writes are billed as misses.
|
|
15
|
+
* Verified 2026-09-27 against https://api-docs.deepseek.com/quick_start/pricing/
|
|
16
|
+
*/
|
|
17
|
+
const OFF_PEAK = {
|
|
18
|
+
flash: {
|
|
19
|
+
usd: { cache: '0.003', miss: '0.15', output: '0.6' },
|
|
20
|
+
cny: { cache: '0.02', miss: '1', output: '4' },
|
|
21
|
+
},
|
|
22
|
+
pro: {
|
|
23
|
+
usd: { cache: '0.022', miss: '0.66', output: '1.98' },
|
|
24
|
+
cny: { cache: '0.15', miss: '4.5', output: '13.5' },
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Days the table above describes. Append a new period when prices change; the
|
|
30
|
+
* fold stamps each row with the hour its usage landed in.
|
|
31
|
+
*/
|
|
32
|
+
const RATE_PERIODS = [{ from: Date.parse('2026-09-10T04:00:00Z'), until: Infinity, rates: OFF_PEAK }]
|
|
33
|
+
|
|
34
|
+
/** Providers whose prices this table describes. */
|
|
35
|
+
const PRICED_PROVIDERS = ['deepseek-official', 'deepseek-account']
|
|
36
|
+
|
|
37
|
+
/** Models that bill at the flash rate. */
|
|
38
|
+
const FLASH_MODELS = ['deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp']
|
|
39
|
+
|
|
40
|
+
/** Models that bill at the pro rate. */
|
|
41
|
+
const PRO_MODELS = ['deepseek-v4-pro']
|
|
42
|
+
|
|
43
|
+
const bucketsSchema = z.object({
|
|
44
|
+
uncachedInputTokens: z.number().int().nonnegative(),
|
|
45
|
+
outputTokens: z.number().int().nonnegative(),
|
|
46
|
+
cacheReadTokens: z.number().int().nonnegative(),
|
|
47
|
+
cacheWriteTokens: z.number().int().nonnegative(),
|
|
48
|
+
}).strict()
|
|
49
|
+
|
|
50
|
+
const rowSchema = z.object({
|
|
51
|
+
provider: z.string(),
|
|
52
|
+
model: z.string(),
|
|
53
|
+
requests: z.number().int().nonnegative(),
|
|
54
|
+
hour: z.number().int().nonnegative(),
|
|
55
|
+
peak: bucketsSchema,
|
|
56
|
+
offPeak: bucketsSchema,
|
|
57
|
+
}).strict()
|
|
58
|
+
|
|
59
|
+
const viewSchema = z.object({ models: z.array(rowSchema) }).strict()
|
|
60
|
+
|
|
61
|
+
const lastSchema = z.object({
|
|
62
|
+
turn: z.number().int().nonnegative(),
|
|
63
|
+
step: z.number().int().nonnegative(),
|
|
64
|
+
key: z.string(),
|
|
65
|
+
peak: z.boolean(),
|
|
66
|
+
buckets: bucketsSchema,
|
|
67
|
+
}).strict()
|
|
68
|
+
|
|
69
|
+
const stateSchema = z.object({
|
|
70
|
+
rows: z.record(z.string(), rowSchema),
|
|
71
|
+
pending: z.object({ provider: z.string(), model: z.string() }).strict().nullable(),
|
|
72
|
+
last: lastSchema.nullable(),
|
|
73
|
+
view: viewSchema,
|
|
74
|
+
}).strict()
|
|
75
|
+
|
|
76
|
+
/** One priced model row: tokens, requests, and money when a rate is known. */
|
|
77
|
+
const pricedModelSchema = z.object({
|
|
78
|
+
provider: z.string(),
|
|
79
|
+
model: z.string(),
|
|
80
|
+
requests: z.number().int().nonnegative(),
|
|
81
|
+
cacheRead: z.number().int().nonnegative(),
|
|
82
|
+
uncached: z.number().int().nonnegative(),
|
|
83
|
+
cacheWrite: z.number().int().nonnegative(),
|
|
84
|
+
output: z.number().int().nonnegative(),
|
|
85
|
+
incomplete: z.boolean(),
|
|
86
|
+
costCny: z.string().optional(),
|
|
87
|
+
costUsd: z.string().optional(),
|
|
88
|
+
}).strict()
|
|
89
|
+
|
|
90
|
+
/** The wire view: session totals plus one row per model. */
|
|
91
|
+
const pricedSessionSchema = z.object({
|
|
92
|
+
tokens: z.number().int().nonnegative(),
|
|
93
|
+
cacheRead: z.number().int().nonnegative(),
|
|
94
|
+
uncached: z.number().int().nonnegative(),
|
|
95
|
+
cacheWrite: z.number().int().nonnegative(),
|
|
96
|
+
output: z.number().int().nonnegative(),
|
|
97
|
+
requests: z.number().int().nonnegative(),
|
|
98
|
+
incomplete: z.boolean(),
|
|
99
|
+
models: z.array(pricedModelSchema),
|
|
100
|
+
costCny: z.string().optional(),
|
|
101
|
+
costUsd: z.string().optional(),
|
|
102
|
+
}).strict()
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} provider - routed provider id.
|
|
106
|
+
* @param {string} model - routed model id.
|
|
107
|
+
* @param {number} time - event time in milliseconds.
|
|
108
|
+
* @returns {object | undefined} the rate table for that route, when one is known.
|
|
109
|
+
*/
|
|
110
|
+
export function rateTable(provider, model, time) {
|
|
111
|
+
if (!PRICED_PROVIDERS.includes(provider)) return undefined
|
|
112
|
+
const kind = PRO_MODELS.includes(model) ? 'pro' : FLASH_MODELS.includes(model) ? 'flash' : undefined
|
|
113
|
+
if (kind === undefined) return undefined
|
|
114
|
+
const period = RATE_PERIODS.find(candidate => time >= candidate.from && time < candidate.until)
|
|
115
|
+
return period === undefined ? undefined : period.rates[kind]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {number} tokens - tokens billed at that rate.
|
|
120
|
+
* @param {string} perMillion - published price per million tokens.
|
|
121
|
+
* @param {boolean} peak - whether to double the price.
|
|
122
|
+
* @returns {number} money for those tokens.
|
|
123
|
+
*/
|
|
124
|
+
function price(tokens, perMillion, peak) {
|
|
125
|
+
const rate = Number(perMillion)
|
|
126
|
+
return (peak ? rate * 2 : rate) * tokens / 1_000_000
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {object} band - one band's four counts.
|
|
131
|
+
* @returns {object} the same counts under the names the surface shows.
|
|
132
|
+
*/
|
|
133
|
+
function bandTokens(band) {
|
|
134
|
+
return {
|
|
135
|
+
cacheRead: band.cacheReadTokens,
|
|
136
|
+
uncached: band.uncachedInputTokens,
|
|
137
|
+
cacheWrite: band.cacheWriteTokens,
|
|
138
|
+
output: band.outputTokens,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {object} row - one usage row.
|
|
144
|
+
* @returns {object} both bands added up.
|
|
145
|
+
*/
|
|
146
|
+
function addCounts(row) {
|
|
147
|
+
const peak = bandTokens(row.peak)
|
|
148
|
+
const off = bandTokens(row.offPeak)
|
|
149
|
+
return {
|
|
150
|
+
cacheRead: peak.cacheRead + off.cacheRead,
|
|
151
|
+
uncached: peak.uncached + off.uncached,
|
|
152
|
+
cacheWrite: peak.cacheWrite + off.cacheWrite,
|
|
153
|
+
output: peak.output + off.output,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {object} band - one band's four counts.
|
|
159
|
+
* @param {object} table - rate table for the route.
|
|
160
|
+
* @param {boolean} peak - whether that band is billed as peak.
|
|
161
|
+
* @returns {{ cny: number, usd: number }} money for that band.
|
|
162
|
+
*/
|
|
163
|
+
function priceBand(band, table, peak) {
|
|
164
|
+
// Cache writes are a disjoint input bucket; DeepSeek bills them with misses.
|
|
165
|
+
const input = band.uncachedInputTokens + band.cacheWriteTokens
|
|
166
|
+
return {
|
|
167
|
+
cny: price(band.cacheReadTokens, table.cny.cache, peak)
|
|
168
|
+
+ price(input, table.cny.miss, peak)
|
|
169
|
+
+ price(band.outputTokens, table.cny.output, peak),
|
|
170
|
+
usd: price(band.cacheReadTokens, table.usd.cache, peak)
|
|
171
|
+
+ price(input, table.usd.miss, peak)
|
|
172
|
+
+ price(band.outputTokens, table.usd.output, peak),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The priced view of a session that has recorded nothing yet. */
|
|
177
|
+
const EMPTY_PRICED = {
|
|
178
|
+
tokens: 0, cacheRead: 0, uncached: 0, cacheWrite: 0, output: 0, requests: 0, incomplete: false, models: [],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Price the durable model projection at the published DeepSeek rates.
|
|
183
|
+
* Unknown providers, models, or periods keep their tokens and mark the subtotal
|
|
184
|
+
* incomplete, so the surface never reports money it cannot stand behind.
|
|
185
|
+
* @param {object} usage - the projection's raw view.
|
|
186
|
+
* @returns {object} token totals, money for priced models, and one row per model.
|
|
187
|
+
*/
|
|
188
|
+
export function priceModelUsage(usage) {
|
|
189
|
+
if (usage === undefined || !Array.isArray(usage.models) || usage.models.length === 0) return EMPTY_PRICED
|
|
190
|
+
let cacheRead = 0
|
|
191
|
+
let uncached = 0
|
|
192
|
+
let cacheWrite = 0
|
|
193
|
+
let output = 0
|
|
194
|
+
let requests = 0
|
|
195
|
+
let cny = 0
|
|
196
|
+
let usd = 0
|
|
197
|
+
let priced = false
|
|
198
|
+
let incomplete = false
|
|
199
|
+
const grouped = new Map()
|
|
200
|
+
for (const row of usage.models) {
|
|
201
|
+
const counts = addCounts(row)
|
|
202
|
+
cacheRead += counts.cacheRead
|
|
203
|
+
uncached += counts.uncached
|
|
204
|
+
cacheWrite += counts.cacheWrite
|
|
205
|
+
output += counts.output
|
|
206
|
+
requests += row.requests
|
|
207
|
+
const table = rateTable(row.provider, row.model, row.hour * 3_600_000)
|
|
208
|
+
let costs
|
|
209
|
+
if (table !== undefined) {
|
|
210
|
+
const peakCost = priceBand(row.peak, table, true)
|
|
211
|
+
const offCost = priceBand(row.offPeak, table, false)
|
|
212
|
+
costs = { cny: peakCost.cny + offCost.cny, usd: peakCost.usd + offCost.usd }
|
|
213
|
+
}
|
|
214
|
+
if (costs === undefined) incomplete = true
|
|
215
|
+
else {
|
|
216
|
+
cny += costs.cny
|
|
217
|
+
usd += costs.usd
|
|
218
|
+
priced = true
|
|
219
|
+
}
|
|
220
|
+
const key = JSON.stringify([row.provider, row.model])
|
|
221
|
+
const previous = grouped.get(key)
|
|
222
|
+
const merged = {
|
|
223
|
+
provider: row.provider,
|
|
224
|
+
model: row.model,
|
|
225
|
+
requests: row.requests + (previous?.requests ?? 0),
|
|
226
|
+
cacheRead: counts.cacheRead + (previous?.cacheRead ?? 0),
|
|
227
|
+
uncached: counts.uncached + (previous?.uncached ?? 0),
|
|
228
|
+
cacheWrite: counts.cacheWrite + (previous?.cacheWrite ?? 0),
|
|
229
|
+
output: counts.output + (previous?.output ?? 0),
|
|
230
|
+
incomplete: costs === undefined || previous?.incomplete === true,
|
|
231
|
+
}
|
|
232
|
+
if (costs !== undefined || previous?.costUsd !== undefined) {
|
|
233
|
+
merged.costCny = ((costs?.cny ?? 0) + Number(previous?.costCny ?? 0)).toFixed(8)
|
|
234
|
+
merged.costUsd = ((costs?.usd ?? 0) + Number(previous?.costUsd ?? 0)).toFixed(8)
|
|
235
|
+
}
|
|
236
|
+
grouped.set(key, merged)
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
tokens: cacheRead + uncached + cacheWrite + output,
|
|
240
|
+
cacheRead,
|
|
241
|
+
uncached,
|
|
242
|
+
cacheWrite,
|
|
243
|
+
output,
|
|
244
|
+
requests,
|
|
245
|
+
incomplete,
|
|
246
|
+
models: [...grouped.values()],
|
|
247
|
+
...priced ? { costCny: cny.toFixed(8), costUsd: usd.toFixed(8) } : {},
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The durable unit this feature registers. The fold is `entries/usage.js`; the
|
|
253
|
+
* wire view is its priced shape, so the browser half receives money, not rates.
|
|
254
|
+
*/
|
|
255
|
+
export const costProjection = {
|
|
256
|
+
key: 'polishCost',
|
|
257
|
+
stateVersion: 1,
|
|
258
|
+
stateSchema,
|
|
259
|
+
init: initialModelUsage,
|
|
260
|
+
apply: applyModelUsage,
|
|
261
|
+
wire: { viewSchema: pricedSessionSchema, view: state => priceModelUsage(state.view) },
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Mount the host half of the cost surface.
|
|
266
|
+
* @param {object} ctx - the feature's own cordis context; its effects release the registration.
|
|
267
|
+
* @returns {Promise<void>} resolves once the projection is registered.
|
|
268
|
+
*/
|
|
269
|
+
export async function mountCost(ctx) {
|
|
270
|
+
const projections = ctx.get('sessionProjections')
|
|
271
|
+
if (projections === undefined) return
|
|
272
|
+
await projections.register(costProjection)
|
|
273
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `polish` row: every feature of the bundle behind one switch each.
|
|
3
|
+
*
|
|
4
|
+
* Mounting this row mounts the host half of each enabled feature. A feature that
|
|
5
|
+
* is switched off is disposed, not just ignored, so its listeners and routes go
|
|
6
|
+
* away — a switch that only hid behaviour would keep costing the app.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-aryacode-polish
|
|
9
|
+
*/
|
|
10
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
|
+
import { flag } from './config.js'
|
|
12
|
+
import { configSchema, features } from './registry.js'
|
|
13
|
+
|
|
14
|
+
/** The plugin name inside a composition. */
|
|
15
|
+
export const name = 'aryacode-polish'
|
|
16
|
+
|
|
17
|
+
/** No services are required; features that need one declare it in their own module. */
|
|
18
|
+
export const inject = []
|
|
19
|
+
|
|
20
|
+
export { features }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The row's config: one volatile boolean per registered feature, derived from
|
|
24
|
+
* `entries/registry.js` so a new feature gains its switch automatically.
|
|
25
|
+
*/
|
|
26
|
+
export const Config = z.object(configSchema(z))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the enabled features and follow the switches at runtime.
|
|
30
|
+
*
|
|
31
|
+
* @param ctx - plugin context.
|
|
32
|
+
* @param config - the row config; every field is a volatile reference, not a value.
|
|
33
|
+
*/
|
|
34
|
+
export function apply(ctx, config = {}) {
|
|
35
|
+
/** @type {Map<string, { dispose: () => unknown }>} */
|
|
36
|
+
const mounted = new Map()
|
|
37
|
+
|
|
38
|
+
// The loader commits a volatile-only change into the live references and emits
|
|
39
|
+
// `loader/volatile-update` instead of re-running apply(), so the switches have
|
|
40
|
+
// to mount and dispose their feature in place — otherwise the page would claim
|
|
41
|
+
// a feature is on while nothing is running.
|
|
42
|
+
const sync = () => {
|
|
43
|
+
for (const feature of features) {
|
|
44
|
+
const wanted = flag(config[feature.id], feature.defaultOn ?? true)
|
|
45
|
+
const running = mounted.get(feature.id)
|
|
46
|
+
if (wanted && running === undefined) {
|
|
47
|
+
if (typeof feature.mount === 'function') mounted.set(feature.id, ctx.plugin(feature.mount))
|
|
48
|
+
} else if (!wanted && running !== undefined) {
|
|
49
|
+
void running.dispose()
|
|
50
|
+
mounted.delete(feature.id)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
sync()
|
|
56
|
+
ctx.on('loader/volatile-update', sync)
|
|
57
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The feature list — the one place a quality-of-life feature is declared.
|
|
3
|
+
*
|
|
4
|
+
* One entry is one feature, and one feature is one switch: the row's `Config`
|
|
5
|
+
* schema is derived from this array (see `configSchema`), so adding an entry
|
|
6
|
+
* here is what makes the switch appear in the Configure page. There is no second
|
|
7
|
+
* registration to forget.
|
|
8
|
+
*
|
|
9
|
+
* Entry shape:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* id: 'spellcheck', // the config field name and the switch name
|
|
13
|
+
* label: 'Spelling corrections', // the words the Configure page shows
|
|
14
|
+
* description: 'Right-click a misspelled word to pick a correction.',
|
|
15
|
+
* defaultOn: true, // the switch defaults to on
|
|
16
|
+
* mount: spellcheck, // optional host half, a Cordis plugin
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* `mount` is the host half: a Cordis plugin object (or function) that this row
|
|
20
|
+
* plugs in when the switch is on and disposes when it is off. A feature with no
|
|
21
|
+
* host work omits `mount` and lives only in the client face.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-aryacode-polish-registry
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mountCost } from './cost.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every feature this bundle ships. Spelling corrections are investigated and
|
|
30
|
+
* documented in `docs/parked-spellcheck.md`; they are parked because a
|
|
31
|
+
* right-click correction menu needs a shell change no plugin can reach.
|
|
32
|
+
*
|
|
33
|
+
* @type {ReadonlyArray<{ id: string, label: string, description: string, defaultOn: boolean, mount?: unknown }>}
|
|
34
|
+
*/
|
|
35
|
+
export const features = [
|
|
36
|
+
{
|
|
37
|
+
id: 'sessionCost',
|
|
38
|
+
label: 'Estimated session cost',
|
|
39
|
+
description: 'Show what the open session has cost so far, priced from recorded usage at the published DeepSeek rates.',
|
|
40
|
+
defaultOn: true,
|
|
41
|
+
mount: mountCost,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'accountBalance',
|
|
45
|
+
label: 'Account balance',
|
|
46
|
+
description: 'Show the remaining account balance in the sidebar foot, above the account row.',
|
|
47
|
+
defaultOn: true,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive the row's config shape from the feature list.
|
|
53
|
+
*
|
|
54
|
+
* Every field is boolean, defaults to the feature's own default, and is
|
|
55
|
+
* `.volatile()`: a volatile field is committed into the running fiber without a
|
|
56
|
+
* remount, so flipping a switch does not restart the plugin — `apply()` listens
|
|
57
|
+
* for `loader/volatile-update` and mounts or disposes the feature in place.
|
|
58
|
+
*
|
|
59
|
+
* @param z - the schemastery factory (`import z from '@deepseek-ai/schemastery'`).
|
|
60
|
+
* @param list - features to derive from; the real list unless a test passes one.
|
|
61
|
+
* @returns a schema shape: one field per feature.
|
|
62
|
+
*/
|
|
63
|
+
export function configSchema(z, list = features) {
|
|
64
|
+
const shape = {}
|
|
65
|
+
for (const feature of list) {
|
|
66
|
+
shape[feature.id] = z
|
|
67
|
+
.boolean()
|
|
68
|
+
.default(feature.defaultOn ?? true)
|
|
69
|
+
.volatile()
|
|
70
|
+
.description(feature.description)
|
|
71
|
+
}
|
|
72
|
+
return shape
|
|
73
|
+
}
|
package/entries/usage.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-attributed provider usage, replayed from the durable session log.
|
|
3
|
+
*
|
|
4
|
+
* The Host keeps this fold so the browser half never has to read the event
|
|
5
|
+
* window itself: the projection's wire view carries everything the cost
|
|
6
|
+
* surface shows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The empty wire view handed to a session with no usage yet. */
|
|
10
|
+
const EMPTY_VIEW = { models: [] }
|
|
11
|
+
|
|
12
|
+
/** One empty token band. */
|
|
13
|
+
function zero() {
|
|
14
|
+
return { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Beijing peak is weekdays 09:00-12:00 and 14:00-18:00; weekends are off-peak
|
|
19
|
+
* all day. DeepSeek bills peak hours at twice the off-peak rate.
|
|
20
|
+
* @param {number} time - event time in milliseconds.
|
|
21
|
+
* @returns {boolean} whether that instant is a billed peak instant.
|
|
22
|
+
*/
|
|
23
|
+
export function isBeijingPeak(time) {
|
|
24
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
25
|
+
timeZone: 'Asia/Shanghai', weekday: 'short', hour: 'numeric', hourCycle: 'h23',
|
|
26
|
+
}).formatToParts(new Date(time))
|
|
27
|
+
const weekday = parts.find(part => part.type === 'weekday')?.value
|
|
28
|
+
const hour = Number(parts.find(part => part.type === 'hour')?.value)
|
|
29
|
+
if (weekday === 'Sat' || weekday === 'Sun' || !Number.isInteger(hour)) return false
|
|
30
|
+
return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @returns {boolean} whether the value is a usable non-negative integer count. */
|
|
34
|
+
function isCount(value) {
|
|
35
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} usage - provider-reported usage for one call.
|
|
40
|
+
* @returns {object | undefined} the four disjoint buckets, or undefined when the sample is unusable.
|
|
41
|
+
*/
|
|
42
|
+
function bucketsFrom(usage) {
|
|
43
|
+
if (!isCount(usage.inputTokens) || !isCount(usage.outputTokens)) return undefined
|
|
44
|
+
if (usage.cacheReadTokens !== undefined && !isCount(usage.cacheReadTokens)) return undefined
|
|
45
|
+
if (usage.cacheWriteTokens !== undefined && !isCount(usage.cacheWriteTokens)) return undefined
|
|
46
|
+
return {
|
|
47
|
+
uncachedInputTokens: usage.inputTokens,
|
|
48
|
+
outputTokens: usage.outputTokens,
|
|
49
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
50
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} left - first band.
|
|
56
|
+
* @param {object} right - second band.
|
|
57
|
+
* @returns {boolean} whether the two bands hold the same four counts.
|
|
58
|
+
*/
|
|
59
|
+
function bucketsEqual(left, right) {
|
|
60
|
+
return left.uncachedInputTokens === right.uncachedInputTokens
|
|
61
|
+
&& left.outputTokens === right.outputTokens
|
|
62
|
+
&& left.cacheReadTokens === right.cacheReadTokens
|
|
63
|
+
&& left.cacheWriteTokens === right.cacheWriteTokens
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The last raw stream chunk of one type, scanning backwards.
|
|
68
|
+
* @param {readonly object[]} stream - compact records from one durable settlement.
|
|
69
|
+
* @param {string} type - chunk type that only appears as a raw record.
|
|
70
|
+
* @returns {object | undefined} the stream's final chunk of that type.
|
|
71
|
+
*/
|
|
72
|
+
function lastChunk(stream, type) {
|
|
73
|
+
if (!Array.isArray(stream)) return undefined
|
|
74
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
75
|
+
const record = stream[index]
|
|
76
|
+
if (record?.type === 'chunk' && record.chunk?.type === type) return record.chunk
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {object} event - one durable session event.
|
|
83
|
+
* @returns {object | undefined} the provider usage reported by that event.
|
|
84
|
+
*/
|
|
85
|
+
function usageOf(event) {
|
|
86
|
+
if (event.type === 'assistant/message' && event.data?.usage !== undefined) return event.data.usage
|
|
87
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
|
|
88
|
+
return lastChunk(event.data?.stream, 'usage')?.usage
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {object} route - provider and model.
|
|
93
|
+
* @param {number} hour - UTC hour bucket.
|
|
94
|
+
* @returns {string} the row key for one route in one hour.
|
|
95
|
+
*/
|
|
96
|
+
function routeKey(route, hour) {
|
|
97
|
+
return JSON.stringify([route.provider, route.model, hour])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} event - one durable session event.
|
|
102
|
+
* @param {object | null} pending - route carried over from the request header.
|
|
103
|
+
* @returns {object} the route that produced this sample.
|
|
104
|
+
*/
|
|
105
|
+
function routeOf(event, pending) {
|
|
106
|
+
if (event.type === 'assistant/message') {
|
|
107
|
+
const source = event.data?.message?.source
|
|
108
|
+
if (typeof source?.provider === 'string' && source.provider.length > 0
|
|
109
|
+
&& typeof source?.model === 'string' && source.model.length > 0) {
|
|
110
|
+
return { provider: source.provider, model: source.model }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return pending ?? { provider: '', model: '' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} event - one `request/header` event.
|
|
118
|
+
* @returns {object | undefined} the routed provider and model, when both are named.
|
|
119
|
+
*/
|
|
120
|
+
function headerRoute(event) {
|
|
121
|
+
const config = event.data?.header?.config
|
|
122
|
+
const provider = config?.provider
|
|
123
|
+
const model = config?.model
|
|
124
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return undefined
|
|
125
|
+
if (provider.length === 0 || model.length === 0) return undefined
|
|
126
|
+
return { provider, model }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {object} row - one usage row.
|
|
131
|
+
* @returns {number} every bucket of both bands added up.
|
|
132
|
+
*/
|
|
133
|
+
function totalTokens(row) {
|
|
134
|
+
const bands = [row.peak, row.offPeak]
|
|
135
|
+
return bands.reduce((sum, band) => sum
|
|
136
|
+
+ band.uncachedInputTokens + band.cacheReadTokens + band.cacheWriteTokens + band.outputTokens, 0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {Record<string, object>} rows - usage rows by key.
|
|
141
|
+
* @returns {object} the wire view: the non-empty rows, heaviest first.
|
|
142
|
+
*/
|
|
143
|
+
function viewOf(rows) {
|
|
144
|
+
const models = Object.values(rows).filter(row => totalTokens(row) > 0).sort((left, right) => {
|
|
145
|
+
const delta = totalTokens(right) - totalTokens(left)
|
|
146
|
+
if (delta !== 0) return delta
|
|
147
|
+
if (left.model === right.model && left.provider === right.provider) return left.hour - right.hour
|
|
148
|
+
if (left.model === right.model) return left.provider < right.provider ? -1 : 1
|
|
149
|
+
return left.model < right.model ? -1 : 1
|
|
150
|
+
})
|
|
151
|
+
return models.length === 0 ? EMPTY_VIEW : { models }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {object} band - band to change.
|
|
156
|
+
* @param {object} delta - counts to add or remove.
|
|
157
|
+
* @param {1 | -1} sign - direction.
|
|
158
|
+
* @returns {object} the changed band.
|
|
159
|
+
*/
|
|
160
|
+
function addBand(band, delta, sign) {
|
|
161
|
+
return {
|
|
162
|
+
uncachedInputTokens: band.uncachedInputTokens + sign * delta.uncachedInputTokens,
|
|
163
|
+
outputTokens: band.outputTokens + sign * delta.outputTokens,
|
|
164
|
+
cacheReadTokens: band.cacheReadTokens + sign * delta.cacheReadTokens,
|
|
165
|
+
cacheWriteTokens: band.cacheWriteTokens + sign * delta.cacheWriteTokens,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Record one sample, replacing the sample this turn and step reported before.
|
|
171
|
+
* @param {object} state - previous fold.
|
|
172
|
+
* @param {object} route - provider and model that produced the sample.
|
|
173
|
+
* @param {boolean} peak - whether the sample fell in a billed peak hour.
|
|
174
|
+
* @param {object} buckets - the sample's four disjoint counts.
|
|
175
|
+
* @param {number} turn - turn index.
|
|
176
|
+
* @param {number} step - step index within the turn.
|
|
177
|
+
* @param {number} hour - UTC hour bucket.
|
|
178
|
+
* @returns {object} the updated fold.
|
|
179
|
+
*/
|
|
180
|
+
function withSample(state, route, peak, buckets, turn, step, hour) {
|
|
181
|
+
const key = routeKey(route, hour)
|
|
182
|
+
const previous = state.last !== null && state.last.turn === turn && state.last.step === step
|
|
183
|
+
? state.last
|
|
184
|
+
: undefined
|
|
185
|
+
if (previous !== undefined
|
|
186
|
+
&& previous.key === key
|
|
187
|
+
&& previous.peak === peak
|
|
188
|
+
&& bucketsEqual(previous.buckets, buckets)) return state
|
|
189
|
+
|
|
190
|
+
const rows = { ...state.rows }
|
|
191
|
+
const touch = (modelKey, bandPeak, delta, sign, requests) => {
|
|
192
|
+
const current = rows[modelKey] ?? {
|
|
193
|
+
provider: route.provider,
|
|
194
|
+
model: route.model,
|
|
195
|
+
hour,
|
|
196
|
+
requests: 0,
|
|
197
|
+
peak: zero(),
|
|
198
|
+
offPeak: zero(),
|
|
199
|
+
}
|
|
200
|
+
rows[modelKey] = {
|
|
201
|
+
...current,
|
|
202
|
+
requests: current.requests + requests,
|
|
203
|
+
peak: bandPeak ? addBand(current.peak, delta, sign) : current.peak,
|
|
204
|
+
offPeak: bandPeak ? current.offPeak : addBand(current.offPeak, delta, sign),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (previous === undefined) touch(key, peak, buckets, 1, 1)
|
|
209
|
+
else {
|
|
210
|
+
touch(previous.key, previous.peak, previous.buckets, -1, previous.key === key ? 0 : -1)
|
|
211
|
+
touch(key, peak, buckets, 1, previous.key === key ? 0 : 1)
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...state,
|
|
215
|
+
rows,
|
|
216
|
+
last: { turn, step, key, peak, buckets },
|
|
217
|
+
view: viewOf(rows),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fold one durable event.
|
|
223
|
+
* @param {object} state - previous fold.
|
|
224
|
+
* @param {object} event - next durable event.
|
|
225
|
+
* @returns {object} the updated fold.
|
|
226
|
+
*/
|
|
227
|
+
export function applyModelUsage(state, event) {
|
|
228
|
+
if (event.type === 'request/header') {
|
|
229
|
+
const route = headerRoute(event)
|
|
230
|
+
if (route === undefined) return { ...state, pending: null }
|
|
231
|
+
if (state.pending?.provider === route.provider && state.pending.model === route.model) return state
|
|
232
|
+
return { ...state, pending: route }
|
|
233
|
+
}
|
|
234
|
+
if (event.type === 'llm/retry-started') {
|
|
235
|
+
return state.last?.turn === event.data?.turn && state.last?.step === event.data?.step
|
|
236
|
+
? { ...state, last: null }
|
|
237
|
+
: state
|
|
238
|
+
}
|
|
239
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return state
|
|
240
|
+
const sample = usageOf(event)
|
|
241
|
+
if (sample === undefined) return state
|
|
242
|
+
const buckets = bucketsFrom(sample)
|
|
243
|
+
if (buckets === undefined) return state
|
|
244
|
+
return withSample(
|
|
245
|
+
state, routeOf(event, state.pending), isBeijingPeak(event.time), buckets,
|
|
246
|
+
event.data?.turn ?? 0, event.data?.step ?? 0, Math.floor(event.time / 3_600_000),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @returns {object} an empty accumulator.
|
|
252
|
+
*/
|
|
253
|
+
export function initialModelUsage() {
|
|
254
|
+
return { rows: {}, pending: null, last: null, view: EMPTY_VIEW }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {readonly object[]} events - durable events in session order.
|
|
259
|
+
* @returns {object} usage grouped by provider, model, and hour.
|
|
260
|
+
*/
|
|
261
|
+
export function deriveModelUsage(events) {
|
|
262
|
+
let state = initialModelUsage()
|
|
263
|
+
for (const event of events) state = applyModelUsage(state, event)
|
|
264
|
+
return state.view
|
|
265
|
+
}
|