@narumitw/pi-analytics 0.45.0

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.
@@ -0,0 +1,313 @@
1
+ import type { Database } from "@tursodatabase/database";
2
+ import type { ProviderErrorCategory } from "../types.js";
3
+
4
+ export type TimeRangeId = "today" | "7d" | "30d" | "all";
5
+ export interface TimeRange {
6
+ id?: TimeRangeId;
7
+ fromMs: number;
8
+ toMs: number;
9
+ }
10
+
11
+ export interface OverviewStats {
12
+ responseCycles: number;
13
+ llmCalls: number;
14
+ callsPerResponse: number;
15
+ p95CallsPerResponse: number;
16
+ toolCalls: number;
17
+ toolErrors: number;
18
+ skillActivations: number;
19
+ providerErrors: number;
20
+ recoveredErrors: number;
21
+ }
22
+
23
+ export interface ModelCount {
24
+ provider?: string;
25
+ model?: string;
26
+ count: number;
27
+ }
28
+
29
+ export interface SkillStats {
30
+ name: string;
31
+ count: number;
32
+ modelInitiated: number;
33
+ userInitiated: number;
34
+ lastOccurredAtMs: number;
35
+ models: ModelCount[];
36
+ }
37
+
38
+ export interface ToolStats {
39
+ name: string;
40
+ count: number;
41
+ errors: number;
42
+ averageDurationMs: number;
43
+ lastOccurredAtMs: number;
44
+ models: ModelCount[];
45
+ }
46
+
47
+ export interface ReliabilityStats {
48
+ http429: number;
49
+ http5xx: number;
50
+ recovered: number;
51
+ terminal: number;
52
+ categories: Record<ProviderErrorCategory, number>;
53
+ }
54
+
55
+ export interface ResponseStats {
56
+ count: number;
57
+ llmCalls: number;
58
+ average: number;
59
+ median: number;
60
+ p95: number;
61
+ maximum: number;
62
+ distribution: { one: number; twoToThree: number; fourToSix: number; sevenPlus: number };
63
+ }
64
+
65
+ export interface AnalyticsSnapshot {
66
+ overview: OverviewStats;
67
+ skills: SkillStats[];
68
+ tools: ToolStats[];
69
+ reliability: ReliabilityStats;
70
+ responses: ResponseStats;
71
+ }
72
+
73
+ const DAY_MS = 24 * 60 * 60 * 1_000;
74
+ const ERROR_CATEGORIES: readonly ProviderErrorCategory[] = [
75
+ "dns",
76
+ "timeout",
77
+ "connection_refused",
78
+ "connection_reset",
79
+ "tls",
80
+ "network_other",
81
+ "provider_other",
82
+ ];
83
+
84
+ export function resolveTimeRange(id: TimeRangeId, now = Date.now()): TimeRange {
85
+ let fromMs = 0;
86
+ if (id === "today") {
87
+ const date = new Date(now);
88
+ fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
89
+ } else if (id === "7d") fromMs = now - 7 * DAY_MS;
90
+ else if (id === "30d") fromMs = now - 30 * DAY_MS;
91
+ return { id, fromMs, toMs: now + 1 };
92
+ }
93
+
94
+ export async function querySnapshot(
95
+ database: Database,
96
+ range: TimeRange,
97
+ ): Promise<AnalyticsSnapshot> {
98
+ const [runs, skillRows, toolRows, categoryRows, statusRows] = await Promise.all([
99
+ database.all(
100
+ `SELECT generation_count, tool_call_count, tool_error_count,
101
+ skill_activation_count, provider_error_count, recovered_error_count
102
+ FROM response_runs
103
+ WHERE started_at_ms >= ? AND started_at_ms < ?
104
+ ORDER BY generation_count`,
105
+ range.fromMs,
106
+ range.toMs,
107
+ ),
108
+ database.all(
109
+ `SELECT s.skill_name, s.initiated_by, s.provider, s.model,
110
+ COUNT(*) AS count, MAX(s.occurred_at_ms) AS last_at
111
+ FROM skill_activations s
112
+ JOIN response_runs r ON r.id = s.run_id
113
+ WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
114
+ GROUP BY s.skill_name, s.initiated_by, s.provider, s.model`,
115
+ range.fromMs,
116
+ range.toMs,
117
+ ),
118
+ database.all(
119
+ `SELECT t.tool_name, t.provider, t.model, COUNT(*) AS count,
120
+ SUM(t.is_error) AS errors, AVG(COALESCE(t.duration_ms, 0)) AS average_duration,
121
+ MAX(t.started_at_ms) AS last_at
122
+ FROM tool_calls t
123
+ JOIN response_runs r ON r.id = t.run_id
124
+ WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
125
+ GROUP BY t.tool_name, t.provider, t.model`,
126
+ range.fromMs,
127
+ range.toMs,
128
+ ),
129
+ database.all(
130
+ `SELECT e.category, COUNT(*) AS count, SUM(e.terminal) AS terminal
131
+ FROM provider_errors e
132
+ JOIN response_runs r ON r.id = e.run_id
133
+ WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
134
+ GROUP BY e.category`,
135
+ range.fromMs,
136
+ range.toMs,
137
+ ),
138
+ database.all(
139
+ `SELECT p.status, COUNT(*) AS count
140
+ FROM provider_responses p
141
+ JOIN model_generations g ON g.id = p.generation_id
142
+ JOIN response_runs r ON r.id = g.run_id
143
+ WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
144
+ GROUP BY p.status`,
145
+ range.fromMs,
146
+ range.toMs,
147
+ ),
148
+ ]);
149
+
150
+ const generationCounts = runs.map((row) => numberValue(row.generation_count));
151
+ const responseStats = responseStatistics(generationCounts);
152
+ const overview: OverviewStats = {
153
+ responseCycles: runs.length,
154
+ llmCalls: sum(generationCounts),
155
+ callsPerResponse: responseStats.average,
156
+ p95CallsPerResponse: responseStats.p95,
157
+ toolCalls: sum(runs.map((row) => numberValue(row.tool_call_count))),
158
+ toolErrors: sum(runs.map((row) => numberValue(row.tool_error_count))),
159
+ skillActivations: sum(runs.map((row) => numberValue(row.skill_activation_count))),
160
+ providerErrors: sum(runs.map((row) => numberValue(row.provider_error_count))),
161
+ recoveredErrors: sum(runs.map((row) => numberValue(row.recovered_error_count))),
162
+ };
163
+
164
+ const categories = Object.fromEntries(
165
+ ERROR_CATEGORIES.map((category) => [category, 0]),
166
+ ) as Record<ProviderErrorCategory, number>;
167
+ let terminal = 0;
168
+ for (const row of categoryRows) {
169
+ const category = String(row.category) as ProviderErrorCategory;
170
+ if (category in categories) categories[category] = numberValue(row.count);
171
+ terminal += numberValue(row.terminal);
172
+ }
173
+ let http429 = 0;
174
+ let http5xx = 0;
175
+ for (const row of statusRows) {
176
+ const status = numberValue(row.status);
177
+ if (status === 429) http429 += numberValue(row.count);
178
+ if (status >= 500 && status < 600) http5xx += numberValue(row.count);
179
+ }
180
+
181
+ return {
182
+ overview,
183
+ skills: foldSkills(skillRows),
184
+ tools: foldTools(toolRows),
185
+ reliability: {
186
+ http429,
187
+ http5xx,
188
+ recovered: overview.recoveredErrors,
189
+ terminal,
190
+ categories,
191
+ },
192
+ responses: responseStats,
193
+ };
194
+ }
195
+
196
+ function foldSkills(rows: Array<Record<string, unknown>>): SkillStats[] {
197
+ const result = new Map<string, SkillStats>();
198
+ for (const row of rows) {
199
+ const name = String(row.skill_name);
200
+ const count = numberValue(row.count);
201
+ const item = result.get(name) ?? {
202
+ name,
203
+ count: 0,
204
+ modelInitiated: 0,
205
+ userInitiated: 0,
206
+ lastOccurredAtMs: 0,
207
+ models: [],
208
+ };
209
+ item.count += count;
210
+ if (row.initiated_by === "user") item.userInitiated += count;
211
+ else item.modelInitiated += count;
212
+ item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
213
+ mergeModelCount(item.models, {
214
+ provider: optionalString(row.provider),
215
+ model: optionalString(row.model),
216
+ count,
217
+ });
218
+ result.set(name, item);
219
+ }
220
+ return [...result.values()]
221
+ .map((item) => ({ ...item, models: sortModels(item.models) }))
222
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
223
+ }
224
+
225
+ function foldTools(rows: Array<Record<string, unknown>>): ToolStats[] {
226
+ const result = new Map<string, ToolStats & { weightedDuration: number }>();
227
+ for (const row of rows) {
228
+ const name = String(row.tool_name);
229
+ const count = numberValue(row.count);
230
+ const average = numberValue(row.average_duration);
231
+ const item = result.get(name) ?? {
232
+ name,
233
+ count: 0,
234
+ errors: 0,
235
+ averageDurationMs: 0,
236
+ weightedDuration: 0,
237
+ lastOccurredAtMs: 0,
238
+ models: [],
239
+ };
240
+ item.count += count;
241
+ item.errors += numberValue(row.errors);
242
+ item.weightedDuration += average * count;
243
+ item.averageDurationMs = item.count > 0 ? item.weightedDuration / item.count : 0;
244
+ item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
245
+ mergeModelCount(item.models, {
246
+ provider: optionalString(row.provider),
247
+ model: optionalString(row.model),
248
+ count,
249
+ });
250
+ result.set(name, item);
251
+ }
252
+ return [...result.values()]
253
+ .map(({ weightedDuration: _, ...item }) => ({ ...item, models: sortModels(item.models) }))
254
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
255
+ }
256
+
257
+ function responseStatistics(generationCounts: number[]): ResponseStats {
258
+ const sorted = [...generationCounts].sort((left, right) => left - right);
259
+ const count = sorted.length;
260
+ const llmCalls = sum(sorted);
261
+ const nearestRank = (percentile: number) =>
262
+ count === 0 ? 0 : (sorted[Math.max(0, Math.ceil(percentile * count) - 1)] ?? 0);
263
+ const median =
264
+ count === 0
265
+ ? 0
266
+ : count % 2 === 1
267
+ ? (sorted[Math.floor(count / 2)] ?? 0)
268
+ : ((sorted[count / 2 - 1] ?? 0) + (sorted[count / 2] ?? 0)) / 2;
269
+ return {
270
+ count,
271
+ llmCalls,
272
+ average: count > 0 ? llmCalls / count : 0,
273
+ median,
274
+ p95: nearestRank(0.95),
275
+ maximum: sorted.at(-1) ?? 0,
276
+ distribution: {
277
+ one: sorted.filter((value) => value === 1).length,
278
+ twoToThree: sorted.filter((value) => value >= 2 && value <= 3).length,
279
+ fourToSix: sorted.filter((value) => value >= 4 && value <= 6).length,
280
+ sevenPlus: sorted.filter((value) => value >= 7).length,
281
+ },
282
+ };
283
+ }
284
+
285
+ function mergeModelCount(models: ModelCount[], next: ModelCount): void {
286
+ const existing = models.find(
287
+ ({ provider, model }) => provider === next.provider && model === next.model,
288
+ );
289
+ if (existing) existing.count += next.count;
290
+ else models.push(next);
291
+ }
292
+
293
+ function sortModels(models: ModelCount[]): ModelCount[] {
294
+ return models.sort(
295
+ (left, right) =>
296
+ right.count - left.count ||
297
+ `${left.provider ?? ""}/${left.model ?? ""}`.localeCompare(
298
+ `${right.provider ?? ""}/${right.model ?? ""}`,
299
+ ),
300
+ );
301
+ }
302
+
303
+ function optionalString(value: unknown): string | undefined {
304
+ return typeof value === "string" ? value : undefined;
305
+ }
306
+
307
+ function numberValue(value: unknown): number {
308
+ return typeof value === "number" && Number.isFinite(value) ? value : Number(value) || 0;
309
+ }
310
+
311
+ function sum(values: readonly number[]): number {
312
+ return values.reduce((total, value) => total + value, 0);
313
+ }
@@ -0,0 +1,249 @@
1
+ import type { Database, Transaction } from "@tursodatabase/database";
2
+ import type { SettledRun } from "../types.js";
3
+ import type { OpenedAnalyticsDatabase } from "./database.js";
4
+ import type { AnalyticsSnapshot, TimeRange } from "./queries.js";
5
+ import { querySnapshot } from "./queries.js";
6
+
7
+ const MAX_PENDING_RUNS = 100;
8
+ const WRITE_ATTEMPTS = 6;
9
+
10
+ export class AnalyticsStore {
11
+ private readonly pending: SettledRun[] = [];
12
+ private readonly activeQueries = new Set<Promise<unknown>>();
13
+ private readonly query: typeof querySnapshot;
14
+ private mutationTail: Promise<void> = Promise.resolve();
15
+ private closed = false;
16
+ private closePromise: Promise<void> | undefined;
17
+
18
+ constructor(
19
+ private readonly opened: OpenedAnalyticsDatabase,
20
+ dependencies: { querySnapshot?: typeof querySnapshot } = {},
21
+ ) {
22
+ this.query = dependencies.querySnapshot ?? querySnapshot;
23
+ }
24
+
25
+ get path(): string {
26
+ return this.opened.path;
27
+ }
28
+
29
+ recordRun(run: SettledRun): Promise<void> {
30
+ if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
31
+ if (this.pending.length >= MAX_PENDING_RUNS) this.pending.shift();
32
+ this.pending.push(run);
33
+ return this.enqueueMutation(() => this.flushPending());
34
+ }
35
+
36
+ getSnapshot(range: TimeRange): Promise<AnalyticsSnapshot> {
37
+ if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
38
+ const query = (async () => {
39
+ await this.mutationTail;
40
+ return this.query(this.opened.connection, range);
41
+ })();
42
+ this.activeQueries.add(query);
43
+ void query.finally(() => this.activeQueries.delete(query)).catch(() => undefined);
44
+ return query;
45
+ }
46
+
47
+ clearAll(): Promise<number> {
48
+ if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
49
+ let deleted = 0;
50
+ return this.enqueueMutation(async () => {
51
+ await withWriteRetry(async () => {
52
+ const transaction = this.opened.connection.transactionAsync(async (tx) => {
53
+ const row = (await tx.get("SELECT COUNT(*) AS count FROM response_runs")) as {
54
+ count?: number;
55
+ };
56
+ deleted = Number(row?.count ?? 0);
57
+ for (const table of [
58
+ "provider_responses",
59
+ "provider_errors",
60
+ "tool_calls",
61
+ "skill_activations",
62
+ "model_generations",
63
+ "response_runs",
64
+ ]) {
65
+ await tx.exec(`DELETE FROM ${table}`);
66
+ }
67
+ });
68
+ await transaction.immediate();
69
+ });
70
+ }).then(() => deleted);
71
+ }
72
+
73
+ close(): Promise<void> {
74
+ if (this.closePromise) return this.closePromise;
75
+ this.closed = true;
76
+ this.closePromise = (async () => {
77
+ await this.mutationTail.catch(() => undefined);
78
+ await Promise.allSettled([...this.activeQueries]);
79
+ let pendingError: unknown;
80
+ try {
81
+ await this.flushPending();
82
+ } catch (error) {
83
+ pendingError = error;
84
+ this.pending.length = 0;
85
+ }
86
+ await this.opened.close();
87
+ if (pendingError !== undefined) {
88
+ throw new Error("Analytics pending writes could not be saved before close.", {
89
+ cause: pendingError,
90
+ });
91
+ }
92
+ })();
93
+ return this.closePromise;
94
+ }
95
+
96
+ private enqueueMutation(operation: () => Promise<void>): Promise<void> {
97
+ const result = this.mutationTail.then(operation);
98
+ this.mutationTail = result.catch(() => undefined);
99
+ return result;
100
+ }
101
+
102
+ private async flushPending(): Promise<void> {
103
+ while (this.pending.length > 0) {
104
+ const run = this.pending[0];
105
+ if (!run) return;
106
+ await withWriteRetry(() => writeRun(this.opened.connection, run));
107
+ this.pending.shift();
108
+ }
109
+ }
110
+ }
111
+
112
+ async function writeRun(database: Database, run: SettledRun): Promise<void> {
113
+ const transaction = database.transactionAsync(async (tx) => {
114
+ const existing = await tx.get("SELECT id FROM response_runs WHERE id = ?", run.id);
115
+ if (existing) return;
116
+ await insertRun(tx, run);
117
+ for (const generation of run.generations) {
118
+ await tx.run(
119
+ `INSERT INTO model_generations(
120
+ id, run_id, ordinal, provider, model, thinking_level, started_at_ms,
121
+ finished_at_ms, duration_ms, stop_reason, outcome
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
123
+ generation.id,
124
+ run.id,
125
+ generation.ordinal,
126
+ generation.provider ?? null,
127
+ generation.model ?? null,
128
+ generation.thinkingLevel ?? null,
129
+ generation.startedAtMs,
130
+ generation.finishedAtMs ?? null,
131
+ generation.durationMs ?? null,
132
+ generation.stopReason ?? null,
133
+ generation.outcome,
134
+ );
135
+ for (const response of generation.responses) {
136
+ await tx.run(
137
+ `INSERT INTO provider_responses(generation_id, ordinal, occurred_at_ms, status)
138
+ VALUES (?, ?, ?, ?)`,
139
+ generation.id,
140
+ response.ordinal,
141
+ response.occurredAtMs,
142
+ response.status,
143
+ );
144
+ }
145
+ }
146
+ for (const tool of run.tools) {
147
+ await tx.run(
148
+ `INSERT INTO tool_calls(
149
+ id, run_id, ordinal, tool_name, provider, model, started_at_ms,
150
+ finished_at_ms, duration_ms, is_error, completion_state
151
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
152
+ tool.id,
153
+ run.id,
154
+ tool.ordinal,
155
+ tool.name,
156
+ tool.provider ?? null,
157
+ tool.model ?? null,
158
+ tool.startedAtMs,
159
+ tool.finishedAtMs ?? null,
160
+ tool.durationMs ?? null,
161
+ tool.isError ? 1 : 0,
162
+ tool.completionState,
163
+ );
164
+ }
165
+ for (const skill of run.skills) {
166
+ await tx.run(
167
+ `INSERT INTO skill_activations(
168
+ id, run_id, occurred_at_ms, skill_name, initiated_by, provider, model
169
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
170
+ skill.id,
171
+ run.id,
172
+ skill.occurredAtMs,
173
+ skill.name,
174
+ skill.initiatedBy,
175
+ skill.provider ?? null,
176
+ skill.model ?? null,
177
+ );
178
+ }
179
+ for (const error of run.providerErrors) {
180
+ await tx.run(
181
+ `INSERT INTO provider_errors(
182
+ id, run_id, generation_id, occurred_at_ms, provider, model,
183
+ category, recovered, terminal
184
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
185
+ error.id,
186
+ run.id,
187
+ error.generationId ?? null,
188
+ error.occurredAtMs,
189
+ error.provider ?? null,
190
+ error.model ?? null,
191
+ error.category,
192
+ error.recovered ? 1 : 0,
193
+ error.terminal ? 1 : 0,
194
+ );
195
+ }
196
+ });
197
+ await transaction.immediate();
198
+ }
199
+
200
+ function insertRun(tx: Transaction, run: SettledRun): Promise<unknown> {
201
+ return tx.run(
202
+ `INSERT INTO response_runs(
203
+ id, started_at_ms, finished_at_ms, duration_ms, trigger_source,
204
+ initial_provider, initial_model, outcome, attempt_count, generation_count,
205
+ tool_call_count, tool_error_count, skill_activation_count,
206
+ provider_error_count, recovered_error_count
207
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
208
+ run.id,
209
+ run.startedAtMs,
210
+ run.finishedAtMs,
211
+ run.durationMs,
212
+ run.triggerSource,
213
+ run.initialProvider ?? null,
214
+ run.initialModel ?? null,
215
+ run.outcome,
216
+ run.attemptCount,
217
+ run.generations.length,
218
+ run.tools.length,
219
+ run.toolErrorCount,
220
+ run.skills.length,
221
+ run.providerErrorCount,
222
+ run.recoveredErrorCount,
223
+ );
224
+ }
225
+
226
+ async function withWriteRetry(operation: () => Promise<void>): Promise<void> {
227
+ let lastError: unknown;
228
+ for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
229
+ try {
230
+ await operation();
231
+ return;
232
+ } catch (error) {
233
+ lastError = error;
234
+ if (!isConflict(error) || attempt + 1 >= WRITE_ATTEMPTS) throw error;
235
+ await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
236
+ }
237
+ }
238
+ throw lastError;
239
+ }
240
+
241
+ function isConflict(error: unknown): boolean {
242
+ const message =
243
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
244
+ return (
245
+ message.includes("statement was interrupted") ||
246
+ message.includes("database is locked") ||
247
+ message.includes("database is busy")
248
+ );
249
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ export type TriggerSource = "interactive" | "rpc" | "extension" | "unknown";
2
+ export type RunOutcome =
3
+ | "success"
4
+ | "recovered_success"
5
+ | "error"
6
+ | "aborted"
7
+ | "length"
8
+ | "interrupted";
9
+ export type GenerationOutcome =
10
+ | "pending"
11
+ | "stop"
12
+ | "tool_use"
13
+ | "error"
14
+ | "aborted"
15
+ | "length"
16
+ | "interrupted";
17
+ export type ProviderErrorCategory =
18
+ | "dns"
19
+ | "timeout"
20
+ | "connection_refused"
21
+ | "connection_reset"
22
+ | "tls"
23
+ | "network_other"
24
+ | "provider_other";
25
+
26
+ export interface ModelIdentity {
27
+ provider: string;
28
+ model: string;
29
+ thinkingLevel?: string;
30
+ }
31
+
32
+ export interface ProviderResponseRecord {
33
+ ordinal: number;
34
+ occurredAtMs: number;
35
+ status: number;
36
+ }
37
+
38
+ export interface GenerationRecord {
39
+ id: string;
40
+ ordinal: number;
41
+ provider?: string;
42
+ model?: string;
43
+ thinkingLevel?: string;
44
+ startedAtMs: number;
45
+ finishedAtMs?: number;
46
+ durationMs?: number;
47
+ stopReason?: string;
48
+ outcome: GenerationOutcome;
49
+ responses: ProviderResponseRecord[];
50
+ }
51
+
52
+ export interface ToolCallRecord {
53
+ id: string;
54
+ ordinal: number;
55
+ name: string;
56
+ provider?: string;
57
+ model?: string;
58
+ startedAtMs: number;
59
+ finishedAtMs?: number;
60
+ durationMs?: number;
61
+ isError: boolean;
62
+ completionState: "running" | "finished" | "interrupted";
63
+ }
64
+
65
+ export interface SkillActivationRecord {
66
+ id: string;
67
+ name: string;
68
+ initiatedBy: "user" | "model";
69
+ occurredAtMs: number;
70
+ provider?: string;
71
+ model?: string;
72
+ }
73
+
74
+ export interface ProviderErrorRecord {
75
+ id: string;
76
+ generationId?: string;
77
+ occurredAtMs: number;
78
+ provider?: string;
79
+ model?: string;
80
+ category: ProviderErrorCategory;
81
+ recovered: boolean;
82
+ terminal: boolean;
83
+ }
84
+
85
+ export interface SettledRun {
86
+ id: string;
87
+ startedAtMs: number;
88
+ finishedAtMs: number;
89
+ durationMs: number;
90
+ triggerSource: TriggerSource;
91
+ initialProvider?: string;
92
+ initialModel?: string;
93
+ outcome: RunOutcome;
94
+ attemptCount: number;
95
+ generations: GenerationRecord[];
96
+ tools: ToolCallRecord[];
97
+ skills: SkillActivationRecord[];
98
+ providerErrors: ProviderErrorRecord[];
99
+ toolErrorCount: number;
100
+ providerErrorCount: number;
101
+ recoveredErrorCount: number;
102
+ }