dsh-all-usage 1.0.9 → 1.1.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 +54 -0
- package/README.md +59 -12
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/lib/client.js +1668 -100
- package/lib/index.js +1118 -167
- package/lib/pricing.js +645 -0
- package/package.json +3 -1
- package/screenshots.json +5 -0
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-all-usage 插件 Host 半(永久版)
|
|
2
2
|
// 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
|
|
3
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
import { COST_SCHEMA_VERSION, addCostAggregate, calculateCost, createEmptyPricingState, decimalSubtract, fetchModelsDevCatalog, emptyCostAggregate, normalizeCostSnapshot, normalizePricingState, officialProviderIds, resolvePricing, serializeCostAggregate, serializePricingState } from './pricing.js'
|
|
4
5
|
|
|
5
6
|
const name = 'dsh-all-usage'
|
|
6
7
|
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
|
|
@@ -60,13 +61,28 @@ function sendJson(res, code, value) {
|
|
|
60
61
|
function readBody(req, maxBytes) {
|
|
61
62
|
return new Promise((resolve) => {
|
|
62
63
|
const chunks = []
|
|
63
|
-
|
|
64
|
+
const declaredLength = Number(requestHeader(req, 'content-length'))
|
|
65
|
+
let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
|
|
66
|
+
let tooLarge = size > maxBytes
|
|
67
|
+
let settled = false
|
|
68
|
+
const finish = (text, oversized) => {
|
|
69
|
+
if (settled) return
|
|
70
|
+
settled = true
|
|
71
|
+
resolve({ text, tooLarge: oversized })
|
|
72
|
+
}
|
|
64
73
|
req.on('data', (chunk) => {
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
if (tooLarge) return
|
|
75
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
76
|
+
size += value.length
|
|
77
|
+
if (size > maxBytes) {
|
|
78
|
+
tooLarge = true
|
|
79
|
+
chunks.length = 0
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
chunks.push(value)
|
|
67
83
|
})
|
|
68
|
-
req.on('end', () =>
|
|
69
|
-
req.on('error', () =>
|
|
84
|
+
req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
|
|
85
|
+
req.on('error', () => finish('', false))
|
|
70
86
|
})
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -91,15 +107,24 @@ function apply(ctx) {
|
|
|
91
107
|
// One canonical usage contribution per session turn/step. This makes retries and
|
|
92
108
|
// replacement messages update a logical model call instead of double-counting it.
|
|
93
109
|
const usageByStep = new Map()
|
|
110
|
+
const turnRecords = new Map()
|
|
111
|
+
const usageByLocalDate = new Map()
|
|
112
|
+
const usageByUtcDate = new Map()
|
|
113
|
+
const turnsByLocalDate = new Map()
|
|
114
|
+
const turnsByUtcDate = new Map()
|
|
94
115
|
const sessionModel = new Map()
|
|
95
|
-
const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
116
|
+
const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
96
117
|
const sessionCount = new Set()
|
|
97
118
|
const sessionSeq = new Map()
|
|
98
119
|
const chains = new Map()
|
|
120
|
+
const liveResyncPending = new Set()
|
|
121
|
+
const liveResyncTimers = new Map()
|
|
122
|
+
const liveResyncAttempts = new Map()
|
|
99
123
|
const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
|
|
100
124
|
const aliases = {}
|
|
101
125
|
let kvUnit = null
|
|
102
126
|
let aliasWriteChain = Promise.resolve()
|
|
127
|
+
let aliasesReady = Promise.resolve()
|
|
103
128
|
let balanceCache = { fetchedAt: 0, payload: null }
|
|
104
129
|
const requestToken = randomBytes(32).toString('base64url')
|
|
105
130
|
// A non-secret identity distinguishes HMR/restart revision resets from a stale page.
|
|
@@ -120,10 +145,22 @@ function apply(ctx) {
|
|
|
120
145
|
sessionsFailed: 0,
|
|
121
146
|
}
|
|
122
147
|
const ledgerRecords = new Map()
|
|
148
|
+
const queryCache = new Map()
|
|
149
|
+
const recordsQueryCache = new Map()
|
|
150
|
+
let snapshotCache = null
|
|
123
151
|
let ledgerUnit = null
|
|
124
152
|
let ledgerReady = Promise.resolve()
|
|
125
153
|
let ledgerWriteChain = Promise.resolve()
|
|
126
|
-
|
|
154
|
+
let pricingState = createEmptyPricingState()
|
|
155
|
+
const pricingResolutionCache = new Map()
|
|
156
|
+
let pricingUnit = null
|
|
157
|
+
let pricingReady = Promise.resolve()
|
|
158
|
+
let pricingWriteChain = Promise.resolve()
|
|
159
|
+
let pricingSyncInFlight = false
|
|
160
|
+
let pricingSyncTimer = null
|
|
161
|
+
const LEDGER_VERSION = 3
|
|
162
|
+
const PREVIOUS_LEDGER_VERSION = 2
|
|
163
|
+
const LEGACY_LEDGER_VERSION = 1
|
|
127
164
|
let ledgerRevision = Date.now()
|
|
128
165
|
let disposed = false
|
|
129
166
|
let baselineRetryDelay = 1000
|
|
@@ -146,21 +183,103 @@ function apply(ctx) {
|
|
|
146
183
|
const d = new Date(ms)
|
|
147
184
|
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
148
185
|
}
|
|
186
|
+
function dateKeys(ms) {
|
|
187
|
+
const d = new Date(ms)
|
|
188
|
+
const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
189
|
+
const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
190
|
+
return { local, utc }
|
|
191
|
+
}
|
|
149
192
|
function num(v) {
|
|
150
193
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
151
194
|
}
|
|
195
|
+
function validEventTime(value) {
|
|
196
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
197
|
+
}
|
|
198
|
+
function addDateIndex(index, date, key) {
|
|
199
|
+
if (typeof date !== 'string' || date === '') return
|
|
200
|
+
let keys = index.get(date)
|
|
201
|
+
if (keys === undefined) { keys = new Set(); index.set(date, keys) }
|
|
202
|
+
keys.add(key)
|
|
203
|
+
}
|
|
204
|
+
function removeDateIndex(index, date, key) {
|
|
205
|
+
if (typeof date !== 'string' || date === '') return
|
|
206
|
+
const keys = index.get(date)
|
|
207
|
+
if (keys === undefined) return
|
|
208
|
+
keys.delete(key)
|
|
209
|
+
if (keys.size === 0) index.delete(date)
|
|
210
|
+
}
|
|
211
|
+
function indexUsage(item) {
|
|
212
|
+
addDateIndex(usageByLocalDate, item.date, item.key)
|
|
213
|
+
addDateIndex(usageByUtcDate, item.dateUtc, item.key)
|
|
214
|
+
}
|
|
215
|
+
function unindexUsage(item) {
|
|
216
|
+
removeDateIndex(usageByLocalDate, item.date, item.key)
|
|
217
|
+
removeDateIndex(usageByUtcDate, item.dateUtc, item.key)
|
|
218
|
+
}
|
|
219
|
+
function indexTurn(turn) {
|
|
220
|
+
addDateIndex(turnsByLocalDate, turn.date, turn.key)
|
|
221
|
+
addDateIndex(turnsByUtcDate, turn.dateUtc, turn.key)
|
|
222
|
+
}
|
|
223
|
+
function indexedEntries(index, source, scope, heatStart, today) {
|
|
224
|
+
const result = []
|
|
225
|
+
for (const [date, keys] of index) {
|
|
226
|
+
if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
|
|
227
|
+
for (const key of keys) {
|
|
228
|
+
const item = source.get(key)
|
|
229
|
+
if (item !== undefined) result.push({ item, date })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
function indexedEntriesInRange(index, source, start, end) {
|
|
235
|
+
const result = []
|
|
236
|
+
for (const [date, keys] of index) {
|
|
237
|
+
if (date < start || date > end) continue
|
|
238
|
+
for (const key of keys) {
|
|
239
|
+
const item = source.get(key)
|
|
240
|
+
if (item !== undefined) result.push({ item, date })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result
|
|
244
|
+
}
|
|
245
|
+
function usageBasisEqual(first, identity, values) {
|
|
246
|
+
if (first === null || first === undefined) return false
|
|
247
|
+
const firstIdentity = first.identity || first.modelId
|
|
248
|
+
const left = coerceIdentity(firstIdentity)
|
|
249
|
+
const right = coerceIdentity(identity)
|
|
250
|
+
if (left.identityKey !== right.identityKey) return false
|
|
251
|
+
return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
|
|
252
|
+
}
|
|
253
|
+
function resolveCurrentPricing(identity) {
|
|
254
|
+
const normalized = coerceIdentity(identity)
|
|
255
|
+
const key = normalized.identityKey
|
|
256
|
+
const cached = pricingResolutionCache.get(key)
|
|
257
|
+
if (cached !== undefined) return cached
|
|
258
|
+
const resolved = resolvePricing(normalized, pricingState)
|
|
259
|
+
pricingResolutionCache.set(key, resolved)
|
|
260
|
+
while (pricingResolutionCache.size > 5000) pricingResolutionCache.delete(pricingResolutionCache.keys().next().value)
|
|
261
|
+
return resolved
|
|
262
|
+
}
|
|
263
|
+
function costForUsage(values, identity, previous) {
|
|
264
|
+
const previousCost = normalizeCostSnapshot(previous && previous.cost)
|
|
265
|
+
if (previousCost !== null && previousCost.pricingMode === 'official-model' && usageBasisEqual(previous, identity, values)) return previousCost
|
|
266
|
+
return calculateCost(values, resolveCurrentPricing(identity))
|
|
267
|
+
}
|
|
152
268
|
// Coalesce synchronous aggregation writes without adding a timer lifecycle.
|
|
269
|
+
function commitPendingStats() {
|
|
270
|
+
if (!statsDirtyScheduled) return
|
|
271
|
+
statsDirtyScheduled = false
|
|
272
|
+
if (disposed) return
|
|
273
|
+
statsRevision += 1
|
|
274
|
+
statsUpdatedAt = Date.now()
|
|
275
|
+
}
|
|
153
276
|
function markStatsChanged() {
|
|
277
|
+
snapshotCache = null
|
|
278
|
+
recordsQueryCache.clear()
|
|
154
279
|
if (disposed || statsDirtyScheduled) return
|
|
155
280
|
statsDirtyScheduled = true
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (disposed) return
|
|
159
|
-
statsRevision += 1
|
|
160
|
-
statsUpdatedAt = Date.now()
|
|
161
|
-
}
|
|
162
|
-
if (typeof queueMicrotask === 'function') queueMicrotask(flush)
|
|
163
|
-
else Promise.resolve().then(flush)
|
|
281
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
|
|
282
|
+
else Promise.resolve().then(commitPendingStats)
|
|
164
283
|
}
|
|
165
284
|
function resetSyncState() {
|
|
166
285
|
sync.lastStartedAt = 0
|
|
@@ -218,6 +337,23 @@ function apply(ctx) {
|
|
|
218
337
|
perWorkspace.clear()
|
|
219
338
|
perModel.clear()
|
|
220
339
|
usageByStep.clear()
|
|
340
|
+
turnRecords.clear()
|
|
341
|
+
usageByLocalDate.clear()
|
|
342
|
+
usageByUtcDate.clear()
|
|
343
|
+
turnsByLocalDate.clear()
|
|
344
|
+
turnsByUtcDate.clear()
|
|
345
|
+
for (const timer of liveResyncTimers.values()) clearTimeout(timer)
|
|
346
|
+
liveResyncTimers.clear()
|
|
347
|
+
liveResyncAttempts.clear()
|
|
348
|
+
liveResyncPending.clear()
|
|
349
|
+
reconcileHintScheduled = false
|
|
350
|
+
if (baselineFallbackTimer !== null) {
|
|
351
|
+
clearTimeout(baselineFallbackTimer)
|
|
352
|
+
baselineFallbackTimer = null
|
|
353
|
+
}
|
|
354
|
+
baselineRetryScheduled = false
|
|
355
|
+
queryCache.clear()
|
|
356
|
+
recordsQueryCache.clear()
|
|
221
357
|
sessionModel.clear()
|
|
222
358
|
sessionCount.clear()
|
|
223
359
|
sessionSeq.clear()
|
|
@@ -229,6 +365,7 @@ function apply(ctx) {
|
|
|
229
365
|
totals.cacheRead = 0
|
|
230
366
|
totals.cacheWrite = 0
|
|
231
367
|
totals.reasoning = 0
|
|
368
|
+
Object.assign(totals.cost, emptyCostAggregate())
|
|
232
369
|
scan.started = false
|
|
233
370
|
scan.done = false
|
|
234
371
|
scan.scanned = 0
|
|
@@ -242,7 +379,7 @@ function apply(ctx) {
|
|
|
242
379
|
function ensureDay(dayMap, date) {
|
|
243
380
|
let day = dayMap.get(date)
|
|
244
381
|
if (day === undefined) {
|
|
245
|
-
day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
|
|
382
|
+
day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: emptyCostAggregate(), perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set(), sessionRefs: new Map() }
|
|
246
383
|
dayMap.set(date, day)
|
|
247
384
|
}
|
|
248
385
|
return day
|
|
@@ -250,7 +387,7 @@ function apply(ctx) {
|
|
|
250
387
|
function ensureWs(wsId) {
|
|
251
388
|
let ws = perWorkspace.get(wsId)
|
|
252
389
|
if (ws === undefined) {
|
|
253
|
-
ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
390
|
+
ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
254
391
|
perWorkspace.set(wsId, ws)
|
|
255
392
|
}
|
|
256
393
|
return ws
|
|
@@ -258,24 +395,26 @@ function apply(ctx) {
|
|
|
258
395
|
function ensureDayWs(day, wsId) {
|
|
259
396
|
let w = day.byWs.get(wsId)
|
|
260
397
|
if (w === undefined) {
|
|
261
|
-
w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
398
|
+
w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
262
399
|
day.byWs.set(wsId, w)
|
|
263
400
|
}
|
|
264
401
|
return w
|
|
265
402
|
}
|
|
266
|
-
function ensureModel(
|
|
267
|
-
|
|
403
|
+
function ensureModel(value) {
|
|
404
|
+
const identity = coerceIdentity(value)
|
|
405
|
+
let item = perModel.get(identity.identityKey)
|
|
268
406
|
if (item === undefined) {
|
|
269
|
-
item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
270
|
-
perModel.set(
|
|
407
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
408
|
+
perModel.set(identity.identityKey, item)
|
|
271
409
|
}
|
|
272
410
|
return item
|
|
273
411
|
}
|
|
274
|
-
function ensureDayModel(day,
|
|
275
|
-
|
|
412
|
+
function ensureDayModel(day, value) {
|
|
413
|
+
const identity = coerceIdentity(value)
|
|
414
|
+
let item = day.byModel.get(identity.identityKey)
|
|
276
415
|
if (item === undefined) {
|
|
277
|
-
item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
278
|
-
day.byModel.set(
|
|
416
|
+
item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
417
|
+
day.byModel.set(identity.identityKey, item)
|
|
279
418
|
}
|
|
280
419
|
return item
|
|
281
420
|
}
|
|
@@ -298,75 +437,187 @@ function apply(ctx) {
|
|
|
298
437
|
function noValues(target) {
|
|
299
438
|
return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
|
|
300
439
|
}
|
|
301
|
-
function
|
|
440
|
+
function adjustDaySession(day, sid, direction) {
|
|
441
|
+
if (sid === undefined || sid === null) return
|
|
442
|
+
const current = day.sessionRefs.get(sid) || 0
|
|
443
|
+
const next = current + direction
|
|
444
|
+
if (next > 0) {
|
|
445
|
+
day.sessionRefs.set(sid, next)
|
|
446
|
+
day.sessionIds.add(sid)
|
|
447
|
+
} else {
|
|
448
|
+
day.sessionRefs.delete(sid)
|
|
449
|
+
day.sessionIds.delete(sid)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost) {
|
|
302
453
|
const day = ensureDay(dayMap, date)
|
|
303
|
-
|
|
454
|
+
adjustDaySession(day, sid, direction)
|
|
304
455
|
adjustValues(day.tokens, values, direction)
|
|
456
|
+
addCostAggregateDirection(day.cost, cost, direction)
|
|
305
457
|
const dayWs = ensureDayWs(day, wsId)
|
|
306
458
|
adjustValues(dayWs, values, direction)
|
|
459
|
+
addCostAggregateDirection(dayWs.cost, cost, direction)
|
|
307
460
|
if (noValues(dayWs)) day.byWs.delete(wsId)
|
|
308
|
-
const dayModel = ensureDayModel(day,
|
|
461
|
+
const dayModel = ensureDayModel(day, identity)
|
|
309
462
|
dayModel.calls += direction
|
|
310
463
|
adjustValues(dayModel, values, direction)
|
|
311
|
-
|
|
464
|
+
addCostAggregateDirection(dayModel.cost, cost, direction)
|
|
465
|
+
if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
|
|
312
466
|
}
|
|
313
|
-
function
|
|
314
|
-
|
|
467
|
+
function addCostAggregateDirection(target, cost, direction) {
|
|
468
|
+
if (direction === 1) {
|
|
469
|
+
addCostAggregate(target, cost)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (direction !== -1) return
|
|
473
|
+
const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
|
|
474
|
+
if (status === 'priced') {
|
|
475
|
+
target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
|
|
476
|
+
target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
|
|
477
|
+
target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
|
|
478
|
+
target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
|
|
479
|
+
target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
|
|
480
|
+
target.total = decimalSubtract(target.total, cost.total)
|
|
481
|
+
target.pricedCalls -= 1
|
|
482
|
+
} else if (status === 'ambiguous') target.ambiguousCalls -= 1
|
|
483
|
+
else if (status === 'unsupported') target.unsupportedCalls -= 1
|
|
484
|
+
else target.unpricedCalls -= 1
|
|
485
|
+
}
|
|
486
|
+
function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost) {
|
|
487
|
+
const normalized = coerceIdentity(identity)
|
|
488
|
+
const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
|
|
489
|
+
const modelTotals = ensureModel(normalized)
|
|
315
490
|
modelTotals.calls += direction
|
|
316
491
|
adjustValues(modelTotals, values, direction)
|
|
317
|
-
|
|
492
|
+
addCostAggregateDirection(modelTotals.cost, cost, direction)
|
|
493
|
+
if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(normalized.identityKey)
|
|
318
494
|
adjustValues(totals, values, direction)
|
|
495
|
+
addCostAggregateDirection(totals.cost, cost, direction)
|
|
319
496
|
const ws = ensureWs(wsId)
|
|
320
497
|
adjustValues(ws, values, direction)
|
|
321
|
-
|
|
322
|
-
adjustDay(
|
|
498
|
+
addCostAggregateDirection(ws.cost, cost, direction)
|
|
499
|
+
adjustDay(byDay, dates.local, wsId, values, normalized, direction, sid, cost)
|
|
500
|
+
adjustDay(byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost)
|
|
323
501
|
}
|
|
324
|
-
function usageStepKey(sid, data, seq) {
|
|
502
|
+
function usageStepKey(sid, data, seq, fallback) {
|
|
325
503
|
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
326
504
|
const step = data && typeof data.step === 'number' ? data.step : null
|
|
327
505
|
if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
|
|
328
|
-
|
|
506
|
+
if (typeof seq === 'number') return sid + ':event:' + seq
|
|
507
|
+
return sid + ':event:' + (fallback === undefined ? JSON.stringify(data || {}) : String(fallback))
|
|
329
508
|
}
|
|
330
|
-
function addUsage(wsId, time, usage, model, sid, data, seq) {
|
|
509
|
+
function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
|
|
510
|
+
if (!validEventTime(time)) return
|
|
331
511
|
const values = usageValues(usage)
|
|
332
|
-
const
|
|
512
|
+
const identity = coerceIdentity(model)
|
|
333
513
|
const eventSeq = typeof seq === 'number' ? seq : -1
|
|
334
514
|
// v1.0.7: a usage event carrying no billable token in any bucket must not add a
|
|
335
515
|
// meaningless row nor wipe previously recorded real usage (cc-switch
|
|
336
516
|
// has_billable_tokens parity). Pure cache-read requests are billable and pass.
|
|
337
517
|
if (noValues(values)) return
|
|
518
|
+
const dates = dateKeys(time)
|
|
338
519
|
const key = usageStepKey(sid, data, seq)
|
|
339
520
|
const previous = usageByStep.get(key)
|
|
340
521
|
// A late replay of an older raw event cannot replace the canonical later step.
|
|
341
522
|
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
|
|
342
|
-
if (previous !== undefined)
|
|
343
|
-
|
|
523
|
+
if (previous !== undefined) {
|
|
524
|
+
unindexUsage(previous)
|
|
525
|
+
adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc }, previous.cost)
|
|
526
|
+
}
|
|
527
|
+
const cost = costForUsage(values, identity, previous)
|
|
528
|
+
const next = { key, seq: eventSeq, wsId, time, date: dates.local, dateUtc: dates.utc, values, identity, modelId: identity.label, cost, turn: data && typeof data.turn === 'number' ? data.turn : null, step: data && typeof data.step === 'number' ? data.step : null, materialization, sid }
|
|
344
529
|
usageByStep.set(key, next)
|
|
345
|
-
|
|
530
|
+
indexUsage(next)
|
|
531
|
+
adjustUsage(wsId, time, values, identity, 1, sid, dates, cost)
|
|
346
532
|
markStatsChanged()
|
|
347
533
|
}
|
|
348
534
|
function addDayTurn(dayMap, date, wsId, sid) {
|
|
349
535
|
const day = ensureDay(dayMap, date)
|
|
350
|
-
|
|
536
|
+
adjustDaySession(day, sid, 1)
|
|
351
537
|
day.turns += 1
|
|
352
538
|
day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
|
|
353
539
|
}
|
|
354
|
-
function
|
|
540
|
+
function turnRecordKey(sid, turn, seq, time) {
|
|
541
|
+
if (typeof turn === 'number' && Number.isFinite(turn)) return sid + ':turn:' + turn
|
|
542
|
+
if (typeof seq === 'number' && Number.isFinite(seq)) return sid + ':event:' + seq
|
|
543
|
+
return sid + ':time:' + String(time)
|
|
544
|
+
}
|
|
545
|
+
function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
|
|
546
|
+
if (!validEventTime(time)) return
|
|
547
|
+
const key = turnRecordKey(sid, turn, seq, time)
|
|
548
|
+
if (turnRecords.has(key)) return
|
|
549
|
+
const normalized = coerceIdentity(identity)
|
|
550
|
+
const dates = dateKeys(time)
|
|
551
|
+
const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
|
|
552
|
+
turnRecords.set(key, record)
|
|
553
|
+
indexTurn(record)
|
|
355
554
|
ensureWs(wsId).turns += 1
|
|
356
555
|
totals.turns += 1
|
|
357
|
-
addDayTurn(byDay,
|
|
358
|
-
addDayTurn(byDayUtc,
|
|
556
|
+
addDayTurn(byDay, dates.local, wsId, sid)
|
|
557
|
+
addDayTurn(byDayUtc, dates.utc, wsId, sid)
|
|
359
558
|
markStatsChanged()
|
|
360
559
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
return
|
|
560
|
+
const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
|
|
561
|
+
function textOrNull(value) {
|
|
562
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
563
|
+
}
|
|
564
|
+
function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
|
|
565
|
+
const model = actualModel || requestedModel
|
|
566
|
+
if (model !== null) return provider === null ? model : provider + ' / ' + model
|
|
567
|
+
return legacyLabel || UNKNOWN_MODEL_LABEL
|
|
568
|
+
}
|
|
569
|
+
function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
|
|
570
|
+
const normalizedProvider = textOrNull(provider)
|
|
571
|
+
const normalizedRequested = textOrNull(requestedModel)
|
|
572
|
+
const normalizedActual = textOrNull(actualModel)
|
|
573
|
+
const normalizedLegacy = textOrNull(legacyLabel)
|
|
574
|
+
const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
|
|
575
|
+
return {
|
|
576
|
+
identityKey: key,
|
|
577
|
+
provider: normalizedProvider,
|
|
578
|
+
requestedModel: normalizedRequested,
|
|
579
|
+
actualModel: normalizedActual,
|
|
580
|
+
label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
|
|
581
|
+
legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function identityFromLegacy(label) {
|
|
585
|
+
return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
|
|
586
|
+
}
|
|
587
|
+
function isCanonicalIdentity(value) {
|
|
588
|
+
return value !== null && typeof value === 'object' && typeof value.identityKey === 'string' && typeof value.label === 'string' && (value.provider === null || typeof value.provider === 'string') && (value.requestedModel === null || typeof value.requestedModel === 'string') && (value.actualModel === null || typeof value.actualModel === 'string') && typeof value.legacy === 'boolean'
|
|
589
|
+
}
|
|
590
|
+
function coerceIdentity(value) {
|
|
591
|
+
if (isCanonicalIdentity(value)) return value
|
|
592
|
+
if (value !== null && typeof value === 'object') {
|
|
593
|
+
return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
|
|
594
|
+
}
|
|
595
|
+
if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
|
|
596
|
+
return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
|
|
597
|
+
}
|
|
598
|
+
function routeObject(data) {
|
|
599
|
+
if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
|
|
600
|
+
const config = data && data.header && data.header.config
|
|
601
|
+
return config && typeof config === 'object' ? config : null
|
|
602
|
+
}
|
|
603
|
+
function identityFromRoute(data, fallback) {
|
|
604
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
605
|
+
const route = routeObject(data)
|
|
606
|
+
if (route === null) return base
|
|
607
|
+
return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
|
|
608
|
+
}
|
|
609
|
+
function identityFromMessage(data, fallback) {
|
|
610
|
+
const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
|
|
611
|
+
const source = data && data.message && data.message.source
|
|
612
|
+
if (source === null || typeof source !== 'object') return base
|
|
613
|
+
return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
|
|
364
614
|
}
|
|
365
615
|
function modelFromRoute(data) {
|
|
366
|
-
|
|
616
|
+
const identity = identityFromRoute(data)
|
|
617
|
+
return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
|
|
367
618
|
}
|
|
368
619
|
function modelFromMessage(data, fallback) {
|
|
369
|
-
return
|
|
620
|
+
return identityFromMessage(data, fallback).label
|
|
370
621
|
}
|
|
371
622
|
function nextLedgerRevision() {
|
|
372
623
|
ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
|
|
@@ -375,64 +626,120 @@ function apply(ctx) {
|
|
|
375
626
|
function ledgerEventKey(event, index) {
|
|
376
627
|
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
377
628
|
}
|
|
378
|
-
function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
|
|
629
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord) {
|
|
379
630
|
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
380
631
|
const events = session && Array.isArray(session.events) ? session.events : []
|
|
381
632
|
if (sid === '' || workspaceId === undefined) return null
|
|
382
633
|
const turns = new Map()
|
|
383
634
|
const usage = new Map()
|
|
384
|
-
|
|
635
|
+
const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
|
|
636
|
+
let currentIdentity = makeIdentity(null, null, null, null)
|
|
385
637
|
for (let index = 0; index < events.length; index += 1) {
|
|
386
638
|
const event = events[index]
|
|
387
639
|
if (event === null || typeof event !== 'object') continue
|
|
388
640
|
const data = event.data
|
|
389
641
|
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
390
|
-
|
|
391
|
-
if (model !== undefined) currentModel = model
|
|
642
|
+
currentIdentity = identityFromRoute(data, currentIdentity)
|
|
392
643
|
continue
|
|
393
644
|
}
|
|
394
645
|
if (event.type === 'turn/end') {
|
|
646
|
+
if (!validEventTime(event.time)) continue
|
|
395
647
|
const key = ledgerEventKey(event, index)
|
|
396
|
-
|
|
648
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
649
|
+
turns.set(key, { key, seq: typeof event.seq === 'number' ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
|
|
397
650
|
continue
|
|
398
651
|
}
|
|
399
|
-
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
|
|
652
|
+
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined || !validEventTime(event.time)) continue
|
|
400
653
|
const values = usageValues(data.usage)
|
|
401
654
|
// v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
|
|
402
655
|
// durable ledger (cc-switch has_billable_tokens parity).
|
|
403
656
|
if (noValues(values)) continue
|
|
404
|
-
const
|
|
405
|
-
? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
|
|
657
|
+
const identity = identityFromMessage(data, currentIdentity)
|
|
406
658
|
const eventSeq = typeof event.seq === 'number' ? event.seq : -1
|
|
407
|
-
const key = usageStepKey(sid, data, event.seq)
|
|
659
|
+
const key = usageStepKey(sid, data, event.seq, index)
|
|
408
660
|
const previous = usage.get(key)
|
|
409
661
|
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
410
|
-
|
|
662
|
+
const previousItem = previousUsage.get(key)
|
|
663
|
+
const previousCost = normalizeCostSnapshot(previousItem && previousItem.cost)
|
|
664
|
+
const cost = previousItem !== undefined && previousCost !== null && previousCost.pricingMode === 'official-model' && usageBasisEqual(previousItem, identity, values) ? previousCost : calculateCost(values, resolveCurrentPricing(identity))
|
|
665
|
+
usage.set(key, {
|
|
666
|
+
key,
|
|
667
|
+
seq: eventSeq,
|
|
668
|
+
time: event.time,
|
|
669
|
+
workspaceId,
|
|
670
|
+
identity,
|
|
671
|
+
modelId: identity.label,
|
|
672
|
+
cost,
|
|
673
|
+
turn: data && typeof data.turn === 'number' ? data.turn : null,
|
|
674
|
+
step: data && typeof data.step === 'number' ? data.step : null,
|
|
675
|
+
values,
|
|
676
|
+
})
|
|
677
|
+
currentIdentity = identity
|
|
411
678
|
}
|
|
412
|
-
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
679
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
413
680
|
}
|
|
414
681
|
function normalizeLedgerRecord(raw, key) {
|
|
415
|
-
if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
682
|
+
if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
416
683
|
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
684
|
+
const needsUpgrade = raw.version !== LEDGER_VERSION || raw.usage.some((item) => { const cost = normalizeCostSnapshot(item && item.cost); return item === null || typeof item !== 'object' || item.identity === undefined || cost === null || cost.pricingMode !== 'official-model' })
|
|
417
685
|
const turnMap = new Map()
|
|
418
686
|
for (const turn of raw.turns) {
|
|
419
|
-
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
|
|
687
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: typeof turn.seq === 'number' ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
|
|
420
688
|
}
|
|
421
689
|
const usageMap = new Map()
|
|
422
690
|
for (const item of raw.usage) {
|
|
423
|
-
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined ||
|
|
424
|
-
const
|
|
691
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
692
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
693
|
+
const normalizedCost = normalizeCostSnapshot(item.cost)
|
|
694
|
+
const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
425
695
|
const previous = usageMap.get(normalized.key)
|
|
426
696
|
if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
|
|
427
697
|
}
|
|
428
698
|
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
429
699
|
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
430
|
-
|
|
700
|
+
const lastUsage = Array.from(usageMap.values()).at(-1)
|
|
701
|
+
const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
|
|
702
|
+
const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
703
|
+
Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
|
|
704
|
+
return normalizedRecord
|
|
705
|
+
}
|
|
706
|
+
function prepareLedgerRecord(record) {
|
|
707
|
+
if (record === null || record === undefined) return record
|
|
708
|
+
let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
|
|
709
|
+
for (const item of record.usage) {
|
|
710
|
+
const cost = normalizeCostSnapshot(item.cost)
|
|
711
|
+
if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
|
|
712
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
713
|
+
item.cost = calculateCost(item.values, resolveCurrentPricing(identity))
|
|
714
|
+
changed = true
|
|
715
|
+
}
|
|
716
|
+
if (changed) {
|
|
717
|
+
record.version = LEDGER_VERSION
|
|
718
|
+
record.updatedAt = nextLedgerRevision()
|
|
719
|
+
record.needsUpgrade = false
|
|
720
|
+
ledgerRecords.set(record.sessionId, record)
|
|
721
|
+
void persistLedgerRecord(record)
|
|
722
|
+
}
|
|
723
|
+
return record
|
|
431
724
|
}
|
|
432
|
-
function applyLedgerRecord(record) {
|
|
725
|
+
function applyLedgerRecord(record, materialization = 'ledger-reuse') {
|
|
433
726
|
if (record === null || record === undefined) return
|
|
434
|
-
|
|
435
|
-
|
|
727
|
+
prepareLedgerRecord(record)
|
|
728
|
+
if (record.lastIdentity !== undefined) sessionModel.set(record.sessionId, record.lastIdentity)
|
|
729
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
|
|
730
|
+
for (const item of record.usage) {
|
|
731
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
732
|
+
const dates = dateKeys(item.time)
|
|
733
|
+
const previous = usageByStep.get(item.key)
|
|
734
|
+
if (previous !== undefined) {
|
|
735
|
+
unindexUsage(previous)
|
|
736
|
+
adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc }, previous.cost)
|
|
737
|
+
}
|
|
738
|
+
const next = { key: item.key, seq: item.seq, wsId: item.workspaceId, time: item.time, date: dates.local, dateUtc: dates.utc, values: item.values, identity, modelId: identity.label, cost: item.cost, turn: item.turn, step: item.step, materialization, sid: record.sessionId }
|
|
739
|
+
adjustUsage(item.workspaceId, item.time, item.values, identity, 1, record.sessionId, dates, item.cost)
|
|
740
|
+
usageByStep.set(item.key, next)
|
|
741
|
+
indexUsage(next)
|
|
742
|
+
}
|
|
436
743
|
if (record.turns.length > 0 || record.usage.length > 0) {
|
|
437
744
|
sessionCount.add(record.sessionId)
|
|
438
745
|
if (record.usage.length > 0) markStatsChanged()
|
|
@@ -441,6 +748,10 @@ function apply(ctx) {
|
|
|
441
748
|
function ledgerRank(record) {
|
|
442
749
|
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
443
750
|
}
|
|
751
|
+
function replaceLedgerRecord(record) {
|
|
752
|
+
ledgerRecords.set(record.sessionId, record)
|
|
753
|
+
return record
|
|
754
|
+
}
|
|
444
755
|
function storeLedgerRecord(record) {
|
|
445
756
|
const current = ledgerRecords.get(record.sessionId)
|
|
446
757
|
if (current !== undefined) {
|
|
@@ -452,7 +763,7 @@ function apply(ctx) {
|
|
|
452
763
|
return record
|
|
453
764
|
}
|
|
454
765
|
function persistLedgerRecord(record) {
|
|
455
|
-
if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
766
|
+
if (disposed || ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
456
767
|
const write = ledgerWriteChain.then(async () => {
|
|
457
768
|
if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
|
|
458
769
|
})
|
|
@@ -461,34 +772,54 @@ function apply(ctx) {
|
|
|
461
772
|
})
|
|
462
773
|
return ledgerWriteChain
|
|
463
774
|
}
|
|
464
|
-
function foldEvent(wsId, time, type, data, sid, seq) {
|
|
775
|
+
function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
|
|
776
|
+
if ((type === 'turn/end' || type === 'assistant/message') && !validEventTime(time)) return
|
|
465
777
|
if (type === 'request/context' || type === 'request/header') {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
else if (type === 'assistant/message' && data && data.usage)
|
|
778
|
+
sessionModel.set(sid, identityFromRoute(data, sessionModel.get(sid)))
|
|
779
|
+
} else if (type === 'turn/end') {
|
|
780
|
+
addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, sessionModel.get(sid), materialization, seq)
|
|
781
|
+
} else if (type === 'assistant/message' && data && data.usage) {
|
|
782
|
+
const identity = identityFromMessage(data, sessionModel.get(sid))
|
|
783
|
+
sessionModel.set(sid, identity)
|
|
784
|
+
addUsage(wsId, time, data.usage, identity, sid, data, seq, materialization)
|
|
785
|
+
}
|
|
470
786
|
}
|
|
471
|
-
function foldEvents(wsId, events, fromSeq, sid) {
|
|
787
|
+
function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
|
|
472
788
|
for (const ev of events) {
|
|
473
789
|
if (fromSeq !== undefined) {
|
|
474
790
|
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
475
791
|
if (s <= fromSeq) continue
|
|
476
792
|
}
|
|
477
|
-
if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq)
|
|
793
|
+
if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq, materialization)
|
|
478
794
|
}
|
|
479
795
|
}
|
|
480
796
|
function lastSeqOf(events) {
|
|
481
|
-
let last =
|
|
797
|
+
let last = -1
|
|
482
798
|
for (const ev of events) {
|
|
483
799
|
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
484
800
|
if (s > last) last = s
|
|
485
801
|
}
|
|
486
802
|
return last
|
|
487
803
|
}
|
|
804
|
+
function sequenceProfile(events) {
|
|
805
|
+
let previous = -1
|
|
806
|
+
let last = -1
|
|
807
|
+
let nonMonotonic = false
|
|
808
|
+
for (const ev of events) {
|
|
809
|
+
const seq = ev && typeof ev.seq === 'number' ? ev.seq : -1
|
|
810
|
+
if (seq < 0) continue
|
|
811
|
+
if (seq <= previous) nonMonotonic = true
|
|
812
|
+
previous = seq
|
|
813
|
+
if (seq > last) last = seq
|
|
814
|
+
}
|
|
815
|
+
return { lastSeq: last, nonMonotonic }
|
|
816
|
+
}
|
|
488
817
|
function enqueue(sid, task) {
|
|
489
818
|
const prev = chains.get(sid) || Promise.resolve()
|
|
490
819
|
const next = prev.then(() => task(), () => task())
|
|
491
820
|
chains.set(sid, next)
|
|
821
|
+
const cleanup = () => { if (chains.get(sid) === next) chains.delete(sid) }
|
|
822
|
+
void next.then(cleanup, cleanup)
|
|
492
823
|
return next
|
|
493
824
|
}
|
|
494
825
|
function wsForLiveSession(session, sid) {
|
|
@@ -501,37 +832,89 @@ function apply(ctx) {
|
|
|
501
832
|
if (wsId !== undefined) memberOf.set(sid, wsId)
|
|
502
833
|
return wsId
|
|
503
834
|
}
|
|
835
|
+
function cancelLiveResync(sid) {
|
|
836
|
+
const timer = liveResyncTimers.get(sid)
|
|
837
|
+
if (timer !== undefined) {
|
|
838
|
+
clearTimeout(timer)
|
|
839
|
+
liveResyncTimers.delete(sid)
|
|
840
|
+
}
|
|
841
|
+
liveResyncAttempts.delete(sid)
|
|
842
|
+
liveResyncPending.delete(sid)
|
|
843
|
+
}
|
|
844
|
+
function scheduleLiveResync(sid, wsId, generation) {
|
|
845
|
+
if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid) || liveResyncTimers.has(sid)) return
|
|
846
|
+
const attempt = liveResyncAttempts.get(sid) || 0
|
|
847
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
|
|
848
|
+
const timer = setTimeout(() => {
|
|
849
|
+
liveResyncTimers.delete(sid)
|
|
850
|
+
if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid)) return
|
|
851
|
+
void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
|
|
852
|
+
}, delay)
|
|
853
|
+
liveResyncTimers.set(sid, timer)
|
|
854
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
855
|
+
}
|
|
856
|
+
function foldLiveFallback(sid, wsId, event) {
|
|
857
|
+
if (event === null || event === undefined) return
|
|
858
|
+
foldEvent(wsId, event.time, event.type, event.data, sid, event.seq, 'live')
|
|
859
|
+
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
860
|
+
if (seq >= 0) {
|
|
861
|
+
const current = sessionSeq.get(sid)
|
|
862
|
+
if (current === undefined || seq > current) sessionSeq.set(sid, seq)
|
|
863
|
+
}
|
|
864
|
+
sessionCount.add(sid)
|
|
865
|
+
}
|
|
866
|
+
async function syncLiveSession(sid, wsId, event, generation) {
|
|
867
|
+
try {
|
|
868
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
869
|
+
if (disposed || generation !== aggregationGeneration) return true
|
|
870
|
+
if (snap && Array.isArray(snap.events)) {
|
|
871
|
+
const previousLast = sessionSeq.get(sid)
|
|
872
|
+
const snapshotLast = lastSeqOf(snap.events)
|
|
873
|
+
foldEvents(wsId, snap.events, undefined, sid, 'live')
|
|
874
|
+
let nextLast = Math.max(previousLast === undefined ? -1 : previousLast, snapshotLast)
|
|
875
|
+
const eventSeq = event === null || event === undefined || typeof event.seq !== 'number' ? -1 : event.seq
|
|
876
|
+
const needsFollowup = event !== null && event !== undefined && (eventSeq < 0 || eventSeq > snapshotLast)
|
|
877
|
+
if (needsFollowup) {
|
|
878
|
+
foldLiveFallback(sid, wsId, event)
|
|
879
|
+
const current = sessionSeq.get(sid)
|
|
880
|
+
nextLast = Math.max(nextLast, current === undefined ? -1 : current)
|
|
881
|
+
}
|
|
882
|
+
sessionSeq.set(sid, nextLast)
|
|
883
|
+
sessionCount.add(sid)
|
|
884
|
+
if (needsFollowup) scheduleLiveResync(sid, wsId, generation)
|
|
885
|
+
else cancelLiveResync(sid)
|
|
886
|
+
return true
|
|
887
|
+
}
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// Keep the event as a fallback and retry a complete session sync later.
|
|
890
|
+
}
|
|
891
|
+
return false
|
|
892
|
+
}
|
|
893
|
+
async function resyncLiveSession(sid, wsId, generation) {
|
|
894
|
+
if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid)) return
|
|
895
|
+
if (await syncLiveSession(sid, wsId, null, generation)) return
|
|
896
|
+
liveResyncAttempts.set(sid, (liveResyncAttempts.get(sid) || 0) + 1)
|
|
897
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
898
|
+
}
|
|
504
899
|
async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
|
|
505
900
|
if (disposed || generation !== aggregationGeneration) return
|
|
506
901
|
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
507
902
|
const last = sessionSeq.get(sid)
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
sessionCount.add(sid)
|
|
516
|
-
}
|
|
517
|
-
} catch (err) { /* retry on the next event */ }
|
|
903
|
+
const needsSync = last === undefined || liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1)
|
|
904
|
+
if (needsSync) {
|
|
905
|
+
liveResyncPending.add(sid)
|
|
906
|
+
if (await syncLiveSession(sid, wsId, event, generation)) return
|
|
907
|
+
foldLiveFallback(sid, wsId, event)
|
|
908
|
+
liveResyncAttempts.set(sid, (liveResyncAttempts.get(sid) || 0) + 1)
|
|
909
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
518
910
|
return
|
|
519
911
|
}
|
|
520
|
-
if (seq
|
|
521
|
-
|
|
522
|
-
try {
|
|
523
|
-
const snap = await ctx.sessionQuery.readSession(sid)
|
|
524
|
-
if (disposed || generation !== aggregationGeneration) return
|
|
525
|
-
if (snap && Array.isArray(snap.events)) {
|
|
526
|
-
foldEvents(wsId, snap.events, last, sid)
|
|
527
|
-
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
528
|
-
}
|
|
529
|
-
} catch (err) { /* keep last; retry later */ }
|
|
912
|
+
if (seq < 0) {
|
|
913
|
+
foldLiveFallback(sid, wsId, event)
|
|
530
914
|
return
|
|
531
915
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
sessionCount.add(sid)
|
|
916
|
+
if (seq <= last) return
|
|
917
|
+
foldLiveFallback(sid, wsId, event)
|
|
535
918
|
}
|
|
536
919
|
|
|
537
920
|
// ---------- durable usage ledger ----------
|
|
@@ -557,6 +940,197 @@ function apply(ctx) {
|
|
|
557
940
|
}
|
|
558
941
|
}
|
|
559
942
|
|
|
943
|
+
|
|
944
|
+
// ---------- pricing catalog and cost backfill ----------
|
|
945
|
+
function adjustCostOnly(item, cost, direction) {
|
|
946
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
947
|
+
const dates = item && typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
|
|
948
|
+
const targets = []
|
|
949
|
+
const seen = new Set()
|
|
950
|
+
for (const target of [
|
|
951
|
+
ensureModel(identity).cost,
|
|
952
|
+
totals.cost,
|
|
953
|
+
ensureWs(item.wsId).cost,
|
|
954
|
+
ensureDay(byDay, dates.local).cost,
|
|
955
|
+
ensureDay(byDayUtc, dates.utc).cost,
|
|
956
|
+
ensureDayWs(ensureDay(byDay, dates.local), item.wsId).cost,
|
|
957
|
+
ensureDayWs(ensureDay(byDayUtc, dates.utc), item.wsId).cost,
|
|
958
|
+
ensureDayModel(ensureDay(byDay, dates.local), identity).cost,
|
|
959
|
+
ensureDayModel(ensureDay(byDayUtc, dates.utc), identity).cost,
|
|
960
|
+
]) {
|
|
961
|
+
if (seen.has(target)) continue
|
|
962
|
+
seen.add(target)
|
|
963
|
+
targets.push(target)
|
|
964
|
+
}
|
|
965
|
+
for (const target of targets) addCostAggregateDirection(target, cost, direction)
|
|
966
|
+
}
|
|
967
|
+
function usedPricingModels() {
|
|
968
|
+
const rows = []
|
|
969
|
+
const seen = new Set()
|
|
970
|
+
for (const item of usageByStep.values()) {
|
|
971
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
972
|
+
if (seen.has(identity.identityKey)) continue
|
|
973
|
+
seen.add(identity.identityKey)
|
|
974
|
+
const resolved = resolveCurrentPricing(identity)
|
|
975
|
+
rows.push({ identityKey: identity.identityKey, provider: identity.provider, requestedModel: identity.requestedModel, actualModel: identity.actualModel, model: identity.label, status: resolved.status, reason: resolved.reason || '', pricingModel: resolved.pricingModel || null, providerId: resolved.providerId || null, source: resolved.source || 'none', currency: resolved.currency || 'USD', rates: resolved.rates || null, tiered: resolved.tiered === true })
|
|
976
|
+
if (rows.length >= 500) break
|
|
977
|
+
}
|
|
978
|
+
rows.sort((a, b) => String(a.model).localeCompare(String(b.model)))
|
|
979
|
+
return rows
|
|
980
|
+
}
|
|
981
|
+
function pricingModelSearch(query, limit = 20) {
|
|
982
|
+
const raw = typeof query === 'string' ? query.trim().toLowerCase() : ''
|
|
983
|
+
if (raw === '') return []
|
|
984
|
+
const normalized = raw.replace(/\s+/g, ' ')
|
|
985
|
+
const selected = new Map()
|
|
986
|
+
for (const entry of pricingState.catalogEntries) {
|
|
987
|
+
const official = officialProviderIds(entry.modelId)
|
|
988
|
+
if (!official.has(String(entry.providerId || '').toLowerCase())) continue
|
|
989
|
+
const modelId = entry.modelId.toLowerCase()
|
|
990
|
+
const displayName = String(entry.displayName || '').toLowerCase()
|
|
991
|
+
if (!modelId.includes(normalized) && !displayName.includes(normalized)) continue
|
|
992
|
+
const score = modelId === normalized ? 0 : modelId.startsWith(normalized) ? 1 : displayName.startsWith(normalized) ? 2 : 3
|
|
993
|
+
const previous = selected.get(entry.modelId)
|
|
994
|
+
if (previous === undefined || score < previous.score) selected.set(entry.modelId, { value: entry.modelId, label: entry.displayName, providerId: entry.providerId, score })
|
|
995
|
+
}
|
|
996
|
+
return Array.from(selected.values()).sort((a, b) => a.score - b.score || a.value.localeCompare(b.value)).slice(0, Math.max(1, Math.min(50, Number.isInteger(limit) ? limit : 20))).map(({ score, ...entry }) => entry)
|
|
997
|
+
}
|
|
998
|
+
function pricingSnapshot() {
|
|
999
|
+
const state = pricingState
|
|
1000
|
+
const used = usedPricingModels()
|
|
1001
|
+
return {
|
|
1002
|
+
schemaVersion: state.schemaVersion,
|
|
1003
|
+
source: { ...state.source },
|
|
1004
|
+
sync: { ...state.sync },
|
|
1005
|
+
catalogModelCount: state.catalogEntries.length,
|
|
1006
|
+
overrideCount: state.overrides.length,
|
|
1007
|
+
mappingCount: state.mappings.length,
|
|
1008
|
+
configured: state.catalogEntries.length > 0 || state.overrides.length > 0 || state.mappings.length > 0,
|
|
1009
|
+
config: { sync: { ...state.sync }, mappings: state.mappings.map(({ provider, identityKey, ...mapping }) => ({ ...mapping })), overrides: state.overrides.map(({ providerId, ...entry }) => ({ ...entry })) },
|
|
1010
|
+
usedModels: used,
|
|
1011
|
+
cost: serializeCostAggregate(totals.cost),
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
async function loadPricing() {
|
|
1015
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
1016
|
+
try {
|
|
1017
|
+
const backend = storage.backend.get('json')
|
|
1018
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
1019
|
+
const unit = await backend.kv.open({ name: 'all_usage_pricing', version: 0, tables: [], hasGlobal: true })
|
|
1020
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
1021
|
+
pricingUnit = unit
|
|
1022
|
+
const snapshot = await unit.loadAll()
|
|
1023
|
+
const global = snapshot && snapshot.global
|
|
1024
|
+
const raw = global && typeof global === 'object' && global.pricing !== undefined ? global.pricing : global
|
|
1025
|
+
pricingState = normalizePricingState(raw)
|
|
1026
|
+
pricingResolutionCache.clear()
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
console.error('[all-usage] pricing catalog unavailable:', err)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function persistPricing() {
|
|
1032
|
+
if (disposed) return pricingWriteChain
|
|
1033
|
+
const payload = { pricing: serializePricingState(pricingState) }
|
|
1034
|
+
pricingWriteChain = pricingWriteChain.then(async () => {
|
|
1035
|
+
if (pricingUnit === null || pricingUnit === undefined) return
|
|
1036
|
+
await pricingUnit.setGlobal(payload)
|
|
1037
|
+
})
|
|
1038
|
+
pricingWriteChain = pricingWriteChain.catch((err) => {
|
|
1039
|
+
console.error('[all-usage] pricing persist failed:', err)
|
|
1040
|
+
})
|
|
1041
|
+
return pricingWriteChain
|
|
1042
|
+
}
|
|
1043
|
+
async function syncPricing(force = false) {
|
|
1044
|
+
await pricingReady
|
|
1045
|
+
if (pricingSyncInFlight) return { ok: false, message: 'pricing-sync-in-progress', pricing: pricingSnapshot() }
|
|
1046
|
+
const now = Date.now()
|
|
1047
|
+
if (!force && pricingState.sync.lastSuccessAt > 0 && now - pricingState.sync.lastSuccessAt < pricingState.sync.intervalMs) return { ok: true, skipped: true, pricing: pricingSnapshot() }
|
|
1048
|
+
pricingSyncInFlight = true
|
|
1049
|
+
pricingState.sync.lastAttemptAt = now
|
|
1050
|
+
markStatsChanged()
|
|
1051
|
+
try {
|
|
1052
|
+
const result = await fetchModelsDevCatalog()
|
|
1053
|
+
if (!result.ok) {
|
|
1054
|
+
pricingState.source.lastError = result.error
|
|
1055
|
+
pricingState.sync.lastError = result.error
|
|
1056
|
+
markStatsChanged()
|
|
1057
|
+
await persistPricing()
|
|
1058
|
+
return { ok: false, message: result.error, pricing: pricingSnapshot() }
|
|
1059
|
+
}
|
|
1060
|
+
pricingState = normalizePricingState({
|
|
1061
|
+
...serializePricingState(pricingState),
|
|
1062
|
+
source: { url: result.catalog.sourceUrl, fetchedAt: result.catalog.fetchedAt, catalogHash: result.catalog.catalogHash, lastError: '' },
|
|
1063
|
+
sync: { ...pricingState.sync, lastSuccessAt: result.catalog.fetchedAt, lastError: '' },
|
|
1064
|
+
catalogEntries: result.catalog.entries,
|
|
1065
|
+
})
|
|
1066
|
+
pricingResolutionCache.clear()
|
|
1067
|
+
const backfill = backfillUnpricedCosts()
|
|
1068
|
+
markStatsChanged()
|
|
1069
|
+
await persistPricing()
|
|
1070
|
+
await ledgerWriteChain
|
|
1071
|
+
return { ok: true, skipped: false, backfill, pricing: pricingSnapshot() }
|
|
1072
|
+
} finally {
|
|
1073
|
+
pricingSyncInFlight = false
|
|
1074
|
+
schedulePricingSync()
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
function backfillUnpricedCosts() {
|
|
1078
|
+
let considered = 0
|
|
1079
|
+
let priced = 0
|
|
1080
|
+
const touched = new Set()
|
|
1081
|
+
for (const item of usageByStep.values()) {
|
|
1082
|
+
const oldCost = normalizeCostSnapshot(item.cost)
|
|
1083
|
+
if (oldCost !== null && oldCost.pricingMode === 'official-model' && oldCost.status === 'priced') continue
|
|
1084
|
+
considered += 1
|
|
1085
|
+
const next = calculateCost(item.values, resolveCurrentPricing(item.identity || item.modelId))
|
|
1086
|
+
if (next.status !== 'priced') continue
|
|
1087
|
+
if (oldCost !== null) adjustCostOnly(item, oldCost, -1)
|
|
1088
|
+
item.cost = next
|
|
1089
|
+
adjustCostOnly(item, next, 1)
|
|
1090
|
+
const record = ledgerRecords.get(item.sid)
|
|
1091
|
+
if (record !== undefined) {
|
|
1092
|
+
const stored = record.usage.find((candidate) => candidate.key === item.key)
|
|
1093
|
+
if (stored !== undefined) { stored.cost = next; touched.add(record) }
|
|
1094
|
+
}
|
|
1095
|
+
priced += 1
|
|
1096
|
+
}
|
|
1097
|
+
for (const record of touched) {
|
|
1098
|
+
record.version = LEDGER_VERSION
|
|
1099
|
+
record.updatedAt = nextLedgerRevision()
|
|
1100
|
+
record.needsUpgrade = false
|
|
1101
|
+
void persistLedgerRecord(record)
|
|
1102
|
+
}
|
|
1103
|
+
let remaining = 0
|
|
1104
|
+
for (const item of usageByStep.values()) {
|
|
1105
|
+
const cost = normalizeCostSnapshot(item.cost)
|
|
1106
|
+
if (cost === null || cost.status !== 'priced') remaining += 1
|
|
1107
|
+
}
|
|
1108
|
+
return { considered, priced, remaining }
|
|
1109
|
+
}
|
|
1110
|
+
function updatePricingState(raw, backfill) {
|
|
1111
|
+
const input = raw && typeof raw === 'object' && raw.pricing && typeof raw.pricing === 'object' ? raw.pricing : raw
|
|
1112
|
+
const current = serializePricingState(pricingState)
|
|
1113
|
+
const merged = { ...current, ...(input && typeof input === 'object' ? input : {}) }
|
|
1114
|
+
if (input && typeof input === 'object' && input.sync && typeof input.sync === 'object' && !Array.isArray(input.sync)) merged.sync = { ...current.sync, ...input.sync }
|
|
1115
|
+
pricingState = normalizePricingState(merged)
|
|
1116
|
+
pricingResolutionCache.clear()
|
|
1117
|
+
const result = backfill === true ? backfillUnpricedCosts() : { considered: 0, priced: 0, remaining: totals.cost.unpricedCalls + totals.cost.ambiguousCalls + totals.cost.unsupportedCalls }
|
|
1118
|
+
markStatsChanged()
|
|
1119
|
+
schedulePricingSync()
|
|
1120
|
+
return result
|
|
1121
|
+
}
|
|
1122
|
+
function schedulePricingSync() {
|
|
1123
|
+
if (pricingSyncTimer !== null) { clearTimeout(pricingSyncTimer); pricingSyncTimer = null }
|
|
1124
|
+
if (disposed || pricingState.sync.autoEnabled !== true) return
|
|
1125
|
+
const elapsed = pricingState.sync.lastSuccessAt > 0 ? Date.now() - pricingState.sync.lastSuccessAt : pricingState.sync.intervalMs
|
|
1126
|
+
const delay = Math.max(0, pricingState.sync.intervalMs - elapsed)
|
|
1127
|
+
pricingSyncTimer = setTimeout(() => {
|
|
1128
|
+
pricingSyncTimer = null
|
|
1129
|
+
void syncPricing(true)
|
|
1130
|
+
}, delay)
|
|
1131
|
+
if (pricingSyncTimer && typeof pricingSyncTimer.unref === 'function') pricingSyncTimer.unref()
|
|
1132
|
+
}
|
|
1133
|
+
|
|
560
1134
|
// ---------- baseline scan over durable logs ----------
|
|
561
1135
|
function scheduleNativeBaselineRetry(generation, delay) {
|
|
562
1136
|
if (disposed || baselineFallbackTimer !== null) return
|
|
@@ -572,9 +1146,10 @@ function apply(ctx) {
|
|
|
572
1146
|
const delay = baselineRetryDelay
|
|
573
1147
|
baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
|
|
574
1148
|
void safeContextTimeout(delay).then((ready) => {
|
|
1149
|
+
if (generation !== aggregationGeneration) return undefined
|
|
575
1150
|
baselineRetryScheduled = false
|
|
576
|
-
if (ready &&
|
|
577
|
-
if (!ready &&
|
|
1151
|
+
if (ready && !scan.started && !scan.done) return runBaseline(generation)
|
|
1152
|
+
if (!ready && !disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
578
1153
|
return undefined
|
|
579
1154
|
})
|
|
580
1155
|
}
|
|
@@ -582,7 +1157,7 @@ function apply(ctx) {
|
|
|
582
1157
|
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
583
1158
|
scan.started = true
|
|
584
1159
|
beginSync()
|
|
585
|
-
await ledgerReady
|
|
1160
|
+
await Promise.all([ledgerReady, pricingReady])
|
|
586
1161
|
if (disposed || generation !== aggregationGeneration) return
|
|
587
1162
|
let setupFailed = false
|
|
588
1163
|
try {
|
|
@@ -654,7 +1229,7 @@ function apply(ctx) {
|
|
|
654
1229
|
}
|
|
655
1230
|
for (const [sid, record] of ledgerRecords) {
|
|
656
1231
|
if (!listedSessionIds.has(sid)) {
|
|
657
|
-
applyLedgerRecord(record)
|
|
1232
|
+
applyLedgerRecord(record, 'ledger-recovery')
|
|
658
1233
|
if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
659
1234
|
}
|
|
660
1235
|
}
|
|
@@ -678,18 +1253,15 @@ function apply(ctx) {
|
|
|
678
1253
|
await enqueue(sid, async () => {
|
|
679
1254
|
if (disposed || generation !== aggregationGeneration) return
|
|
680
1255
|
try {
|
|
681
|
-
if (sessionSeq.has(sid)) return
|
|
1256
|
+
if (sessionSeq.has(sid) && !liveResyncPending.has(sid)) return
|
|
682
1257
|
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
683
1258
|
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
684
1259
|
// record is applied directly and the live feed keeps catching new events.
|
|
685
1260
|
const previousRecord = ledgerRecords.get(sid)
|
|
686
1261
|
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
687
|
-
if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
1262
|
+
if (!liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
688
1263
|
sync.sessionsSkippedByRevision += 1
|
|
689
|
-
applyLedgerRecord(previousRecord)
|
|
690
|
-
for (const item of previousRecord.usage) {
|
|
691
|
-
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
|
|
692
|
-
}
|
|
1264
|
+
applyLedgerRecord(previousRecord, 'ledger-reuse')
|
|
693
1265
|
sessionSeq.set(sid, previousRecord.lastSeq)
|
|
694
1266
|
sessionCount.add(sid)
|
|
695
1267
|
markStatsChanged()
|
|
@@ -705,53 +1277,43 @@ function apply(ctx) {
|
|
|
705
1277
|
// session applies its canonical record directly and never re-folds;
|
|
706
1278
|
// a changed session seeds the previous record once, then folds only the
|
|
707
1279
|
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
708
|
-
const
|
|
1280
|
+
const sequence = sequenceProfile(snap.events)
|
|
1281
|
+
const currentLastSeq = sequence.lastSeq
|
|
709
1282
|
const previous = ledgerRecords.get(sid)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
for (const item of previous.usage) {
|
|
715
|
-
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
|
|
716
|
-
}
|
|
717
|
-
if (previous.lastSeq >= currentLastSeq) {
|
|
718
|
-
// Content unchanged despite a revision change (rare: ctime-only churn):
|
|
719
|
-
// refresh the stored revision so future restarts can skip the read.
|
|
720
|
-
if (revision !== undefined && previous.lastRevision !== revision) {
|
|
721
|
-
previous.lastRevision = revision
|
|
722
|
-
void persistLedgerRecord(previous)
|
|
723
|
-
}
|
|
724
|
-
sessionSeq.set(sid, previous.lastSeq)
|
|
725
|
-
sessionCount.add(sid)
|
|
726
|
-
return
|
|
727
|
-
}
|
|
728
|
-
foldEvents(wsId, snap.events, previous.lastSeq, sid)
|
|
1283
|
+
const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && !sequence.nonMonotonic && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
|
|
1284
|
+
if (canFoldTail) {
|
|
1285
|
+
applyLedgerRecord(previous, 'ledger-reuse')
|
|
1286
|
+
foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
|
|
729
1287
|
} else {
|
|
730
|
-
|
|
1288
|
+
// A changed revision with no new tail may still contain a replacement;
|
|
1289
|
+
// rebuild from the complete read instead of trusting lastSeq alone.
|
|
1290
|
+
foldEvents(wsId, snap.events, undefined, sid, 'scan')
|
|
731
1291
|
}
|
|
732
|
-
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
|
|
733
|
-
const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
|
|
1292
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous)
|
|
1293
|
+
const canonical = ledger === null ? ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
|
|
734
1294
|
if (canonical === ledger) {
|
|
735
1295
|
void persistLedgerRecord(ledger)
|
|
736
|
-
} else if (canonical !== undefined) {
|
|
737
|
-
applyLedgerRecord(canonical)
|
|
738
1296
|
}
|
|
739
|
-
sessionSeq.
|
|
1297
|
+
const observedLastSeq = sessionSeq.get(sid)
|
|
1298
|
+
const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
|
|
1299
|
+
sessionSeq.set(sid, nextLastSeq)
|
|
740
1300
|
sessionCount.add(sid)
|
|
1301
|
+
if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
|
|
1302
|
+
else scheduleLiveResync(sid, wsId, generation)
|
|
741
1303
|
}
|
|
742
1304
|
} catch (err) {
|
|
743
1305
|
if (generation !== aggregationGeneration) return
|
|
744
1306
|
sync.sessionsFailed += 1
|
|
1307
|
+
scan.failed += 1
|
|
745
1308
|
noteSyncError('session-read-failed')
|
|
746
1309
|
const saved = ledgerRecords.get(sid)
|
|
747
1310
|
if (saved !== undefined) {
|
|
748
|
-
applyLedgerRecord(saved)
|
|
1311
|
+
applyLedgerRecord(saved, 'ledger-recovery')
|
|
749
1312
|
if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
750
1313
|
sessionSeq.set(sid, -1)
|
|
751
1314
|
sessionCount.add(sid)
|
|
752
1315
|
} else {
|
|
753
1316
|
sessionSeq.set(sid, -1)
|
|
754
|
-
scan.failed += 1
|
|
755
1317
|
}
|
|
756
1318
|
} finally {
|
|
757
1319
|
if (generation === aggregationGeneration) {
|
|
@@ -770,6 +1332,12 @@ function apply(ctx) {
|
|
|
770
1332
|
if (disposed || generation !== aggregationGeneration) return
|
|
771
1333
|
await ledgerWriteChain
|
|
772
1334
|
if (disposed || generation !== aggregationGeneration) return
|
|
1335
|
+
const costBackfill = backfillUnpricedCosts()
|
|
1336
|
+
if (costBackfill.priced > 0) {
|
|
1337
|
+
await ledgerWriteChain
|
|
1338
|
+
markStatsChanged()
|
|
1339
|
+
}
|
|
1340
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
773
1341
|
knownSessionIds.clear()
|
|
774
1342
|
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
775
1343
|
scan.done = true
|
|
@@ -826,11 +1394,13 @@ function apply(ctx) {
|
|
|
826
1394
|
reconcilePending = true
|
|
827
1395
|
if (reconcileHintScheduled || reconcileInFlight) return
|
|
828
1396
|
reconcileHintScheduled = true
|
|
1397
|
+
const generation = aggregationGeneration
|
|
829
1398
|
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
1399
|
+
if (generation !== aggregationGeneration) return
|
|
830
1400
|
reconcileHintScheduled = false
|
|
831
1401
|
if (ready && !disposed) void reconcileSessions()
|
|
832
1402
|
}, () => {
|
|
833
|
-
reconcileHintScheduled = false
|
|
1403
|
+
if (generation === aggregationGeneration) reconcileHintScheduled = false
|
|
834
1404
|
})
|
|
835
1405
|
}
|
|
836
1406
|
function scheduleReconcileTimer() {
|
|
@@ -861,11 +1431,11 @@ function apply(ctx) {
|
|
|
861
1431
|
})
|
|
862
1432
|
ctx.on('session/flush', async (session) => {
|
|
863
1433
|
if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
864
|
-
await ledgerReady
|
|
1434
|
+
await Promise.all([ledgerReady, pricingReady])
|
|
865
1435
|
if (disposed) return
|
|
866
1436
|
const wsId = wsForLiveSession(session, session.id)
|
|
867
1437
|
if (wsId === undefined) return
|
|
868
|
-
const ledger = buildLedgerRecord(session, wsId, 'flush')
|
|
1438
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, ledgerRecords.get(session.id))
|
|
869
1439
|
if (ledger === null) return
|
|
870
1440
|
const canonical = storeLedgerRecord(ledger)
|
|
871
1441
|
if (canonical === ledger) await persistLedgerRecord(ledger)
|
|
@@ -881,6 +1451,7 @@ function apply(ctx) {
|
|
|
881
1451
|
const backend = storage.backend.get('json')
|
|
882
1452
|
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
883
1453
|
const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
|
|
1454
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
884
1455
|
kvUnit = unit
|
|
885
1456
|
const snap = await unit.loadAll()
|
|
886
1457
|
const g = snap && snap.global
|
|
@@ -896,6 +1467,7 @@ function apply(ctx) {
|
|
|
896
1467
|
}
|
|
897
1468
|
}
|
|
898
1469
|
function persistAliases() {
|
|
1470
|
+
if (disposed) return aliasWriteChain
|
|
899
1471
|
const snapshotAliases = {}
|
|
900
1472
|
for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
|
|
901
1473
|
aliasWriteChain = aliasWriteChain.then(() => {
|
|
@@ -906,7 +1478,9 @@ function apply(ctx) {
|
|
|
906
1478
|
})
|
|
907
1479
|
}
|
|
908
1480
|
function setAlias(wsId, raw) {
|
|
909
|
-
|
|
1481
|
+
if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, aliases) }
|
|
1482
|
+
if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, aliases) }
|
|
1483
|
+
const alias = raw.trim().slice(0, 80)
|
|
910
1484
|
if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
|
|
911
1485
|
if (alias === '') delete aliases[wsId]
|
|
912
1486
|
else aliases[wsId] = alias
|
|
@@ -914,7 +1488,7 @@ function apply(ctx) {
|
|
|
914
1488
|
markStatsChanged()
|
|
915
1489
|
return { ok: true, aliases: Object.assign({}, aliases) }
|
|
916
1490
|
}
|
|
917
|
-
ctx.effect(() => () => {
|
|
1491
|
+
ctx.effect(() => async () => {
|
|
918
1492
|
disposed = true
|
|
919
1493
|
if (reconcileTimer !== null) {
|
|
920
1494
|
clearTimeout(reconcileTimer)
|
|
@@ -924,17 +1498,22 @@ function apply(ctx) {
|
|
|
924
1498
|
clearTimeout(baselineFallbackTimer)
|
|
925
1499
|
baselineFallbackTimer = null
|
|
926
1500
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1501
|
+
if (pricingSyncTimer !== null) {
|
|
1502
|
+
clearTimeout(pricingSyncTimer)
|
|
1503
|
+
pricingSyncTimer = null
|
|
1504
|
+
}
|
|
1505
|
+
for (const timer of liveResyncTimers.values()) clearTimeout(timer)
|
|
1506
|
+
liveResyncTimers.clear()
|
|
1507
|
+
liveResyncAttempts.clear()
|
|
1508
|
+
liveResyncPending.clear()
|
|
1509
|
+
baselineRetryScheduled = false
|
|
1510
|
+
chains.clear()
|
|
1511
|
+
await Promise.all([aliasesReady, ledgerReady, pricingReady, aliasWriteChain, ledgerWriteChain, pricingWriteChain])
|
|
1512
|
+
const units = [kvUnit, ledgerUnit, pricingUnit]
|
|
930
1513
|
kvUnit = null
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const unit = ledgerUnit
|
|
935
|
-
await ledgerWriteChain
|
|
936
|
-
if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
|
|
937
|
-
if (ledgerUnit === unit) ledgerUnit = null
|
|
1514
|
+
ledgerUnit = null
|
|
1515
|
+
pricingUnit = null
|
|
1516
|
+
await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
|
|
938
1517
|
})
|
|
939
1518
|
|
|
940
1519
|
// ---------- snapshot for the client ----------
|
|
@@ -942,8 +1521,16 @@ function apply(ctx) {
|
|
|
942
1521
|
return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
|
|
943
1522
|
}
|
|
944
1523
|
function statusSnapshot() {
|
|
1524
|
+
commitPendingStats()
|
|
945
1525
|
return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
|
|
946
1526
|
}
|
|
1527
|
+
function serializeIdentity(identity) {
|
|
1528
|
+
const value = coerceIdentity(identity)
|
|
1529
|
+
return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
|
|
1530
|
+
}
|
|
1531
|
+
function serializeModelAggregate(item) {
|
|
1532
|
+
return { ...serializeIdentity(item), calls: item.calls, input: item.input, output: item.output, cacheRead: item.cacheRead, cacheWrite: item.cacheWrite, reasoning: item.reasoning, cost: serializeCostAggregate(item.cost) }
|
|
1533
|
+
}
|
|
947
1534
|
function serializeDays(dayMap) {
|
|
948
1535
|
const result = []
|
|
949
1536
|
for (const pair of dayMap) {
|
|
@@ -955,21 +1542,28 @@ function apply(ctx) {
|
|
|
955
1542
|
sessions: day.sessionIds.size,
|
|
956
1543
|
sessionIds: Array.from(day.sessionIds).sort(),
|
|
957
1544
|
tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
|
|
1545
|
+
cost: serializeCostAggregate(day.cost),
|
|
958
1546
|
perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
|
|
959
|
-
byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
960
|
-
byModel: Array.from(day.byModel, (p) => (
|
|
1547
|
+
byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning, cost: serializeCostAggregate(day.byWs.get(p[0]).cost) })),
|
|
1548
|
+
byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
|
|
961
1549
|
})
|
|
962
1550
|
}
|
|
963
1551
|
result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
964
1552
|
return result
|
|
965
1553
|
}
|
|
966
1554
|
function snapshot() {
|
|
967
|
-
|
|
1555
|
+
commitPendingStats()
|
|
1556
|
+
const generatedAt = Date.now()
|
|
1557
|
+
if (snapshotCache !== null && snapshotCache.revision === statsRevision) return Object.assign({}, snapshotCache.value, { generatedAt })
|
|
1558
|
+
const value = {
|
|
968
1559
|
...statusSnapshot(),
|
|
969
|
-
generatedAt
|
|
1560
|
+
generatedAt,
|
|
1561
|
+
usageSchemaVersion: 3,
|
|
1562
|
+
costSchemaVersion: COST_SCHEMA_VERSION,
|
|
970
1563
|
requestToken,
|
|
971
1564
|
workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
|
|
972
1565
|
aliases: Object.assign({}, aliases),
|
|
1566
|
+
pricing: pricingSnapshot(),
|
|
973
1567
|
tokenSemantics: {
|
|
974
1568
|
processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
|
|
975
1569
|
cacheRead: 'reused context tokens; not newly generated output',
|
|
@@ -987,12 +1581,283 @@ function apply(ctx) {
|
|
|
987
1581
|
gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
|
|
988
1582
|
},
|
|
989
1583
|
},
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1584
|
+
costSemantics: {
|
|
1585
|
+
source: 'models.dev',
|
|
1586
|
+
currency: 'USD',
|
|
1587
|
+
buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
|
|
1588
|
+
input: 'fresh (DSH TokenUsage already excludes cache)',
|
|
1589
|
+
reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
|
|
1590
|
+
multiplier: 'applies only to final total',
|
|
1591
|
+
providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
|
|
1592
|
+
historical: 'positive cost snapshots are stable; only unresolved usage is eligible for backfill',
|
|
1593
|
+
},
|
|
1594
|
+
totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning, cost: serializeCostAggregate(totals.cost) },
|
|
1595
|
+
perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning, cost: serializeCostAggregate(p[1].cost) })),
|
|
1596
|
+
perModel: Array.from(perModel.values(), (item) => serializeModelAggregate(item)),
|
|
993
1597
|
byDay: serializeDays(byDay),
|
|
994
1598
|
byDayUtc: serializeDays(byDayUtc),
|
|
995
1599
|
}
|
|
1600
|
+
snapshotCache = { revision: statsRevision, value }
|
|
1601
|
+
return Object.assign({}, value, { generatedAt })
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function validDateText(value) {
|
|
1605
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
1606
|
+
const year = Number(value.slice(0, 4))
|
|
1607
|
+
const month = Number(value.slice(5, 7))
|
|
1608
|
+
const day = Number(value.slice(8, 10))
|
|
1609
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
1610
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
1611
|
+
}
|
|
1612
|
+
function shiftDateText(value, days, utc) {
|
|
1613
|
+
const parts = value.split('-').map(Number)
|
|
1614
|
+
const date = utc ? new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + days)) : new Date(parts[0], parts[1] - 1, parts[2] + days)
|
|
1615
|
+
return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
|
|
1616
|
+
}
|
|
1617
|
+
function queryScopeFromRequest(req) {
|
|
1618
|
+
let url
|
|
1619
|
+
try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
|
|
1620
|
+
const rawUtc = url.searchParams.get('utc')
|
|
1621
|
+
if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
|
|
1622
|
+
const utc = rawUtc === '1'
|
|
1623
|
+
const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
|
|
1624
|
+
const start = url.searchParams.get('start') || today
|
|
1625
|
+
const end = url.searchParams.get('end') || today
|
|
1626
|
+
if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
|
|
1627
|
+
const readParam = (name, max) => {
|
|
1628
|
+
const value = url.searchParams.get(name)
|
|
1629
|
+
if (value === null || value === '') return undefined
|
|
1630
|
+
return value.length <= max ? value : null
|
|
1631
|
+
}
|
|
1632
|
+
const workspaceId = readParam('workspaceId', 256)
|
|
1633
|
+
const provider = readParam('provider', 256)
|
|
1634
|
+
const modelKey = readParam('modelKey', 1024)
|
|
1635
|
+
if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
|
|
1636
|
+
return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
|
|
1637
|
+
}
|
|
1638
|
+
function scopeFingerprint(scope) {
|
|
1639
|
+
return JSON.stringify({ start: scope.start, end: scope.end, utc: scope.utc === true, workspaceId: scope.workspaceId || null, provider: scope.provider || null, modelKey: scope.modelKey || null })
|
|
1640
|
+
}
|
|
1641
|
+
function dateInScope(date, scope) {
|
|
1642
|
+
return date >= scope.start && date <= scope.end
|
|
1643
|
+
}
|
|
1644
|
+
function modelNameOfIdentity(identity) {
|
|
1645
|
+
const normalized = coerceIdentity(identity)
|
|
1646
|
+
const structured = normalized.actualModel || normalized.requestedModel
|
|
1647
|
+
if (structured !== null) return structured
|
|
1648
|
+
if (normalized.legacy && typeof normalized.label === 'string') {
|
|
1649
|
+
const separator = normalized.label.indexOf(' / ')
|
|
1650
|
+
if (separator > 0) return normalized.label.slice(separator + 3)
|
|
1651
|
+
}
|
|
1652
|
+
return normalized.label
|
|
1653
|
+
}
|
|
1654
|
+
function identityMatchesScope(identity, scope) {
|
|
1655
|
+
const normalized = coerceIdentity(identity)
|
|
1656
|
+
if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
|
|
1657
|
+
if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
|
|
1658
|
+
return true
|
|
1659
|
+
}
|
|
1660
|
+
function queryMetric() {
|
|
1661
|
+
return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
|
|
1662
|
+
}
|
|
1663
|
+
function queryAggregate() {
|
|
1664
|
+
return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
|
|
1665
|
+
}
|
|
1666
|
+
function queryDay(aggregate, date) {
|
|
1667
|
+
let day = aggregate.days.get(date)
|
|
1668
|
+
if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
|
|
1669
|
+
return day
|
|
1670
|
+
}
|
|
1671
|
+
function queryWorkspace(aggregate, workspaceId) {
|
|
1672
|
+
let row = aggregate.workspaces.get(workspaceId)
|
|
1673
|
+
if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
|
|
1674
|
+
return row
|
|
1675
|
+
}
|
|
1676
|
+
function queryModel(aggregate, identity) {
|
|
1677
|
+
const normalized = coerceIdentity(identity)
|
|
1678
|
+
let row = aggregate.models.get(normalized.identityKey)
|
|
1679
|
+
if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
|
|
1680
|
+
return row
|
|
1681
|
+
}
|
|
1682
|
+
function addQueryTokens(metric, values) {
|
|
1683
|
+
metric.input += values.input
|
|
1684
|
+
metric.output += values.output
|
|
1685
|
+
metric.cacheRead += values.cacheRead
|
|
1686
|
+
metric.cacheWrite += values.cacheWrite
|
|
1687
|
+
metric.reasoning += values.reasoning
|
|
1688
|
+
}
|
|
1689
|
+
function addQueryTurn(aggregate, turn, date) {
|
|
1690
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
|
|
1691
|
+
const model = queryModel(aggregate, turn.identity)
|
|
1692
|
+
targets.push(model)
|
|
1693
|
+
const key = turn.key
|
|
1694
|
+
for (const target of targets) {
|
|
1695
|
+
if (target.turnKeys.has(key)) continue
|
|
1696
|
+
target.turnKeys.add(key)
|
|
1697
|
+
target.turns += 1
|
|
1698
|
+
target.sessions.add(turn.sid)
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function addQueryUsage(aggregate, item, date) {
|
|
1702
|
+
const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, item.wsId)]
|
|
1703
|
+
const model = queryModel(aggregate, item.identity || item.modelId)
|
|
1704
|
+
targets.push(model)
|
|
1705
|
+
for (const target of targets) {
|
|
1706
|
+
target.calls += 1
|
|
1707
|
+
target.sessions.add(item.sid)
|
|
1708
|
+
addQueryTokens(target, item.values)
|
|
1709
|
+
addCostAggregate(target.cost, item.cost)
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function finalizeQueryMetric(metric) {
|
|
1713
|
+
return { turns: metric.turns, calls: metric.calls, sessions: metric.sessions.size, input: metric.input, output: metric.output, cacheRead: metric.cacheRead, cacheWrite: metric.cacheWrite, reasoning: metric.reasoning, cost: serializeCostAggregate(metric.cost) }
|
|
1714
|
+
}
|
|
1715
|
+
function finalizeQueryAggregate(aggregate) {
|
|
1716
|
+
const daily = Array.from(aggregate.days.values()).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0).map((day) => ({ date: day.date, ...finalizeQueryMetric(day), tokens: { input: day.input, output: day.output, cacheRead: day.cacheRead, cacheWrite: day.cacheWrite, reasoning: day.reasoning } }))
|
|
1717
|
+
const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
|
|
1718
|
+
const perModel = Array.from(aggregate.models.values()).filter((row) => row.calls > 0 || row.input > 0 || row.output > 0 || row.cacheRead > 0 || row.cacheWrite > 0 || row.reasoning > 0).map((row) => ({ identityKey: row.identityKey, provider: row.provider, requestedModel: row.requestedModel, actualModel: row.actualModel, model: row.model, legacy: row.legacy, ...finalizeQueryMetric(row) }))
|
|
1719
|
+
return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
|
|
1720
|
+
}
|
|
1721
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
1722
|
+
function hourStartOf(time, utc) {
|
|
1723
|
+
const date = new Date(time)
|
|
1724
|
+
return utc ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()) : new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime()
|
|
1725
|
+
}
|
|
1726
|
+
function calendarStartOf(dateText, utc) {
|
|
1727
|
+
const parts = dateText.split('-').map(Number)
|
|
1728
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
|
|
1729
|
+
}
|
|
1730
|
+
function nextCalendarStartOf(dateText, utc) {
|
|
1731
|
+
const parts = dateText.split('-').map(Number)
|
|
1732
|
+
return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
|
|
1733
|
+
}
|
|
1734
|
+
function hourlyRangeOf(scope, nowMs) {
|
|
1735
|
+
if (scope.start !== scope.end) return null
|
|
1736
|
+
const start = calendarStartOf(scope.start, scope.utc)
|
|
1737
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1738
|
+
const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
|
|
1739
|
+
return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
|
|
1740
|
+
}
|
|
1741
|
+
function serializeTrendMetric(time, metric) {
|
|
1742
|
+
const value = finalizeQueryMetric(metric || queryMetric())
|
|
1743
|
+
return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
|
|
1744
|
+
}
|
|
1745
|
+
function queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs) {
|
|
1746
|
+
const range = hourlyRangeOf(scope, nowMs)
|
|
1747
|
+
if (range === null) return []
|
|
1748
|
+
const buckets = new Map()
|
|
1749
|
+
const metricFor = (time) => {
|
|
1750
|
+
const index = Math.floor((hourStartOf(time, scope.utc) - range.start) / HOUR_MS)
|
|
1751
|
+
if (index < 0 || index >= range.count) return null
|
|
1752
|
+
let metric = buckets.get(index)
|
|
1753
|
+
if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
|
|
1754
|
+
return metric
|
|
1755
|
+
}
|
|
1756
|
+
for (const entry of matchingUsage) {
|
|
1757
|
+
if (!dateInScope(entry.date, scope)) continue
|
|
1758
|
+
const metric = metricFor(entry.item.time)
|
|
1759
|
+
if (metric === null) continue
|
|
1760
|
+
metric.calls += 1
|
|
1761
|
+
metric.sessions.add(entry.item.sid)
|
|
1762
|
+
addQueryTokens(metric, entry.item.values)
|
|
1763
|
+
addCostAggregate(metric.cost, entry.item.cost)
|
|
1764
|
+
}
|
|
1765
|
+
for (const entry of matchingTurns) {
|
|
1766
|
+
if (!dateInScope(entry.date, scope)) continue
|
|
1767
|
+
const metric = metricFor(entry.turn.time)
|
|
1768
|
+
if (metric === null || metric.turnKeys.has(entry.turn.key)) continue
|
|
1769
|
+
metric.turnKeys.add(entry.turn.key)
|
|
1770
|
+
metric.turns += 1
|
|
1771
|
+
metric.sessions.add(entry.turn.sid)
|
|
1772
|
+
}
|
|
1773
|
+
return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(range.start + index * HOUR_MS, buckets.get(index)))
|
|
1774
|
+
}
|
|
1775
|
+
function queryUsageScope(scope) {
|
|
1776
|
+
commitPendingStats()
|
|
1777
|
+
const nowMs = Date.now()
|
|
1778
|
+
const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
|
|
1779
|
+
const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
|
|
1780
|
+
const key = statsRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
|
|
1781
|
+
const cached = queryCache.get(key)
|
|
1782
|
+
if (cached !== undefined) return cached
|
|
1783
|
+
const now = new Date(nowMs)
|
|
1784
|
+
const weekday = scope.utc ? now.getUTCDay() : now.getDay()
|
|
1785
|
+
const sunday = shiftDateText(today, -weekday, scope.utc)
|
|
1786
|
+
const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
|
|
1787
|
+
const matchingUsage = []
|
|
1788
|
+
const matchingTurnKeys = new Set()
|
|
1789
|
+
const usageIndex = scope.utc ? usageByUtcDate : usageByLocalDate
|
|
1790
|
+
for (const indexed of indexedEntries(usageIndex, usageByStep, scope, heatStart, today)) {
|
|
1791
|
+
const item = indexed.item
|
|
1792
|
+
const date = indexed.date
|
|
1793
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
|
|
1794
|
+
if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
|
|
1795
|
+
matchingUsage.push({ item, date })
|
|
1796
|
+
if (item.turn !== null && item.turn !== undefined) matchingTurnKeys.add(item.sid + ':turn:' + item.turn)
|
|
1797
|
+
}
|
|
1798
|
+
const matchingTurns = []
|
|
1799
|
+
const turnIndex = scope.utc ? turnsByUtcDate : turnsByLocalDate
|
|
1800
|
+
for (const indexed of indexedEntries(turnIndex, turnRecords, scope, heatStart, today)) {
|
|
1801
|
+
const turn = indexed.item
|
|
1802
|
+
const date = indexed.date
|
|
1803
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && turn.wsId !== scope.workspaceId) continue
|
|
1804
|
+
if ((scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)) {
|
|
1805
|
+
if (!identityMatchesScope(turn.identity, scope) && !matchingTurnKeys.has(turn.key)) continue
|
|
1806
|
+
}
|
|
1807
|
+
matchingTurns.push({ turn, date })
|
|
1808
|
+
}
|
|
1809
|
+
const selected = queryAggregate()
|
|
1810
|
+
const heatmap = queryAggregate()
|
|
1811
|
+
for (const entry of matchingUsage) {
|
|
1812
|
+
if (dateInScope(entry.date, scope)) addQueryUsage(selected, entry.item, entry.date)
|
|
1813
|
+
if (entry.date >= heatStart && entry.date <= today) addQueryUsage(heatmap, entry.item, entry.date)
|
|
1814
|
+
}
|
|
1815
|
+
for (const entry of matchingTurns) {
|
|
1816
|
+
if (dateInScope(entry.date, scope)) addQueryTurn(selected, entry.turn, entry.date)
|
|
1817
|
+
if (entry.date >= heatStart && entry.date <= today) addQueryTurn(heatmap, entry.turn, entry.date)
|
|
1818
|
+
}
|
|
1819
|
+
const hourly = queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs)
|
|
1820
|
+
const result = { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scope: JSON.parse(scopeFingerprint(scope)), partial: !scan.done, completeThrough: { revision: statsRevision, at: statsUpdatedAt }, ...finalizeQueryAggregate(selected), hourly, heatmap: finalizeQueryAggregate(heatmap).daily }
|
|
1821
|
+
queryCache.set(key, result)
|
|
1822
|
+
while (queryCache.size > 20) queryCache.delete(queryCache.keys().next().value)
|
|
1823
|
+
return result
|
|
1824
|
+
}
|
|
1825
|
+
function opaqueRecordId(item) {
|
|
1826
|
+
return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
|
|
1827
|
+
}
|
|
1828
|
+
function recordOrder(a, b) {
|
|
1829
|
+
return b.time - a.time || b.seq - a.seq || (a.sid < b.sid ? -1 : a.sid > b.sid ? 1 : 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
|
|
1830
|
+
}
|
|
1831
|
+
function queryRecords(scope, cursor, limit) {
|
|
1832
|
+
commitPendingStats()
|
|
1833
|
+
const fingerprint = scopeFingerprint(scope)
|
|
1834
|
+
let offset = 0
|
|
1835
|
+
if (cursor !== undefined && cursor !== '') {
|
|
1836
|
+
try {
|
|
1837
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
1838
|
+
if (decoded.revision !== statsRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
|
|
1839
|
+
offset = decoded.offset
|
|
1840
|
+
} catch (err) { return { error: 'bad-cursor' } }
|
|
1841
|
+
}
|
|
1842
|
+
const cacheKey = statsRevision + ':' + fingerprint
|
|
1843
|
+
let rows = recordsQueryCache.get(cacheKey)
|
|
1844
|
+
if (rows === undefined) {
|
|
1845
|
+
const index = scope.utc ? usageByUtcDate : usageByLocalDate
|
|
1846
|
+
rows = indexedEntriesInRange(index, usageByStep, scope.start, scope.end).filter(({ item }) => {
|
|
1847
|
+
if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
|
|
1848
|
+
return identityMatchesScope(item.identity || item.modelId, scope)
|
|
1849
|
+
})
|
|
1850
|
+
rows.sort((left, right) => recordOrder(left.item, right.item))
|
|
1851
|
+
recordsQueryCache.set(cacheKey, rows)
|
|
1852
|
+
while (recordsQueryCache.size > 20) recordsQueryCache.delete(recordsQueryCache.keys().next().value)
|
|
1853
|
+
}
|
|
1854
|
+
const page = rows.slice(offset, offset + limit)
|
|
1855
|
+
const items = page.map(({ item, date }) => {
|
|
1856
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
1857
|
+
return { id: opaqueRecordId(item), date, time: item.time, workspaceId: item.wsId, provider: identity.provider, requestedModel: identity.requestedModel, actualModel: identity.actualModel, model: identity.label, identityKey: identity.identityKey, turn: item.turn, step: item.step, seq: item.seq, values: item.values, cost: item.cost, materialization: item.materialization || 'unknown' }
|
|
1858
|
+
})
|
|
1859
|
+
const nextOffset = offset + items.length
|
|
1860
|
+
return { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId, revision: statsRevision, scope: JSON.parse(fingerprint), items, hasMore: nextOffset < rows.length, nextCursor: nextOffset < rows.length ? Buffer.from(JSON.stringify({ revision: statsRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
|
|
996
1861
|
}
|
|
997
1862
|
|
|
998
1863
|
// ---------- account balance (DeepSeek open platform) ----------
|
|
@@ -1099,6 +1964,89 @@ function apply(ctx) {
|
|
|
1099
1964
|
// ---------- HTTP data routes for the client half ----------
|
|
1100
1965
|
if (webServer !== undefined) {
|
|
1101
1966
|
const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
|
|
1967
|
+
ctx.effect(() => webServer.register({
|
|
1968
|
+
kind: 'exact',
|
|
1969
|
+
path: '/api/all-usage/query',
|
|
1970
|
+
handler: (req, res) => {
|
|
1971
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1972
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1973
|
+
const parsed = queryScopeFromRequest(req)
|
|
1974
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
1975
|
+
sendJson(res, 200, queryUsageScope(parsed.scope))
|
|
1976
|
+
},
|
|
1977
|
+
}))
|
|
1978
|
+
ctx.effect(() => webServer.register({
|
|
1979
|
+
kind: 'exact',
|
|
1980
|
+
path: '/api/all-usage/records',
|
|
1981
|
+
handler: (req, res) => {
|
|
1982
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1983
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1984
|
+
const parsed = queryScopeFromRequest(req)
|
|
1985
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
1986
|
+
let limit = 50
|
|
1987
|
+
let cursor
|
|
1988
|
+
try {
|
|
1989
|
+
const url = new URL(req.url || '/', 'http://all-usage.local')
|
|
1990
|
+
const rawLimit = url.searchParams.get('limit')
|
|
1991
|
+
if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
|
|
1992
|
+
cursor = url.searchParams.get('cursor') || undefined
|
|
1993
|
+
} catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
|
|
1994
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
|
|
1995
|
+
const result = queryRecords(parsed.scope, cursor, limit)
|
|
1996
|
+
if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
|
|
1997
|
+
sendJson(res, 200, result)
|
|
1998
|
+
},
|
|
1999
|
+
}))
|
|
2000
|
+
ctx.effect(() => webServer.register({
|
|
2001
|
+
kind: 'exact',
|
|
2002
|
+
path: '/api/all-usage/pricing/models',
|
|
2003
|
+
handler: (req, res) => {
|
|
2004
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
2005
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
2006
|
+
let query = ''
|
|
2007
|
+
let limit = 20
|
|
2008
|
+
try {
|
|
2009
|
+
const url = new URL(req.url || '/', 'http://all-usage.local')
|
|
2010
|
+
query = url.searchParams.get('q') || ''
|
|
2011
|
+
const rawLimit = url.searchParams.get('limit')
|
|
2012
|
+
if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
|
|
2013
|
+
} catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
|
|
2014
|
+
if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
|
|
2015
|
+
sendJson(res, 200, { items: pricingModelSearch(query, limit) })
|
|
2016
|
+
},
|
|
2017
|
+
}))
|
|
2018
|
+
ctx.effect(() => webServer.register({
|
|
2019
|
+
kind: 'exact',
|
|
2020
|
+
path: '/api/all-usage/pricing',
|
|
2021
|
+
handler: async (req, res) => {
|
|
2022
|
+
if (req.method === 'GET') {
|
|
2023
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
2024
|
+
sendJson(res, 200, pricingSnapshot())
|
|
2025
|
+
return
|
|
2026
|
+
}
|
|
2027
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
2028
|
+
if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
2029
|
+
const body = await readBody(req, 256 * 1024)
|
|
2030
|
+
if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
|
|
2031
|
+
let args = null
|
|
2032
|
+
try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
|
|
2033
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
|
|
2034
|
+
const result = updatePricingState(args.pricing || args, args.backfill === true)
|
|
2035
|
+
await persistPricing()
|
|
2036
|
+
await ledgerWriteChain
|
|
2037
|
+
sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
|
|
2038
|
+
},
|
|
2039
|
+
}))
|
|
2040
|
+
ctx.effect(() => webServer.register({
|
|
2041
|
+
kind: 'exact',
|
|
2042
|
+
path: '/api/all-usage/pricing/sync',
|
|
2043
|
+
handler: async (req, res) => {
|
|
2044
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
2045
|
+
if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
2046
|
+
const result = await syncPricing(true)
|
|
2047
|
+
sendJson(res, result.ok ? 200 : 502, result)
|
|
2048
|
+
},
|
|
2049
|
+
}))
|
|
1102
2050
|
ctx.effect(() => webServer.register({
|
|
1103
2051
|
kind: 'exact',
|
|
1104
2052
|
path: '/api/all-usage/status',
|
|
@@ -1145,23 +2093,26 @@ function apply(ctx) {
|
|
|
1145
2093
|
}
|
|
1146
2094
|
if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
1147
2095
|
const body = await readBody(req, 16 * 1024)
|
|
2096
|
+
if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
|
|
1148
2097
|
let args = null
|
|
1149
2098
|
try {
|
|
1150
|
-
args = JSON.parse(body)
|
|
2099
|
+
args = JSON.parse(body.text)
|
|
1151
2100
|
} catch (err) { /* invalid json */ }
|
|
1152
|
-
const
|
|
2101
|
+
const validAliasRequest = args !== null && args !== undefined && typeof args === 'object' && !Array.isArray(args) && typeof args.workspaceId === 'string' && args.workspaceId.length > 0 && args.workspaceId.length <= 256 && typeof args.alias === 'string'
|
|
2102
|
+
const result = validAliasRequest
|
|
1153
2103
|
? setAlias(args.workspaceId, args.alias)
|
|
1154
2104
|
: { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
|
|
1155
|
-
sendJson(res, 200, result)
|
|
2105
|
+
sendJson(res, result.ok ? 200 : 400, result)
|
|
1156
2106
|
},
|
|
1157
2107
|
}))
|
|
1158
2108
|
}
|
|
1159
2109
|
|
|
1160
2110
|
// ---------- start the historical backfill immediately ----------
|
|
1161
2111
|
ledgerReady = loadLedger()
|
|
2112
|
+
pricingReady = loadPricing().then(() => { schedulePricingSync() })
|
|
1162
2113
|
void runBaseline()
|
|
1163
2114
|
scheduleReconcileTimer()
|
|
1164
|
-
|
|
2115
|
+
aliasesReady = loadAliases()
|
|
1165
2116
|
}
|
|
1166
2117
|
|
|
1167
2118
|
export { name, inject, apply }
|