paranoid-cli 1.0.0__py3-none-any.whl

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 (48) hide show
  1. backend/__init__.py +3 -0
  2. backend/config.py +53 -0
  3. backend/db/__init__.py +0 -0
  4. backend/db/crud.py +630 -0
  5. backend/db/schema.py +218 -0
  6. backend/db/seed.py +295 -0
  7. backend/db/vectors.py +323 -0
  8. backend/export/__init__.py +0 -0
  9. backend/main.py +90 -0
  10. backend/models/__init__.py +72 -0
  11. backend/models/enums.py +98 -0
  12. backend/models/extended.py +321 -0
  13. backend/models/state.py +205 -0
  14. backend/pipeline/__init__.py +19 -0
  15. backend/pipeline/input_parser.py +436 -0
  16. backend/pipeline/nodes.py +656 -0
  17. backend/pipeline/prompts/__init__.py +37 -0
  18. backend/pipeline/prompts/attack_tree.py +217 -0
  19. backend/pipeline/prompts/maestro.py +429 -0
  20. backend/pipeline/prompts/stride.py +510 -0
  21. backend/pipeline/prompts/test_case.py +115 -0
  22. backend/pipeline/runner.py +527 -0
  23. backend/providers/__init__.py +25 -0
  24. backend/providers/anthropic.py +155 -0
  25. backend/providers/base.py +214 -0
  26. backend/providers/ollama.py +168 -0
  27. backend/providers/openai.py +160 -0
  28. backend/routes/__init__.py +0 -0
  29. backend/rules/__init__.py +0 -0
  30. cli/__init__.py +0 -0
  31. cli/commands/__init__.py +1 -0
  32. cli/commands/config.py +304 -0
  33. cli/commands/run.py +482 -0
  34. cli/commands/version.py +76 -0
  35. cli/context.py +160 -0
  36. cli/errors.py +43 -0
  37. cli/input/__init__.py +1 -0
  38. cli/input/file_loader.py +172 -0
  39. cli/main.py +48 -0
  40. cli/output/__init__.py +1 -0
  41. cli/output/console.py +117 -0
  42. cli/output/json_writer.py +254 -0
  43. paranoid_cli-1.0.0.dist-info/METADATA +480 -0
  44. paranoid_cli-1.0.0.dist-info/RECORD +48 -0
  45. paranoid_cli-1.0.0.dist-info/WHEEL +5 -0
  46. paranoid_cli-1.0.0.dist-info/entry_points.txt +2 -0
  47. paranoid_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  48. paranoid_cli-1.0.0.dist-info/top_level.txt +2 -0
