dsh-llm-verifier 0.1.13 → 0.1.16
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/LICENSE +22 -22
- package/README.md +328 -301
- package/cordis.patch.yml +4 -4
- package/lib/caller-BWwSVNZf.js.map +1 -0
- package/lib/client.js +176 -14
- package/lib/client.js.map +1 -0
- package/lib/core.js.map +1 -0
- package/lib/index.js +400 -51
- package/lib/index.js.map +1 -0
- package/lib/types/auto.d.ts.map +1 -0
- package/lib/types/auto.js +8 -2
- package/lib/types/auto.js.map +1 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/cache.js.map +1 -0
- package/lib/types/caller.d.ts.map +1 -0
- package/lib/types/caller.js.map +1 -0
- package/lib/types/client-i18n.d.ts +27 -0
- package/lib/types/client-i18n.d.ts.map +1 -0
- package/lib/types/client-i18n.js +18 -0
- package/lib/types/client-i18n.js.map +1 -0
- package/lib/types/client.d.ts +15 -2
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/client.js +137 -10
- package/lib/types/client.js.map +1 -0
- package/lib/types/config.d.ts +4 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +5 -3
- package/lib/types/config.js.map +1 -0
- package/lib/types/core.d.ts.map +1 -0
- package/lib/types/core.js.map +1 -0
- package/lib/types/engine.d.ts.map +1 -0
- package/lib/types/engine.js.map +1 -0
- package/lib/types/images.d.ts.map +1 -0
- package/lib/types/images.js.map +1 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +162 -9
- package/lib/types/index.js.map +1 -0
- package/lib/types/plan-gate.d.ts +27 -0
- package/lib/types/plan-gate.d.ts.map +1 -0
- package/lib/types/plan-gate.js +60 -0
- package/lib/types/plan-gate.js.map +1 -0
- package/lib/types/router.d.ts +28 -32
- package/lib/types/router.d.ts.map +1 -0
- package/lib/types/router.js +41 -9
- package/lib/types/router.js.map +1 -0
- package/lib/types/session.d.ts +11 -0
- package/lib/types/session.d.ts.map +1 -0
- package/lib/types/session.js +41 -11
- package/lib/types/session.js.map +1 -0
- package/lib/types/statistics.d.ts.map +1 -0
- package/lib/types/statistics.js.map +1 -0
- package/lib/types/team-gate.d.ts +20 -0
- package/lib/types/team-gate.d.ts.map +1 -0
- package/lib/types/team-gate.js +59 -0
- package/lib/types/team-gate.js.map +1 -0
- package/lib/types/top-logprobs.d.ts.map +1 -0
- package/lib/types/top-logprobs.js.map +1 -0
- package/lib/types/topic-storage.d.ts.map +1 -0
- package/lib/types/topic-storage.js.map +1 -0
- package/package.json +114 -112
- package/src/auto.test.ts +95 -86
- package/src/auto.ts +140 -133
- package/src/cache.ts +115 -115
- package/src/caller.test.ts +34 -34
- package/src/caller.ts +151 -151
- package/src/client-i18n.ts +18 -0
- package/src/client.tsx +383 -213
- package/src/config.ts +189 -183
- package/src/core.test.ts +71 -71
- package/src/core.ts +205 -205
- package/src/engine.test.ts +136 -136
- package/src/engine.ts +140 -140
- package/src/images.ts +33 -33
- package/src/index.ts +394 -247
- package/src/parity.test.ts +85 -85
- package/src/plan-gate.test.ts +56 -0
- package/src/plan-gate.ts +66 -0
- package/src/router.test.ts +107 -89
- package/src/router.ts +361 -309
- package/src/session.test.ts +71 -36
- package/src/session.ts +132 -103
- package/src/statistics.test.ts +80 -80
- package/src/statistics.ts +328 -328
- package/src/team-gate.test.ts +92 -0
- package/src/team-gate.ts +80 -0
- package/src/top-logprobs.test.ts +84 -84
- package/src/top-logprobs.ts +153 -153
- package/src/topic-storage.test.ts +31 -31
- package/src/topic-storage.ts +42 -42
package/src/statistics.ts
CHANGED
|
@@ -1,328 +1,328 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { dirname, join } from 'node:path'
|
|
4
|
-
import type { RunStats } from './engine.ts'
|
|
5
|
-
|
|
6
|
-
export const VERIFIER_TOOL_NAMES = ['verifier_route_classify', 'verifier_compare', 'verifier_select', 'verifier_track', 'verifier_current_session'] as const
|
|
7
|
-
export type VerifierToolName = typeof VERIFIER_TOOL_NAMES[number]
|
|
8
|
-
|
|
9
|
-
export interface InvocationRecord {
|
|
10
|
-
id: string
|
|
11
|
-
toolName: VerifierToolName
|
|
12
|
-
sessionId?: string
|
|
13
|
-
startedAt: number
|
|
14
|
-
finishedAt: number
|
|
15
|
-
durationMs: number
|
|
16
|
-
success: boolean
|
|
17
|
-
errorName?: string
|
|
18
|
-
errorMessage?: string
|
|
19
|
-
provider: string
|
|
20
|
-
model: string
|
|
21
|
-
stats: RunStats
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface StatisticsDocument {
|
|
25
|
-
version: 1
|
|
26
|
-
records: InvocationRecord[]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface DailyStatistics {
|
|
30
|
-
date: string
|
|
31
|
-
invocations: number
|
|
32
|
-
successes: number
|
|
33
|
-
failures: number
|
|
34
|
-
calls: number
|
|
35
|
-
tokens: number
|
|
36
|
-
estimatedCostUsd: number
|
|
37
|
-
byTool: Record<string, number>
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ToolStatistics {
|
|
41
|
-
toolName: VerifierToolName
|
|
42
|
-
invocations: number
|
|
43
|
-
successes: number
|
|
44
|
-
failures: number
|
|
45
|
-
successRate: number
|
|
46
|
-
averageDurationMs: number
|
|
47
|
-
calls: number
|
|
48
|
-
tokens: number
|
|
49
|
-
cacheHits: number
|
|
50
|
-
cacheMisses: number
|
|
51
|
-
estimatedCostUsd: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ModelStatistics {
|
|
55
|
-
provider: string
|
|
56
|
-
model: string
|
|
57
|
-
invocations: number
|
|
58
|
-
calls: number
|
|
59
|
-
tokens: number
|
|
60
|
-
estimatedCostUsd: number
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface StatisticsTotals {
|
|
64
|
-
invocations: number
|
|
65
|
-
successes: number
|
|
66
|
-
failures: number
|
|
67
|
-
successRate: number
|
|
68
|
-
averageDurationMs: number
|
|
69
|
-
calls: number
|
|
70
|
-
attempts: number
|
|
71
|
-
retries: number
|
|
72
|
-
inputTokens: number
|
|
73
|
-
cachedInputTokens: number
|
|
74
|
-
outputTokens: number
|
|
75
|
-
reasoningTokens: number
|
|
76
|
-
tokens: number
|
|
77
|
-
cacheHits: number
|
|
78
|
-
cacheMisses: number
|
|
79
|
-
cacheHitRate: number
|
|
80
|
-
estimatedCostUsd: number
|
|
81
|
-
topLogprobScores: number
|
|
82
|
-
explicitTagScores: number
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface StatisticsOverview {
|
|
86
|
-
generatedAt: number
|
|
87
|
-
fromMs: number
|
|
88
|
-
toMs: number
|
|
89
|
-
sessionId?: string
|
|
90
|
-
totals: StatisticsTotals
|
|
91
|
-
daily: DailyStatistics[]
|
|
92
|
-
tools: ToolStatistics[]
|
|
93
|
-
models: ModelStatistics[]
|
|
94
|
-
recent: InvocationRecord[]
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface StatisticsQuery {
|
|
98
|
-
fromMs: number
|
|
99
|
-
toMs: number
|
|
100
|
-
timezoneOffsetMinutes?: number
|
|
101
|
-
sessionId?: string
|
|
102
|
-
recentLimit?: number
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface InvocationInput {
|
|
106
|
-
toolName: VerifierToolName
|
|
107
|
-
sessionId?: string
|
|
108
|
-
startedAt: number
|
|
109
|
-
finishedAt?: number
|
|
110
|
-
success: boolean
|
|
111
|
-
errorName?: string
|
|
112
|
-
errorMessage?: string
|
|
113
|
-
provider: string
|
|
114
|
-
model: string
|
|
115
|
-
stats: RunStats
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function finite(value: number): number { return Number.isFinite(value) ? value : 0 }
|
|
119
|
-
function ratio(numerator: number, denominator: number): number { return denominator > 0 ? numerator / denominator : 0 }
|
|
120
|
-
function tokens(stats: RunStats): number { return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens }
|
|
121
|
-
function cleanError(value: string | undefined): string | undefined { return value === undefined ? undefined : value.slice(0, 500) }
|
|
122
|
-
|
|
123
|
-
function blankTotals(): StatisticsTotals {
|
|
124
|
-
return { invocations: 0, successes: 0, failures: 0, successRate: 0, averageDurationMs: 0, calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, tokens: 0, cacheHits: 0, cacheMisses: 0, cacheHitRate: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function addRecord(target: StatisticsTotals, record: InvocationRecord): void {
|
|
128
|
-
const stats = record.stats
|
|
129
|
-
target.invocations += 1
|
|
130
|
-
record.success ? target.successes += 1 : target.failures += 1
|
|
131
|
-
target.averageDurationMs += record.durationMs
|
|
132
|
-
target.calls += stats.calls
|
|
133
|
-
target.attempts += stats.attempts
|
|
134
|
-
target.retries += stats.retries
|
|
135
|
-
target.inputTokens += stats.inputTokens
|
|
136
|
-
target.cachedInputTokens += stats.cachedInputTokens
|
|
137
|
-
target.outputTokens += stats.outputTokens
|
|
138
|
-
target.reasoningTokens += stats.reasoningTokens
|
|
139
|
-
target.tokens += tokens(stats)
|
|
140
|
-
target.cacheHits += stats.cacheHits
|
|
141
|
-
target.cacheMisses += stats.cacheMisses
|
|
142
|
-
target.estimatedCostUsd += stats.estimatedCostUsd
|
|
143
|
-
target.topLogprobScores += stats.topLogprobScores
|
|
144
|
-
target.explicitTagScores += stats.explicitTagScores
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function finishTotals(target: StatisticsTotals): StatisticsTotals {
|
|
148
|
-
target.averageDurationMs = target.invocations > 0 ? target.averageDurationMs / target.invocations : 0
|
|
149
|
-
target.successRate = ratio(target.successes, target.invocations)
|
|
150
|
-
target.cacheHitRate = ratio(target.cacheHits, target.cacheHits + target.cacheMisses)
|
|
151
|
-
return target
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function localDate(time: number, timezoneOffsetMinutes: number): string {
|
|
155
|
-
return new Date(time - timezoneOffsetMinutes * 60_000).toISOString().slice(0, 10)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function isRecord(value: unknown): value is InvocationRecord {
|
|
159
|
-
if (typeof value !== 'object' || value === null) return false
|
|
160
|
-
const row = value as Partial<InvocationRecord>
|
|
161
|
-
return typeof row.id === 'string' && VERIFIER_TOOL_NAMES.includes(row.toolName as VerifierToolName) && typeof row.startedAt === 'number' && typeof row.finishedAt === 'number' && typeof row.durationMs === 'number' && typeof row.success === 'boolean' && typeof row.provider === 'string' && typeof row.model === 'string' && typeof row.stats === 'object' && row.stats !== null
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function resolveStatisticsFile(cacheFile: string): string {
|
|
165
|
-
return join(dirname(cacheFile), 'statistics-v1.json')
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Combine independently persisted topic summaries for the all-topics dashboard. */
|
|
169
|
-
export function mergeStatisticsOverviews(overviews: readonly StatisticsOverview[], query: StatisticsQuery): StatisticsOverview {
|
|
170
|
-
const totals = blankTotals()
|
|
171
|
-
const daily = new Map<string, DailyStatistics>()
|
|
172
|
-
const tools = new Map<VerifierToolName, ToolStatistics>()
|
|
173
|
-
const models = new Map<string, ModelStatistics>()
|
|
174
|
-
let weightedDuration = 0
|
|
175
|
-
for (const overview of overviews) {
|
|
176
|
-
const source = overview.totals
|
|
177
|
-
weightedDuration += source.averageDurationMs * source.invocations
|
|
178
|
-
for (const key of ['invocations', 'successes', 'failures', 'calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens', 'tokens', 'cacheHits', 'cacheMisses', 'estimatedCostUsd', 'topLogprobScores', 'explicitTagScores'] as const) totals[key] += source[key]
|
|
179
|
-
for (const row of overview.daily) {
|
|
180
|
-
const target = daily.get(row.date) ?? { date: row.date, invocations: 0, successes: 0, failures: 0, calls: 0, tokens: 0, estimatedCostUsd: 0, byTool: {} }
|
|
181
|
-
for (const key of ['invocations', 'successes', 'failures', 'calls', 'tokens', 'estimatedCostUsd'] as const) target[key] += row[key]
|
|
182
|
-
for (const [tool, count] of Object.entries(row.byTool)) target.byTool[tool] = (target.byTool[tool] ?? 0) + count
|
|
183
|
-
daily.set(row.date, target)
|
|
184
|
-
}
|
|
185
|
-
for (const row of overview.tools) {
|
|
186
|
-
const target = tools.get(row.toolName) ?? { toolName: row.toolName, invocations: 0, successes: 0, failures: 0, successRate: 0, averageDurationMs: 0, calls: 0, tokens: 0, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0 }
|
|
187
|
-
target.averageDurationMs = (target.averageDurationMs * target.invocations + row.averageDurationMs * row.invocations) / (target.invocations + row.invocations || 1)
|
|
188
|
-
for (const key of ['invocations', 'successes', 'failures', 'calls', 'tokens', 'cacheHits', 'cacheMisses', 'estimatedCostUsd'] as const) target[key] += row[key]
|
|
189
|
-
target.successRate = ratio(target.successes, target.invocations)
|
|
190
|
-
tools.set(row.toolName, target)
|
|
191
|
-
}
|
|
192
|
-
for (const row of overview.models) {
|
|
193
|
-
const key = row.provider + '\u0000' + row.model
|
|
194
|
-
const target = models.get(key) ?? { provider: row.provider, model: row.model, invocations: 0, calls: 0, tokens: 0, estimatedCostUsd: 0 }
|
|
195
|
-
for (const field of ['invocations', 'calls', 'tokens', 'estimatedCostUsd'] as const) target[field] += row[field]
|
|
196
|
-
models.set(key, target)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
totals.averageDurationMs = ratio(weightedDuration, totals.invocations)
|
|
200
|
-
totals.successRate = ratio(totals.successes, totals.invocations)
|
|
201
|
-
totals.cacheHitRate = ratio(totals.cacheHits, totals.cacheHits + totals.cacheMisses)
|
|
202
|
-
const limit = Math.min(200, Math.max(1, Math.trunc(query.recentLimit ?? 40)))
|
|
203
|
-
return {
|
|
204
|
-
generatedAt: Date.now(), fromMs: query.fromMs, toMs: query.toMs,
|
|
205
|
-
...(query.sessionId ? { sessionId: query.sessionId } : {}), totals,
|
|
206
|
-
daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
|
207
|
-
tools: [...tools.values()].sort((a, b) => b.invocations - a.invocations || a.toolName.localeCompare(b.toolName)),
|
|
208
|
-
models: [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model)),
|
|
209
|
-
recent: overviews.flatMap(value => value.recent).sort((a, b) => b.startedAt - a.startedAt).slice(0, limit),
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export class StatisticsStore {
|
|
214
|
-
private loaded = false
|
|
215
|
-
private records: InvocationRecord[] = []
|
|
216
|
-
private writing: Promise<void> = Promise.resolve()
|
|
217
|
-
|
|
218
|
-
constructor(private readonly file: string, private readonly maxEntries = 50_000) {
|
|
219
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) throw new Error('llm-verifier: statistics maxEntries must be a positive integer')
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async record(input: InvocationInput): Promise<InvocationRecord> {
|
|
223
|
-
const finishedAt = input.finishedAt ?? Date.now()
|
|
224
|
-
const record: InvocationRecord = {
|
|
225
|
-
id: randomUUID(),
|
|
226
|
-
toolName: input.toolName,
|
|
227
|
-
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
228
|
-
startedAt: input.startedAt,
|
|
229
|
-
finishedAt,
|
|
230
|
-
durationMs: Math.max(0, finishedAt - input.startedAt),
|
|
231
|
-
success: input.success,
|
|
232
|
-
...(input.errorName ? { errorName: cleanError(input.errorName) } : {}),
|
|
233
|
-
...(input.errorMessage ? { errorMessage: cleanError(input.errorMessage) } : {}),
|
|
234
|
-
provider: input.provider,
|
|
235
|
-
model: input.model,
|
|
236
|
-
stats: { ...input.stats },
|
|
237
|
-
}
|
|
238
|
-
const operation = async () => {
|
|
239
|
-
await this.load()
|
|
240
|
-
this.records.push(record)
|
|
241
|
-
if (this.records.length > this.maxEntries) this.records.splice(0, this.records.length - this.maxEntries)
|
|
242
|
-
await this.persist()
|
|
243
|
-
}
|
|
244
|
-
this.writing = this.writing.then(operation, operation)
|
|
245
|
-
await this.writing
|
|
246
|
-
return record
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async overview(query: StatisticsQuery): Promise<StatisticsOverview> {
|
|
250
|
-
if (!Number.isFinite(query.fromMs) || !Number.isFinite(query.toMs) || query.fromMs >= query.toMs) throw new Error('llm-verifier: statistics range must be finite and increasing')
|
|
251
|
-
await this.writing.catch(() => {})
|
|
252
|
-
await this.load()
|
|
253
|
-
const offset = Number.isFinite(query.timezoneOffsetMinutes) ? Math.trunc(query.timezoneOffsetMinutes ?? 0) : 0
|
|
254
|
-
const limit = Math.min(200, Math.max(1, Math.trunc(query.recentLimit ?? 40)))
|
|
255
|
-
const selected = this.records.filter(record => record.startedAt >= query.fromMs && record.startedAt < query.toMs && (query.sessionId === undefined || record.sessionId === query.sessionId))
|
|
256
|
-
const totals = blankTotals()
|
|
257
|
-
const daily = new Map<string, DailyStatistics>()
|
|
258
|
-
const tools = new Map<VerifierToolName, { totals: StatisticsTotals; duration: number }>()
|
|
259
|
-
const models = new Map<string, ModelStatistics>()
|
|
260
|
-
for (const record of selected) {
|
|
261
|
-
addRecord(totals, record)
|
|
262
|
-
const date = localDate(record.startedAt, offset)
|
|
263
|
-
const day = daily.get(date) ?? { date, invocations: 0, successes: 0, failures: 0, calls: 0, tokens: 0, estimatedCostUsd: 0, byTool: {} }
|
|
264
|
-
day.invocations += 1
|
|
265
|
-
record.success ? day.successes += 1 : day.failures += 1
|
|
266
|
-
day.calls += record.stats.calls
|
|
267
|
-
day.tokens += tokens(record.stats)
|
|
268
|
-
day.estimatedCostUsd += record.stats.estimatedCostUsd
|
|
269
|
-
day.byTool[record.toolName] = (day.byTool[record.toolName] ?? 0) + 1
|
|
270
|
-
daily.set(date, day)
|
|
271
|
-
const tool = tools.get(record.toolName) ?? { totals: blankTotals(), duration: 0 }
|
|
272
|
-
addRecord(tool.totals, record)
|
|
273
|
-
tool.duration += record.durationMs
|
|
274
|
-
tools.set(record.toolName, tool)
|
|
275
|
-
const modelKey = record.provider + '\u0000' + record.model
|
|
276
|
-
const model = models.get(modelKey) ?? { provider: record.provider, model: record.model, invocations: 0, calls: 0, tokens: 0, estimatedCostUsd: 0 }
|
|
277
|
-
model.invocations += 1
|
|
278
|
-
model.calls += record.stats.calls
|
|
279
|
-
model.tokens += tokens(record.stats)
|
|
280
|
-
model.estimatedCostUsd += record.stats.estimatedCostUsd
|
|
281
|
-
models.set(modelKey, model)
|
|
282
|
-
}
|
|
283
|
-
finishTotals(totals)
|
|
284
|
-
const toolRows = [...tools.entries()].map(([toolName, value]) => {
|
|
285
|
-
const summary = finishTotals(value.totals)
|
|
286
|
-
return { toolName, invocations: summary.invocations, successes: summary.successes, failures: summary.failures, successRate: summary.successRate, averageDurationMs: summary.averageDurationMs, calls: summary.calls, tokens: summary.tokens, cacheHits: summary.cacheHits, cacheMisses: summary.cacheMisses, estimatedCostUsd: summary.estimatedCostUsd }
|
|
287
|
-
}).sort((a, b) => b.invocations - a.invocations || a.toolName.localeCompare(b.toolName))
|
|
288
|
-
return {
|
|
289
|
-
generatedAt: Date.now(),
|
|
290
|
-
fromMs: query.fromMs,
|
|
291
|
-
toMs: query.toMs,
|
|
292
|
-
...(query.sessionId ? { sessionId: query.sessionId } : {}),
|
|
293
|
-
totals,
|
|
294
|
-
daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
|
295
|
-
tools: toolRows,
|
|
296
|
-
models: [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model)),
|
|
297
|
-
recent: [...selected].sort((a, b) => b.startedAt - a.startedAt).slice(0, limit),
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private async load(): Promise<void> {
|
|
302
|
-
if (this.loaded) return
|
|
303
|
-
this.loaded = true
|
|
304
|
-
try {
|
|
305
|
-
const document = JSON.parse(await readFile(this.file, 'utf8')) as Partial<StatisticsDocument>
|
|
306
|
-
if (document.version === 1 && Array.isArray(document.records)) this.records = document.records.filter(isRecord).slice(-this.maxEntries)
|
|
307
|
-
} catch (error) {
|
|
308
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private async persist(): Promise<void> {
|
|
313
|
-
const snapshot: StatisticsDocument = { version: 1, records: this.records }
|
|
314
|
-
await mkdir(dirname(this.file), { recursive: true })
|
|
315
|
-
const temporary = this.file + '.tmp-' + process.pid + '-' + randomUUID()
|
|
316
|
-
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
317
|
-
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function emptyRunStats(): RunStats {
|
|
322
|
-
return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 }
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export function errorDetails(error: unknown): { errorName: string; errorMessage: string } {
|
|
326
|
-
if (error instanceof Error) return { errorName: error.name || 'Error', errorMessage: error.message || String(error) }
|
|
327
|
-
return { errorName: 'Error', errorMessage: String(error) }
|
|
328
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import type { RunStats } from './engine.ts'
|
|
5
|
+
|
|
6
|
+
export const VERIFIER_TOOL_NAMES = ['verifier_route_classify', 'verifier_compare', 'verifier_select', 'verifier_track', 'verifier_current_session'] as const
|
|
7
|
+
export type VerifierToolName = typeof VERIFIER_TOOL_NAMES[number]
|
|
8
|
+
|
|
9
|
+
export interface InvocationRecord {
|
|
10
|
+
id: string
|
|
11
|
+
toolName: VerifierToolName
|
|
12
|
+
sessionId?: string
|
|
13
|
+
startedAt: number
|
|
14
|
+
finishedAt: number
|
|
15
|
+
durationMs: number
|
|
16
|
+
success: boolean
|
|
17
|
+
errorName?: string
|
|
18
|
+
errorMessage?: string
|
|
19
|
+
provider: string
|
|
20
|
+
model: string
|
|
21
|
+
stats: RunStats
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface StatisticsDocument {
|
|
25
|
+
version: 1
|
|
26
|
+
records: InvocationRecord[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DailyStatistics {
|
|
30
|
+
date: string
|
|
31
|
+
invocations: number
|
|
32
|
+
successes: number
|
|
33
|
+
failures: number
|
|
34
|
+
calls: number
|
|
35
|
+
tokens: number
|
|
36
|
+
estimatedCostUsd: number
|
|
37
|
+
byTool: Record<string, number>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ToolStatistics {
|
|
41
|
+
toolName: VerifierToolName
|
|
42
|
+
invocations: number
|
|
43
|
+
successes: number
|
|
44
|
+
failures: number
|
|
45
|
+
successRate: number
|
|
46
|
+
averageDurationMs: number
|
|
47
|
+
calls: number
|
|
48
|
+
tokens: number
|
|
49
|
+
cacheHits: number
|
|
50
|
+
cacheMisses: number
|
|
51
|
+
estimatedCostUsd: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ModelStatistics {
|
|
55
|
+
provider: string
|
|
56
|
+
model: string
|
|
57
|
+
invocations: number
|
|
58
|
+
calls: number
|
|
59
|
+
tokens: number
|
|
60
|
+
estimatedCostUsd: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StatisticsTotals {
|
|
64
|
+
invocations: number
|
|
65
|
+
successes: number
|
|
66
|
+
failures: number
|
|
67
|
+
successRate: number
|
|
68
|
+
averageDurationMs: number
|
|
69
|
+
calls: number
|
|
70
|
+
attempts: number
|
|
71
|
+
retries: number
|
|
72
|
+
inputTokens: number
|
|
73
|
+
cachedInputTokens: number
|
|
74
|
+
outputTokens: number
|
|
75
|
+
reasoningTokens: number
|
|
76
|
+
tokens: number
|
|
77
|
+
cacheHits: number
|
|
78
|
+
cacheMisses: number
|
|
79
|
+
cacheHitRate: number
|
|
80
|
+
estimatedCostUsd: number
|
|
81
|
+
topLogprobScores: number
|
|
82
|
+
explicitTagScores: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StatisticsOverview {
|
|
86
|
+
generatedAt: number
|
|
87
|
+
fromMs: number
|
|
88
|
+
toMs: number
|
|
89
|
+
sessionId?: string
|
|
90
|
+
totals: StatisticsTotals
|
|
91
|
+
daily: DailyStatistics[]
|
|
92
|
+
tools: ToolStatistics[]
|
|
93
|
+
models: ModelStatistics[]
|
|
94
|
+
recent: InvocationRecord[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface StatisticsQuery {
|
|
98
|
+
fromMs: number
|
|
99
|
+
toMs: number
|
|
100
|
+
timezoneOffsetMinutes?: number
|
|
101
|
+
sessionId?: string
|
|
102
|
+
recentLimit?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface InvocationInput {
|
|
106
|
+
toolName: VerifierToolName
|
|
107
|
+
sessionId?: string
|
|
108
|
+
startedAt: number
|
|
109
|
+
finishedAt?: number
|
|
110
|
+
success: boolean
|
|
111
|
+
errorName?: string
|
|
112
|
+
errorMessage?: string
|
|
113
|
+
provider: string
|
|
114
|
+
model: string
|
|
115
|
+
stats: RunStats
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function finite(value: number): number { return Number.isFinite(value) ? value : 0 }
|
|
119
|
+
function ratio(numerator: number, denominator: number): number { return denominator > 0 ? numerator / denominator : 0 }
|
|
120
|
+
function tokens(stats: RunStats): number { return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens }
|
|
121
|
+
function cleanError(value: string | undefined): string | undefined { return value === undefined ? undefined : value.slice(0, 500) }
|
|
122
|
+
|
|
123
|
+
function blankTotals(): StatisticsTotals {
|
|
124
|
+
return { invocations: 0, successes: 0, failures: 0, successRate: 0, averageDurationMs: 0, calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, tokens: 0, cacheHits: 0, cacheMisses: 0, cacheHitRate: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function addRecord(target: StatisticsTotals, record: InvocationRecord): void {
|
|
128
|
+
const stats = record.stats
|
|
129
|
+
target.invocations += 1
|
|
130
|
+
record.success ? target.successes += 1 : target.failures += 1
|
|
131
|
+
target.averageDurationMs += record.durationMs
|
|
132
|
+
target.calls += stats.calls
|
|
133
|
+
target.attempts += stats.attempts
|
|
134
|
+
target.retries += stats.retries
|
|
135
|
+
target.inputTokens += stats.inputTokens
|
|
136
|
+
target.cachedInputTokens += stats.cachedInputTokens
|
|
137
|
+
target.outputTokens += stats.outputTokens
|
|
138
|
+
target.reasoningTokens += stats.reasoningTokens
|
|
139
|
+
target.tokens += tokens(stats)
|
|
140
|
+
target.cacheHits += stats.cacheHits
|
|
141
|
+
target.cacheMisses += stats.cacheMisses
|
|
142
|
+
target.estimatedCostUsd += stats.estimatedCostUsd
|
|
143
|
+
target.topLogprobScores += stats.topLogprobScores
|
|
144
|
+
target.explicitTagScores += stats.explicitTagScores
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function finishTotals(target: StatisticsTotals): StatisticsTotals {
|
|
148
|
+
target.averageDurationMs = target.invocations > 0 ? target.averageDurationMs / target.invocations : 0
|
|
149
|
+
target.successRate = ratio(target.successes, target.invocations)
|
|
150
|
+
target.cacheHitRate = ratio(target.cacheHits, target.cacheHits + target.cacheMisses)
|
|
151
|
+
return target
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function localDate(time: number, timezoneOffsetMinutes: number): string {
|
|
155
|
+
return new Date(time - timezoneOffsetMinutes * 60_000).toISOString().slice(0, 10)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isRecord(value: unknown): value is InvocationRecord {
|
|
159
|
+
if (typeof value !== 'object' || value === null) return false
|
|
160
|
+
const row = value as Partial<InvocationRecord>
|
|
161
|
+
return typeof row.id === 'string' && VERIFIER_TOOL_NAMES.includes(row.toolName as VerifierToolName) && typeof row.startedAt === 'number' && typeof row.finishedAt === 'number' && typeof row.durationMs === 'number' && typeof row.success === 'boolean' && typeof row.provider === 'string' && typeof row.model === 'string' && typeof row.stats === 'object' && row.stats !== null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveStatisticsFile(cacheFile: string): string {
|
|
165
|
+
return join(dirname(cacheFile), 'statistics-v1.json')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Combine independently persisted topic summaries for the all-topics dashboard. */
|
|
169
|
+
export function mergeStatisticsOverviews(overviews: readonly StatisticsOverview[], query: StatisticsQuery): StatisticsOverview {
|
|
170
|
+
const totals = blankTotals()
|
|
171
|
+
const daily = new Map<string, DailyStatistics>()
|
|
172
|
+
const tools = new Map<VerifierToolName, ToolStatistics>()
|
|
173
|
+
const models = new Map<string, ModelStatistics>()
|
|
174
|
+
let weightedDuration = 0
|
|
175
|
+
for (const overview of overviews) {
|
|
176
|
+
const source = overview.totals
|
|
177
|
+
weightedDuration += source.averageDurationMs * source.invocations
|
|
178
|
+
for (const key of ['invocations', 'successes', 'failures', 'calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens', 'tokens', 'cacheHits', 'cacheMisses', 'estimatedCostUsd', 'topLogprobScores', 'explicitTagScores'] as const) totals[key] += source[key]
|
|
179
|
+
for (const row of overview.daily) {
|
|
180
|
+
const target = daily.get(row.date) ?? { date: row.date, invocations: 0, successes: 0, failures: 0, calls: 0, tokens: 0, estimatedCostUsd: 0, byTool: {} }
|
|
181
|
+
for (const key of ['invocations', 'successes', 'failures', 'calls', 'tokens', 'estimatedCostUsd'] as const) target[key] += row[key]
|
|
182
|
+
for (const [tool, count] of Object.entries(row.byTool)) target.byTool[tool] = (target.byTool[tool] ?? 0) + count
|
|
183
|
+
daily.set(row.date, target)
|
|
184
|
+
}
|
|
185
|
+
for (const row of overview.tools) {
|
|
186
|
+
const target = tools.get(row.toolName) ?? { toolName: row.toolName, invocations: 0, successes: 0, failures: 0, successRate: 0, averageDurationMs: 0, calls: 0, tokens: 0, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0 }
|
|
187
|
+
target.averageDurationMs = (target.averageDurationMs * target.invocations + row.averageDurationMs * row.invocations) / (target.invocations + row.invocations || 1)
|
|
188
|
+
for (const key of ['invocations', 'successes', 'failures', 'calls', 'tokens', 'cacheHits', 'cacheMisses', 'estimatedCostUsd'] as const) target[key] += row[key]
|
|
189
|
+
target.successRate = ratio(target.successes, target.invocations)
|
|
190
|
+
tools.set(row.toolName, target)
|
|
191
|
+
}
|
|
192
|
+
for (const row of overview.models) {
|
|
193
|
+
const key = row.provider + '\u0000' + row.model
|
|
194
|
+
const target = models.get(key) ?? { provider: row.provider, model: row.model, invocations: 0, calls: 0, tokens: 0, estimatedCostUsd: 0 }
|
|
195
|
+
for (const field of ['invocations', 'calls', 'tokens', 'estimatedCostUsd'] as const) target[field] += row[field]
|
|
196
|
+
models.set(key, target)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
totals.averageDurationMs = ratio(weightedDuration, totals.invocations)
|
|
200
|
+
totals.successRate = ratio(totals.successes, totals.invocations)
|
|
201
|
+
totals.cacheHitRate = ratio(totals.cacheHits, totals.cacheHits + totals.cacheMisses)
|
|
202
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(query.recentLimit ?? 40)))
|
|
203
|
+
return {
|
|
204
|
+
generatedAt: Date.now(), fromMs: query.fromMs, toMs: query.toMs,
|
|
205
|
+
...(query.sessionId ? { sessionId: query.sessionId } : {}), totals,
|
|
206
|
+
daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
|
207
|
+
tools: [...tools.values()].sort((a, b) => b.invocations - a.invocations || a.toolName.localeCompare(b.toolName)),
|
|
208
|
+
models: [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model)),
|
|
209
|
+
recent: overviews.flatMap(value => value.recent).sort((a, b) => b.startedAt - a.startedAt).slice(0, limit),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export class StatisticsStore {
|
|
214
|
+
private loaded = false
|
|
215
|
+
private records: InvocationRecord[] = []
|
|
216
|
+
private writing: Promise<void> = Promise.resolve()
|
|
217
|
+
|
|
218
|
+
constructor(private readonly file: string, private readonly maxEntries = 50_000) {
|
|
219
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) throw new Error('llm-verifier: statistics maxEntries must be a positive integer')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async record(input: InvocationInput): Promise<InvocationRecord> {
|
|
223
|
+
const finishedAt = input.finishedAt ?? Date.now()
|
|
224
|
+
const record: InvocationRecord = {
|
|
225
|
+
id: randomUUID(),
|
|
226
|
+
toolName: input.toolName,
|
|
227
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
228
|
+
startedAt: input.startedAt,
|
|
229
|
+
finishedAt,
|
|
230
|
+
durationMs: Math.max(0, finishedAt - input.startedAt),
|
|
231
|
+
success: input.success,
|
|
232
|
+
...(input.errorName ? { errorName: cleanError(input.errorName) } : {}),
|
|
233
|
+
...(input.errorMessage ? { errorMessage: cleanError(input.errorMessage) } : {}),
|
|
234
|
+
provider: input.provider,
|
|
235
|
+
model: input.model,
|
|
236
|
+
stats: { ...input.stats },
|
|
237
|
+
}
|
|
238
|
+
const operation = async () => {
|
|
239
|
+
await this.load()
|
|
240
|
+
this.records.push(record)
|
|
241
|
+
if (this.records.length > this.maxEntries) this.records.splice(0, this.records.length - this.maxEntries)
|
|
242
|
+
await this.persist()
|
|
243
|
+
}
|
|
244
|
+
this.writing = this.writing.then(operation, operation)
|
|
245
|
+
await this.writing
|
|
246
|
+
return record
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async overview(query: StatisticsQuery): Promise<StatisticsOverview> {
|
|
250
|
+
if (!Number.isFinite(query.fromMs) || !Number.isFinite(query.toMs) || query.fromMs >= query.toMs) throw new Error('llm-verifier: statistics range must be finite and increasing')
|
|
251
|
+
await this.writing.catch(() => {})
|
|
252
|
+
await this.load()
|
|
253
|
+
const offset = Number.isFinite(query.timezoneOffsetMinutes) ? Math.trunc(query.timezoneOffsetMinutes ?? 0) : 0
|
|
254
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(query.recentLimit ?? 40)))
|
|
255
|
+
const selected = this.records.filter(record => record.startedAt >= query.fromMs && record.startedAt < query.toMs && (query.sessionId === undefined || record.sessionId === query.sessionId))
|
|
256
|
+
const totals = blankTotals()
|
|
257
|
+
const daily = new Map<string, DailyStatistics>()
|
|
258
|
+
const tools = new Map<VerifierToolName, { totals: StatisticsTotals; duration: number }>()
|
|
259
|
+
const models = new Map<string, ModelStatistics>()
|
|
260
|
+
for (const record of selected) {
|
|
261
|
+
addRecord(totals, record)
|
|
262
|
+
const date = localDate(record.startedAt, offset)
|
|
263
|
+
const day = daily.get(date) ?? { date, invocations: 0, successes: 0, failures: 0, calls: 0, tokens: 0, estimatedCostUsd: 0, byTool: {} }
|
|
264
|
+
day.invocations += 1
|
|
265
|
+
record.success ? day.successes += 1 : day.failures += 1
|
|
266
|
+
day.calls += record.stats.calls
|
|
267
|
+
day.tokens += tokens(record.stats)
|
|
268
|
+
day.estimatedCostUsd += record.stats.estimatedCostUsd
|
|
269
|
+
day.byTool[record.toolName] = (day.byTool[record.toolName] ?? 0) + 1
|
|
270
|
+
daily.set(date, day)
|
|
271
|
+
const tool = tools.get(record.toolName) ?? { totals: blankTotals(), duration: 0 }
|
|
272
|
+
addRecord(tool.totals, record)
|
|
273
|
+
tool.duration += record.durationMs
|
|
274
|
+
tools.set(record.toolName, tool)
|
|
275
|
+
const modelKey = record.provider + '\u0000' + record.model
|
|
276
|
+
const model = models.get(modelKey) ?? { provider: record.provider, model: record.model, invocations: 0, calls: 0, tokens: 0, estimatedCostUsd: 0 }
|
|
277
|
+
model.invocations += 1
|
|
278
|
+
model.calls += record.stats.calls
|
|
279
|
+
model.tokens += tokens(record.stats)
|
|
280
|
+
model.estimatedCostUsd += record.stats.estimatedCostUsd
|
|
281
|
+
models.set(modelKey, model)
|
|
282
|
+
}
|
|
283
|
+
finishTotals(totals)
|
|
284
|
+
const toolRows = [...tools.entries()].map(([toolName, value]) => {
|
|
285
|
+
const summary = finishTotals(value.totals)
|
|
286
|
+
return { toolName, invocations: summary.invocations, successes: summary.successes, failures: summary.failures, successRate: summary.successRate, averageDurationMs: summary.averageDurationMs, calls: summary.calls, tokens: summary.tokens, cacheHits: summary.cacheHits, cacheMisses: summary.cacheMisses, estimatedCostUsd: summary.estimatedCostUsd }
|
|
287
|
+
}).sort((a, b) => b.invocations - a.invocations || a.toolName.localeCompare(b.toolName))
|
|
288
|
+
return {
|
|
289
|
+
generatedAt: Date.now(),
|
|
290
|
+
fromMs: query.fromMs,
|
|
291
|
+
toMs: query.toMs,
|
|
292
|
+
...(query.sessionId ? { sessionId: query.sessionId } : {}),
|
|
293
|
+
totals,
|
|
294
|
+
daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
|
295
|
+
tools: toolRows,
|
|
296
|
+
models: [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model)),
|
|
297
|
+
recent: [...selected].sort((a, b) => b.startedAt - a.startedAt).slice(0, limit),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async load(): Promise<void> {
|
|
302
|
+
if (this.loaded) return
|
|
303
|
+
this.loaded = true
|
|
304
|
+
try {
|
|
305
|
+
const document = JSON.parse(await readFile(this.file, 'utf8')) as Partial<StatisticsDocument>
|
|
306
|
+
if (document.version === 1 && Array.isArray(document.records)) this.records = document.records.filter(isRecord).slice(-this.maxEntries)
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async persist(): Promise<void> {
|
|
313
|
+
const snapshot: StatisticsDocument = { version: 1, records: this.records }
|
|
314
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
315
|
+
const temporary = this.file + '.tmp-' + process.pid + '-' + randomUUID()
|
|
316
|
+
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
317
|
+
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function emptyRunStats(): RunStats {
|
|
322
|
+
return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function errorDetails(error: unknown): { errorName: string; errorMessage: string } {
|
|
326
|
+
if (error instanceof Error) return { errorName: error.name || 'Error', errorMessage: error.message || String(error) }
|
|
327
|
+
return { errorName: 'Error', errorMessage: String(error) }
|
|
328
|
+
}
|