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.
- cli_modelarium/__init__.py +6 -0
- cli_modelarium/__main__.py +8 -0
- cli_modelarium/assertions.py +596 -0
- cli_modelarium/banner.py +96 -0
- cli_modelarium/batch.py +425 -0
- cli_modelarium/cli.py +2577 -0
- cli_modelarium/exceptions.py +88 -0
- cli_modelarium/hallucination.py +384 -0
- cli_modelarium/io_safety.py +112 -0
- cli_modelarium/judging.py +469 -0
- cli_modelarium/models_registry.py +138 -0
- cli_modelarium/output_formatters.py +1108 -0
- cli_modelarium/pricing.py +199 -0
- cli_modelarium/providers/__init__.py +7 -0
- cli_modelarium/providers/_utils.py +26 -0
- cli_modelarium/providers/anthropic_provider.py +148 -0
- cli_modelarium/providers/base.py +87 -0
- cli_modelarium/providers/deepseek_provider.py +15 -0
- cli_modelarium/providers/google_provider.py +135 -0
- cli_modelarium/providers/groq_provider.py +15 -0
- cli_modelarium/providers/local_provider.py +94 -0
- cli_modelarium/providers/mistral_provider.py +172 -0
- cli_modelarium/providers/openai_provider.py +163 -0
- cli_modelarium/providers/openrouter_provider.py +33 -0
- cli_modelarium/providers/xai_provider.py +15 -0
- cli_modelarium/run_statistics.py +1202 -0
- cli_modelarium/security.py +202 -0
- cli_modelarium/streaming.py +416 -0
- cli_modelarium-0.1.3.dist-info/METADATA +764 -0
- cli_modelarium-0.1.3.dist-info/RECORD +34 -0
- cli_modelarium-0.1.3.dist-info/WHEEL +4 -0
- cli_modelarium-0.1.3.dist-info/entry_points.txt +2 -0
- cli_modelarium-0.1.3.dist-info/licenses/LICENSE +201 -0
- 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
|