backend/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Paranoid backend package."""
2
+
3
+ __version__ = "1.0.0"
backend/config.py ADDED
@@ -0,0 +1,53 @@
1
+ """Application configuration using pydantic-settings."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Application settings loaded from environment variables."""
10
+
11
+ model_config = SettingsConfigDict(
12
+ env_file=".env",
13
+ env_file_encoding="utf-8",
14
+ case_sensitive=False,
15
+ extra="ignore",
16
+ )
17
+
18
+ # LLM Provider settings
19
+ anthropic_api_key: str = ""
20
+ openai_api_key: str = ""
21
+ ollama_base_url: str = "http://host.docker.internal:11434"
22
+ default_provider: Literal["anthropic", "openai", "ollama"] = "anthropic"
23
+ default_model: str = "claude-sonnet-4-20250514"
24
+ default_iterations: int = 3
25
+
26
+ # Embedding settings
27
+ embedding_model: str = "BAAI/bge-small-en-v1.5"
28
+
29
+ # Database settings
30
+ db_path: str = "./data/paranoid.db"
31
+
32
+ # Server settings
33
+ host: str = "0.0.0.0"
34
+ port: int = 8000
35
+ log_level: Literal["debug", "info", "warning", "error"] = "info"
36
+
37
+ # Prompt configuration
38
+ summary_max_words: int = 40
39
+ threat_description_min_words: int = 35
40
+ threat_description_max_words: int = 50
41
+ mitigation_min_items: int = 2
42
+ mitigation_max_items: int = 5
43
+
44
+ # Pipeline configuration
45
+ max_iteration_count: int = 15
46
+ min_iteration_count: int = 1
47
+
48
+ # Deduplication threshold for rule engine
49
+ similarity_threshold: float = 0.85
50
+
51
+
52
+ # Global settings instance
53
+ settings = Settings()
backend/db/__init__.py ADDED
File without changes
backend/db/crud.py ADDED
@@ -0,0 +1,630 @@
1
+ """Async CRUD operations for SQLite database."""
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ import aiosqlite
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def generate_id() -> str:
16
+ """Generate a UUID for database records."""
17
+ return str(uuid.uuid4())
18
+
19
+
20
+ def now_iso() -> str:
21
+ """Get current timestamp in ISO 8601 format."""
22
+ return datetime.now(UTC).isoformat()
23
+
24
+
25
+ # Threat Models CRUD
26
+
27
+
28
+ async def create_threat_model(
29
+ db_path: str,
30
+ title: str,
31
+ description: str,
32
+ provider: str,
33
+ model: str,
34
+ framework: str = "STRIDE",
35
+ iteration_count: int = 1,
36
+ ) -> str:
37
+ """Create a new threat model."""
38
+ model_id = generate_id()
39
+ now = now_iso()
40
+
41
+ async with aiosqlite.connect(db_path) as db:
42
+ await db.execute("PRAGMA foreign_keys = ON;")
43
+ await db.execute(
44
+ """
45
+ INSERT INTO threat_models (
46
+ id, title, description, framework, provider, model,
47
+ status, iteration_count, created_at, updated_at
48
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
49
+ """,
50
+ (
51
+ model_id,
52
+ title,
53
+ description,
54
+ framework,
55
+ provider,
56
+ model,
57
+ "pending",
58
+ iteration_count,
59
+ now,
60
+ now,
61
+ ),
62
+ )
63
+ await db.commit()
64
+
65
+ logger.info(f"Created threat model {model_id}")
66
+ return model_id
67
+
68
+
69
+ async def get_threat_model(db_path: str, model_id: str) -> dict[str, Any] | None:
70
+ """Get a threat model by ID."""
71
+ async with aiosqlite.connect(db_path) as db:
72
+ db.row_factory = aiosqlite.Row
73
+ async with db.execute(
74
+ "SELECT * FROM threat_models WHERE id = ?", (model_id,)
75
+ ) as cursor:
76
+ row = await cursor.fetchone()
77
+ return dict(row) if row else None
78
+
79
+
80
+ async def update_threat_model_status(
81
+ db_path: str, model_id: str, status: str
82
+ ) -> None:
83
+ """Update threat model status."""
84
+ async with aiosqlite.connect(db_path) as db:
85
+ await db.execute("PRAGMA foreign_keys = ON;")
86
+ await db.execute(
87
+ """
88
+ UPDATE threat_models
89
+ SET status = ?, updated_at = ?
90
+ WHERE id = ?
91
+ """,
92
+ (status, now_iso(), model_id),
93
+ )
94
+ await db.commit()
95
+
96
+ logger.info(f"Updated threat model {model_id} status to {status}")
97
+
98
+
99
+ async def update_threat_model(
100
+ db_path: str,
101
+ model_id: str,
102
+ title: str | None = None,
103
+ description: str | None = None,
104
+ framework: str | None = None,
105
+ status: str | None = None,
106
+ ) -> None:
107
+ """
108
+ Update threat model details. Only provided fields will be updated.
109
+
110
+ Args:
111
+ db_path: Path to SQLite database
112
+ model_id: ID of threat model to update
113
+ title: Model title
114
+ description: Model description
115
+ framework: Framework (STRIDE, MAESTRO, etc.)
116
+ status: Status (pending, in_progress, completed, failed)
117
+ """
118
+ update_fields = []
119
+ params = []
120
+
121
+ if title is not None:
122
+ update_fields.append("title = ?")
123
+ params.append(title)
124
+
125
+ if description is not None:
126
+ update_fields.append("description = ?")
127
+ params.append(description)
128
+
129
+ if framework is not None:
130
+ update_fields.append("framework = ?")
131
+ params.append(framework)
132
+
133
+ if status is not None:
134
+ update_fields.append("status = ?")
135
+ params.append(status)
136
+
137
+ # Always update timestamp
138
+ update_fields.append("updated_at = ?")
139
+ params.append(now_iso())
140
+ params.append(model_id)
141
+
142
+ if len(update_fields) == 1:
143
+ logger.warning(f"No fields provided to update for model {model_id}")
144
+ return
145
+
146
+ query = f"UPDATE threat_models SET {', '.join(update_fields)} WHERE id = ?"
147
+
148
+ async with aiosqlite.connect(db_path) as db:
149
+ await db.execute("PRAGMA foreign_keys = ON;")
150
+ await db.execute(query, params)
151
+ await db.commit()
152
+
153
+ logger.info(f"Updated threat model {model_id}")
154
+
155
+
156
+ async def list_threat_models(db_path: str, limit: int = 50) -> list[dict[str, Any]]:
157
+ """List all threat models."""
158
+ async with aiosqlite.connect(db_path) as db:
159
+ db.row_factory = aiosqlite.Row
160
+ async with db.execute(
161
+ "SELECT * FROM threat_models ORDER BY created_at DESC LIMIT ?", (limit,)
162
+ ) as cursor:
163
+ rows = await cursor.fetchall()
164
+ return [dict(row) for row in rows]
165
+
166
+
167
+ # Threats CRUD
168
+
169
+
170
+ async def create_threat(
171
+ db_path: str,
172
+ model_id: str,
173
+ name: str,
174
+ description: str,
175
+ target: str,
176
+ impact: str,
177
+ likelihood: str,
178
+ mitigations: list[str],
179
+ stride_category: str | None = None,
180
+ maestro_category: str | None = None,
181
+ dread_score: float | None = None,
182
+ iteration_number: int = 1,
183
+ ) -> str:
184
+ """Create a new threat."""
185
+ threat_id = generate_id()
186
+ now = now_iso()
187
+ mitigations_json = json.dumps(mitigations)
188
+
189
+ async with aiosqlite.connect(db_path) as db:
190
+ await db.execute("PRAGMA foreign_keys = ON;")
191
+ await db.execute(
192
+ """
193
+ INSERT INTO threats (
194
+ id, model_id, stride_category, maestro_category, name,
195
+ description, target, impact, likelihood, dread_score,
196
+ mitigations, status, iteration_number, created_at, updated_at
197
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
198
+ """,
199
+ (
200
+ threat_id,
201
+ model_id,
202
+ stride_category,
203
+ maestro_category,
204
+ name,
205
+ description,
206
+ target,
207
+ impact,
208
+ likelihood,
209
+ dread_score,
210
+ mitigations_json,
211
+ "pending",
212
+ iteration_number,
213
+ now,
214
+ now,
215
+ ),
216
+ )
217
+ await db.commit()
218
+
219
+ logger.info(f"Created threat {threat_id} for model {model_id}")
220
+ return threat_id
221
+
222
+
223
+ async def get_threat(db_path: str, threat_id: str) -> dict[str, Any] | None:
224
+ """Get a threat by ID."""
225
+ async with aiosqlite.connect(db_path) as db:
226
+ db.row_factory = aiosqlite.Row
227
+ async with db.execute(
228
+ "SELECT * FROM threats WHERE id = ?", (threat_id,)
229
+ ) as cursor:
230
+ row = await cursor.fetchone()
231
+ if row:
232
+ threat = dict(row)
233
+ threat["mitigations"] = json.loads(threat["mitigations"])
234
+ return threat
235
+ return None
236
+
237
+
238
+ async def list_threats(
239
+ db_path: str, model_id: str, status: str | None = None
240
+ ) -> list[dict[str, Any]]:
241
+ """List threats for a model, optionally filtered by status."""
242
+ async with aiosqlite.connect(db_path) as db:
243
+ db.row_factory = aiosqlite.Row
244
+
245
+ if status:
246
+ query = "SELECT * FROM threats WHERE model_id = ? AND status = ? ORDER BY created_at"
247
+ params = (model_id, status)
248
+ else:
249
+ query = "SELECT * FROM threats WHERE model_id = ? ORDER BY created_at"
250
+ params = (model_id,)
251
+
252
+ async with db.execute(query, params) as cursor:
253
+ rows = await cursor.fetchall()
254
+ threats = []
255
+ for row in rows:
256
+ threat = dict(row)
257
+ threat["mitigations"] = json.loads(threat["mitigations"])
258
+ threats.append(threat)
259
+ return threats
260
+
261
+
262
+ async def update_threat_status(
263
+ db_path: str, threat_id: str, status: str
264
+ ) -> None:
265
+ """Update threat status (pending/approved/rejected)."""
266
+ async with aiosqlite.connect(db_path) as db:
267
+ await db.execute("PRAGMA foreign_keys = ON;")
268
+ await db.execute(
269
+ """
270
+ UPDATE threats
271
+ SET status = ?, updated_at = ?
272
+ WHERE id = ?
273
+ """,
274
+ (status, now_iso(), threat_id),
275
+ )
276
+ await db.commit()
277
+
278
+ logger.info(f"Updated threat {threat_id} status to {status}")
279
+
280
+
281
+ async def update_threat(
282
+ db_path: str,
283
+ threat_id: str,
284
+ name: str | None = None,
285
+ description: str | None = None,
286
+ target: str | None = None,
287
+ impact: str | None = None,
288
+ likelihood: str | None = None,
289
+ mitigations: list[str] | None = None,
290
+ stride_category: str | None = None,
291
+ maestro_category: str | None = None,
292
+ dread_damage: int | None = None,
293
+ dread_reproducibility: int | None = None,
294
+ dread_exploitability: int | None = None,
295
+ dread_affected_users: int | None = None,
296
+ dread_discoverability: int | None = None,
297
+ dread_score: float | None = None,
298
+ ) -> None:
299
+ """
300
+ Update threat details. Only provided fields will be updated.
301
+
302
+ Args:
303
+ db_path: Path to SQLite database
304
+ threat_id: ID of threat to update
305
+ name: Threat name
306
+ description: Threat description
307
+ target: Threat target
308
+ impact: Impact assessment
309
+ likelihood: Likelihood assessment
310
+ mitigations: List of mitigation strategies
311
+ stride_category: STRIDE category
312
+ maestro_category: MAESTRO category
313
+ dread_damage: DREAD damage score (0-10)
314
+ dread_reproducibility: DREAD reproducibility score (0-10)
315
+ dread_exploitability: DREAD exploitability score (0-10)
316
+ dread_affected_users: DREAD affected users score (0-10)
317
+ dread_discoverability: DREAD discoverability score (0-10)
318
+ dread_score: Overall DREAD score
319
+ """
320
+ # Build dynamic UPDATE query for only provided fields
321
+ update_fields = []
322
+ params = []
323
+
324
+ if name is not None:
325
+ update_fields.append("name = ?")
326
+ params.append(name)
327
+
328
+ if description is not None:
329
+ update_fields.append("description = ?")
330
+ params.append(description)
331
+
332
+ if target is not None:
333
+ update_fields.append("target = ?")
334
+ params.append(target)
335
+
336
+ if impact is not None:
337
+ update_fields.append("impact = ?")
338
+ params.append(impact)
339
+
340
+ if likelihood is not None:
341
+ update_fields.append("likelihood = ?")
342
+ params.append(likelihood)
343
+
344
+ if mitigations is not None:
345
+ update_fields.append("mitigations = ?")
346
+ params.append(json.dumps(mitigations))
347
+
348
+ if stride_category is not None:
349
+ update_fields.append("stride_category = ?")
350
+ params.append(stride_category)
351
+
352
+ if maestro_category is not None:
353
+ update_fields.append("maestro_category = ?")
354
+ params.append(maestro_category)
355
+
356
+ if dread_damage is not None:
357
+ update_fields.append("dread_damage = ?")
358
+ params.append(dread_damage)
359
+
360
+ if dread_reproducibility is not None:
361
+ update_fields.append("dread_reproducibility = ?")
362
+ params.append(dread_reproducibility)
363
+
364
+ if dread_exploitability is not None:
365
+ update_fields.append("dread_exploitability = ?")
366
+ params.append(dread_exploitability)
367
+
368
+ if dread_affected_users is not None:
369
+ update_fields.append("dread_affected_users = ?")
370
+ params.append(dread_affected_users)
371
+
372
+ if dread_discoverability is not None:
373
+ update_fields.append("dread_discoverability = ?")
374
+ params.append(dread_discoverability)
375
+
376
+ if dread_score is not None:
377
+ update_fields.append("dread_score = ?")
378
+ params.append(dread_score)
379
+
380
+ # Always update the updated_at timestamp
381
+ update_fields.append("updated_at = ?")
382
+ params.append(now_iso())
383
+
384
+ # Add threat_id as final parameter
385
+ params.append(threat_id)
386
+
387
+ if len(update_fields) == 1: # Only updated_at, nothing to update
388
+ logger.warning(f"No fields provided to update for threat {threat_id}")
389
+ return
390
+
391
+ query = f"UPDATE threats SET {', '.join(update_fields)} WHERE id = ?"
392
+
393
+ async with aiosqlite.connect(db_path) as db:
394
+ await db.execute("PRAGMA foreign_keys = ON;")
395
+ await db.execute(query, params)
396
+ await db.commit()
397
+
398
+ logger.info(f"Updated threat {threat_id} with {len(update_fields)} fields")
399
+
400
+
401
+ # Assets CRUD
402
+
403
+
404
+ async def create_asset(
405
+ db_path: str, model_id: str, asset_type: str, name: str, description: str
406
+ ) -> str:
407
+ """Create a new asset."""
408
+ asset_id = generate_id()
409
+ now = now_iso()
410
+
411
+ async with aiosqlite.connect(db_path) as db:
412
+ await db.execute("PRAGMA foreign_keys = ON;")
413
+ await db.execute(
414
+ """
415
+ INSERT INTO assets (id, model_id, type, name, description, created_at)
416
+ VALUES (?, ?, ?, ?, ?, ?)
417
+ """,
418
+ (asset_id, model_id, asset_type, name, description, now),
419
+ )
420
+ await db.commit()
421
+
422
+ return asset_id
423
+
424
+
425
+ async def list_assets(db_path: str, model_id: str) -> list[dict[str, Any]]:
426
+ """List all assets for a model."""
427
+ async with aiosqlite.connect(db_path) as db:
428
+ db.row_factory = aiosqlite.Row
429
+ async with db.execute(
430
+ "SELECT * FROM assets WHERE model_id = ? ORDER BY created_at", (model_id,)
431
+ ) as cursor:
432
+ rows = await cursor.fetchall()
433
+ return [dict(row) for row in rows]
434
+
435
+
436
+ async def update_asset(
437
+ db_path: str,
438
+ asset_id: str,
439
+ name: str | None = None,
440
+ description: str | None = None,
441
+ asset_type: str | None = None,
442
+ ) -> None:
443
+ """
444
+ Update asset details. Only provided fields will be updated.
445
+
446
+ Args:
447
+ db_path: Path to SQLite database
448
+ asset_id: ID of asset to update
449
+ name: Asset name
450
+ description: Asset description
451
+ asset_type: Asset type (Asset/Entity)
452
+ """
453
+ update_fields = []
454
+ params = []
455
+
456
+ if name is not None:
457
+ update_fields.append("name = ?")
458
+ params.append(name)
459
+
460
+ if description is not None:
461
+ update_fields.append("description = ?")
462
+ params.append(description)
463
+
464
+ if asset_type is not None:
465
+ update_fields.append("type = ?")
466
+ params.append(asset_type)
467
+
468
+ params.append(asset_id)
469
+
470
+ if not update_fields:
471
+ logger.warning(f"No fields provided to update for asset {asset_id}")
472
+ return
473
+
474
+ query = f"UPDATE assets SET {', '.join(update_fields)} WHERE id = ?"
475
+
476
+ async with aiosqlite.connect(db_path) as db:
477
+ await db.execute("PRAGMA foreign_keys = ON;")
478
+ await db.execute(query, params)
479
+ await db.commit()
480
+
481
+ logger.info(f"Updated asset {asset_id}")
482
+
483
+
484
+ async def delete_threat(db_path: str, threat_id: str) -> None:
485
+ """
486
+ Delete a threat and its associated data.
487
+
488
+ Args:
489
+ db_path: Path to SQLite database
490
+ threat_id: ID of threat to delete
491
+ """
492
+ async with aiosqlite.connect(db_path) as db:
493
+ await db.execute("PRAGMA foreign_keys = ON;")
494
+
495
+ # Delete threat (CASCADE will handle related records)
496
+ await db.execute("DELETE FROM threats WHERE id = ?", (threat_id,))
497
+ await db.commit()
498
+
499
+ logger.info(f"Deleted threat {threat_id}")
500
+
501
+
502
+ async def delete_threat_model(db_path: str, model_id: str) -> None:
503
+ """
504
+ Delete a threat model and all associated data.
505
+
506
+ Args:
507
+ db_path: Path to SQLite database
508
+ model_id: ID of threat model to delete
509
+ """
510
+ async with aiosqlite.connect(db_path) as db:
511
+ await db.execute("PRAGMA foreign_keys = ON;")
512
+
513
+ # Delete model (CASCADE will handle all related records)
514
+ await db.execute("DELETE FROM threat_models WHERE id = ?", (model_id,))
515
+ await db.commit()
516
+
517
+ logger.info(f"Deleted threat model {model_id}")
518
+
519
+
520
+ # Flows CRUD
521
+
522
+
523
+ async def create_flow(
524
+ db_path: str,
525
+ model_id: str,
526
+ flow_type: str,
527
+ flow_description: str,
528
+ source_entity: str,
529
+ target_entity: str,
530
+ ) -> str:
531
+ """Create a new data flow."""
532
+ flow_id = generate_id()
533
+ now = now_iso()
534
+
535
+ async with aiosqlite.connect(db_path) as db:
536
+ await db.execute("PRAGMA foreign_keys = ON;")
537
+ await db.execute(
538
+ """
539
+ INSERT INTO flows (
540
+ id, model_id, flow_type, flow_description,
541
+ source_entity, target_entity, created_at
542
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
543
+ """,
544
+ (flow_id, model_id, flow_type, flow_description, source_entity, target_entity, now),
545
+ )
546
+ await db.commit()
547
+
548
+ return flow_id
549
+
550
+
551
+ async def list_flows(db_path: str, model_id: str) -> list[dict[str, Any]]:
552
+ """List all data flows for a model."""
553
+ async with aiosqlite.connect(db_path) as db:
554
+ db.row_factory = aiosqlite.Row
555
+ async with db.execute(
556
+ "SELECT * FROM flows WHERE model_id = ? ORDER BY created_at", (model_id,)
557
+ ) as cursor:
558
+ rows = await cursor.fetchall()
559
+ return [dict(row) for row in rows]
560
+
561
+
562
+ # Pipeline Runs CRUD
563
+
564
+
565
+ async def create_pipeline_run(
566
+ db_path: str,
567
+ model_id: str,
568
+ iteration: int,
569
+ step: str,
570
+ input_hash: str,
571
+ output_hash: str,
572
+ provider: str,
573
+ duration_ms: int,
574
+ tokens_used: int | None = None,
575
+ ) -> str:
576
+ """Create a pipeline run audit record."""
577
+ run_id = generate_id()
578
+ now = now_iso()
579
+
580
+ async with aiosqlite.connect(db_path) as db:
581
+ await db.execute("PRAGMA foreign_keys = ON;")
582
+ await db.execute(
583
+ """
584
+ INSERT INTO pipeline_runs (
585
+ id, model_id, iteration, step, input_hash, output_hash,
586
+ provider, tokens_used, duration_ms, created_at
587
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
588
+ """,
589
+ (
590
+ run_id,
591
+ model_id,
592
+ iteration,
593
+ step,
594
+ input_hash,
595
+ output_hash,
596
+ provider,
597
+ tokens_used,
598
+ duration_ms,
599
+ now,
600
+ ),
601
+ )
602
+ await db.commit()
603
+
604
+ return run_id
605
+
606
+
607
+ async def get_pipeline_stats(db_path: str, model_id: str) -> dict[str, Any]:
608
+ """Get pipeline execution statistics for a model."""
609
+ async with aiosqlite.connect(db_path) as db:
610
+ async with db.execute(
611
+ """
612
+ SELECT
613
+ COUNT(*) as total_steps,
614
+ SUM(duration_ms) as total_duration_ms,
615
+ SUM(tokens_used) as total_tokens,
616
+ AVG(duration_ms) as avg_duration_ms
617
+ FROM pipeline_runs
618
+ WHERE model_id = ?
619
+ """,
620
+ (model_id,),
621
+ ) as cursor:
622
+ row = await cursor.fetchone()
623
+ if row:
624
+ return {
625
+ "total_steps": row[0],
626
+ "total_duration_ms": row[1],
627
+ "total_tokens": row[2],
628
+ "avg_duration_ms": row[3],
629
+ }
630
+ return {}