cli-modelarium 0.1.3__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 (34) hide show
  1. cli_modelarium/__init__.py +6 -0
  2. cli_modelarium/__main__.py +8 -0
  3. cli_modelarium/assertions.py +596 -0
  4. cli_modelarium/banner.py +96 -0
  5. cli_modelarium/batch.py +425 -0
  6. cli_modelarium/cli.py +2577 -0
  7. cli_modelarium/exceptions.py +88 -0
  8. cli_modelarium/hallucination.py +384 -0
  9. cli_modelarium/io_safety.py +112 -0
  10. cli_modelarium/judging.py +469 -0
  11. cli_modelarium/models_registry.py +138 -0
  12. cli_modelarium/output_formatters.py +1108 -0
  13. cli_modelarium/pricing.py +199 -0
  14. cli_modelarium/providers/__init__.py +7 -0
  15. cli_modelarium/providers/_utils.py +26 -0
  16. cli_modelarium/providers/anthropic_provider.py +148 -0
  17. cli_modelarium/providers/base.py +87 -0
  18. cli_modelarium/providers/deepseek_provider.py +15 -0
  19. cli_modelarium/providers/google_provider.py +135 -0
  20. cli_modelarium/providers/groq_provider.py +15 -0
  21. cli_modelarium/providers/local_provider.py +94 -0
  22. cli_modelarium/providers/mistral_provider.py +172 -0
  23. cli_modelarium/providers/openai_provider.py +163 -0
  24. cli_modelarium/providers/openrouter_provider.py +33 -0
  25. cli_modelarium/providers/xai_provider.py +15 -0
  26. cli_modelarium/run_statistics.py +1202 -0
  27. cli_modelarium/security.py +202 -0
  28. cli_modelarium/streaming.py +416 -0
  29. cli_modelarium-0.1.3.dist-info/METADATA +764 -0
  30. cli_modelarium-0.1.3.dist-info/RECORD +34 -0
  31. cli_modelarium-0.1.3.dist-info/WHEEL +4 -0
  32. cli_modelarium-0.1.3.dist-info/entry_points.txt +2 -0
  33. cli_modelarium-0.1.3.dist-info/licenses/LICENSE +201 -0
  34. cli_modelarium-0.1.3.dist-info/licenses/NOTICE +102 -0
