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.
Files changed (90) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +328 -301
  3. package/cordis.patch.yml +4 -4
  4. package/lib/caller-BWwSVNZf.js.map +1 -0
  5. package/lib/client.js +176 -14
  6. package/lib/client.js.map +1 -0
  7. package/lib/core.js.map +1 -0
  8. package/lib/index.js +400 -51
  9. package/lib/index.js.map +1 -0
  10. package/lib/types/auto.d.ts.map +1 -0
  11. package/lib/types/auto.js +8 -2
  12. package/lib/types/auto.js.map +1 -0
  13. package/lib/types/cache.d.ts.map +1 -0
  14. package/lib/types/cache.js.map +1 -0
  15. package/lib/types/caller.d.ts.map +1 -0
  16. package/lib/types/caller.js.map +1 -0
  17. package/lib/types/client-i18n.d.ts +27 -0
  18. package/lib/types/client-i18n.d.ts.map +1 -0
  19. package/lib/types/client-i18n.js +18 -0
  20. package/lib/types/client-i18n.js.map +1 -0
  21. package/lib/types/client.d.ts +15 -2
  22. package/lib/types/client.d.ts.map +1 -0
  23. package/lib/types/client.js +137 -10
  24. package/lib/types/client.js.map +1 -0
  25. package/lib/types/config.d.ts +4 -0
  26. package/lib/types/config.d.ts.map +1 -0
  27. package/lib/types/config.js +5 -3
  28. package/lib/types/config.js.map +1 -0
  29. package/lib/types/core.d.ts.map +1 -0
  30. package/lib/types/core.js.map +1 -0
  31. package/lib/types/engine.d.ts.map +1 -0
  32. package/lib/types/engine.js.map +1 -0
  33. package/lib/types/images.d.ts.map +1 -0
  34. package/lib/types/images.js.map +1 -0
  35. package/lib/types/index.d.ts +2 -0
  36. package/lib/types/index.d.ts.map +1 -0
  37. package/lib/types/index.js +162 -9
  38. package/lib/types/index.js.map +1 -0
  39. package/lib/types/plan-gate.d.ts +27 -0
  40. package/lib/types/plan-gate.d.ts.map +1 -0
  41. package/lib/types/plan-gate.js +60 -0
  42. package/lib/types/plan-gate.js.map +1 -0
  43. package/lib/types/router.d.ts +28 -32
  44. package/lib/types/router.d.ts.map +1 -0
  45. package/lib/types/router.js +41 -9
  46. package/lib/types/router.js.map +1 -0
  47. package/lib/types/session.d.ts +11 -0
  48. package/lib/types/session.d.ts.map +1 -0
  49. package/lib/types/session.js +41 -11
  50. package/lib/types/session.js.map +1 -0
  51. package/lib/types/statistics.d.ts.map +1 -0
  52. package/lib/types/statistics.js.map +1 -0
  53. package/lib/types/team-gate.d.ts +20 -0
  54. package/lib/types/team-gate.d.ts.map +1 -0
  55. package/lib/types/team-gate.js +59 -0
  56. package/lib/types/team-gate.js.map +1 -0
  57. package/lib/types/top-logprobs.d.ts.map +1 -0
  58. package/lib/types/top-logprobs.js.map +1 -0
  59. package/lib/types/topic-storage.d.ts.map +1 -0
  60. package/lib/types/topic-storage.js.map +1 -0
  61. package/package.json +114 -112
  62. package/src/auto.test.ts +95 -86
  63. package/src/auto.ts +140 -133
  64. package/src/cache.ts +115 -115
  65. package/src/caller.test.ts +34 -34
  66. package/src/caller.ts +151 -151
  67. package/src/client-i18n.ts +18 -0
  68. package/src/client.tsx +383 -213
  69. package/src/config.ts +189 -183
  70. package/src/core.test.ts +71 -71
  71. package/src/core.ts +205 -205
  72. package/src/engine.test.ts +136 -136
  73. package/src/engine.ts +140 -140
  74. package/src/images.ts +33 -33
  75. package/src/index.ts +394 -247
  76. package/src/parity.test.ts +85 -85
  77. package/src/plan-gate.test.ts +56 -0
  78. package/src/plan-gate.ts +66 -0
  79. package/src/router.test.ts +107 -89
  80. package/src/router.ts +361 -309
  81. package/src/session.test.ts +71 -36
  82. package/src/session.ts +132 -103
  83. package/src/statistics.test.ts +80 -80
  84. package/src/statistics.ts +328 -328
  85. package/src/team-gate.test.ts +92 -0
  86. package/src/team-gate.ts +80 -0
  87. package/src/top-logprobs.test.ts +84 -84
  88. package/src/top-logprobs.ts +153 -153
  89. package/src/topic-storage.test.ts +31 -31
  90. 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
+ }