dsh-model-billing 0.3.1 → 0.3.3
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 +22 -0
- package/client.js +2345 -2284
- package/host.js +156 -1
- package/package.json +68 -68
package/host.js
CHANGED
|
@@ -1139,6 +1139,9 @@ export function apply(ctx) {
|
|
|
1139
1139
|
const onSelect = (request, response) => {
|
|
1140
1140
|
void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
|
|
1141
1141
|
}
|
|
1142
|
+
// Register the samples projection before the routes: the unit folds lazily for
|
|
1143
|
+
// sessions that already existed, and drives every later event eagerly.
|
|
1144
|
+
void billingProjectionHandle(ctx)
|
|
1142
1145
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
|
|
1143
1146
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
|
|
1144
1147
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
|
|
@@ -1161,6 +1164,156 @@ function wantsRefresh(request) {
|
|
|
1161
1164
|
}
|
|
1162
1165
|
}
|
|
1163
1166
|
|
|
1167
|
+
// ---------------------------------------------------------------------------
|
|
1168
|
+
// Billing samples
|
|
1169
|
+
//
|
|
1170
|
+
// `session.ownEvents()` / `snapshotEvents()` are synchronous log reads that
|
|
1171
|
+
// 0.2.2 deprecates for production use, so the fold is fed by a session
|
|
1172
|
+
// projection instead: the registry drives it per committed event, folds the log
|
|
1173
|
+
// lazily for sessions that predate the registration, and checkpoints it with
|
|
1174
|
+
// every other unit. The state keeps a trimmed copy of each billing-relevant
|
|
1175
|
+
// event rather than the durable log, because the fold only reads a few fields.
|
|
1176
|
+
// ---------------------------------------------------------------------------
|
|
1177
|
+
|
|
1178
|
+
const BILLING_SAMPLES_KEY = 'model-billing/samples'
|
|
1179
|
+
const BILLING_SAMPLES_VERSION = 1
|
|
1180
|
+
|
|
1181
|
+
/** The last usage chunk of a durable assistant stream, kept alone. */
|
|
1182
|
+
function usageStreamRecord(stream) {
|
|
1183
|
+
if (!Array.isArray(stream)) return undefined
|
|
1184
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
1185
|
+
const record = stream[index]
|
|
1186
|
+
if (record?.type === 'chunk' && record.chunk?.type === 'usage') {
|
|
1187
|
+
return { type: 'chunk', time: record.time, chunk: { type: 'usage', usage: record.chunk.usage } }
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return undefined
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** One committed event trimmed to the fields `computeSessionUsage` reads. */
|
|
1194
|
+
function billingSample(event) {
|
|
1195
|
+
const seq = event?.seq
|
|
1196
|
+
const time = event?.time
|
|
1197
|
+
switch (event?.type) {
|
|
1198
|
+
case 'turn/start':
|
|
1199
|
+
case 'turn/end':
|
|
1200
|
+
return { type: event.type, seq, time, data: { turn: event.data?.turn } }
|
|
1201
|
+
case 'user/message': {
|
|
1202
|
+
const content = Array.isArray(event.data?.content)
|
|
1203
|
+
? event.data.content.filter((block) => block?.type === 'text').map((block) => ({ type: 'text', text: block.text }))
|
|
1204
|
+
: []
|
|
1205
|
+
return { type: 'user/message', seq, time, data: { content } }
|
|
1206
|
+
}
|
|
1207
|
+
case 'request/header': {
|
|
1208
|
+
const config = event.data?.header?.config
|
|
1209
|
+
return { type: 'request/header', seq, time, data: { header: { config: { provider: config?.provider, model: config?.model } } } }
|
|
1210
|
+
}
|
|
1211
|
+
case 'assistant/message': {
|
|
1212
|
+
const source = event.data?.message?.source
|
|
1213
|
+
const usage = event.data?.usage
|
|
1214
|
+
const record = usageStreamRecord(event.data?.stream)
|
|
1215
|
+
return {
|
|
1216
|
+
type: 'assistant/message',
|
|
1217
|
+
seq,
|
|
1218
|
+
time,
|
|
1219
|
+
data: {
|
|
1220
|
+
turn: event.data?.turn,
|
|
1221
|
+
message: { source: { provider: source?.provider, model: source?.model } },
|
|
1222
|
+
...usage === undefined || usage === null ? {} : { usage },
|
|
1223
|
+
...record === undefined ? {} : { stream: [record] },
|
|
1224
|
+
},
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
case 'assistant/attempt': {
|
|
1228
|
+
const record = usageStreamRecord(event.data?.stream)
|
|
1229
|
+
return {
|
|
1230
|
+
type: 'assistant/attempt',
|
|
1231
|
+
seq,
|
|
1232
|
+
time,
|
|
1233
|
+
// `turn` is what the fold keys the attempt on; without it the sample is dropped.
|
|
1234
|
+
data: { turn: event.data?.turn, step: event.data?.step, ...record === undefined ? {} : { stream: [record] } },
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
case 'tool/call':
|
|
1238
|
+
return { type: 'tool/call', seq, time, data: { turn: event.data?.turn, callId: event.data?.callId, name: event.data?.name } }
|
|
1239
|
+
case 'tool/result': {
|
|
1240
|
+
const message = event.data?.message
|
|
1241
|
+
const error = event.data?.error
|
|
1242
|
+
const meta = event.data?.meta
|
|
1243
|
+
return {
|
|
1244
|
+
type: 'tool/result',
|
|
1245
|
+
seq,
|
|
1246
|
+
time,
|
|
1247
|
+
data: {
|
|
1248
|
+
turn: event.data?.turn,
|
|
1249
|
+
...message === undefined ? {} : { message: { toolCallId: message?.toolCallId, isError: message?.isError } },
|
|
1250
|
+
...error === undefined ? {} : { error },
|
|
1251
|
+
...meta === undefined ? {} : { meta },
|
|
1252
|
+
},
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
default:
|
|
1256
|
+
return undefined
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Shape check for checkpoint restores; the plugin ships no zod, so it is hand-rolled. */
|
|
1261
|
+
const billingSamplesSchema = {
|
|
1262
|
+
parse(value) {
|
|
1263
|
+
if (value === null || typeof value !== 'object' || !Array.isArray(value.samples)) {
|
|
1264
|
+
throw new TypeError('model-billing: unexpected samples projection state')
|
|
1265
|
+
}
|
|
1266
|
+
return value
|
|
1267
|
+
},
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const billingSamplesProjection = {
|
|
1271
|
+
key: BILLING_SAMPLES_KEY,
|
|
1272
|
+
stateVersion: BILLING_SAMPLES_VERSION,
|
|
1273
|
+
stateSchema: billingSamplesSchema,
|
|
1274
|
+
init(header, inheritedEventCount) {
|
|
1275
|
+
return { inheritedCut: Number(inheritedEventCount) || 0, samples: [] }
|
|
1276
|
+
},
|
|
1277
|
+
apply(state, event) {
|
|
1278
|
+
// A fork child owns only the events after its inherited prefix; the prefix
|
|
1279
|
+
// was already billed in the session it came from.
|
|
1280
|
+
if (typeof event?.seq === 'number' && event.seq < state.inheritedCut) return state
|
|
1281
|
+
const sample = billingSample(event)
|
|
1282
|
+
if (sample === undefined) return state
|
|
1283
|
+
return { inheritedCut: state.inheritedCut, samples: [...state.samples, sample] }
|
|
1284
|
+
},
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const billingProjectionPromises = new WeakMap()
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* The billing projection handle for one Host context, or null on a Harness that
|
|
1291
|
+
* predates `sessionProjections`. Registration is memoized per context.
|
|
1292
|
+
*/
|
|
1293
|
+
function billingProjectionHandle(ctx) {
|
|
1294
|
+
const cached = billingProjectionPromises.get(ctx)
|
|
1295
|
+
if (cached !== undefined) return cached
|
|
1296
|
+
let service
|
|
1297
|
+
try {
|
|
1298
|
+
service = ctx?.get?.('sessionProjections')
|
|
1299
|
+
} catch {
|
|
1300
|
+
service = undefined
|
|
1301
|
+
}
|
|
1302
|
+
const promise = service?.register === undefined
|
|
1303
|
+
? Promise.resolve(null)
|
|
1304
|
+
: Promise.resolve(service.register(billingSamplesProjection)).catch(() => null)
|
|
1305
|
+
billingProjectionPromises.set(ctx, promise)
|
|
1306
|
+
return promise
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** The billing samples of one session, from the projection or the log on old hosts. */
|
|
1310
|
+
async function sessionBillingSamples(ctx, session) {
|
|
1311
|
+
const handle = await billingProjectionHandle(ctx)
|
|
1312
|
+
if (handle !== null && handle !== undefined) return handle.stateOf(session).samples
|
|
1313
|
+
if (typeof session.snapshotEvents === 'function') return session.snapshotEvents(session.inheritedEventCount)
|
|
1314
|
+
return session.ownEvents()
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1164
1317
|
const USAGE_CACHE_LIMIT = 50
|
|
1165
1318
|
const usageCache = new Map()
|
|
1166
1319
|
|
|
@@ -1203,7 +1356,7 @@ async function serveUsage(ctx, request, response) {
|
|
|
1203
1356
|
return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
|
|
1204
1357
|
}
|
|
1205
1358
|
const state = await loadState(ctx)
|
|
1206
|
-
const events = session
|
|
1359
|
+
const events = await sessionBillingSamples(ctx, session)
|
|
1207
1360
|
const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
|
|
1208
1361
|
const billing = webToolBilling(ctx)
|
|
1209
1362
|
const webKey = `${billing.search ?? 'free'}:${billing.fetch ?? 'free'}:${billing.start}`
|
|
@@ -1362,6 +1515,8 @@ function messageOf(error) {
|
|
|
1362
1515
|
export const __test = {
|
|
1363
1516
|
applySavedOrder,
|
|
1364
1517
|
authorityOf,
|
|
1518
|
+
billingSample,
|
|
1519
|
+
billingSamplesProjection,
|
|
1365
1520
|
builtinRatesFor,
|
|
1366
1521
|
buildCatalog,
|
|
1367
1522
|
catalogPricing,
|
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-model-billing",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Reorder models in the DSH composer picker, store three per-million-token prices, and estimate per-turn and session cost with built-in DeepSeek peak/off-peak rates.",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"dsh-plugin",
|
|
8
|
-
"deepseek-harness",
|
|
9
|
-
"cordis",
|
|
10
|
-
"model-picker",
|
|
11
|
-
"cost",
|
|
12
|
-
"billing",
|
|
13
|
-
"deepseek"
|
|
14
|
-
],
|
|
15
|
-
"license": "MIT",
|
|
16
|
-
"author": "likaileave",
|
|
17
|
-
"homepage": "https://github.com/likaileave/dsh-model-billing#readme",
|
|
18
|
-
"repository": {
|
|
19
|
-
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/likaileave/dsh-model-billing.git"
|
|
21
|
-
},
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://github.com/likaileave/dsh-model-billing/issues"
|
|
24
|
-
},
|
|
25
|
-
"peerDependencies": {
|
|
26
|
-
"@deepseek-ai/dsh": "^0.2.1-rc.3"
|
|
27
|
-
},
|
|
28
|
-
"peerDependenciesMeta": {
|
|
29
|
-
"@deepseek-ai/dsh": {
|
|
30
|
-
"optional": true
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
"icon": "./icon.svg",
|
|
34
|
-
"meta": {
|
|
35
|
-
"title": "模型与费用 / Models & Cost",
|
|
36
|
-
"description": "调整模型顺序,内置 DeepSeek 官方峰谷价,填写其他模型的每百万 tokens 三档价格,并按会话日志估算逐轮和本会话费用。"
|
|
37
|
-
},
|
|
38
|
-
"exports": {
|
|
39
|
-
".": "./host.js",
|
|
40
|
-
"./client": "./client.js",
|
|
41
|
-
"./locale/*.json": "./locale/*.json",
|
|
42
|
-
"./icon": "./icon.svg",
|
|
43
|
-
"./package.json": "./package.json"
|
|
44
|
-
},
|
|
45
|
-
"files": [
|
|
46
|
-
"host.js",
|
|
47
|
-
"client.js",
|
|
48
|
-
"cordis.patch.yml",
|
|
49
|
-
"locale/*.json",
|
|
50
|
-
"icon.svg",
|
|
51
|
-
"README.md",
|
|
52
|
-
"CHANGELOG.md",
|
|
53
|
-
"LICENSE"
|
|
54
|
-
],
|
|
55
|
-
"scripts": {
|
|
56
|
-
"test": "node --test test/host.test.mjs test/routes.test.mjs",
|
|
57
|
-
"check": "node --check host.js && node --check client.js"
|
|
58
|
-
},
|
|
59
|
-
"dsh": {
|
|
60
|
-
"bundle": {
|
|
61
|
-
"patch": "./cordis.patch.yml"
|
|
62
|
-
},
|
|
63
|
-
"client": {
|
|
64
|
-
"platform": "web",
|
|
65
|
-
"immediately": true
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-model-billing",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Reorder models in the DSH composer picker, store three per-million-token prices, and estimate per-turn and session cost with built-in DeepSeek peak/off-peak rates.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"model-picker",
|
|
11
|
+
"cost",
|
|
12
|
+
"billing",
|
|
13
|
+
"deepseek"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "likaileave",
|
|
17
|
+
"homepage": "https://github.com/likaileave/dsh-model-billing#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/likaileave/dsh-model-billing.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/likaileave/dsh-model-billing/issues"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@deepseek-ai/dsh": "^0.2.1-rc.3"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@deepseek-ai/dsh": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"icon": "./icon.svg",
|
|
34
|
+
"meta": {
|
|
35
|
+
"title": "模型与费用 / Models & Cost",
|
|
36
|
+
"description": "调整模型顺序,内置 DeepSeek 官方峰谷价,填写其他模型的每百万 tokens 三档价格,并按会话日志估算逐轮和本会话费用。"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": "./host.js",
|
|
40
|
+
"./client": "./client.js",
|
|
41
|
+
"./locale/*.json": "./locale/*.json",
|
|
42
|
+
"./icon": "./icon.svg",
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"host.js",
|
|
47
|
+
"client.js",
|
|
48
|
+
"cordis.patch.yml",
|
|
49
|
+
"locale/*.json",
|
|
50
|
+
"icon.svg",
|
|
51
|
+
"README.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
53
|
+
"LICENSE"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --test test/host.test.mjs test/routes.test.mjs",
|
|
57
|
+
"check": "node --check host.js && node --check client.js"
|
|
58
|
+
},
|
|
59
|
+
"dsh": {
|
|
60
|
+
"bundle": {
|
|
61
|
+
"patch": "./cordis.patch.yml"
|
|
62
|
+
},
|
|
63
|
+
"client": {
|
|
64
|
+
"platform": "web",
|
|
65
|
+
"immediately": true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|