@@ -0,0 +1,469 @@
1
+ """LLM-as-a-judge scoring.
2
+
3
+ Three concerns:
4
+
5
+ 1. Prompt building - `build_judge_prompt` formats a judge template with
6
+ criteria, the original user prompt, and the response to evaluate.
7
+ 2. Response parsing - `parse_judge_response` is forgiving about wrapping
8
+ (markdown fences, leading/trailing text) and returns a structured
9
+ result with parse_error set when JSON couldn't be extracted.
10
+ 3. Orchestration - `run_judging` runs every (state, judge) pairing in
11
+ parallel under per-judge-provider semaphores, with self-evaluation
12
+ auto-skip and aggregation across the panel.
13
+
14
+ The judge call itself uses `temperature=0.0` always - deterministic scoring
15
+ makes the panel comparable across runs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import statistics
23
+ import time
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+
27
+ from rich.console import Console
28
+ from rich.panel import Panel
29
+
30
+ from cli_modelarium.exceptions import ModelariumError
31
+ from cli_modelarium.models_registry import get_provider_for_model
32
+ from cli_modelarium.providers.base import BaseProvider
33
+ from cli_modelarium.security import redact_secrets
34
+ from cli_modelarium.streaming import StreamState
35
+
36
+ # Defaults the user picked in the build prompt.
37
+ DEFAULT_CRITERIA: tuple[str, ...] = (
38
+ "Accuracy: Is the response factually correct?",
39
+ "Helpfulness: Does it actually address what was asked?",
40
+ "Clarity: Is the explanation clear and well-structured?",
41
+ )
42
+
43
+ # The exact wording from the build prompt. `{criteria}`, `{prompt}`, and
44
+ # `{response}` are the three substitution points. Double `{{` / `}}` escape
45
+ # the JSON braces so str.format doesn't try to interpret them.
46
+ JUDGE_PROMPT_TEMPLATE = """You are evaluating an AI assistant's response. \
47
+ Rate the response on a scale of 1-10 for overall quality based on these criteria:
48
+
49
+ {criteria}
50
+
51
+ Original prompt: {prompt}
52
+ Response to evaluate: {response}
53
+
54
+ Respond with ONLY a JSON object in this exact format:
55
+ {{"score": <1-10 integer>, "reasoning": "<one sentence explanation>"}}
56
+
57
+ Do not include any other text."""
58
+
59
+ # Printed before every judging run unless --no-judge-tos is passed. See
60
+ # print_tos_disclosure() for the yellow Rich panel.
61
+ TOS_DISCLOSURE = (
62
+ "Reminder: Judge scores are for evaluation only.\n"
63
+ "Do not use to train, fine-tune, or develop competing AI models\n"
64
+ "(violates provider ToS)."
65
+ )
66
+
67
+ # Anthropic / OpenAI both clamp 1-10 in their guidance; outside that range
68
+ # we treat as a parse failure rather than silently clamping.
69
+ SCORE_MIN = 1
70
+ SCORE_MAX = 10
71
+
72
+
73
+ @dataclass
74
+ class JudgeScore:
75
+ """One judge model's verdict on one response."""
76
+
77
+ model: str
78
+ score: int | None
79
+ reasoning: str
80
+ cost_usd: float
81
+ latency_ms: float
82
+ parse_error: str | None = None
83
+ # Phase 10 hallucination preset: when the custom parser extracts a
84
+ # Low/Medium/High classification, it lands here. None for normal judging.
85
+ risk_level: str | None = None
86
+
87
+
88
+ @dataclass
89
+ class JudgeResult:
90
+ """Aggregated panel verdict on one response."""
91
+
92
+ judges: list[JudgeScore] = field(default_factory=list)
93
+ average_score: float | None = None
94
+ std_dev: float | None = None
95
+ # Models that were skipped due to self-evaluation - tracked so callers
96
+ # can surface that information in the display.
97
+ skipped_models: list[str] = field(default_factory=list)
98
+ # Phase 10 hallucination preset: worst-case aggregation across the panel
99
+ # (any High -> High; else any Medium -> Medium; else Low; else None).
100
+ # Populated by hallucination.annotate_risk_levels(), not _aggregate.
101
+ aggregated_risk_level: str | None = None
102
+
103
+
104
+ # ===== Response parsing =====
105
+
106
+
107
+ def parse_judge_response(text: str) -> dict:
108
+ """Parse a judge model's response into {score, reasoning, parse_error}.
109
+
110
+ Tries several strategies in order:
111
+ 1. Strip an outer markdown code fence (```json ... ``` or ``` ... ```)
112
+ 2. Try `json.loads` on the result.
113
+ 3. If that fails, search for the first balanced `{...}` block.
114
+
115
+ Returns a dict with the keys:
116
+ score (int | None): 1..10 or None on any parse failure.
117
+ reasoning (str): the judge's reasoning, or "".
118
+ parse_error (str | None): set when we couldn't extract a valid score.
119
+ """
120
+ text = text.strip()
121
+ if not text:
122
+ return {"score": None, "reasoning": "", "parse_error": "empty response"}
123
+
124
+ candidate = _strip_code_fence(text)
125
+
126
+ try:
127
+ data = json.loads(candidate)
128
+ except json.JSONDecodeError:
129
+ # Fall back to extracting the first {...} object from anywhere in the
130
+ # text. This covers responses with leading/trailing commentary.
131
+ data = _extract_first_json_object(text)
132
+ if data is None:
133
+ return {
134
+ "score": None,
135
+ "reasoning": "",
136
+ "parse_error": "could not extract JSON object",
137
+ }
138
+
139
+ if not isinstance(data, dict):
140
+ return {
141
+ "score": None,
142
+ "reasoning": "",
143
+ "parse_error": f"expected JSON object, got {type(data).__name__}",
144
+ }
145
+
146
+ reasoning_raw = data.get("reasoning", "")
147
+ reasoning = str(reasoning_raw) if reasoning_raw is not None else ""
148
+
149
+ if "score" not in data:
150
+ return {
151
+ "score": None,
152
+ "reasoning": reasoning,
153
+ "parse_error": "missing 'score' field",
154
+ }
155
+
156
+ score, err = _coerce_score(data["score"])
157
+ return {"score": score, "reasoning": reasoning, "parse_error": err}
158
+
159
+
160
+ def _strip_code_fence(text: str) -> str:
161
+ """Strip a leading ```json (or bare ```) fence and matching trailing ```."""
162
+ if not text.startswith("```"):
163
+ return text
164
+ lines = text.split("\n")
165
+ # Drop the opening fence line entirely (handles ``` and ```json).
166
+ lines = lines[1:]
167
+ # Drop trailing ``` lines.
168
+ while lines and lines[-1].strip() == "```":
169
+ lines.pop()
170
+ return "\n".join(lines).strip()
171
+
172
+
173
+ def _extract_first_json_object(text: str) -> dict | None:
174
+ """Find the first balanced {...} block in `text` and parse it as JSON.
175
+
176
+ Returns None if no balanced block parses successfully.
177
+ """
178
+ start = text.find("{")
179
+ if start == -1:
180
+ return None
181
+ depth = 0
182
+ in_string = False
183
+ string_char = ""
184
+ escape_next = False
185
+ for i in range(start, len(text)):
186
+ c = text[i]
187
+ if escape_next:
188
+ escape_next = False
189
+ continue
190
+ if in_string:
191
+ if c == "\\":
192
+ escape_next = True
193
+ continue
194
+ if c == string_char:
195
+ in_string = False
196
+ continue
197
+ if c in ('"', "'"):
198
+ in_string = True
199
+ string_char = c
200
+ continue
201
+ if c == "{":
202
+ depth += 1
203
+ elif c == "}":
204
+ depth -= 1
205
+ if depth == 0:
206
+ try:
207
+ parsed = json.loads(text[start : i + 1])
208
+ if isinstance(parsed, dict):
209
+ return parsed
210
+ return None
211
+ except json.JSONDecodeError:
212
+ return None
213
+ return None
214
+
215
+
216
+ def _coerce_score(raw: object) -> tuple[int | None, str | None]:
217
+ """Coerce a raw score value to an int in [SCORE_MIN, SCORE_MAX].
218
+
219
+ Accepts int, float (rounded to nearest), or numeric string. Booleans are
220
+ rejected (they are int subclasses but make no sense as scores). Returns
221
+ (score, error_message_or_None).
222
+ """
223
+ if isinstance(raw, bool):
224
+ return None, "score is boolean, expected number"
225
+ if isinstance(raw, int):
226
+ if SCORE_MIN <= raw <= SCORE_MAX:
227
+ return raw, None
228
+ return None, f"score {raw} out of range {SCORE_MIN}-{SCORE_MAX}"
229
+ if isinstance(raw, float):
230
+ coerced = int(round(raw))
231
+ if SCORE_MIN <= coerced <= SCORE_MAX:
232
+ return coerced, None
233
+ return None, f"score {raw} out of range {SCORE_MIN}-{SCORE_MAX}"
234
+ if isinstance(raw, str):
235
+ try:
236
+ as_float = float(raw.strip())
237
+ except (ValueError, AttributeError):
238
+ return None, f"score is non-numeric string: {raw!r}"
239
+ coerced = int(round(as_float))
240
+ if SCORE_MIN <= coerced <= SCORE_MAX:
241
+ return coerced, None
242
+ return None, f"score {as_float} out of range {SCORE_MIN}-{SCORE_MAX}"
243
+ return None, f"score has unsupported type {type(raw).__name__}"
244
+
245
+
246
+ # ===== Prompt building =====
247
+
248
+
249
+ def build_judge_prompt(
250
+ original_prompt: str,
251
+ response_to_eval: str,
252
+ criteria: list[str],
253
+ template: str = JUDGE_PROMPT_TEMPLATE,
254
+ ) -> str:
255
+ """Format the judge prompt template with criteria, prompt, and response.
256
+
257
+ Criteria are joined as bullet points. The user's prompt and the response
258
+ are passed verbatim - they are NOT subject to format string interpolation
259
+ (we use `.replace` over the template's three placeholders rather than
260
+ `str.format`, so curly braces in the user's text don't blow up).
261
+ """
262
+ if not criteria:
263
+ criteria = list(DEFAULT_CRITERIA)
264
+ criteria_text = "\n".join(f"- {c}" for c in criteria)
265
+ # Don't use .format() - it would explode on any '{...}' in the response
266
+ # text and is a needless template-injection vector. Direct replacement is
267
+ # safe and the template has only these three placeholders.
268
+ out = template.replace("{criteria}", criteria_text)
269
+ out = out.replace("{prompt}", original_prompt)
270
+ out = out.replace("{response}", response_to_eval)
271
+ return out
272
+
273
+
274
+ # ===== Single judge call =====
275
+
276
+
277
+ async def score_with_judge(
278
+ judge_provider: BaseProvider,
279
+ judge_model: str,
280
+ original_prompt: str,
281
+ response_to_eval: str,
282
+ criteria: list[str],
283
+ template: str = JUDGE_PROMPT_TEMPLATE,
284
+ response_parser: Callable[[str], dict] | None = None,
285
+ ) -> JudgeScore:
286
+ """Run one judge model on one response, returning a JudgeScore.
287
+
288
+ `response_parser` defaults to `parse_judge_response`. Phase 10's
289
+ hallucination preset passes `parse_hallucination_response` here to
290
+ extract the risk_level field alongside score/reasoning.
291
+
292
+ Errors (network failures, parse failures, anything) are captured into
293
+ JudgeScore.parse_error - we do NOT raise out of this function so a
294
+ panel of judges keeps functioning when one of them goes wrong.
295
+ """
296
+ parser = response_parser or parse_judge_response
297
+
298
+ judge_prompt = build_judge_prompt(original_prompt, response_to_eval, criteria, template)
299
+ start = time.monotonic()
300
+ try:
301
+ result = await judge_provider.complete(
302
+ prompt=judge_prompt,
303
+ model=judge_model,
304
+ temperature=0.0,
305
+ )
306
+ except ModelariumError as e:
307
+ return JudgeScore(
308
+ model=judge_model,
309
+ score=None,
310
+ reasoning="",
311
+ cost_usd=0.0,
312
+ latency_ms=(time.monotonic() - start) * 1000,
313
+ parse_error=f"judge call failed: {redact_secrets(str(e))}",
314
+ )
315
+ except Exception as e: # noqa: BLE001 - become a parse_error, not a crash
316
+ return JudgeScore(
317
+ model=judge_model,
318
+ score=None,
319
+ reasoning="",
320
+ cost_usd=0.0,
321
+ latency_ms=(time.monotonic() - start) * 1000,
322
+ parse_error=f"judge call failed: {redact_secrets(str(e))}",
323
+ )
324
+
325
+ parsed = parser(result.output)
326
+ return JudgeScore(
327
+ model=judge_model,
328
+ score=parsed.get("score"),
329
+ reasoning=parsed.get("reasoning", ""),
330
+ cost_usd=result.cost_usd,
331
+ latency_ms=result.latency_ms,
332
+ parse_error=parsed.get("parse_error"),
333
+ risk_level=parsed.get("risk_level"),
334
+ )
335
+
336
+
337
+ # ===== Orchestration =====
338
+
339
+
340
+ async def run_judging(
341
+ items: list[tuple[StreamState, str]],
342
+ judge_models: list[str],
343
+ criteria: list[str] | None,
344
+ provider_factory: Callable[[str], BaseProvider],
345
+ *,
346
+ template: str = JUDGE_PROMPT_TEMPLATE,
347
+ response_parser: Callable[[str], dict] | None = None,
348
+ skip_self_eval: bool = True,
349
+ concurrency: int = 5,
350
+ ) -> list[JudgeResult]:
351
+ """Run every (state, judge) pairing in parallel and aggregate.
352
+
353
+ `items` is a list of (StreamState, original_user_prompt) tuples. Returns
354
+ a list of `JudgeResult` parallel to `items`. State with errors (failed
355
+ main calls) get an empty JudgeResult - we don't waste judge calls on
356
+ them.
357
+
358
+ `skip_self_eval=True` (the default) drops the pairing where a judge's
359
+ model ID equals the state's model ID. The skipped model is recorded in
360
+ the resulting `JudgeResult.skipped_models` so the display layer can
361
+ surface it.
362
+
363
+ Empty `criteria` falls back to `DEFAULT_CRITERIA`.
364
+ """
365
+ if not items or not judge_models:
366
+ return [JudgeResult() for _ in items]
367
+
368
+ criteria = list(criteria) if criteria else list(DEFAULT_CRITERIA)
369
+
370
+ # One semaphore + one instance per UNIQUE judge provider (not per judge
371
+ # model). Lets us keep rate-limit hygiene when a single provider hosts
372
+ # several judges in the panel.
373
+ judge_providers_needed: dict[str, str] = {m: get_provider_for_model(m) for m in judge_models}
374
+ provider_names = sorted(set(judge_providers_needed.values()))
375
+ instances: dict[str, BaseProvider] = {n: provider_factory(n) for n in provider_names}
376
+ semaphores: dict[str, asyncio.Semaphore] = {
377
+ n: asyncio.Semaphore(concurrency) for n in provider_names
378
+ }
379
+
380
+ async def _score_with_sem(
381
+ provider: BaseProvider,
382
+ semaphore: asyncio.Semaphore,
383
+ judge_model: str,
384
+ original_prompt: str,
385
+ response_text: str,
386
+ ) -> JudgeScore:
387
+ async with semaphore:
388
+ return await score_with_judge(
389
+ provider,
390
+ judge_model,
391
+ original_prompt,
392
+ response_text,
393
+ criteria,
394
+ template,
395
+ response_parser=response_parser,
396
+ )
397
+
398
+ async def _score_state(state: StreamState, original_prompt: str) -> JudgeResult:
399
+ if state.error:
400
+ # Don't judge a failed main call. Empty JudgeResult.
401
+ return JudgeResult()
402
+
403
+ tasks = []
404
+ skipped: list[str] = []
405
+ for judge_model in judge_models:
406
+ if skip_self_eval and judge_model == state.model:
407
+ skipped.append(judge_model)
408
+ continue
409
+ provider_name = judge_providers_needed[judge_model]
410
+ tasks.append(
411
+ _score_with_sem(
412
+ instances[provider_name],
413
+ semaphores[provider_name],
414
+ judge_model,
415
+ original_prompt,
416
+ state.text,
417
+ )
418
+ )
419
+
420
+ judges: list[JudgeScore] = list(await asyncio.gather(*tasks)) if tasks else []
421
+ return _aggregate(judges, skipped_models=skipped)
422
+
423
+ return list(await asyncio.gather(*[_score_state(state, prompt) for state, prompt in items]))
424
+
425
+
426
+ def _aggregate(judges: list[JudgeScore], *, skipped_models: list[str] | None = None) -> JudgeResult:
427
+ """Compute average and std_dev across successfully-scored judges."""
428
+ successful = [j for j in judges if j.score is not None]
429
+ if successful:
430
+ scores = [j.score for j in successful if j.score is not None]
431
+ average = statistics.mean(scores) if scores else None
432
+ std = statistics.stdev(scores) if len(scores) >= 2 else None
433
+ else:
434
+ average = None
435
+ std = None
436
+ return JudgeResult(
437
+ judges=judges,
438
+ average_score=average,
439
+ std_dev=std,
440
+ skipped_models=list(skipped_models or []),
441
+ )
442
+
443
+
444
+ # ===== UI helpers =====
445
+
446
+
447
+ def print_tos_disclosure(console: Console) -> None:
448
+ """Print the judge-use ToS reminder as a yellow panel.
449
+
450
+ Always shown when judging is enabled unless --no-judge-tos is passed.
451
+ The wording is the build prompt's verbatim text.
452
+ """
453
+ console.print(
454
+ Panel(
455
+ TOS_DISCLOSURE,
456
+ title="Judge ToS",
457
+ border_style="yellow",
458
+ )
459
+ )
460
+
461
+
462
+ def total_judge_cost(results: list[JudgeResult]) -> float:
463
+ """Sum the cost across every JudgeScore in every JudgeResult."""
464
+ return sum(j.cost_usd for r in results for j in r.judges)
465
+
466
+
467
+ def total_judge_calls(results: list[JudgeResult]) -> int:
468
+ """Count every JudgeScore (successful or failed) across all results."""
469
+ return sum(len(r.judges) for r in results)
@@ -0,0 +1,138 @@
1
+ """Model registry: maps model IDs to providers and defines group shortcuts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cli_modelarium.exceptions import UnknownModelError
6
+ from cli_modelarium.pricing import PRICING, is_local_model
7
+
8
+ # Group shortcuts let users write `--models all-premium` instead of listing
9
+ # six model IDs. Groups are filtered at resolution time against the user's
10
+ # configured providers, so requesting `all-premium` without an Anthropic key
11
+ # just yields the providers the user actually has.
12
+ MODEL_GROUPS: dict[str, list[str]] = {
13
+ "all-premium": [
14
+ "gpt-5.5",
15
+ "claude-opus-4-7",
16
+ "gemini-3.1-pro",
17
+ "grok-4.3",
18
+ "deepseek-v4-pro",
19
+ "mistral-large-latest",
20
+ ],
21
+ "all-flagship": [
22
+ "gpt-5.5",
23
+ "claude-opus-4-7",
24
+ "gemini-3.1-pro",
25
+ "grok-4.3",
26
+ "deepseek-v4-pro",
27
+ "mistral-large-latest",
28
+ ],
29
+ "all-budget": [
30
+ "gpt-5.4-nano",
31
+ "claude-haiku-4-5",
32
+ "gemini-3.1-flash-lite",
33
+ "grok-4.1-fast",
34
+ "deepseek-v4-flash",
35
+ "mistral-small-latest",
36
+ ],
37
+ "all-reasoning": [
38
+ "o3",
39
+ "o4-mini",
40
+ "deepseek-reasoner",
41
+ "magistral-medium-latest",
42
+ "magistral-small-latest",
43
+ ],
44
+ "all-fast": [
45
+ "claude-haiku-4-5",
46
+ "gemini-3-flash",
47
+ "grok-4.1-fast",
48
+ "deepseek-v4-flash",
49
+ "llama-3.3-70b-versatile",
50
+ ],
51
+ "all-cheap": [
52
+ "gpt-4o-mini",
53
+ "claude-haiku-4-5",
54
+ "gemini-2.5-flash-lite",
55
+ "grok-4.1-fast",
56
+ "deepseek-v4-flash",
57
+ "mistral-small-latest",
58
+ ],
59
+ "all-open-weight": [
60
+ "gpt-oss-120b",
61
+ "gpt-oss-20b",
62
+ "llama-3.3-70b-versatile",
63
+ "meta-llama/llama-4-scout-17b-16e-instruct",
64
+ ],
65
+ # Resolved dynamically at call time against the user's configured providers
66
+ # and local models. Listed here so `is_group_name()` recognizes them.
67
+ "all-local": [],
68
+ "all": [],
69
+ }
70
+
71
+ # Group names that need dynamic resolution by the caller (not just lookup).
72
+ DYNAMIC_GROUPS = frozenset({"all-local", "all"})
73
+
74
+
75
+ def get_provider_for_model(model: str) -> str:
76
+ """Return the provider name for a model ID.
77
+
78
+ Raises UnknownModelError if the model is not in the registry.
79
+ """
80
+ if is_local_model(model):
81
+ return "local"
82
+ pricing = PRICING.get(model)
83
+ if pricing is None:
84
+ raise UnknownModelError(
85
+ f"Unknown model: {model}. Run `cli-modelarium list-models` to see supported models."
86
+ )
87
+ return str(pricing["provider"])
88
+
89
+
90
+ def list_models_for_provider(provider: str) -> list[str]:
91
+ """Return all concrete model IDs registered for a provider, sorted alphabetically."""
92
+ return sorted(
93
+ model
94
+ for model, p in PRICING.items()
95
+ if p.get("provider") == provider and not model.endswith("/*")
96
+ )
97
+
98
+
99
+ def all_known_providers() -> list[str]:
100
+ """Return all unique provider names present in the pricing registry, sorted."""
101
+ providers = {str(p["provider"]) for p in PRICING.values()}
102
+ return sorted(providers)
103
+
104
+
105
+ def is_group_name(name: str) -> bool:
106
+ """Return True if `name` is a registered model group shortcut."""
107
+ return name in MODEL_GROUPS
108
+
109
+
110
+ def expand_group(group: str) -> list[str]:
111
+ """Expand a group name to its constituent model IDs.
112
+
113
+ Dynamic groups (`all-local`, `all`) return an empty list - the caller is
114
+ expected to populate them with configured models.
115
+ """
116
+ return list(MODEL_GROUPS.get(group, []))
117
+
118
+
119
+ def parse_models_arg(models_arg: str) -> list[str]:
120
+ """Parse a `--models` CLI value into a flat list of model IDs.
121
+
122
+ Accepts a comma-separated string mixing concrete model IDs and group names.
123
+ Groups are expanded in place. Dynamic groups are returned as-is for the
124
+ caller to resolve against runtime context.
125
+ """
126
+ result: list[str] = []
127
+ for token in (t.strip() for t in models_arg.split(",")):
128
+ if not token:
129
+ continue
130
+ if is_group_name(token):
131
+ expanded = expand_group(token)
132
+ if not expanded and token in DYNAMIC_GROUPS:
133
+ result.append(token)
134
+ else:
135
+ result.extend(expanded)
136
+ else:
137
+ result.append(token)
138
+ return result