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.
Files changed (4) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/client.js +2345 -2284
  3. package/host.js +156 -1
  4. 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.ownEvents()
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.1",
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
+ }