flintai-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 (147) hide show
  1. flintai/__init__.py +0 -0
  2. flintai/cli/__init__.py +0 -0
  3. flintai/cli/__main__.py +3 -0
  4. flintai/cli/builtin_config.json +911 -0
  5. flintai/cli/console.py +116 -0
  6. flintai/cli/eval_cli.py +891 -0
  7. flintai/cli/init_cli.py +144 -0
  8. flintai/cli/main.py +262 -0
  9. flintai/cli/rich_observer.py +89 -0
  10. flintai/cli/runner.py +305 -0
  11. flintai/cli/scan_cli.py +238 -0
  12. flintai/cli/utils.py +37 -0
  13. flintai/cli/version.py +1 -0
  14. flintai/data/detector_prompts/llm01_adversarial.txt +55 -0
  15. flintai/data/detector_prompts/llm01_fixed.txt +8 -0
  16. flintai/data/detector_prompts/llm02_adversarial.txt +75 -0
  17. flintai/data/detector_prompts/llm02_fixed.txt +8 -0
  18. flintai/data/detector_prompts/llm05_adversarial.txt +63 -0
  19. flintai/data/detector_prompts/llm05_fixed.txt +16 -0
  20. flintai/data/detector_prompts/llm06_adversarial.txt +135 -0
  21. flintai/data/detector_prompts/llm06_fixed.txt +15 -0
  22. flintai/data/detector_prompts/llm07_adversarial.txt +185 -0
  23. flintai/data/detector_prompts/llm07_fixed.txt +14 -0
  24. flintai/data/detector_prompts/llm09_adversarial.txt +77 -0
  25. flintai/data/detector_prompts/llm09_fixed.txt +15 -0
  26. flintai/data/llm01_prompt_injection.csv +6708 -0
  27. flintai/data/llm02_sensitive_information.csv +1069 -0
  28. flintai/data/llm05_unsafe_output.csv +3882 -0
  29. flintai/data/llm06_excessive_agency.csv +778 -0
  30. flintai/data/llm07_system_prompt_leakage.csv +971 -0
  31. flintai/data/llm09_hallucination-goals.csv +32419 -0
  32. flintai/data/llm09_hallucination.csv +599 -0
  33. flintai/data/pii_leakage.csv +706 -0
  34. flintai/data/secret_leakage.csv +1380 -0
  35. flintai/eval/__init__.py +0 -0
  36. flintai/eval/common/converter_anthropic.py +151 -0
  37. flintai/eval/common/converter_genai.py +169 -0
  38. flintai/eval/common/converter_openai.py +165 -0
  39. flintai/eval/common/log.py +96 -0
  40. flintai/eval/common/reference.py +25 -0
  41. flintai/eval/common/schema.py +192 -0
  42. flintai/eval/common/utils.py +74 -0
  43. flintai/eval/core/__init__.py +0 -0
  44. flintai/eval/core/detectors/__init__.py +1 -0
  45. flintai/eval/core/detectors/detector.py +25 -0
  46. flintai/eval/core/detectors/detector_garak.py +80 -0
  47. flintai/eval/core/detectors/detector_model.py +76 -0
  48. flintai/eval/core/detectors/detector_pii.py +64 -0
  49. flintai/eval/core/detectors/detector_secret.py +82 -0
  50. flintai/eval/core/detectors/detector_topic_guard.py +70 -0
  51. flintai/eval/core/detectors/detector_toxicity.py +70 -0
  52. flintai/eval/core/eval/__init__.py +0 -0
  53. flintai/eval/core/eval/eval_creator.py +197 -0
  54. flintai/eval/core/eval/evaluation.py +100 -0
  55. flintai/eval/core/eval/evaluation_adversarial.py +523 -0
  56. flintai/eval/core/eval/evaluation_garak_module.py +52 -0
  57. flintai/eval/core/eval/evaluation_garak_probe.py +274 -0
  58. flintai/eval/core/eval/evaluation_message_list.py +44 -0
  59. flintai/eval/core/eval/evaluation_multi.py +207 -0
  60. flintai/eval/core/eval/evaluation_single.py +79 -0
  61. flintai/eval/core/eval/evaluation_single_prompt.py +58 -0
  62. flintai/eval/core/eval/evaluation_topic_guard.py +318 -0
  63. flintai/eval/core/eval/metric_conciseness.py +181 -0
  64. flintai/eval/core/eval/metric_factual_accuracy.py +294 -0
  65. flintai/eval/core/eval/metric_instruction_adherence.py +180 -0
  66. flintai/eval/core/eval/metric_tone.py +183 -0
  67. flintai/eval/core/eval/metric_toxicity.py +147 -0
  68. flintai/eval/core/eval/observer.py +136 -0
  69. flintai/eval/core/eval/probe_adversarial.py +14 -0
  70. flintai/eval/core/message/__init__.py +0 -0
  71. flintai/eval/core/message/message_collection.py +37 -0
  72. flintai/eval/core/message/message_collection_csv.py +46 -0
  73. flintai/eval/core/message/message_collection_garak.py +52 -0
  74. flintai/eval/core/message/message_collection_memory.py +36 -0
  75. flintai/eval/core/models/__init__.py +0 -0
  76. flintai/eval/core/models/generator_model.py +88 -0
  77. flintai/eval/core/models/model.py +111 -0
  78. flintai/eval/core/models/model_adk.py +158 -0
  79. flintai/eval/core/models/model_anthropic.py +51 -0
  80. flintai/eval/core/models/model_anthropic_agent.py +92 -0
  81. flintai/eval/core/models/model_gemini.py +107 -0
  82. flintai/eval/core/models/model_generic_http.py +117 -0
  83. flintai/eval/core/models/model_huggingface.py +61 -0
  84. flintai/eval/core/models/model_langserve.py +94 -0
  85. flintai/eval/core/models/model_litellm.py +38 -0
  86. flintai/eval/core/models/model_ollama.py +50 -0
  87. flintai/eval/core/models/model_openai.py +35 -0
  88. flintai/eval/core/models/model_openai_agent.py +80 -0
  89. flintai/eval/core/models/model_openai_compatible.py +57 -0
  90. flintai/eval/core/models/model_retry.py +134 -0
  91. flintai/eval/core/models/model_sync_wrapper.py +35 -0
  92. flintai/eval/db/__init__.py +0 -0
  93. flintai/eval/db/base/__init__.py +0 -0
  94. flintai/eval/db/base/detectors/__init__.py +1 -0
  95. flintai/eval/db/base/detectors/detector_helpers.py +78 -0
  96. flintai/eval/db/base/detectors/detector_repository.py +91 -0
  97. flintai/eval/db/base/detectors/detector_types.py +77 -0
  98. flintai/eval/db/base/eval/__init__.py +1 -0
  99. flintai/eval/db/base/eval/eval_helpers.py +227 -0
  100. flintai/eval/db/base/eval/eval_repository.py +115 -0
  101. flintai/eval/db/base/eval/eval_run.py +139 -0
  102. flintai/eval/db/base/eval/eval_types.py +129 -0
  103. flintai/eval/db/base/eval/model_eval_repository.py +78 -0
  104. flintai/eval/db/base/eval/model_eval_run_repository.py +70 -0
  105. flintai/eval/db/base/eval/model_eval_run_result_repository.py +40 -0
  106. flintai/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  107. flintai/eval/db/base/eval/model_eval_run_types.py +84 -0
  108. flintai/eval/db/base/eval/model_eval_types.py +71 -0
  109. flintai/eval/db/base/message/__init__.py +0 -0
  110. flintai/eval/db/base/message/message_collection_helpers.py +61 -0
  111. flintai/eval/db/base/message/message_collection_repository.py +92 -0
  112. flintai/eval/db/base/message/message_collection_types.py +89 -0
  113. flintai/eval/db/base/models/__init__.py +0 -0
  114. flintai/eval/db/base/models/model_helpers.py +172 -0
  115. flintai/eval/db/base/models/model_repository.py +95 -0
  116. flintai/eval/db/base/models/model_types.py +156 -0
  117. flintai/eval/db/json/__init__.py +0 -0
  118. flintai/eval/db/json/repository_json.py +603 -0
  119. flintai/scan/__init__.py +0 -0
  120. flintai/scan/agent_scanner.py +797 -0
  121. flintai/scan/config/agent_opengrep_rules.yaml +533 -0
  122. flintai/scan/config/agent_taxonomy.json +481 -0
  123. flintai/scan/config/agentic_cvss_mapping.yaml +136 -0
  124. flintai/scan/config/compliance_mappings.json +110 -0
  125. flintai/scan/config/triage_prompt.txt +335 -0
  126. flintai/scan/constants.py +15 -0
  127. flintai/scan/file_filter.py +156 -0
  128. flintai/scan/llm_provider.py +197 -0
  129. flintai/scan/opengrep_resolver.py +12 -0
  130. flintai/scan/reasoner.py +552 -0
  131. flintai/scan/schema.py +337 -0
  132. flintai/scan/scorer.py +371 -0
  133. flintai/scan/secret_anonymizer.py +72 -0
  134. flintai/scan/static_scanner.py +542 -0
  135. flintai/scan/taxonomy.py +75 -0
  136. flintai/scan/tool_dispatcher.py +754 -0
  137. flintai/scan/trace_logger.py +118 -0
  138. flintai/scan/trace_logger_file.py +143 -0
  139. flintai/scan/trace_logger_log.py +100 -0
  140. flintai/scan/triage.py +496 -0
  141. flintai/schema.py +58 -0
  142. flintai_cli-1.0.0.dist-info/METADATA +442 -0
  143. flintai_cli-1.0.0.dist-info/RECORD +147 -0
  144. flintai_cli-1.0.0.dist-info/WHEEL +5 -0
  145. flintai_cli-1.0.0.dist-info/entry_points.txt +2 -0
  146. flintai_cli-1.0.0.dist-info/licenses/LICENSE +210 -0
  147. flintai_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,891 @@
1
+ """
2
+ Subcommands for `flintai eval`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from datetime import datetime
10
+
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from flintai.cli.console import CLI_WIDTH, console
16
+ from flintai.cli.utils import get_flintai_config_path
17
+ from flintai.eval.core.eval.evaluation import EvaluationStatus
18
+ from flintai.eval.db.base.eval.model_eval_types import DbModelEvaluation
19
+ from flintai.eval.db.json.repository_json import JsonRepository
20
+ from flintai.cli.rich_observer import create_progress
21
+ from flintai.cli.runner import (
22
+ MAX_CONSECUTIVE_RUN_FAILURES,
23
+ CliRunResult,
24
+ log_run_summary,
25
+ print_results,
26
+ run_cli_evaluation,
27
+ write_output,
28
+ )
29
+
30
+ _DEFAULT_CONFIG = str(get_flintai_config_path())
31
+
32
+ _DEFAULT_LOG = f"flintai_{datetime.now().strftime('%Y%m%dT%H%M%S')}.log"
33
+
34
+ _config_parent = argparse.ArgumentParser(add_help=False)
35
+ _config_parent.add_argument(
36
+ "--config",
37
+ default=_DEFAULT_CONFIG,
38
+ help="Path to JSON config file "
39
+ f"(default: {_DEFAULT_CONFIG})",
40
+ )
41
+ _config_parent.add_argument(
42
+ "--log",
43
+ help="Log file path "
44
+ f"(default: {_DEFAULT_LOG})",
45
+ )
46
+
47
+
48
+ _tag_parent = argparse.ArgumentParser(add_help=False)
49
+ _tag_parent.add_argument(
50
+ "--tag", action="append", default=[],
51
+ metavar="KEY=VALUE",
52
+ help="Filter by tag (repeatable)",
53
+ )
54
+
55
+
56
+ def _parse_tags(raw: list[str]) -> dict[str, str]:
57
+ tags: dict[str, str] = {}
58
+ for item in raw:
59
+ if "=" not in item:
60
+ console.print(
61
+ f"[red]Invalid tag format: {item!r} "
62
+ f"(expected KEY=VALUE)[/red]",
63
+ )
64
+ sys.exit(1)
65
+ k, v = item.split("=", 1)
66
+ tags[k] = v
67
+ return tags
68
+
69
+
70
+ def _matches_tags(
71
+ entity_tags: dict[str, str],
72
+ filter_tags: dict[str, str],
73
+ ) -> bool:
74
+ return all(
75
+ entity_tags.get(k) == v
76
+ for k, v in filter_tags.items()
77
+ )
78
+
79
+
80
+ def register(subparsers: argparse._SubParsersAction) -> None:
81
+ eval_parser = subparsers.add_parser(
82
+ "eval", help="Evaluation commands",
83
+ )
84
+ eval_sub = eval_parser.add_subparsers(dest="eval_cmd")
85
+ eval_sub.required = True
86
+
87
+ _register_models(eval_sub)
88
+ _register_evaluations(eval_sub)
89
+ _register_model_evaluations(eval_sub)
90
+ _register_run(eval_sub)
91
+
92
+
93
+ # -- models -------------------------------------------------------
94
+
95
+ def _register_models(
96
+ subparsers: argparse._SubParsersAction,
97
+ ) -> None:
98
+ models_parser = subparsers.add_parser(
99
+ "models", help="Model commands",
100
+ )
101
+ models_sub = models_parser.add_subparsers(
102
+ dest="models_cmd",
103
+ )
104
+ models_sub.required = True
105
+
106
+ models_sub.add_parser(
107
+ "list", help="List all models",
108
+ parents=[_config_parent, _tag_parent],
109
+ )
110
+
111
+ show_parser = models_sub.add_parser(
112
+ "show", help="Show model details",
113
+ parents=[_config_parent],
114
+ )
115
+ show_parser.add_argument("id", help="Model ID")
116
+
117
+
118
+ def handle_models(
119
+ args: argparse.Namespace, store: JsonRepository,
120
+ ) -> None:
121
+ if args.models_cmd == "list":
122
+ _models_list(store, _parse_tags(args.tag))
123
+ elif args.models_cmd == "show":
124
+ _models_show(store, args.id)
125
+
126
+
127
+ def _models_list(
128
+ store: JsonRepository,
129
+ filter_tags: dict[str, str] | None = None,
130
+ ) -> None:
131
+ models = store.models.list()
132
+ if filter_tags:
133
+ models = [
134
+ m for m in models
135
+ if _matches_tags(m.tags, filter_tags)
136
+ ]
137
+ if not models:
138
+ console.print("[dim]No models configured.[/dim]")
139
+ return
140
+ table = Table(title="Models", show_lines=False, width=CLI_WIDTH)
141
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
142
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
143
+ table.add_column("Type", ratio=1, overflow="ellipsis", no_wrap=True)
144
+ for m in models:
145
+ table.add_row(m.id, m.name, m.type.value)
146
+ console.print(table)
147
+
148
+
149
+ def _models_show(store: JsonRepository, model_id: str) -> None:
150
+ model_id = _resolve_id(
151
+ model_id,
152
+ [m.id for m in store.models.list()],
153
+ )
154
+ model = store.models.get(model_id)
155
+
156
+ detail = Table.grid(padding=(0, 2))
157
+ detail.add_column(style="key", justify="right")
158
+ detail.add_column()
159
+ detail.add_row("ID", model.id)
160
+ detail.add_row("Name", model.name)
161
+ detail.add_row("Type", model.type.value)
162
+ detail.add_row("Model name", model.model_name)
163
+ if model.host:
164
+ detail.add_row("Host", model.host)
165
+ if model.endpoint:
166
+ detail.add_row("Endpoint", model.endpoint)
167
+ if model.tags:
168
+ detail.add_row("Tags", _fmt_tags(model.tags))
169
+ if model.description:
170
+ detail.add_row("Description", model.description)
171
+
172
+ console.print(Panel(detail, title=f"[bold]{model.name}[/bold]", expand=False))
173
+
174
+ me_view = store.model_evaluations.list_by_model(
175
+ model.id, limit=100,
176
+ )
177
+ if me_view.items:
178
+ table = Table(
179
+ title=f"Connected evaluations ({me_view.total})",
180
+ width=CLI_WIDTH,
181
+ )
182
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
183
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
184
+ table.add_column("Evaluation", ratio=3, overflow="ellipsis", no_wrap=True)
185
+ for item in me_view.items:
186
+ eval_name = item.evaluation_ref.name if item.evaluation_ref else "?"
187
+ eval_cell = Text(eval_name)
188
+ eval_cell.append("\n")
189
+ eval_cell.append(item.config.evaluation_id, style="dim")
190
+ table.add_row(item.config.id, item.config.name, eval_cell)
191
+ console.print(table)
192
+
193
+
194
+ # -- evaluations ---------------------------------------------------
195
+
196
+ def _register_evaluations(
197
+ subparsers: argparse._SubParsersAction,
198
+ ) -> None:
199
+ evals_parser = subparsers.add_parser(
200
+ "evaluations", help="Evaluation commands",
201
+ )
202
+ evals_sub = evals_parser.add_subparsers(
203
+ dest="evals_cmd",
204
+ )
205
+ evals_sub.required = True
206
+
207
+ evals_sub.add_parser(
208
+ "list", help="List all evaluations",
209
+ parents=[_config_parent, _tag_parent],
210
+ )
211
+
212
+ show_parser = evals_sub.add_parser(
213
+ "show", help="Show evaluation details",
214
+ parents=[_config_parent],
215
+ )
216
+ show_parser.add_argument("id", help="Evaluation ID")
217
+
218
+
219
+ def handle_evaluations(
220
+ args: argparse.Namespace, store: JsonRepository,
221
+ ) -> None:
222
+ if args.evals_cmd == "list":
223
+ _evaluations_list(store, _parse_tags(args.tag))
224
+ elif args.evals_cmd == "show":
225
+ _evaluations_show(store, args.id)
226
+
227
+
228
+ def _evaluations_list(
229
+ store: JsonRepository,
230
+ filter_tags: dict[str, str] | None = None,
231
+ ) -> None:
232
+ evaluations = store.evaluations.list()
233
+ if filter_tags:
234
+ evaluations = [
235
+ e for e in evaluations
236
+ if _matches_tags(e.tags, filter_tags)
237
+ ]
238
+ if not evaluations:
239
+ console.print("[dim]No evaluations configured.[/dim]")
240
+ return
241
+ table = Table(title="Evaluations", show_lines=False, width=CLI_WIDTH)
242
+ table.add_column("ID", style="dim", ratio=2)
243
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
244
+ table.add_column("Type", ratio=2, overflow="ellipsis", no_wrap=True)
245
+ for e in evaluations:
246
+ table.add_row(e.id, e.name, e.type.value)
247
+ console.print(table)
248
+
249
+
250
+ def _evaluations_show(
251
+ store: JsonRepository, eval_id: str,
252
+ ) -> None:
253
+ eval_id = _resolve_id(
254
+ eval_id,
255
+ [e.id for e in store.evaluations.list()],
256
+ )
257
+ evaluation = store.evaluations.get(eval_id)
258
+
259
+ detail = Table.grid(padding=(0, 2))
260
+ detail.add_column(style="key", justify="right")
261
+ detail.add_column()
262
+ detail.add_row("ID", evaluation.id)
263
+ detail.add_row("Name", evaluation.name)
264
+ detail.add_row("Type", evaluation.type.value)
265
+ detail.add_row("Approach", evaluation.approach.value)
266
+ if evaluation.tags:
267
+ detail.add_row("Tags", _fmt_tags(evaluation.tags))
268
+ if evaluation.description:
269
+ detail.add_row("Description", evaluation.description)
270
+ if evaluation.adversarial_goals:
271
+ prompts = evaluation.num_prompts or 5
272
+ turns = evaluation.max_turns or 5
273
+ detail.add_row("Prompts", str(prompts))
274
+ detail.add_row("Max turns", str(turns))
275
+
276
+ console.print(Panel(detail, title=f"[bold]{evaluation.name}[/bold]", expand=False))
277
+
278
+ me_view = store.model_evaluations.list_by_evaluation(
279
+ evaluation.id, limit=100,
280
+ )
281
+ if me_view.items:
282
+ table = Table(
283
+ title=f"Connected models ({me_view.total})",
284
+ width=CLI_WIDTH,
285
+ )
286
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
287
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
288
+ table.add_column("Model", ratio=3, overflow="ellipsis", no_wrap=True)
289
+ for item in me_view.items:
290
+ model_name = item.model_ref.name if item.model_ref else "?"
291
+ model_cell = Text(model_name)
292
+ model_cell.append("\n")
293
+ model_cell.append(item.config.model_id, style="dim")
294
+ table.add_row(item.config.id, item.config.name, model_cell)
295
+ console.print(table)
296
+
297
+
298
+ # -- model-evaluations --------------------------------------------
299
+
300
+ _model_selector_parent = argparse.ArgumentParser(
301
+ add_help=False,
302
+ )
303
+ _model_selector_parent.add_argument(
304
+ "--model", action="append", default=[],
305
+ metavar="ID",
306
+ help="Model ID (repeatable)",
307
+ )
308
+ _model_selector_parent.add_argument(
309
+ "--model-tag", action="append", default=[],
310
+ metavar="KEY=VALUE",
311
+ help="Match models by tag (repeatable)",
312
+ )
313
+
314
+ _eval_selector_parent = argparse.ArgumentParser(
315
+ add_help=False,
316
+ )
317
+ _eval_selector_parent.add_argument(
318
+ "--eval", action="append", default=[],
319
+ metavar="ID",
320
+ help="Evaluation ID (repeatable)",
321
+ )
322
+ _eval_selector_parent.add_argument(
323
+ "--eval-tag", action="append", default=[],
324
+ metavar="KEY=VALUE",
325
+ help="Match evaluations by tag (repeatable)",
326
+ )
327
+
328
+
329
+ def _register_model_evaluations(
330
+ subparsers: argparse._SubParsersAction,
331
+ ) -> None:
332
+ me_parser = subparsers.add_parser(
333
+ "model-evaluations",
334
+ help="Model-evaluation assignment commands",
335
+ )
336
+ me_sub = me_parser.add_subparsers(dest="me_cmd")
337
+ me_sub.required = True
338
+ me_sub.add_parser(
339
+ "list",
340
+ help="List all model-evaluation assignments",
341
+ parents=[
342
+ _config_parent,
343
+ _tag_parent,
344
+ _model_selector_parent,
345
+ _eval_selector_parent,
346
+ ],
347
+ )
348
+ me_sub.add_parser(
349
+ "attach",
350
+ help="Assign evaluations to models",
351
+ parents=[
352
+ _config_parent,
353
+ _model_selector_parent,
354
+ _eval_selector_parent,
355
+ ],
356
+ )
357
+ me_sub.add_parser(
358
+ "detach",
359
+ help="Remove evaluation assignments from models",
360
+ parents=[
361
+ _config_parent,
362
+ _model_selector_parent,
363
+ _eval_selector_parent,
364
+ ],
365
+ )
366
+
367
+
368
+ def handle_model_evaluations(
369
+ args: argparse.Namespace,
370
+ store: JsonRepository,
371
+ user_store: JsonRepository,
372
+ ) -> None:
373
+ if args.me_cmd == "list":
374
+ _model_evaluations_list(
375
+ store,
376
+ filter_tags=_parse_tags(args.tag),
377
+ models=_resolve_models(args, store),
378
+ evaluations=_resolve_evaluations(args, store),
379
+ )
380
+ elif args.me_cmd == "attach":
381
+ _model_evaluations_attach(args, store, user_store)
382
+ elif args.me_cmd == "detach":
383
+ _model_evaluations_detach(args, store, user_store)
384
+
385
+
386
+ def _model_evaluations_list(
387
+ store: JsonRepository,
388
+ filter_tags: dict[str, str] | None = None,
389
+ models: list | None = None,
390
+ evaluations: list | None = None,
391
+ ) -> None:
392
+ items = store.model_evaluations.list_all()
393
+ if filter_tags:
394
+ items = [
395
+ me for me in items
396
+ if _matches_tags(me.tags, filter_tags)
397
+ ]
398
+ if models:
399
+ model_ids = {m.id for m in models}
400
+ items = [me for me in items if me.model_id in model_ids]
401
+ if evaluations:
402
+ eval_ids = {e.id for e in evaluations}
403
+ items = [me for me in items if me.evaluation_id in eval_ids]
404
+ if not items:
405
+ console.print("[dim]No model-evaluation assignments configured.[/dim]")
406
+ return
407
+
408
+ table = Table(
409
+ title="Model-Evaluation Assignments",
410
+ show_lines=False, width=CLI_WIDTH,
411
+ )
412
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
413
+ table.add_column("Name", style="bold", ratio=2, overflow="ellipsis", no_wrap=True)
414
+ table.add_column("Model", ratio=2, overflow="ellipsis", no_wrap=True)
415
+ table.add_column("Evaluation", ratio=3, overflow="ellipsis", no_wrap=True)
416
+
417
+ for me in items:
418
+ model_name = "?"
419
+ eval_name = "?"
420
+ try:
421
+ model_name = store.models.get(
422
+ me.model_id,
423
+ ).name
424
+ except KeyError:
425
+ pass
426
+ try:
427
+ eval_name = store.evaluations.get(
428
+ me.evaluation_id,
429
+ ).name
430
+ except KeyError:
431
+ pass
432
+
433
+ model_cell = Text(model_name)
434
+ model_cell.append("\n")
435
+ model_cell.append(me.model_id, style="dim")
436
+
437
+ eval_cell = Text(eval_name)
438
+ eval_cell.append("\n")
439
+ eval_cell.append(me.evaluation_id, style="dim")
440
+
441
+ table.add_row(me.id, me.name, model_cell, eval_cell)
442
+
443
+ console.print(table)
444
+
445
+
446
+ def _resolve_models(
447
+ args: argparse.Namespace,
448
+ store: JsonRepository,
449
+ ) -> list:
450
+ model_ids = args.model
451
+ model_tags = _parse_tags(args.model_tag)
452
+
453
+ if not model_ids and not model_tags:
454
+ return []
455
+
456
+ models = []
457
+ seen_ids: set[str] = set()
458
+
459
+ for mid in model_ids:
460
+ resolved = _resolve_id(
461
+ mid, [m.id for m in store.models.list()],
462
+ )
463
+ try:
464
+ m = store.models.get(resolved)
465
+ if m.id not in seen_ids:
466
+ models.append(m)
467
+ seen_ids.add(m.id)
468
+ except KeyError:
469
+ console.print(
470
+ f"[red]Model {mid!r} not found.[/red]",
471
+ )
472
+ sys.exit(1)
473
+
474
+ if model_tags:
475
+ for m in store.models.list():
476
+ if (
477
+ m.id not in seen_ids
478
+ and _matches_tags(m.tags, model_tags)
479
+ ):
480
+ models.append(m)
481
+ seen_ids.add(m.id)
482
+
483
+ return models
484
+
485
+
486
+ def _resolve_evaluations(
487
+ args: argparse.Namespace,
488
+ store: JsonRepository,
489
+ ) -> list:
490
+ eval_ids = getattr(args, "eval", [])
491
+ eval_tags = _parse_tags(args.eval_tag)
492
+
493
+ if not eval_ids and not eval_tags:
494
+ return []
495
+
496
+ evaluations = []
497
+ seen_ids: set[str] = set()
498
+
499
+ for eid in eval_ids:
500
+ resolved = _resolve_id(
501
+ eid, [e.id for e in store.evaluations.list()],
502
+ )
503
+ try:
504
+ e = store.evaluations.get(resolved)
505
+ if e.id not in seen_ids:
506
+ evaluations.append(e)
507
+ seen_ids.add(e.id)
508
+ except KeyError:
509
+ console.print(
510
+ f"[red]Evaluation {eid!r} not found.[/red]",
511
+ )
512
+ sys.exit(1)
513
+
514
+ if eval_tags:
515
+ for e in store.evaluations.list():
516
+ if (
517
+ e.id not in seen_ids
518
+ and _matches_tags(e.tags, eval_tags)
519
+ ):
520
+ evaluations.append(e)
521
+ seen_ids.add(e.id)
522
+
523
+ return evaluations
524
+
525
+
526
+ def _model_evaluations_attach(
527
+ args: argparse.Namespace,
528
+ store: JsonRepository,
529
+ user_store: JsonRepository,
530
+ ) -> None:
531
+ models = _resolve_models(args, store)
532
+ evaluations = _resolve_evaluations(args, store)
533
+
534
+ if not models:
535
+ console.print(
536
+ "[red]No models matched. Specify --model "
537
+ "or --model-tag.[/red]",
538
+ )
539
+ return
540
+ if not evaluations:
541
+ console.print(
542
+ "[red]No evaluations matched. Specify --eval "
543
+ "or --eval-tag.[/red]",
544
+ )
545
+ return
546
+
547
+ added: list[DbModelEvaluation] = []
548
+ skipped = 0
549
+
550
+ for m in models:
551
+ for e in evaluations:
552
+ me = DbModelEvaluation(
553
+ model_id=m.id,
554
+ evaluation_id=e.id,
555
+ name=f"{m.name} / {e.name}",
556
+ )
557
+ if user_store.model_evaluations.add(me):
558
+ added.append(me)
559
+ else:
560
+ skipped += 1
561
+
562
+ if added:
563
+ user_store.save()
564
+ table = Table(
565
+ title=f"Attached ({len(added)} new)",
566
+ show_lines=False, width=CLI_WIDTH,
567
+ )
568
+ table.add_column("Model", ratio=2)
569
+ table.add_column("Evaluation", ratio=3)
570
+ for me in added:
571
+ table.add_row(
572
+ _name_for(store, "model", me.model_id),
573
+ _name_for(store, "eval", me.evaluation_id),
574
+ )
575
+ console.print(table)
576
+
577
+ if skipped:
578
+ console.print(
579
+ f"[dim]{skipped} already attached, skipped.[/dim]",
580
+ )
581
+ if not added and not skipped:
582
+ console.print("[dim]Nothing to attach.[/dim]")
583
+
584
+
585
+ def _model_evaluations_detach(
586
+ args: argparse.Namespace,
587
+ store: JsonRepository,
588
+ user_store: JsonRepository,
589
+ ) -> None:
590
+ models = _resolve_models(args, store)
591
+ evaluations = _resolve_evaluations(args, store)
592
+
593
+ if not models and not evaluations:
594
+ console.print(
595
+ "[red]Specify at least --model/--model-tag "
596
+ "or --eval/--eval-tag.[/red]",
597
+ )
598
+ return
599
+
600
+ model_ids = {m.id for m in models} if models else None
601
+ eval_ids = (
602
+ {e.id for e in evaluations} if evaluations else None
603
+ )
604
+
605
+ removed: list[DbModelEvaluation] = []
606
+
607
+ if model_ids and eval_ids:
608
+ for mid in model_ids:
609
+ for eid in eval_ids:
610
+ removed.extend(
611
+ user_store.model_evaluations.remove(
612
+ model_id=mid, evaluation_id=eid,
613
+ ),
614
+ )
615
+ elif model_ids:
616
+ for mid in model_ids:
617
+ removed.extend(
618
+ user_store.model_evaluations.remove(
619
+ model_id=mid,
620
+ ),
621
+ )
622
+ elif eval_ids:
623
+ for eid in eval_ids:
624
+ removed.extend(
625
+ user_store.model_evaluations.remove(
626
+ evaluation_id=eid,
627
+ ),
628
+ )
629
+
630
+ if removed:
631
+ user_store.save()
632
+ table = Table(
633
+ title=f"Detached ({len(removed)} removed)",
634
+ show_lines=False, width=CLI_WIDTH,
635
+ )
636
+ table.add_column("Model", ratio=2)
637
+ table.add_column("Evaluation", ratio=3)
638
+ for me in removed:
639
+ table.add_row(
640
+ _name_for(store, "model", me.model_id),
641
+ _name_for(store, "eval", me.evaluation_id),
642
+ )
643
+ console.print(table)
644
+ else:
645
+ console.print("[dim]No matching assignments found.[/dim]")
646
+
647
+
648
+ def _name_for(
649
+ store: JsonRepository, kind: str, id: str,
650
+ ) -> str:
651
+ try:
652
+ if kind == "model":
653
+ return store.models.get(id).name
654
+ return store.evaluations.get(id).name
655
+ except KeyError:
656
+ return id
657
+
658
+
659
+ # -- run -----------------------------------------------------------
660
+
661
+ def _register_run(
662
+ subparsers: argparse._SubParsersAction,
663
+ ) -> None:
664
+ run_parser = subparsers.add_parser(
665
+ "run", help="Run evaluations",
666
+ parents=[_config_parent],
667
+ )
668
+ run_parser.add_argument(
669
+ "model_evaluation_id",
670
+ nargs="?",
671
+ help="Model-evaluation ID to run",
672
+ )
673
+ run_parser.add_argument(
674
+ "--model",
675
+ help="Run all evaluations for this model ID",
676
+ )
677
+ run_parser.add_argument(
678
+ "--output", "-o",
679
+ help="Output JSON file path "
680
+ "(default: eval_<timestamp>.json)",
681
+ )
682
+ run_parser.add_argument(
683
+ "--concurrency", "-c",
684
+ type=int, default=20,
685
+ help="Max concurrent tasks (default: 20)",
686
+ )
687
+ run_parser.add_argument(
688
+ "--model-tag", action="append", default=[],
689
+ metavar="KEY=VALUE",
690
+ help="Filter by model tag (repeatable)",
691
+ )
692
+ run_parser.add_argument(
693
+ "--eval-tag", action="append", default=[],
694
+ metavar="KEY=VALUE",
695
+ help="Filter by evaluation tag (repeatable)",
696
+ )
697
+
698
+
699
+ async def handle_run(
700
+ args: argparse.Namespace, store: JsonRepository,
701
+ ) -> str | None:
702
+ model_tag_filter = _parse_tags(args.model_tag)
703
+ eval_tag_filter = _parse_tags(args.eval_tag)
704
+
705
+ model_evaluations = _resolve_run_targets(
706
+ store, args, model_tag_filter, eval_tag_filter,
707
+ )
708
+ if not model_evaluations:
709
+ return None
710
+
711
+ eval_names = [
712
+ store.evaluations.get(me.evaluation_id).name
713
+ for me in model_evaluations
714
+ ]
715
+ desc_width = min(max((len(n) for n in eval_names), default=20), 40)
716
+
717
+ progress = create_progress()
718
+
719
+ results: list[CliRunResult] = []
720
+ consecutive_failures = 0
721
+ try:
722
+ progress.start()
723
+ for me in model_evaluations:
724
+ result = await run_cli_evaluation(
725
+ me, store, args.concurrency,
726
+ progress=progress,
727
+ desc_width=desc_width,
728
+ )
729
+ results.append(result)
730
+ if result.summary and result.summary.status == EvaluationStatus.ERROR:
731
+ consecutive_failures += 1
732
+ if consecutive_failures >= MAX_CONSECUTIVE_RUN_FAILURES:
733
+ console.print(
734
+ f"[red]Aborting: {consecutive_failures} consecutive"
735
+ f" evaluations failed. Is the target reachable?[/red]",
736
+ )
737
+ break
738
+ else:
739
+ consecutive_failures = 0
740
+ finally:
741
+ progress.stop()
742
+
743
+ if not results:
744
+ return None
745
+
746
+ print_results(results)
747
+ log_run_summary(results)
748
+
749
+ output_path = args.output or (
750
+ f"eval_"
751
+ f"{datetime.now().strftime('%Y%m%dT%H%M%S')}"
752
+ f".json"
753
+ )
754
+ write_output(results, args.config, output_path)
755
+ return output_path
756
+
757
+
758
+ def _filter_by_tags(
759
+ items: list,
760
+ store: JsonRepository,
761
+ model_tags: dict[str, str],
762
+ eval_tags: dict[str, str],
763
+ ) -> list:
764
+ filtered = []
765
+ for me in items:
766
+ if model_tags:
767
+ try:
768
+ model = store.models.get(me.model_id)
769
+ if not _matches_tags(model.tags, model_tags):
770
+ continue
771
+ except KeyError:
772
+ continue
773
+ if eval_tags:
774
+ try:
775
+ ev = store.evaluations.get(
776
+ me.evaluation_id,
777
+ )
778
+ if not _matches_tags(ev.tags, eval_tags):
779
+ continue
780
+ except KeyError:
781
+ continue
782
+ filtered.append(me)
783
+ return filtered
784
+
785
+
786
+ def _resolve_run_targets(
787
+ store: JsonRepository,
788
+ args: argparse.Namespace,
789
+ model_tags: dict[str, str],
790
+ eval_tags: dict[str, str],
791
+ ) -> list:
792
+ if args.model_evaluation_id and args.model:
793
+ console.print(
794
+ "[red]Error: specify either a model-evaluation ID "
795
+ "or --model, not both.[/red]",
796
+ )
797
+ return []
798
+
799
+ if not args.model_evaluation_id and not args.model:
800
+ console.print(
801
+ "[red]Error: specify a model-evaluation ID "
802
+ "or --model <model-id>.[/red]",
803
+ )
804
+ return []
805
+
806
+ if args.model_evaluation_id:
807
+ me_id = _resolve_id(
808
+ args.model_evaluation_id,
809
+ [me.id for me in
810
+ store.model_evaluations.list_all()],
811
+ )
812
+ try:
813
+ me = store.model_evaluations.get(me_id)
814
+ items = _filter_by_tags(
815
+ [me], store, model_tags, eval_tags,
816
+ )
817
+ if not items:
818
+ console.print(
819
+ "[yellow]Model-evaluation filtered "
820
+ "out by tag filters.[/yellow]",
821
+ )
822
+ return items
823
+ except KeyError:
824
+ console.print(
825
+ f"[red]Error: model-evaluation "
826
+ f"{args.model_evaluation_id!r} not found.[/red]",
827
+ )
828
+ return []
829
+
830
+ model_id = _resolve_id(
831
+ args.model,
832
+ [m.id for m in store.models.list()],
833
+ )
834
+ try:
835
+ model = store.models.get(model_id)
836
+ except KeyError:
837
+ console.print(
838
+ f"[red]Error: model {args.model!r} not found.[/red]",
839
+ )
840
+ return []
841
+
842
+ if model_tags and not _matches_tags(
843
+ model.tags, model_tags,
844
+ ):
845
+ console.print(
846
+ f"[yellow]Model {model_id!r} does not match "
847
+ f"--model-tag filters.[/yellow]",
848
+ )
849
+ return []
850
+
851
+ me_view = store.model_evaluations.list_by_model(
852
+ model_id, limit=1000,
853
+ )
854
+ items = [item.config for item in me_view.items]
855
+ if eval_tags:
856
+ items = _filter_by_tags(
857
+ items, store, {}, eval_tags,
858
+ )
859
+ if not items:
860
+ console.print(
861
+ f"[yellow]No evaluations assigned to model "
862
+ f"{model_id!r} (after tag filtering).[/yellow]",
863
+ )
864
+ return items
865
+
866
+
867
+ # -- formatting helpers -------------------------------------------
868
+
869
+ def _resolve_id(
870
+ prefix: str, all_ids: list[str],
871
+ ) -> str:
872
+ if prefix in all_ids:
873
+ return prefix
874
+ matches = [
875
+ full_id for full_id in all_ids
876
+ if full_id.startswith(prefix)
877
+ ]
878
+ if len(matches) == 1:
879
+ return matches[0]
880
+ if len(matches) > 1:
881
+ console.print(
882
+ f"[yellow]Ambiguous ID prefix {prefix!r}, "
883
+ f"matches: {[m[:12] for m in matches]}[/yellow]",
884
+ )
885
+ return prefix
886
+
887
+
888
+ def _fmt_tags(tags: dict[str, str]) -> str:
889
+ return ", ".join(
890
+ f"{k}={v}" for k, v in tags.items()
891
+ )